(({ columnCount }) => {
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
}}
>
- {Array.from({ length: 12 }).map((_, index) => (
+ {Array.from({ length: itemCount }).map((_, index) => (
))}
diff --git a/src/features/FileManager/FileList/ToolBar/MultiSelectActions.tsx b/src/features/FileManager/FileList/ToolBar/MultiSelectActions.tsx
index 831e0db6ecb..3343d161f31 100644
--- a/src/features/FileManager/FileList/ToolBar/MultiSelectActions.tsx
+++ b/src/features/FileManager/FileList/ToolBar/MultiSelectActions.tsx
@@ -148,7 +148,7 @@ const MultiSelectActions = memo
(
size={'small'}
variant={'filled'}
>
- {t('batchDelete', { ns: 'common' })}
+ {t('delete', { ns: 'common' })}
)}
diff --git a/src/features/FileManager/FileList/index.tsx b/src/features/FileManager/FileList/index.tsx
index 2763bf46ff2..ee9fa7c1768 100644
--- a/src/features/FileManager/FileList/index.tsx
+++ b/src/features/FileManager/FileList/index.tsx
@@ -51,10 +51,15 @@ const FileList = memo(({ knowledgeBaseId, category }) => {
const [selectFileIds, setSelectedFileIds] = useState([]);
const [viewConfig, setViewConfig] = useState({ showFilesInKnowledgeBase: false });
+ const [lastSelectedIndex, setLastSelectedIndex] = useState(null);
+ const [isTransitioning, setIsTransitioning] = useState(false);
const viewMode = useGlobalStore((s) => s.status.fileManagerViewMode || 'list') as ViewMode;
const updateSystemStatus = useGlobalStore((s) => s.updateSystemStatus);
- const setViewMode = (mode: ViewMode) => updateSystemStatus({ fileManagerViewMode: mode });
+ const setViewMode = (mode: ViewMode) => {
+ setIsTransitioning(true);
+ updateSystemStatus({ fileManagerViewMode: mode });
+ };
const [columnCount, setColumnCount] = useState(4);
@@ -105,6 +110,19 @@ const FileList = memo(({ knowledgeBaseId, category }) => {
...viewConfig,
});
+ // Handle view transition with a brief delay to show skeleton
+ React.useEffect(() => {
+ if (isTransitioning && data) {
+ // Use requestAnimationFrame to ensure smooth transition
+ requestAnimationFrame(() => {
+ const timer = setTimeout(() => {
+ setIsTransitioning(false);
+ }, 100);
+ return () => clearTimeout(timer);
+ });
+ }
+ }, [isTransitioning, viewMode, data]);
+
useCheckTaskStatus(data);
// Clean up selected files that no longer exist in the data
@@ -118,6 +136,13 @@ const FileList = memo(({ knowledgeBaseId, category }) => {
}
}, [data]);
+ // Reset lastSelectedIndex when selection is cleared
+ React.useEffect(() => {
+ if (selectFileIds.length === 0) {
+ setLastSelectedIndex(null);
+ }
+ }, [selectFileIds.length]);
+
// Memoize context object to avoid recreating on every render
const masonryContext = useMemo(
() => ({
@@ -161,7 +186,7 @@ const FileList = memo(({ knowledgeBaseId, category }) => {
)}
- {isLoading ? (
+ {isLoading || isTransitioning ? (
viewMode === 'masonry' ? (
) : (
@@ -184,13 +209,30 @@ const FileList = memo(({ knowledgeBaseId, category }) => {
index={index}
key={item.id}
knowledgeBaseId={knowledgeBaseId}
- onSelectedChange={(id, checked) => {
- setSelectedFileIds((prev) => {
- if (checked) {
- return [...prev, id];
- }
- return prev.filter((item) => item !== id);
- });
+ onSelectedChange={(id, checked, shiftKey, clickedIndex) => {
+ if (shiftKey && lastSelectedIndex !== null && selectFileIds.length > 0 && data) {
+ // Range selection with shift key
+ const start = Math.min(lastSelectedIndex, clickedIndex);
+ const end = Math.max(lastSelectedIndex, clickedIndex);
+ const rangeIds = data.slice(start, end + 1).map((item) => item.id);
+
+ setSelectedFileIds((prev) => {
+ // Create a Set for efficient lookup
+ const prevSet = new Set(prev);
+ // Add all items in range
+ rangeIds.forEach((rangeId) => prevSet.add(rangeId));
+ return Array.from(prevSet);
+ });
+ } else {
+ // Normal selection
+ setSelectedFileIds((prev) => {
+ if (checked) {
+ return [...prev, id];
+ }
+ return prev.filter((item) => item !== id);
+ });
+ }
+ setLastSelectedIndex(clickedIndex);
}}
selected={selectFileIds.includes(item.id)}
{...item}
diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts
index c9a2f8c5e6e..543ca14d0e4 100644
--- a/src/locales/default/chat.ts
+++ b/src/locales/default/chat.ts
@@ -175,9 +175,9 @@ export default {
addMember: '添加成员',
allMembers: '全体成员',
createGroup: '创建 Agent 团队',
- noAvailableAgents: '没有可邀请的助手',
- noSelectedAgents: '还未选择助手',
- searchAgents: '搜索助手...',
+ noAvailableAgents: '没有可邀请的 Agent',
+ noSelectedAgents: '还未选择 Agent',
+ searchAgents: '搜索 Agent...',
setInitialMembers: '选择团队成员',
},
@@ -247,7 +247,7 @@ export default {
jumpToMessage: '跳转至第 {{index}} 条消息',
nextMessage: '下一条消息',
previousMessage: '上一条消息',
- senderAssistant: '助手',
+ senderAssistant: 'Agent',
senderUser: '你',
},
diff --git a/src/locales/default/file.ts b/src/locales/default/file.ts
index 39fb8b8b3ab..c84d89b9e8c 100644
--- a/src/locales/default/file.ts
+++ b/src/locales/default/file.ts
@@ -86,6 +86,7 @@ export default {
restTime: '剩余 {{time}}',
},
},
+ fileQueueInfo: '正在上传前 {{count}} 个文件,剩余 {{remaining}} 个文件将排队上传',
totalCount: '共 {{count}} 项',
uploadStatus: {
error: '上传出错',
diff --git a/src/store/file/slices/fileManager/action.test.ts b/src/store/file/slices/fileManager/action.test.ts
index 55ac7a23246..304ed1d4347 100644
--- a/src/store/file/slices/fileManager/action.test.ts
+++ b/src/store/file/slices/fileManager/action.test.ts
@@ -2,17 +2,50 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { mutate } from 'swr';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { FILE_UPLOAD_BLACKLIST } from '@/const/file';
+import { message } from '@/components/AntdStaticMethods';
+import { FILE_UPLOAD_BLACKLIST, MAX_UPLOAD_FILE_COUNT } from '@/const/file';
import { lambdaClient } from '@/libs/trpc/client';
import { fileService } from '@/services/file';
import { ragService } from '@/services/rag';
import { FileListItem } from '@/types/files';
import { UploadFileItem } from '@/types/files/upload';
+import { unzipFile } from '@/utils/unzipFile';
import { useFileStore as useStore } from '../../store';
vi.mock('zustand/traditional');
+// Mock i18next translation function
+vi.mock('i18next', () => ({
+ t: (key: string, options?: any) => {
+ // Return a mock translation string that includes the options for verification
+ if (key === 'uploadDock.fileQueueInfo' && options?.count !== undefined) {
+ return `Uploading ${options.count} files, ${options.remaining} queued`;
+ }
+ return key;
+ },
+}));
+
+// Mock message
+vi.mock('@/components/AntdStaticMethods', () => ({
+ message: {
+ info: vi.fn(),
+ warning: vi.fn(),
+ },
+}));
+
+// Mock unzipFile
+vi.mock('@/utils/unzipFile', () => ({
+ unzipFile: vi.fn(),
+}));
+
+// Mock p-map to run sequentially for easier testing
+vi.mock('p-map', () => ({
+ default: vi.fn(async (items, mapper) => {
+ return Promise.all(items.map(mapper));
+ }),
+}));
+
// Mock SWR
vi.mock('swr', async () => {
const actual = await vi.importActual('swr');
@@ -398,6 +431,108 @@ describe('FileManagerActions', () => {
// Should not auto-parse when upload returns undefined
expect(parseSpy).not.toHaveBeenCalled();
});
+
+ it('should enforce file count limit and queue excess files', async () => {
+ const { result } = renderHook(() => useStore());
+
+ // Create more files than the limit
+ const totalFiles = MAX_UPLOAD_FILE_COUNT + 5;
+ const files = Array.from(
+ { length: totalFiles },
+ (_, i) => new File(['content'], `file-${i}.txt`, { type: 'text/plain' }),
+ );
+
+ vi.spyOn(result.current, 'uploadWithProgress').mockResolvedValue({
+ id: 'file-1',
+ url: 'http://example.com/file-1',
+ });
+ vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
+ vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
+ const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList');
+
+ await act(async () => {
+ await result.current.pushDockFileList(files);
+ });
+
+ // Should add all files to dock (not just first MAX_UPLOAD_FILE_COUNT)
+ expect(dispatchSpy).toHaveBeenCalledWith({
+ atStart: true,
+ files: expect.arrayContaining([
+ expect.objectContaining({ file: expect.any(File), status: 'pending' }),
+ ]),
+ type: 'addFiles',
+ });
+
+ // Verify all files were dispatched
+ const dispatchCall = dispatchSpy.mock.calls.find((call) => call[0].type === 'addFiles');
+ expect(dispatchCall?.[0]).toHaveProperty('files');
+ if (dispatchCall && 'files' in dispatchCall[0]) {
+ expect(dispatchCall[0].files).toHaveLength(totalFiles);
+ }
+ });
+
+ it('should extract ZIP files and upload contents', async () => {
+ const { result } = renderHook(() => useStore());
+
+ const zipFile = new File(['zip content'], 'archive.zip', { type: 'application/zip' });
+ const extractedFiles = [
+ new File(['file1'], 'file1.txt', { type: 'text/plain' }),
+ new File(['file2'], 'file2.txt', { type: 'text/plain' }),
+ ];
+
+ vi.mocked(unzipFile).mockResolvedValue(extractedFiles);
+ vi.spyOn(result.current, 'uploadWithProgress').mockResolvedValue({
+ id: 'file-1',
+ url: 'http://example.com/file-1',
+ });
+ vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
+ vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
+ const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList');
+
+ await act(async () => {
+ await result.current.pushDockFileList([zipFile]);
+ });
+
+ // Should extract ZIP file
+ expect(unzipFile).toHaveBeenCalledWith(zipFile);
+
+ // Should upload extracted files
+ expect(dispatchSpy).toHaveBeenCalledWith({
+ atStart: true,
+ files: extractedFiles.map((file) => ({ file, id: file.name, status: 'pending' })),
+ type: 'addFiles',
+ });
+ });
+
+ it('should handle ZIP extraction errors gracefully', async () => {
+ const { result } = renderHook(() => useStore());
+
+ const zipFile = new File(['zip content'], 'archive.zip', { type: 'application/zip' });
+
+ vi.mocked(unzipFile).mockRejectedValue(new Error('Extraction failed'));
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(result.current, 'uploadWithProgress').mockResolvedValue({
+ id: 'file-1',
+ url: 'http://example.com/file-1',
+ });
+ vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
+ vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
+ const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList');
+
+ await act(async () => {
+ await result.current.pushDockFileList([zipFile]);
+ });
+
+ // Should log error
+ expect(consoleErrorSpy).toHaveBeenCalled();
+
+ // Should fallback to uploading the ZIP file itself
+ expect(dispatchSpy).toHaveBeenCalledWith({
+ atStart: true,
+ files: [{ file: zipFile, id: zipFile.name, status: 'pending' }],
+ type: 'addFiles',
+ });
+ });
});
describe('reEmbeddingChunks', () => {
diff --git a/src/store/file/slices/fileManager/action.ts b/src/store/file/slices/fileManager/action.ts
index f39061e4a9f..690cb1b775d 100644
--- a/src/store/file/slices/fileManager/action.ts
+++ b/src/store/file/slices/fileManager/action.ts
@@ -1,7 +1,8 @@
+import pMap from 'p-map';
import { SWRResponse, mutate } from 'swr';
import { StateCreator } from 'zustand/vanilla';
-import { FILE_UPLOAD_BLACKLIST } from '@/const/file';
+import { FILE_UPLOAD_BLACKLIST, MAX_UPLOAD_FILE_COUNT } from '@/const/file';
import { useClientDataSWR } from '@/libs/swr';
import { fileService } from '@/services/file';
import { ServerService } from '@/services/file/server';
@@ -12,6 +13,7 @@ import {
} from '@/store/file/reducers/uploadFileList';
import { FileListItem, QueryFileListParams } from '@/types/files';
import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported';
+import { unzipFile } from '@/utils/unzipFile';
import { FileStore } from '../../store';
import { fileManagerSelectors } from './selectors';
@@ -89,18 +91,37 @@ export const createFileManageSlice: StateCreator<
pushDockFileList: async (rawFiles, knowledgeBaseId) => {
const { dispatchDockFileList } = get();
- // 0. skip file in blacklist
- const files = rawFiles.filter((file) => !FILE_UPLOAD_BLACKLIST.includes(file.name));
+ // 0. Process ZIP files and extract their contents
+ const filesToUpload: File[] = [];
+ for (const file of rawFiles) {
+ if (file.type === 'application/zip' || file.name.endsWith('.zip')) {
+ try {
+ const extractedFiles = await unzipFile(file);
+ filesToUpload.push(...extractedFiles);
+ } catch (error) {
+ console.error('Failed to extract ZIP file:', error);
+ // If extraction fails, treat it as a regular file
+ filesToUpload.push(file);
+ }
+ } else {
+ filesToUpload.push(file);
+ }
+ }
- // 1. add files
+ // 1. skip file in blacklist
+ const files = filesToUpload.filter((file) => !FILE_UPLOAD_BLACKLIST.includes(file.name));
+
+ // 2. Add all files to dock
dispatchDockFileList({
atStart: true,
files: files.map((file) => ({ file, id: file.name, status: 'pending' })),
type: 'addFiles',
});
- const uploadResults = await Promise.all(
- files.map(async (file) => {
+ // 3. Upload files with concurrency limit using p-map
+ const uploadResults = await pMap(
+ files,
+ async (file) => {
const result = await get().uploadWithProgress({
file,
knowledgeBaseId,
@@ -110,10 +131,11 @@ export const createFileManageSlice: StateCreator<
await get().refreshFileList();
return { file, fileId: result?.id, fileType: file.type };
- }),
+ },
+ { concurrency: MAX_UPLOAD_FILE_COUNT },
);
- // 2. auto-embed files that support chunking
+ // 4. auto-embed files that support chunking
const fileIdsToEmbed = uploadResults
.filter(({ fileType, fileId }) => fileId && !isChunkingUnsupported(fileType))
.map(({ fileId }) => fileId!);
diff --git a/src/utils/unzipFile.test.ts b/src/utils/unzipFile.test.ts
new file mode 100644
index 00000000000..0940160f6cf
--- /dev/null
+++ b/src/utils/unzipFile.test.ts
@@ -0,0 +1,128 @@
+import { zip } from 'fflate';
+import { describe, expect, it } from 'vitest';
+
+import { unzipFile } from './unzipFile';
+
+describe('unzipFile', () => {
+ it('should extract files from a ZIP archive', async () => {
+ // Create a mock ZIP file with test data
+ const testFiles = {
+ 'test.txt': new TextEncoder().encode('Hello, World!'),
+ 'folder/nested.txt': new TextEncoder().encode('Nested file content'),
+ };
+
+ const zipped = await new Promise((resolve, reject) => {
+ zip(testFiles, (error, data) => {
+ if (error) reject(error);
+ else resolve(data);
+ });
+ });
+
+ const zipFile = new File([new Uint8Array(zipped)], 'test.zip', { type: 'application/zip' });
+
+ const extractedFiles = await unzipFile(zipFile);
+
+ expect(extractedFiles).toHaveLength(2);
+ expect(extractedFiles[0].name).toBe('test.txt');
+ expect(extractedFiles[1].name).toBe('nested.txt');
+
+ // Verify file contents
+ const content1 = await extractedFiles[0].text();
+ expect(content1).toBe('Hello, World!');
+
+ const content2 = await extractedFiles[1].text();
+ expect(content2).toBe('Nested file content');
+ });
+
+ it('should skip directories in ZIP archive', async () => {
+ const testFiles = {
+ 'file.txt': new TextEncoder().encode('File content'),
+ 'folder/': new Uint8Array(0), // Directory entry
+ };
+
+ const zipped = await new Promise((resolve, reject) => {
+ zip(testFiles, (error, data) => {
+ if (error) reject(error);
+ else resolve(data);
+ });
+ });
+
+ const zipFile = new File([new Uint8Array(zipped)], 'test.zip', { type: 'application/zip' });
+
+ const extractedFiles = await unzipFile(zipFile);
+
+ expect(extractedFiles).toHaveLength(1);
+ expect(extractedFiles[0].name).toBe('file.txt');
+ });
+
+ it('should skip hidden files and __MACOSX directories', async () => {
+ const testFiles = {
+ '.hidden': new TextEncoder().encode('Hidden file'),
+ '__MACOSX/._file.txt': new TextEncoder().encode('Mac metadata'),
+ 'visible.txt': new TextEncoder().encode('Visible file'),
+ };
+
+ const zipped = await new Promise((resolve, reject) => {
+ zip(testFiles, (error, data) => {
+ if (error) reject(error);
+ else resolve(data);
+ });
+ });
+
+ const zipFile = new File([new Uint8Array(zipped)], 'test.zip', { type: 'application/zip' });
+
+ const extractedFiles = await unzipFile(zipFile);
+
+ expect(extractedFiles).toHaveLength(1);
+ expect(extractedFiles[0].name).toBe('visible.txt');
+ });
+
+ it('should set correct MIME types for extracted files', async () => {
+ const testFiles = {
+ 'document.pdf': new TextEncoder().encode('PDF content'),
+ 'image.png': new TextEncoder().encode('PNG content'),
+ 'code.ts': new TextEncoder().encode('TypeScript code'),
+ };
+
+ const zipped = await new Promise((resolve, reject) => {
+ zip(testFiles, (error, data) => {
+ if (error) reject(error);
+ else resolve(data);
+ });
+ });
+
+ const zipFile = new File([new Uint8Array(zipped)], 'test.zip', { type: 'application/zip' });
+
+ const extractedFiles = await unzipFile(zipFile);
+
+ expect(extractedFiles).toHaveLength(3);
+ expect(extractedFiles.find((f) => f.name === 'document.pdf')?.type).toBe('application/pdf');
+ expect(extractedFiles.find((f) => f.name === 'image.png')?.type).toBe('image/png');
+ expect(extractedFiles.find((f) => f.name === 'code.ts')?.type).toBe('text/typescript');
+ });
+
+ it('should handle empty ZIP files', async () => {
+ const testFiles = {};
+
+ const zipped = await new Promise((resolve, reject) => {
+ zip(testFiles, (error, data) => {
+ if (error) reject(error);
+ else resolve(data);
+ });
+ });
+
+ const zipFile = new File([new Uint8Array(zipped)], 'empty.zip', { type: 'application/zip' });
+
+ const extractedFiles = await unzipFile(zipFile);
+
+ expect(extractedFiles).toHaveLength(0);
+ });
+
+ it('should reject on invalid ZIP file', async () => {
+ const invalidFile = new File([new Uint8Array([1, 2, 3, 4])], 'invalid.zip', {
+ type: 'application/zip',
+ });
+
+ await expect(unzipFile(invalidFile)).rejects.toThrow();
+ });
+});
diff --git a/src/utils/unzipFile.ts b/src/utils/unzipFile.ts
new file mode 100644
index 00000000000..c2b1c0d3a8c
--- /dev/null
+++ b/src/utils/unzipFile.ts
@@ -0,0 +1,122 @@
+import { unzip } from 'fflate';
+
+/**
+ * Determines the MIME type based on file extension
+ */
+const getFileType = (fileName: string): string => {
+ const extension = fileName.split('.').pop()?.toLowerCase() || '';
+
+ const mimeTypes: Record = {
+ // Images
+ bmp: 'image/bmp',
+
+ // Code files
+ c: 'text/x-c',
+
+ cpp: 'text/x-c++',
+
+ cs: 'text/x-csharp',
+
+ css: 'text/css',
+
+ // Documents
+ csv: 'text/csv',
+
+ doc: 'application/msword',
+
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+
+ gif: 'image/gif',
+
+ go: 'text/x-go',
+
+ html: 'text/html',
+
+ java: 'text/x-java',
+
+ jpeg: 'image/jpeg',
+
+ jpg: 'image/jpeg',
+
+ js: 'text/javascript',
+
+ json: 'application/json',
+
+ jsx: 'text/javascript',
+
+ md: 'text/markdown',
+
+ pdf: 'application/pdf',
+
+ php: 'application/x-httpd-php',
+
+ png: 'image/png',
+
+ ppt: 'application/vnd.ms-powerpoint',
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ py: 'text/x-python',
+ rb: 'text/x-ruby',
+ rs: 'text/x-rust',
+ rtf: 'application/rtf',
+ sh: 'application/x-sh',
+ svg: 'image/svg+xml',
+ ts: 'text/typescript',
+ tsx: 'text/typescript',
+ txt: 'text/plain',
+ webp: 'image/webp',
+ xls: 'application/vnd.ms-excel',
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ xml: 'application/xml',
+ };
+
+ return mimeTypes[extension] || 'application/octet-stream';
+};
+
+/**
+ * Extracts files from a ZIP archive
+ * @param zipFile - The ZIP file to extract
+ * @returns Promise that resolves to an array of extracted Files
+ */
+export const unzipFile = async (zipFile: File): Promise => {
+ return new Promise((resolve, reject) => {
+ zipFile
+ .arrayBuffer()
+ .then((arrayBuffer) => {
+ const uint8Array = new Uint8Array(arrayBuffer);
+
+ unzip(uint8Array, (error, unzipped) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+
+ const extractedFiles: File[] = [];
+
+ for (const [path, data] of Object.entries(unzipped)) {
+ // Skip directories and hidden files
+ if (path.endsWith('/') || path.includes('__MACOSX') || path.startsWith('.')) {
+ continue;
+ }
+
+ // Get the filename from the path
+ const fileName = path.split('/').pop() || path;
+
+ // Create a File object from the extracted data
+ const blob = new Blob([new Uint8Array(data)], {
+ type: getFileType(fileName),
+ });
+ const file = new File([blob], fileName, {
+ type: getFileType(fileName),
+ });
+
+ extractedFiles.push(file);
+ }
+
+ resolve(extractedFiles);
+ });
+ })
+ .catch(() => {
+ reject(new Error('Failed to read ZIP file'));
+ });
+ });
+};
diff --git a/vitest.config.mts b/vitest.config.mts
index 89f4fa69990..57c3271aa9b 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -15,6 +15,7 @@ export default defineConfig({
'@/const/locale': resolve(__dirname, './src/const/locale'),
// TODO: after refactor the errorResponse, we can remove it
'@/utils/errorResponse': resolve(__dirname, './src/utils/errorResponse'),
+ '@/utils/unzipFile': resolve(__dirname, './src/utils/unzipFile'),
'@/utils': resolve(__dirname, './packages/utils/src'),
'@/types': resolve(__dirname, './packages/types/src'),
'@/const': resolve(__dirname, './packages/const/src'),