@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,129 @@
|
|
|
1
|
+
import { parseScopeIdChatResponse } from './parseScopeIdResponse';
|
|
2
|
+
import {
|
|
3
|
+
identifyTypePlateImage,
|
|
4
|
+
TypePlateExtractResult,
|
|
5
|
+
TypePlateLookupResult,
|
|
6
|
+
} from './identifyTypePlateImage';
|
|
7
|
+
|
|
8
|
+
const file = new File(['plate'], 'plate.jpg', { type: 'image/jpeg' });
|
|
9
|
+
|
|
10
|
+
function extractFromResponse(response: unknown): TypePlateExtractResult {
|
|
11
|
+
const parsed = parseScopeIdChatResponse(response);
|
|
12
|
+
if (parsed.status !== 'extracted') {
|
|
13
|
+
return { status: 'not_found', extractedScopeId: '' };
|
|
14
|
+
}
|
|
15
|
+
return { status: 'extracted', extractedScopeId: parsed.serialNumber };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('identifyTypePlateImage', () => {
|
|
19
|
+
it('does not look up a machine when OCR returns null', async () => {
|
|
20
|
+
const lookup = jest.fn();
|
|
21
|
+
const result = await identifyTypePlateImage({
|
|
22
|
+
file,
|
|
23
|
+
isCurrent: () => true,
|
|
24
|
+
extract: async () => extractFromResponse({ serial_number: null }),
|
|
25
|
+
lookup,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(result).toEqual({ status: 'not_found' });
|
|
29
|
+
expect(lookup).not.toHaveBeenCalled();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns not found when the scopes lookup has no item keys', async () => {
|
|
33
|
+
const lookup = jest.fn(
|
|
34
|
+
async (): Promise<TypePlateLookupResult> => ({ status: 'empty' }),
|
|
35
|
+
);
|
|
36
|
+
const result = await identifyTypePlateImage({
|
|
37
|
+
file,
|
|
38
|
+
isCurrent: () => true,
|
|
39
|
+
extract: async () => extractFromResponse({ serial_number: '2234GH' }),
|
|
40
|
+
lookup,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(lookup).toHaveBeenCalledWith('2234GH');
|
|
44
|
+
expect(result).toEqual({ status: 'not_found' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns not found when the scopes request fails', async () => {
|
|
48
|
+
const result = await identifyTypePlateImage({
|
|
49
|
+
file,
|
|
50
|
+
isCurrent: () => true,
|
|
51
|
+
extract: async () => extractFromResponse({ serial_number: '2234GH' }),
|
|
52
|
+
lookup: async () => {
|
|
53
|
+
throw new Error('network');
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(result).toEqual({ status: 'not_found' });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('identifies a machine only after the lookup succeeds', async () => {
|
|
61
|
+
const result = await identifyTypePlateImage({
|
|
62
|
+
file,
|
|
63
|
+
isCurrent: () => true,
|
|
64
|
+
extract: async () =>
|
|
65
|
+
extractFromResponse({ serial_number: 'Ab-12/c 9' }),
|
|
66
|
+
lookup: async () => ({
|
|
67
|
+
status: 'found',
|
|
68
|
+
itemKeys: ['sku-1'],
|
|
69
|
+
filterAttributes: [],
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result).toEqual({
|
|
74
|
+
status: 'identified',
|
|
75
|
+
serialNumber: 'Ab-12/c 9',
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('ignores an OCR response after a newer attempt or manual switch', async () => {
|
|
80
|
+
let current = true;
|
|
81
|
+
const lookup = jest.fn();
|
|
82
|
+
const result = await identifyTypePlateImage({
|
|
83
|
+
file,
|
|
84
|
+
isCurrent: () => current,
|
|
85
|
+
extract: async () => {
|
|
86
|
+
current = false;
|
|
87
|
+
return extractFromResponse({ serial_number: 'OLD' });
|
|
88
|
+
},
|
|
89
|
+
lookup,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(result).toEqual({ status: 'ignored' });
|
|
93
|
+
expect(lookup).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('ignores a scopes lookup that finishes after the popup closes', async () => {
|
|
97
|
+
let current = true;
|
|
98
|
+
const result = await identifyTypePlateImage({
|
|
99
|
+
file,
|
|
100
|
+
isCurrent: () => current,
|
|
101
|
+
extract: async () => extractFromResponse({ serial_number: '2234GH' }),
|
|
102
|
+
lookup: async () => {
|
|
103
|
+
current = false;
|
|
104
|
+
return {
|
|
105
|
+
status: 'found',
|
|
106
|
+
itemKeys: ['sku-1'],
|
|
107
|
+
filterAttributes: [],
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual({ status: 'ignored' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('ignores a failed OCR response after the attempt is no longer current', async () => {
|
|
116
|
+
let current = true;
|
|
117
|
+
const result = await identifyTypePlateImage({
|
|
118
|
+
file,
|
|
119
|
+
isCurrent: () => current,
|
|
120
|
+
extract: async () => {
|
|
121
|
+
current = false;
|
|
122
|
+
throw new Error('ai failed');
|
|
123
|
+
},
|
|
124
|
+
lookup: async () => ({ status: 'not_found' }),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(result).toEqual({ status: 'ignored' });
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface TypePlateExtractResult {
|
|
2
|
+
extractedScopeId: string;
|
|
3
|
+
status: 'extracted' | 'not_found' | 'unavailable';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type TypePlateLookupResult =
|
|
7
|
+
| {
|
|
8
|
+
status: 'found';
|
|
9
|
+
itemKeys: string[];
|
|
10
|
+
filterAttributes: { key?: string; values: string[] }[];
|
|
11
|
+
}
|
|
12
|
+
| { status: 'empty' | 'not_found' | 'unavailable' };
|
|
13
|
+
|
|
14
|
+
export type TypePlateImageIdentification =
|
|
15
|
+
| { status: 'ignored' }
|
|
16
|
+
| { status: 'identified'; serialNumber: string }
|
|
17
|
+
| { status: 'not_found' }
|
|
18
|
+
| { status: 'unavailable' };
|
|
19
|
+
|
|
20
|
+
interface IdentifyTypePlateImageOptions {
|
|
21
|
+
file: File;
|
|
22
|
+
isCurrent: () => boolean;
|
|
23
|
+
extract: (file: File) => Promise<TypePlateExtractResult>;
|
|
24
|
+
lookup: (serialNumber: string) => Promise<TypePlateLookupResult>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* OCR and the scopes lookup are separate. Confirmation is returned only when
|
|
29
|
+
* the lookup finds item keys. A stale attempt returns `ignored` and must not
|
|
30
|
+
* update the popup.
|
|
31
|
+
*/
|
|
32
|
+
export async function identifyTypePlateImage({
|
|
33
|
+
file,
|
|
34
|
+
isCurrent,
|
|
35
|
+
extract,
|
|
36
|
+
lookup,
|
|
37
|
+
}: IdentifyTypePlateImageOptions): Promise<TypePlateImageIdentification> {
|
|
38
|
+
let extracted: TypePlateExtractResult;
|
|
39
|
+
try {
|
|
40
|
+
extracted = await extract(file);
|
|
41
|
+
} catch {
|
|
42
|
+
if (!isCurrent()) {
|
|
43
|
+
return { status: 'ignored' };
|
|
44
|
+
}
|
|
45
|
+
return { status: 'not_found' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!isCurrent()) {
|
|
49
|
+
return { status: 'ignored' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (extracted.status !== 'extracted') {
|
|
53
|
+
return {
|
|
54
|
+
status: extracted.status === 'unavailable' ? 'unavailable' : 'not_found',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let lookupResult: TypePlateLookupResult;
|
|
59
|
+
try {
|
|
60
|
+
lookupResult = await lookup(extracted.extractedScopeId);
|
|
61
|
+
} catch {
|
|
62
|
+
if (!isCurrent()) {
|
|
63
|
+
return { status: 'ignored' };
|
|
64
|
+
}
|
|
65
|
+
return { status: 'not_found' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!isCurrent()) {
|
|
69
|
+
return { status: 'ignored' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (lookupResult.status !== 'found') {
|
|
73
|
+
return {
|
|
74
|
+
status:
|
|
75
|
+
lookupResult.status === 'unavailable' ? 'unavailable' : 'not_found',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
status: 'identified',
|
|
81
|
+
serialNumber: extracted.extractedScopeId,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { parseScopeIdChatResponse } from './parseScopeIdResponse';
|
|
2
|
+
|
|
3
|
+
describe('parseScopeIdChatResponse', () => {
|
|
4
|
+
it('reads a serial number from the parsed chatAnalysis object', () => {
|
|
5
|
+
expect(parseScopeIdChatResponse({ serial_number: '2234GH' })).toEqual({
|
|
6
|
+
status: 'extracted',
|
|
7
|
+
serialNumber: '2234GH',
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('reads a JSON string', () => {
|
|
12
|
+
expect(
|
|
13
|
+
parseScopeIdChatResponse('{"serial_number":"2234GH"}'),
|
|
14
|
+
).toEqual({
|
|
15
|
+
status: 'extracted',
|
|
16
|
+
serialNumber: '2234GH',
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('reads markdown-fenced JSON', () => {
|
|
21
|
+
expect(
|
|
22
|
+
parseScopeIdChatResponse('```json\n{"serial_number":"2234GH"}\n```'),
|
|
23
|
+
).toEqual({
|
|
24
|
+
status: 'extracted',
|
|
25
|
+
serialNumber: '2234GH',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('preserves case, internal characters, and surrounding spaces', () => {
|
|
30
|
+
expect(
|
|
31
|
+
parseScopeIdChatResponse({ serial_number: ' Ab-12/c 9 ' }),
|
|
32
|
+
).toEqual({
|
|
33
|
+
status: 'extracted',
|
|
34
|
+
serialNumber: ' Ab-12/c 9 ',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('treats null as not found', () => {
|
|
39
|
+
expect(parseScopeIdChatResponse({ serial_number: null })).toEqual({
|
|
40
|
+
status: 'not_found',
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects an empty or whitespace-only value', () => {
|
|
45
|
+
expect(parseScopeIdChatResponse({ serial_number: '' })).toEqual({
|
|
46
|
+
status: 'not_found',
|
|
47
|
+
});
|
|
48
|
+
expect(parseScopeIdChatResponse({ serial_number: ' ' })).toEqual({
|
|
49
|
+
status: 'not_found',
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects invalid value types', () => {
|
|
54
|
+
expect(parseScopeIdChatResponse({ serial_number: 12345 })).toEqual({
|
|
55
|
+
status: 'not_found',
|
|
56
|
+
});
|
|
57
|
+
expect(parseScopeIdChatResponse({ serial_number: false })).toEqual({
|
|
58
|
+
status: 'not_found',
|
|
59
|
+
});
|
|
60
|
+
expect(parseScopeIdChatResponse({ serial_number: {} })).toEqual({
|
|
61
|
+
status: 'not_found',
|
|
62
|
+
});
|
|
63
|
+
expect(parseScopeIdChatResponse({ serial_number: [] })).toEqual({
|
|
64
|
+
status: 'not_found',
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects a missing serial_number property', () => {
|
|
69
|
+
expect(parseScopeIdChatResponse({})).toEqual({ status: 'not_found' });
|
|
70
|
+
expect(
|
|
71
|
+
parseScopeIdChatResponse({ other: '2234GH' }),
|
|
72
|
+
).toEqual({ status: 'not_found' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not use malformed JSON as a serial number', () => {
|
|
76
|
+
expect(parseScopeIdChatResponse('{"serial_number":')).toEqual({
|
|
77
|
+
status: 'not_found',
|
|
78
|
+
});
|
|
79
|
+
expect(
|
|
80
|
+
parseScopeIdChatResponse('```json\n{"serial_number":\n```'),
|
|
81
|
+
).toEqual({ status: 'not_found' });
|
|
82
|
+
expect(
|
|
83
|
+
parseScopeIdChatResponse('Result: {"serial_number":"2234GH"}'),
|
|
84
|
+
).toEqual({ status: 'not_found' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('still accepts a legacy plain serial and NOT_FOUND', () => {
|
|
88
|
+
expect(parseScopeIdChatResponse('2234GH')).toEqual({
|
|
89
|
+
status: 'extracted',
|
|
90
|
+
serialNumber: '2234GH',
|
|
91
|
+
});
|
|
92
|
+
expect(parseScopeIdChatResponse(' Ab-12 ')).toEqual({
|
|
93
|
+
status: 'extracted',
|
|
94
|
+
serialNumber: 'Ab-12',
|
|
95
|
+
});
|
|
96
|
+
expect(parseScopeIdChatResponse('NOT_FOUND')).toEqual({
|
|
97
|
+
status: 'not_found',
|
|
98
|
+
});
|
|
99
|
+
expect(parseScopeIdChatResponse(' NOT_FOUND ')).toEqual({
|
|
100
|
+
status: 'not_found',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { normalizeChatResponse } from 'services/normalizeChatResponse';
|
|
2
|
+
|
|
3
|
+
export type ScopeIdParseResult =
|
|
4
|
+
| { status: 'extracted'; serialNumber: string }
|
|
5
|
+
| { status: 'not_found' };
|
|
6
|
+
|
|
7
|
+
const LEGACY_NOT_FOUND = 'NOT_FOUND';
|
|
8
|
+
|
|
9
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Legacy plain-string responses are still accepted during rollout.
|
|
15
|
+
* Anything that looks like JSON, including a failed parse, is not a serial.
|
|
16
|
+
*/
|
|
17
|
+
function isUnparsedStructuredText(value: string): boolean {
|
|
18
|
+
const trimmed = value.trim();
|
|
19
|
+
return (
|
|
20
|
+
trimmed.startsWith('{') ||
|
|
21
|
+
trimmed.startsWith('[') ||
|
|
22
|
+
trimmed.startsWith('```') ||
|
|
23
|
+
trimmed.includes('{')
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseSerialNumberValue(value: unknown): ScopeIdParseResult {
|
|
28
|
+
if (typeof value !== 'string') {
|
|
29
|
+
return { status: 'not_found' };
|
|
30
|
+
}
|
|
31
|
+
if (value.trim().length === 0 || value.trim() === LEGACY_NOT_FOUND) {
|
|
32
|
+
return { status: 'not_found' };
|
|
33
|
+
}
|
|
34
|
+
return { status: 'extracted', serialNumber: value };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `chatAnalysis` returns `normalizeChatResponse(response.data)`.
|
|
39
|
+
* Axios already parses a JSON body into an object. A string body may still be
|
|
40
|
+
* raw JSON or a markdown fence; `normalizeChatResponse` parses those.
|
|
41
|
+
* There is no extra content wrapper around the model output.
|
|
42
|
+
*
|
|
43
|
+
* Only `serial_number` is read. The string is kept exactly, including case
|
|
44
|
+
* and internal characters. Trimming is used only to reject a blank value.
|
|
45
|
+
*/
|
|
46
|
+
export function parseScopeIdChatResponse(
|
|
47
|
+
response: unknown,
|
|
48
|
+
): ScopeIdParseResult {
|
|
49
|
+
const normalized = normalizeChatResponse(response);
|
|
50
|
+
|
|
51
|
+
if (isRecord(normalized)) {
|
|
52
|
+
if (!Object.prototype.hasOwnProperty.call(normalized, 'serial_number')) {
|
|
53
|
+
return { status: 'not_found' };
|
|
54
|
+
}
|
|
55
|
+
return parseSerialNumberValue(normalized.serial_number);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof normalized !== 'string') {
|
|
59
|
+
return { status: 'not_found' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isUnparsedStructuredText(normalized)) {
|
|
63
|
+
return { status: 'not_found' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const trimmed = normalized.trim();
|
|
67
|
+
if (trimmed.length === 0 || trimmed === LEGACY_NOT_FOUND) {
|
|
68
|
+
return { status: 'not_found' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { status: 'extracted', serialNumber: trimmed };
|
|
72
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ToastHelper } from 'helpers/ToastHelper';
|
|
2
|
+
import { TypePlateScopeResult } from 'hooks/useImageSearch';
|
|
3
|
+
import { ScopeSource, AppliedScopeInput } from 'stores/scope/scopeStore';
|
|
4
|
+
|
|
5
|
+
export type { ScopeSource, AppliedScopeInput };
|
|
6
|
+
|
|
7
|
+
export function notifyScopeResult(
|
|
8
|
+
result: TypePlateScopeResult,
|
|
9
|
+
t: (key: string) => string,
|
|
10
|
+
) {
|
|
11
|
+
if (result.status === 'not_found' || result.status === 'empty') {
|
|
12
|
+
const defaultErrorMessage =
|
|
13
|
+
window.settings.scopes?.typePlatePopupErrorMessage ||
|
|
14
|
+
'The serial number was not found. Please check again.';
|
|
15
|
+
ToastHelper.error(t(defaultErrorMessage));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getImageFileKey(file: File): string {
|
|
20
|
+
return `${file.name}:${file.size}:${file.lastModified}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isSameAppliedInput(
|
|
24
|
+
current: AppliedScopeInput,
|
|
25
|
+
lastApplied: AppliedScopeInput,
|
|
26
|
+
): boolean {
|
|
27
|
+
if (current.source !== lastApplied.source) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (current.source === 'manual' && lastApplied.source === 'manual') {
|
|
31
|
+
return current.value === lastApplied.value;
|
|
32
|
+
}
|
|
33
|
+
return (
|
|
34
|
+
current.source === 'image' &&
|
|
35
|
+
lastApplied.source === 'image' &&
|
|
36
|
+
current.fileKey === lastApplied.fileKey
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function toImageFile(
|
|
41
|
+
image: File | string | null | undefined,
|
|
42
|
+
): Promise<File | null> {
|
|
43
|
+
if (image instanceof File) {
|
|
44
|
+
return image;
|
|
45
|
+
}
|
|
46
|
+
if (typeof image !== 'string' || image.length === 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const response = await fetch(image);
|
|
50
|
+
const blob = await response.blob();
|
|
51
|
+
return new File([blob], 'type-plate.jpg', {
|
|
52
|
+
type: blob.type || 'image/jpeg',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
1
2
|
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
import { Icon } from '@nyris/nyris-react-components';
|
|
2
4
|
|
|
3
5
|
import ImagePreview from './ImagePreview';
|
|
4
6
|
import useRequestStore from 'stores/request/requestStore';
|
|
@@ -14,6 +16,7 @@ export default function SidePanel({ className }: { className?: string }) {
|
|
|
14
16
|
const settings = window.settings;
|
|
15
17
|
const isSmartFiltersEnabled = !!settings.useSmartFilters;
|
|
16
18
|
const requestImages = useRequestStore(state => state.requestImages);
|
|
19
|
+
const [isRequestQualityExpanded, setIsRequestQualityExpanded] = useState(true);
|
|
17
20
|
|
|
18
21
|
const specificationFilter = useRequestStore(
|
|
19
22
|
state => state.specificationFilter,
|
|
@@ -27,6 +30,7 @@ export default function SidePanel({ className }: { className?: string }) {
|
|
|
27
30
|
const showSidePanel = useUiStore(state => state.showSidePanel);
|
|
28
31
|
|
|
29
32
|
const imageAnalysis = useResultStore(state => state.imageAnalysis);
|
|
33
|
+
const requestQuality = useResultStore(state => state.requestQuality);
|
|
30
34
|
|
|
31
35
|
const showPostFilter = window.settings?.postFilterOption;
|
|
32
36
|
|
|
@@ -153,6 +157,7 @@ export default function SidePanel({ className }: { className?: string }) {
|
|
|
153
157
|
}
|
|
154
158
|
return (
|
|
155
159
|
<IdentifiedAttributes
|
|
160
|
+
key={key}
|
|
156
161
|
attr={key}
|
|
157
162
|
value={value}
|
|
158
163
|
specificationFilter={specificationFilter}
|
|
@@ -171,6 +176,34 @@ export default function SidePanel({ className }: { className?: string }) {
|
|
|
171
176
|
/>
|
|
172
177
|
)}
|
|
173
178
|
|
|
179
|
+
{window.settings.requestQuality && requestQuality && (
|
|
180
|
+
<div className="mx-4 rounded-3xl bg-[#F3F4F8] border border-[#e4e3ff] p-3.5 text-[#3B3E5F]">
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
className="w-full flex items-center justify-between gap-2"
|
|
184
|
+
onClick={() =>
|
|
185
|
+
setIsRequestQualityExpanded(prev => !prev)
|
|
186
|
+
}
|
|
187
|
+
>
|
|
188
|
+
<span className="text-[#3B3E5F] font-bold text-sm leading-4">
|
|
189
|
+
Request Image Quality
|
|
190
|
+
</span>
|
|
191
|
+
<Icon
|
|
192
|
+
name="arrow_down"
|
|
193
|
+
className={twMerge(
|
|
194
|
+
'fill-[#3B3E5F] w-3 h-3 shrink-0 transition-transform',
|
|
195
|
+
isRequestQualityExpanded && 'rotate-180',
|
|
196
|
+
)}
|
|
197
|
+
/>
|
|
198
|
+
</button>
|
|
199
|
+
{isRequestQualityExpanded && (
|
|
200
|
+
<div className="text-[#767A9F] text-sm mt-2.5">
|
|
201
|
+
{requestQuality}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
174
207
|
{(isGoogleGroundingLoading ||
|
|
175
208
|
googleGroundingResponse ||
|
|
176
209
|
groundingError) && (
|
|
@@ -8,11 +8,20 @@ import { Icon } from '@nyris/nyris-react-components';
|
|
|
8
8
|
import { useImageSearch } from 'hooks/useImageSearch';
|
|
9
9
|
import PreFilterModal from './PreFilter/PreFilterModal';
|
|
10
10
|
import useRequestStore from 'stores/request/requestStore';
|
|
11
|
+
import { hasActiveMachineScope } from 'stores/scope/scopeStore';
|
|
12
|
+
import { firstMachinePartsListingText } from 'stores/machinePartsListing/machinePartsListingStore';
|
|
11
13
|
import Tooltip from './Tooltip/TooltipComponent';
|
|
12
14
|
import { useMediaQuery } from 'react-responsive';
|
|
13
15
|
import ImageUpload from './ImageUpload';
|
|
16
|
+
import { TypePlateUpload } from './TypePlateUpload';
|
|
14
17
|
import useUiStore from 'stores/ui/uiStore';
|
|
15
18
|
import useConversationStore from 'stores/chat/conversationStore';
|
|
19
|
+
import { trackClick } from 'services/Feedback';
|
|
20
|
+
import { CLICK_SUBTYPE } from 'services/feedbackSubtypes';
|
|
21
|
+
|
|
22
|
+
function machinePartsListingText() {
|
|
23
|
+
return firstMachinePartsListingText();
|
|
24
|
+
}
|
|
16
25
|
|
|
17
26
|
function TextSearch({
|
|
18
27
|
className,
|
|
@@ -63,7 +72,11 @@ function TextSearch({
|
|
|
63
72
|
useState<boolean>(false);
|
|
64
73
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
|
65
74
|
|
|
75
|
+
const isScopeMode = !!settings.scopes?.scopeID;
|
|
66
76
|
const showPreFilter = useMemo(() => {
|
|
77
|
+
if (settings.scopes?.scopeID) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
67
80
|
if (settings.shouldUseUserMetadata && user) {
|
|
68
81
|
if (user['/user_metadata']?.value) {
|
|
69
82
|
setMetaFilter(user['/user_metadata'].value);
|
|
@@ -81,23 +94,38 @@ function TextSearch({
|
|
|
81
94
|
}, [
|
|
82
95
|
setMetaFilter,
|
|
83
96
|
settings.preFilterOption,
|
|
97
|
+
settings.scopes?.scopeID,
|
|
84
98
|
settings.shouldUseUserMetadata,
|
|
85
99
|
user,
|
|
86
100
|
]);
|
|
87
101
|
|
|
88
102
|
const visualSearch = useMemo(() => requestImages.length > 0, [requestImages]);
|
|
89
|
-
const { singleImageSearch } = useImageSearch();
|
|
103
|
+
const { singleImageSearch, searchMachinePartsListing } = useImageSearch();
|
|
90
104
|
|
|
91
105
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
106
|
const searchOrRedirect = useCallback(
|
|
93
107
|
debounce((value: any) => {
|
|
94
108
|
setQuery(value);
|
|
95
|
-
if (requestImages.length === 0 && value === ''
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
if (requestImages.length === 0 && value === '') {
|
|
110
|
+
if (hasActiveMachineScope()) {
|
|
111
|
+
searchMachinePartsListing({
|
|
112
|
+
text: machinePartsListingText() || '',
|
|
113
|
+
showFeedback: true,
|
|
114
|
+
clearPostFilters: true,
|
|
115
|
+
});
|
|
116
|
+
navigate('/result');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!specifications) {
|
|
120
|
+
navigate('/');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
98
123
|
}
|
|
99
124
|
|
|
100
|
-
if (
|
|
125
|
+
if (
|
|
126
|
+
requestImages.length === 0 &&
|
|
127
|
+
(hasActiveMachineScope() || !window.settings?.algolia.enabled)
|
|
128
|
+
) {
|
|
101
129
|
singleImageSearch({
|
|
102
130
|
image: requestImages[0],
|
|
103
131
|
imageRegion: regions[0],
|
|
@@ -114,10 +142,28 @@ function TextSearch({
|
|
|
114
142
|
);
|
|
115
143
|
|
|
116
144
|
const onChangeText = (event: any) => {
|
|
117
|
-
|
|
118
|
-
|
|
145
|
+
const nextValue = event.currentTarget.value;
|
|
146
|
+
setValueInput(nextValue);
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
nextValue === '' &&
|
|
150
|
+
requestImages.length === 0 &&
|
|
151
|
+
hasActiveMachineScope()
|
|
152
|
+
) {
|
|
153
|
+
searchOrRedirect.cancel();
|
|
154
|
+
setQuery('');
|
|
155
|
+
searchMachinePartsListing({
|
|
156
|
+
text: machinePartsListingText() || '',
|
|
157
|
+
showFeedback: true,
|
|
158
|
+
clearPostFilters: true,
|
|
159
|
+
});
|
|
160
|
+
navigate('/result');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
119
163
|
|
|
120
|
-
|
|
164
|
+
searchOrRedirect(nextValue);
|
|
165
|
+
|
|
166
|
+
if (nextValue === '' && !specifications) {
|
|
121
167
|
setValueInput('');
|
|
122
168
|
setQuery('');
|
|
123
169
|
}
|
|
@@ -179,6 +225,11 @@ function TextSearch({
|
|
|
179
225
|
showPreFilter ? setToggleModalFilterDesktop(true) : false
|
|
180
226
|
}
|
|
181
227
|
>
|
|
228
|
+
{isScopeMode && (
|
|
229
|
+
<TypePlateUpload
|
|
230
|
+
iconOnly={location.pathname === '/result'}
|
|
231
|
+
/>
|
|
232
|
+
)}
|
|
182
233
|
{showPreFilter && (
|
|
183
234
|
<Tooltip
|
|
184
235
|
content={
|
|
@@ -342,7 +393,7 @@ function TextSearch({
|
|
|
342
393
|
{nameplateNotificationText}
|
|
343
394
|
</div>
|
|
344
395
|
)}
|
|
345
|
-
{!showPreFilter && (
|
|
396
|
+
{!isScopeMode && !showPreFilter && (
|
|
346
397
|
<div className="p-2 hidden desktop:block">
|
|
347
398
|
<Icon name="search" width={16} height={16} />
|
|
348
399
|
</div>
|
|
@@ -390,7 +441,7 @@ function TextSearch({
|
|
|
390
441
|
location.pathname === '/result' &&
|
|
391
442
|
'desktop:bg-[#E7E8F1] focus:bg-[#FFFFFF]',
|
|
392
443
|
)}
|
|
393
|
-
placeholder={t('Search')}
|
|
444
|
+
placeholder={t('Search part names, SKU, or description...')}
|
|
394
445
|
value={valueInput}
|
|
395
446
|
onChange={onChangeText}
|
|
396
447
|
onFocus={() => setIsInputFocused(true)}
|
|
@@ -419,6 +470,7 @@ function TextSearch({
|
|
|
419
470
|
event.preventDefault();
|
|
420
471
|
}}
|
|
421
472
|
onClick={() => {
|
|
473
|
+
searchOrRedirect.cancel();
|
|
422
474
|
if (visualSearch) {
|
|
423
475
|
navigate('/result');
|
|
424
476
|
setValueInput('');
|
|
@@ -438,6 +490,17 @@ function TextSearch({
|
|
|
438
490
|
}
|
|
439
491
|
return;
|
|
440
492
|
}
|
|
493
|
+
if (hasActiveMachineScope()) {
|
|
494
|
+
setQuery('');
|
|
495
|
+
setValueInput('');
|
|
496
|
+
searchMachinePartsListing({
|
|
497
|
+
text: machinePartsListingText() || '',
|
|
498
|
+
showFeedback: false,
|
|
499
|
+
clearPostFilters: true,
|
|
500
|
+
});
|
|
501
|
+
navigate('/result');
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
441
504
|
setSpecifications(null);
|
|
442
505
|
setQuery('');
|
|
443
506
|
setValueInput('');
|
|
@@ -451,7 +514,7 @@ function TextSearch({
|
|
|
451
514
|
{showImageUpload && <ImageUpload onCameraClick={onCameraClick} />}
|
|
452
515
|
{aiMode &&
|
|
453
516
|
window.settings.vizo?.chat &&
|
|
454
|
-
requestImages.length !== 0 &&
|
|
517
|
+
(requestImages.length !== 0 || valueInput) &&
|
|
455
518
|
!(isMobile && isInputFocused) && (
|
|
456
519
|
<div className="relative">
|
|
457
520
|
<button
|
|
@@ -467,6 +530,9 @@ function TextSearch({
|
|
|
467
530
|
)}
|
|
468
531
|
onClick={() => {
|
|
469
532
|
const nextIsAiModeOpen = !isAiModeOpen;
|
|
533
|
+
if (nextIsAiModeOpen) {
|
|
534
|
+
trackClick(CLICK_SUBTYPE.CHAT_WITH_VIZO);
|
|
535
|
+
}
|
|
470
536
|
toggleAiMode();
|
|
471
537
|
setIsChatOpen(nextIsAiModeOpen);
|
|
472
538
|
}}
|