@nyris/nyris-webapp 0.3.98 → 0.4.0
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 +7 -6
- package/build/index.html +1 -1
- package/build/js/settings.example.js +42 -0
- package/build/static/css/main.a7ff7a7f.css +4 -0
- package/build/static/css/main.a7ff7a7f.css.map +1 -0
- package/build/static/js/main.916c157c.js +3 -0
- package/build/static/js/{main.93fdbd6e.js.map → main.916c157c.js.map} +1 -1
- package/build/static/media/plate_info.6beaea36d35053be3a45.png +0 -0
- package/package.json +3 -3
- package/public/js/settings.example.js +42 -0
- package/src/common/assets/images/plate_info.png +0 -0
- package/src/components/Cart.tsx +41 -0
- package/src/components/ChatAssistant/ChatAssistant.test.tsx +213 -0
- package/src/components/ChatAssistant/ChatAssistant.tsx +18 -1
- package/src/components/ChatAssistant/MobileChatAssistant.tsx +18 -1
- package/src/components/ChatAssistant/OptionChip.tsx +23 -1
- package/src/components/ChatAssistant/useChatAssistantLogic.ts +35 -14
- package/src/components/GoBackButton.tsx +29 -2
- package/src/components/Header.tsx +3 -0
- package/src/components/HitsPerPage.tsx +55 -18
- package/src/components/ImagePreview.tsx +31 -1
- package/src/components/ImageUpload.tsx +12 -1
- package/src/components/Inquiry/InquiryModal.tsx +61 -26
- package/src/components/Pagination.tsx +33 -6
- package/src/components/PostFilter/PostFilter.tsx +8 -1
- package/src/components/PostFilter/PostFilterFindApi.test.tsx +249 -0
- package/src/components/PostFilter/PostFilterFindApi.tsx +128 -52
- package/src/components/PreFilter/PreFilter.tsx +10 -2
- package/src/components/Product/Product.tsx +60 -10
- package/src/components/Product/ProductDetailView.tsx +23 -1
- package/src/components/Product/ProductDetailViewModal.tsx +5 -1
- package/src/components/Product/ProductList.tsx +51 -18
- package/src/components/Rfq/RfqModal.tsx +3 -0
- package/src/components/ScopeSelect/ScopeSelectForm.tsx +514 -0
- package/src/components/ScopeSelect/ScopeSelectImageActions.tsx +62 -0
- package/src/components/ScopeSelect/ScopeSelectImagePreview.tsx +141 -0
- package/src/components/ScopeSelect/ScopeSelectInfo.tsx +68 -0
- package/src/components/ScopeSelect/ScopeSelectInput.tsx +123 -0
- package/src/components/ScopeSelect/ScopeSelectModal.tsx +310 -0
- package/src/components/ScopeSelect/identifyTypePlateImage.test.ts +129 -0
- package/src/components/ScopeSelect/identifyTypePlateImage.ts +83 -0
- package/src/components/ScopeSelect/parseScopeIdResponse.test.ts +103 -0
- package/src/components/ScopeSelect/parseScopeIdResponse.ts +72 -0
- package/src/components/ScopeSelect/scopeSelectHelpers.ts +54 -0
- package/src/components/SidePanel.tsx +33 -0
- package/src/components/TextSearch.tsx +77 -11
- package/src/components/Tooltip/TooltipComponent.tsx +12 -4
- package/src/components/TypePlateUpload.tsx +132 -0
- package/src/hooks/chatProductMetadata.test.ts +307 -0
- package/src/hooks/useBadResultsRecovery.ts +26 -49
- package/src/hooks/useEffectiveGroundingResults.ts +8 -6
- package/src/hooks/useGoodResultsChat.ts +28 -39
- package/src/hooks/useImageSearch.ts +290 -12
- package/src/hooks/useMachinePartsListingPagination.ts +95 -0
- package/src/hooks/useResultEvaluator.ts +63 -73
- package/src/index.css +1 -1
- package/src/layouts/AppLayout.tsx +43 -3
- package/src/pages/Home.tsx +60 -14
- package/src/pages/Result.tsx +194 -14
- package/src/services/Feedback.ts +225 -34
- package/src/services/feedbackSubtypes.ts +63 -0
- package/src/services/filter.ts +1 -1
- package/src/services/initialMachinePartsText.payload.test.ts +61 -0
- package/src/services/initialMachinePartsText.test.ts +33 -0
- package/src/services/initialMachinePartsText.ts +11 -0
- package/src/services/machinePartsPage.test.ts +74 -0
- package/src/services/machinePartsPage.ts +120 -0
- package/src/services/normalizeChatResponse.ts +26 -0
- package/src/services/scopeLookup.test.ts +67 -0
- package/src/services/scopeLookup.ts +39 -0
- package/src/services/visualSearch.listingOptions.test.ts +79 -0
- package/src/services/visualSearch.ts +71 -3
- package/src/services/vizo.ts +4 -3
- package/src/stores/chat/conversationStore.test.ts +133 -0
- package/src/stores/chat/conversationStore.ts +55 -14
- package/src/stores/machinePartsListing/machinePartsListingStore.ts +35 -0
- package/src/stores/request/filter/filter.initialState.ts +1 -0
- package/src/stores/request/filter/filter.slice.ts +2 -0
- package/src/stores/result/prodcuts/products.initialState.ts +1 -0
- package/src/stores/result/prodcuts/products.slice.ts +4 -0
- package/src/stores/scope/scopeStore.ts +67 -4
- package/src/stores/types.ts +19 -14
- package/src/translations.ts +213 -0
- package/src/types.ts +41 -0
- package/src/utils/chatProductMetadata.test.ts +130 -0
- package/src/utils/chatProductMetadata.ts +93 -0
- package/src/utils/compressImage.ts +3 -3
- package/src/utils/fetchProductImage.ts +1 -0
- package/src/utils/normalizeChatAssistantResponse.ts +227 -0
- package/src/utils/productCardImageUrl.test.ts +41 -0
- package/src/utils/productCardImageUrl.ts +49 -0
- package/src/utils/textSearchFilter.test.ts +41 -0
- package/src/utils/textSearchFilter.ts +13 -6
- package/build/static/css/main.81216c5d.css +0 -4
- package/build/static/css/main.81216c5d.css.map +0 -1
- package/build/static/js/main.93fdbd6e.js +0 -3
- /package/build/static/js/{main.93fdbd6e.js.LICENSE.txt → main.916c157c.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import NyrisAPI, { NyrisAPISettings } from '@nyris/nyris-api';
|
|
2
|
+
|
|
3
|
+
function entries(body: FormData) {
|
|
4
|
+
return Array.from(body.entries()).map(([key, value]) => [key, String(value)]);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
async function capture(
|
|
8
|
+
text: string | undefined,
|
|
9
|
+
scopeIds: string[],
|
|
10
|
+
) {
|
|
11
|
+
const api = new NyrisAPI({
|
|
12
|
+
apiKey: 'test-key',
|
|
13
|
+
baseUrl: 'https://api.nyris.io',
|
|
14
|
+
jpegQuality: 0.9,
|
|
15
|
+
maxWidth: 1024,
|
|
16
|
+
maxHeight: 1024,
|
|
17
|
+
xOptions: false,
|
|
18
|
+
} as NyrisAPISettings);
|
|
19
|
+
const request = jest.fn().mockResolvedValue({ data: { results: [] } });
|
|
20
|
+
(api as unknown as { httpClient: { request: jest.Mock } }).httpClient = {
|
|
21
|
+
request,
|
|
22
|
+
};
|
|
23
|
+
await api.find(
|
|
24
|
+
text !== undefined ? { text } : {},
|
|
25
|
+
undefined,
|
|
26
|
+
undefined,
|
|
27
|
+
undefined,
|
|
28
|
+
undefined,
|
|
29
|
+
scopeIds,
|
|
30
|
+
);
|
|
31
|
+
return request.mock.calls[0][0] as {
|
|
32
|
+
url: string;
|
|
33
|
+
data: FormData | null;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('initial machine find payload', () => {
|
|
38
|
+
it('sends the caller-provided item-key batch and scope id', async () => {
|
|
39
|
+
const itemKeys = Array.from(
|
|
40
|
+
{ length: 15 },
|
|
41
|
+
(_, index) => `ITEM-${index + 1}`,
|
|
42
|
+
);
|
|
43
|
+
const text = itemKeys.slice(10).join(', ');
|
|
44
|
+
const config = await capture(text, ['TestScope2']);
|
|
45
|
+
|
|
46
|
+
expect(config.url).toBe('https://api.nyris.io/find/v1.1');
|
|
47
|
+
expect(entries(config.data as FormData)).toEqual([
|
|
48
|
+
['text', text],
|
|
49
|
+
['scope[0]', 'TestScope2'],
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('sends a keyword without item keys when a machine scope is active', async () => {
|
|
54
|
+
const config = await capture('pump', ['TestScope2']);
|
|
55
|
+
|
|
56
|
+
expect(entries(config.data as FormData)).toEqual([
|
|
57
|
+
['text', 'pump'],
|
|
58
|
+
['scope[0]', 'TestScope2'],
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { initialMachinePartsText } from './initialMachinePartsText';
|
|
2
|
+
|
|
3
|
+
function keys(count: number) {
|
|
4
|
+
return Array.from({ length: count }, (_, index) => `ITEM-${index + 1}`);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
describe('initialMachinePartsText', () => {
|
|
8
|
+
it('joins fewer than 20 item keys in order', () => {
|
|
9
|
+
const itemKeys = keys(3);
|
|
10
|
+
expect(initialMachinePartsText(itemKeys)).toBe(
|
|
11
|
+
'ITEM-1, ITEM-2, ITEM-3',
|
|
12
|
+
);
|
|
13
|
+
expect(itemKeys).toEqual(keys(3));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('joins exactly 20 item keys', () => {
|
|
17
|
+
const text = initialMachinePartsText(keys(20));
|
|
18
|
+
expect(text?.split(', ')).toHaveLength(20);
|
|
19
|
+
expect(text?.endsWith('ITEM-20')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('stops at the first 20 item keys', () => {
|
|
23
|
+
const itemKeys = keys(21);
|
|
24
|
+
const text = initialMachinePartsText(itemKeys);
|
|
25
|
+
expect(text?.split(', ')).toHaveLength(20);
|
|
26
|
+
expect(text?.includes('ITEM-21')).toBe(false);
|
|
27
|
+
expect(itemKeys).toHaveLength(21);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns undefined when there are no item keys', () => {
|
|
31
|
+
expect(initialMachinePartsText([])).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MACHINE_PARTS_PAGE_SIZE,
|
|
3
|
+
machinePartsPageText,
|
|
4
|
+
} from './machinePartsPage';
|
|
5
|
+
|
|
6
|
+
/** First page at the default page size. The source array is not changed. */
|
|
7
|
+
export function initialMachinePartsText(
|
|
8
|
+
itemKeys: readonly string[],
|
|
9
|
+
): string | undefined {
|
|
10
|
+
return machinePartsPageText(itemKeys, 0, DEFAULT_MACHINE_PARTS_PAGE_SIZE);
|
|
11
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isScopeOnlyMachinePartsListing,
|
|
3
|
+
machinePartsPageCount,
|
|
4
|
+
machinePartsPageItemKeys,
|
|
5
|
+
machinePartsPageText,
|
|
6
|
+
visibleMachinePartsPageIndexes,
|
|
7
|
+
} from './machinePartsPage';
|
|
8
|
+
|
|
9
|
+
function keys(count: number) {
|
|
10
|
+
return Array.from({ length: count }, (_, index) => `ITEM-${index + 1}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('machinePartsPage', () => {
|
|
14
|
+
it('splits 15 item keys into pages of 10 without overlap', () => {
|
|
15
|
+
const itemKeys = keys(15);
|
|
16
|
+
expect(machinePartsPageCount(itemKeys.length, 10)).toBe(2);
|
|
17
|
+
expect(machinePartsPageItemKeys(itemKeys, 0, 10)).toEqual(keys(10));
|
|
18
|
+
expect(machinePartsPageItemKeys(itemKeys, 1, 10)).toEqual(
|
|
19
|
+
keys(15).slice(10),
|
|
20
|
+
);
|
|
21
|
+
expect(machinePartsPageText(itemKeys, 1, 10)).toBe(
|
|
22
|
+
'ITEM-11, ITEM-12, ITEM-13, ITEM-14, ITEM-15',
|
|
23
|
+
);
|
|
24
|
+
expect(itemKeys).toHaveLength(15);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('counts pages for 611 item keys from the key list, not the response', () => {
|
|
28
|
+
expect(machinePartsPageCount(611, 10)).toBe(62);
|
|
29
|
+
expect(machinePartsPageCount(611, 20)).toBe(31);
|
|
30
|
+
expect(machinePartsPageCount(611, 30)).toBe(21);
|
|
31
|
+
expect(machinePartsPageCount(611, 40)).toBe(16);
|
|
32
|
+
expect(machinePartsPageCount(611, 50)).toBe(13);
|
|
33
|
+
|
|
34
|
+
const itemKeys = keys(611);
|
|
35
|
+
const lastPage = machinePartsPageItemKeys(itemKeys, 30, 20);
|
|
36
|
+
expect(lastPage).toHaveLength(11);
|
|
37
|
+
expect(lastPage[0]).toBe('ITEM-601');
|
|
38
|
+
expect(lastPage[lastPage.length - 1]).toBe('ITEM-611');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('keeps a full last page when the key count divides evenly', () => {
|
|
42
|
+
expect(machinePartsPageCount(10000, 50)).toBe(200);
|
|
43
|
+
expect(machinePartsPageItemKeys(keys(10000), 199, 50)).toHaveLength(50);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns no text for an empty page', () => {
|
|
47
|
+
expect(machinePartsPageText(keys(15), 2, 10)).toBeUndefined();
|
|
48
|
+
expect(machinePartsPageCount(0, 20)).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('shows every page when the count fits, otherwise a window', () => {
|
|
52
|
+
expect(visibleMachinePartsPageIndexes(0, 2)).toEqual([0, 1]);
|
|
53
|
+
expect(visibleMachinePartsPageIndexes(0, 62)).toHaveLength(7);
|
|
54
|
+
expect(visibleMachinePartsPageIndexes(61, 62)).toContain(61);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('treats an active machine scope as a listing even if Algolia is on', () => {
|
|
58
|
+
const listing = {
|
|
59
|
+
scopeEnabled: true,
|
|
60
|
+
scopeId: 'TestScope2',
|
|
61
|
+
itemKeyCount: 15,
|
|
62
|
+
keyword: '',
|
|
63
|
+
hasSearchImage: false,
|
|
64
|
+
hasPostFilters: false,
|
|
65
|
+
};
|
|
66
|
+
expect(isScopeOnlyMachinePartsListing(listing)).toBe(true);
|
|
67
|
+
expect(
|
|
68
|
+
isScopeOnlyMachinePartsListing({ ...listing, keyword: 'pump' }),
|
|
69
|
+
).toBe(false);
|
|
70
|
+
expect(
|
|
71
|
+
isScopeOnlyMachinePartsListing({ ...listing, scopeEnabled: false }),
|
|
72
|
+
).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export const DEFAULT_MACHINE_PARTS_PAGE_SIZE = 20;
|
|
2
|
+
|
|
3
|
+
export function normalizeMachinePartsPageSize(pageSize: number): number {
|
|
4
|
+
if (!Number.isFinite(pageSize) || pageSize <= 0) {
|
|
5
|
+
return DEFAULT_MACHINE_PARTS_PAGE_SIZE;
|
|
6
|
+
}
|
|
7
|
+
return Math.floor(pageSize);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 0-based page index, matching the existing pagination control. */
|
|
11
|
+
export function normalizeMachinePartsPageIndex(pageIndex: number): number {
|
|
12
|
+
if (!Number.isFinite(pageIndex) || pageIndex <= 0) {
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
return Math.floor(pageIndex);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function machinePartsPageCount(
|
|
19
|
+
itemKeyCount: number,
|
|
20
|
+
pageSize: number,
|
|
21
|
+
): number {
|
|
22
|
+
if (!Number.isFinite(itemKeyCount) || itemKeyCount <= 0) {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
const size = normalizeMachinePartsPageSize(pageSize);
|
|
26
|
+
return Math.ceil(itemKeyCount / size);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Does not mutate `itemKeys`. */
|
|
30
|
+
export function machinePartsPageItemKeys(
|
|
31
|
+
itemKeys: readonly string[],
|
|
32
|
+
pageIndex: number,
|
|
33
|
+
pageSize: number,
|
|
34
|
+
): string[] {
|
|
35
|
+
const size = normalizeMachinePartsPageSize(pageSize);
|
|
36
|
+
const start = normalizeMachinePartsPageIndex(pageIndex) * size;
|
|
37
|
+
return itemKeys.slice(start, start + size);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function machinePartsPageText(
|
|
41
|
+
itemKeys: readonly string[],
|
|
42
|
+
pageIndex: number,
|
|
43
|
+
pageSize: number,
|
|
44
|
+
): string | undefined {
|
|
45
|
+
const text = machinePartsPageItemKeys(itemKeys, pageIndex, pageSize).join(
|
|
46
|
+
', ',
|
|
47
|
+
);
|
|
48
|
+
if (text.length === 0) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Page numbers shown around the current page. Indexes are 0-based. */
|
|
55
|
+
export function visibleMachinePartsPageIndexes(
|
|
56
|
+
pageIndex: number,
|
|
57
|
+
pageCount: number,
|
|
58
|
+
padding = 3,
|
|
59
|
+
): number[] {
|
|
60
|
+
if (pageCount <= 0) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const current = Math.min(
|
|
64
|
+
normalizeMachinePartsPageIndex(pageIndex),
|
|
65
|
+
pageCount - 1,
|
|
66
|
+
);
|
|
67
|
+
if (pageCount <= padding * 2 + 1) {
|
|
68
|
+
return Array.from({ length: pageCount }, (_, index) => index);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let start = Math.max(0, current - padding);
|
|
72
|
+
let end = Math.min(pageCount - 1, current + padding);
|
|
73
|
+
if (current - padding < 0) {
|
|
74
|
+
end = Math.min(pageCount - 1, end + (padding - current));
|
|
75
|
+
}
|
|
76
|
+
if (current + padding > pageCount - 1) {
|
|
77
|
+
start = Math.max(0, start - (current + padding - (pageCount - 1)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const pages: number[] = [];
|
|
81
|
+
for (let index = start; index <= end; index += 1) {
|
|
82
|
+
pages.push(index);
|
|
83
|
+
}
|
|
84
|
+
return pages;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function hasActivePostFilters(
|
|
88
|
+
selections: Record<string, string[]> | undefined,
|
|
89
|
+
): boolean {
|
|
90
|
+
return Object.values(selections || {}).some(
|
|
91
|
+
values => Array.isArray(values) && values.length > 0,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isScopeOnlyMachinePartsListing({
|
|
96
|
+
scopeEnabled,
|
|
97
|
+
scopeId,
|
|
98
|
+
itemKeyCount,
|
|
99
|
+
keyword,
|
|
100
|
+
hasSearchImage,
|
|
101
|
+
hasPostFilters,
|
|
102
|
+
}: {
|
|
103
|
+
scopeEnabled: boolean;
|
|
104
|
+
scopeId: string | null;
|
|
105
|
+
itemKeyCount: number;
|
|
106
|
+
keyword: string;
|
|
107
|
+
hasSearchImage: boolean;
|
|
108
|
+
hasPostFilters: boolean;
|
|
109
|
+
}): boolean {
|
|
110
|
+
if (!scopeEnabled) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (!scopeId || itemKeyCount <= 0) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (keyword.trim().length > 0 || hasSearchImage || hasPostFilters) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat API sometimes returns the JSON payload as a string, optionally
|
|
3
|
+
* wrapped in a markdown code fence (```json ... ```). Parse such strings into
|
|
4
|
+
* objects; leave plain-text responses (e.g. request_quality) untouched.
|
|
5
|
+
*
|
|
6
|
+
* A JSON object embedded in surrounding text is left as text. Callers must not
|
|
7
|
+
* treat that leftover text as structured data.
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeChatResponse(data: unknown): unknown {
|
|
10
|
+
if (typeof data !== 'string') {
|
|
11
|
+
return data;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const fenced = data.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
15
|
+
const candidate = (fenced ? fenced[1] : data).trim();
|
|
16
|
+
|
|
17
|
+
if (!candidate.startsWith('{') && !candidate.startsWith('[')) {
|
|
18
|
+
return data;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(candidate);
|
|
23
|
+
} catch {
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { getScopes } from 'services/filter';
|
|
2
|
+
import { lookupScopeById } from './scopeLookup';
|
|
3
|
+
|
|
4
|
+
jest.mock('services/filter', () => ({
|
|
5
|
+
getScopes: jest.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
jest.mock('utils/nyrisApiKey', () => ({
|
|
9
|
+
isNyrisApiKeyReady: () => true,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const getScopesMock = getScopes as jest.MockedFunction<typeof getScopes>;
|
|
13
|
+
|
|
14
|
+
describe('lookupScopeById', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
getScopesMock.mockReset();
|
|
17
|
+
window.settings = {
|
|
18
|
+
scopes: { scopeID: true },
|
|
19
|
+
apiKey: 'test-key',
|
|
20
|
+
} as typeof window.settings;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('sends the serial number unchanged', async () => {
|
|
24
|
+
getScopesMock.mockResolvedValue({
|
|
25
|
+
itemKeys: ['sku-1'],
|
|
26
|
+
filterAttributes: [],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const result = await lookupScopeById('Ab-12/c 9');
|
|
30
|
+
|
|
31
|
+
expect(getScopesMock).toHaveBeenCalledWith(
|
|
32
|
+
'Ab-12/c 9',
|
|
33
|
+
window.settings,
|
|
34
|
+
);
|
|
35
|
+
expect(result).toEqual({
|
|
36
|
+
status: 'found',
|
|
37
|
+
itemKeys: ['sku-1'],
|
|
38
|
+
filterAttributes: [],
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not call the scopes API for a blank id', async () => {
|
|
43
|
+
const result = await lookupScopeById(' ');
|
|
44
|
+
|
|
45
|
+
expect(getScopesMock).not.toHaveBeenCalled();
|
|
46
|
+
expect(result).toEqual({ status: 'not_found' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns empty when the machine has no item keys', async () => {
|
|
50
|
+
getScopesMock.mockResolvedValue({
|
|
51
|
+
itemKeys: [],
|
|
52
|
+
filterAttributes: [],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await expect(lookupScopeById('2234GH')).resolves.toEqual({
|
|
56
|
+
status: 'empty',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns not found when the scopes request fails', async () => {
|
|
61
|
+
getScopesMock.mockRejectedValue(new Error('unavailable'));
|
|
62
|
+
|
|
63
|
+
await expect(lookupScopeById('2234GH')).resolves.toEqual({
|
|
64
|
+
status: 'not_found',
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getScopes } from 'services/filter';
|
|
2
|
+
import { isNyrisApiKeyReady } from 'utils/nyrisApiKey';
|
|
3
|
+
import { TypePlateLookupResult } from 'components/ScopeSelect/identifyTypePlateImage';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /v1/scopes?scopeIds=... using the existing client encoding.
|
|
7
|
+
* Does not write the scope store. Callers apply a machine only after confirm.
|
|
8
|
+
* The id is sent unchanged; trimming only rejects a blank value.
|
|
9
|
+
*/
|
|
10
|
+
export async function lookupScopeById(
|
|
11
|
+
scopeId: string,
|
|
12
|
+
): Promise<TypePlateLookupResult> {
|
|
13
|
+
const settings = window.settings;
|
|
14
|
+
if (!settings.scopes?.scopeID) {
|
|
15
|
+
return { status: 'unavailable' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!isNyrisApiKeyReady()) {
|
|
19
|
+
return { status: 'unavailable' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (scopeId.trim().length === 0 || scopeId.trim() === 'NOT_FOUND') {
|
|
23
|
+
return { status: 'not_found' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const scopesResponse = await getScopes(scopeId, settings);
|
|
28
|
+
const itemKeys = (scopesResponse.itemKeys || []).filter(Boolean);
|
|
29
|
+
const filterAttributes = scopesResponse.filterAttributes || [];
|
|
30
|
+
|
|
31
|
+
if (itemKeys.length === 0) {
|
|
32
|
+
return { status: 'empty' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { status: 'found', itemKeys, filterAttributes };
|
|
36
|
+
} catch {
|
|
37
|
+
return { status: 'not_found' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { machinePartsPageText } from './machinePartsPage';
|
|
2
|
+
import { machinePartsListingXOptions } from './visualSearch';
|
|
3
|
+
|
|
4
|
+
const X_OPTIONS = 'hybrid_search.text.reranking=false';
|
|
5
|
+
|
|
6
|
+
function keys(count: number) {
|
|
7
|
+
return Array.from({ length: count }, (_, index) => `ITEM-${index + 1}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const listing = {
|
|
11
|
+
hasSearchImage: false,
|
|
12
|
+
hasFilters: false,
|
|
13
|
+
scopeId: 'TestScope2',
|
|
14
|
+
itemKeys: keys(15),
|
|
15
|
+
keyword: '',
|
|
16
|
+
pageSize: 10,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('machinePartsListingXOptions', () => {
|
|
20
|
+
it('sends the override for each scope-only item key page', () => {
|
|
21
|
+
expect(
|
|
22
|
+
machinePartsListingXOptions({
|
|
23
|
+
...listing,
|
|
24
|
+
pageIndex: 0,
|
|
25
|
+
text: machinePartsPageText(listing.itemKeys, 0, 10),
|
|
26
|
+
}),
|
|
27
|
+
).toBe(X_OPTIONS);
|
|
28
|
+
expect(
|
|
29
|
+
machinePartsListingXOptions({
|
|
30
|
+
...listing,
|
|
31
|
+
pageIndex: 1,
|
|
32
|
+
text: machinePartsPageText(listing.itemKeys, 1, 10),
|
|
33
|
+
}),
|
|
34
|
+
).toBe(X_OPTIONS);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('omits the override for a keyword, image, filters, or another page', () => {
|
|
38
|
+
const firstPage = machinePartsPageText(listing.itemKeys, 0, 10);
|
|
39
|
+
expect(
|
|
40
|
+
machinePartsListingXOptions({
|
|
41
|
+
...listing,
|
|
42
|
+
pageIndex: 1,
|
|
43
|
+
text: firstPage,
|
|
44
|
+
}),
|
|
45
|
+
).toBeUndefined();
|
|
46
|
+
expect(
|
|
47
|
+
machinePartsListingXOptions({
|
|
48
|
+
...listing,
|
|
49
|
+
pageIndex: 0,
|
|
50
|
+
text: 'pump',
|
|
51
|
+
keyword: 'pump',
|
|
52
|
+
}),
|
|
53
|
+
).toBeUndefined();
|
|
54
|
+
expect(
|
|
55
|
+
machinePartsListingXOptions({
|
|
56
|
+
...listing,
|
|
57
|
+
pageIndex: 0,
|
|
58
|
+
text: firstPage,
|
|
59
|
+
hasSearchImage: true,
|
|
60
|
+
}),
|
|
61
|
+
).toBeUndefined();
|
|
62
|
+
expect(
|
|
63
|
+
machinePartsListingXOptions({
|
|
64
|
+
...listing,
|
|
65
|
+
pageIndex: 0,
|
|
66
|
+
text: firstPage,
|
|
67
|
+
hasFilters: true,
|
|
68
|
+
}),
|
|
69
|
+
).toBeUndefined();
|
|
70
|
+
expect(
|
|
71
|
+
machinePartsListingXOptions({
|
|
72
|
+
...listing,
|
|
73
|
+
pageIndex: 0,
|
|
74
|
+
text: firstPage,
|
|
75
|
+
scopeId: null,
|
|
76
|
+
}),
|
|
77
|
+
).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -10,6 +10,45 @@ import { isEqual } from 'lodash';
|
|
|
10
10
|
import { DEFAULT_REGION } from '../constants';
|
|
11
11
|
import { getUserLocation } from 'helpers/getGeoLocation';
|
|
12
12
|
import { getNyrisSettings } from 'utils/nyrisApiKey';
|
|
13
|
+
import useRequestStore from 'stores/request/requestStore';
|
|
14
|
+
import useScopeStore from 'stores/scope/scopeStore';
|
|
15
|
+
import { machinePartsPageText } from './machinePartsPage';
|
|
16
|
+
import useMachinePartsListingStore from 'stores/machinePartsListing/machinePartsListingStore';
|
|
17
|
+
|
|
18
|
+
const MACHINE_PARTS_LISTING_X_OPTIONS = 'hybrid_search.text.reranking=false';
|
|
19
|
+
|
|
20
|
+
/** Scope-only listing: generated item-key text, no keyword, image, or filters. */
|
|
21
|
+
export function machinePartsListingXOptions({
|
|
22
|
+
text,
|
|
23
|
+
hasSearchImage,
|
|
24
|
+
hasFilters,
|
|
25
|
+
scopeId,
|
|
26
|
+
itemKeys,
|
|
27
|
+
keyword,
|
|
28
|
+
pageIndex,
|
|
29
|
+
pageSize,
|
|
30
|
+
}: {
|
|
31
|
+
text?: string;
|
|
32
|
+
hasSearchImage: boolean;
|
|
33
|
+
hasFilters: boolean;
|
|
34
|
+
scopeId: string | null;
|
|
35
|
+
itemKeys: readonly string[];
|
|
36
|
+
keyword: string;
|
|
37
|
+
pageIndex: number;
|
|
38
|
+
pageSize: number;
|
|
39
|
+
}): string | undefined {
|
|
40
|
+
if (!scopeId || hasSearchImage || hasFilters) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const listingText = machinePartsPageText(itemKeys, pageIndex, pageSize);
|
|
44
|
+
if (!listingText || text !== listingText) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (keyword.trim().length > 0) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return MACHINE_PARTS_LISTING_X_OPTIONS;
|
|
51
|
+
}
|
|
13
52
|
|
|
14
53
|
export interface Filter {
|
|
15
54
|
key?: string;
|
|
@@ -53,6 +92,18 @@ export const findRegions = async (
|
|
|
53
92
|
};
|
|
54
93
|
};
|
|
55
94
|
|
|
95
|
+
/** Validated machine scope id. Item keys from /v1/scopes are not used. */
|
|
96
|
+
function appliedMachineScopeId(): string | null {
|
|
97
|
+
if (!window.settings?.scopes?.scopeID) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const { scopeId } = useScopeStore.getState();
|
|
101
|
+
if (typeof scopeId !== 'string' || scopeId.trim().length === 0) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return scopeId;
|
|
105
|
+
}
|
|
106
|
+
|
|
56
107
|
export const find = async ({
|
|
57
108
|
image,
|
|
58
109
|
settings,
|
|
@@ -69,7 +120,8 @@ export const find = async ({
|
|
|
69
120
|
scopeIds?: string;
|
|
70
121
|
}) => {
|
|
71
122
|
const nyrisApi = new NyrisAPI(getNyrisSettings(settings));
|
|
72
|
-
let options: ImageSearchOptions =
|
|
123
|
+
let options: ImageSearchOptions =
|
|
124
|
+
typeof text === 'string' && text.length > 0 ? { text } : {};
|
|
73
125
|
|
|
74
126
|
if (window.settings?.geoLocation) {
|
|
75
127
|
try {
|
|
@@ -85,13 +137,29 @@ export const find = async ({
|
|
|
85
137
|
if (region) {
|
|
86
138
|
options = { ...options, cropRect: region };
|
|
87
139
|
}
|
|
140
|
+
const machineScopeId = appliedMachineScopeId();
|
|
141
|
+
const listingPage = useMachinePartsListingStore.getState();
|
|
142
|
+
const xOptions = machinePartsListingXOptions({
|
|
143
|
+
text,
|
|
144
|
+
hasSearchImage: Boolean(image),
|
|
145
|
+
hasFilters: Boolean(filters && filters.length > 0),
|
|
146
|
+
scopeId: machineScopeId,
|
|
147
|
+
itemKeys: useScopeStore.getState().itemKeys || [],
|
|
148
|
+
keyword: useRequestStore.getState().query || '',
|
|
149
|
+
pageIndex: listingPage.pageIndex,
|
|
150
|
+
pageSize: listingPage.pageSize,
|
|
151
|
+
});
|
|
88
152
|
return nyrisApi.find(
|
|
89
153
|
options,
|
|
90
154
|
image,
|
|
91
155
|
filters,
|
|
92
156
|
undefined,
|
|
93
|
-
|
|
94
|
-
|
|
157
|
+
xOptions,
|
|
158
|
+
machineScopeId
|
|
159
|
+
? [machineScopeId]
|
|
160
|
+
: scopeIds
|
|
161
|
+
? [scopeIds]
|
|
162
|
+
: [],
|
|
95
163
|
);
|
|
96
164
|
};
|
|
97
165
|
|
package/src/services/vizo.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { RectCoords } from '@nyris/nyris-api';
|
|
|
2
2
|
import axios from 'axios';
|
|
3
3
|
import { cropImage } from 'utils/cropImageToBase64';
|
|
4
4
|
import { getEffectiveApiKey } from 'utils/nyrisApiKey';
|
|
5
|
+
import { normalizeChatResponse } from './normalizeChatResponse';
|
|
5
6
|
|
|
6
7
|
function resolveAiOrNyrisApiKey(explicitApiKey?: string): string {
|
|
7
8
|
if (explicitApiKey) {
|
|
@@ -330,20 +331,20 @@ export async function chatAnalysis({
|
|
|
330
331
|
}: ChatAnalysisParams): Promise<any> {
|
|
331
332
|
try {
|
|
332
333
|
const response = await axios.post(
|
|
333
|
-
|
|
334
|
+
`${window.settings.baseUrl}/api/ai-analysis/chat`,
|
|
334
335
|
{
|
|
335
336
|
promptKey,
|
|
336
337
|
multimodalInputs,
|
|
337
338
|
},
|
|
338
339
|
{
|
|
339
340
|
headers: {
|
|
340
|
-
'x-api-key': apiKey
|
|
341
|
+
'x-api-key': resolveAiOrNyrisApiKey(apiKey),
|
|
341
342
|
'Content-Type': 'application/json',
|
|
342
343
|
},
|
|
343
344
|
},
|
|
344
345
|
);
|
|
345
346
|
|
|
346
|
-
return response.data;
|
|
347
|
+
return normalizeChatResponse(response.data);
|
|
347
348
|
} catch (error: any) {
|
|
348
349
|
if (error.response) {
|
|
349
350
|
throw new Error(`API error: ${error.response.statusText}`);
|