@lobehub/chat 1.142.2 → 1.142.3

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 (71) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/changelog/v1.json +9 -0
  3. package/locales/ar/chat.json +4 -4
  4. package/locales/ar/file.json +1 -0
  5. package/locales/ar/models.json +1 -1
  6. package/locales/bg-BG/chat.json +4 -4
  7. package/locales/bg-BG/file.json +1 -0
  8. package/locales/bg-BG/models.json +1 -1
  9. package/locales/de-DE/chat.json +4 -4
  10. package/locales/de-DE/file.json +1 -0
  11. package/locales/de-DE/models.json +1 -1
  12. package/locales/en-US/chat.json +4 -4
  13. package/locales/en-US/file.json +1 -0
  14. package/locales/en-US/models.json +1 -1
  15. package/locales/es-ES/chat.json +4 -4
  16. package/locales/es-ES/file.json +1 -0
  17. package/locales/es-ES/models.json +1 -1
  18. package/locales/fa-IR/chat.json +4 -4
  19. package/locales/fa-IR/file.json +1 -0
  20. package/locales/fa-IR/models.json +1 -1
  21. package/locales/fr-FR/chat.json +4 -4
  22. package/locales/fr-FR/file.json +1 -0
  23. package/locales/fr-FR/models.json +1 -1
  24. package/locales/it-IT/chat.json +4 -4
  25. package/locales/it-IT/file.json +1 -0
  26. package/locales/ja-JP/chat.json +4 -4
  27. package/locales/ja-JP/file.json +1 -0
  28. package/locales/ja-JP/models.json +1 -1
  29. package/locales/ko-KR/chat.json +4 -4
  30. package/locales/ko-KR/file.json +1 -0
  31. package/locales/ko-KR/models.json +1 -1
  32. package/locales/nl-NL/chat.json +4 -4
  33. package/locales/nl-NL/file.json +1 -0
  34. package/locales/nl-NL/models.json +1 -1
  35. package/locales/pl-PL/chat.json +4 -4
  36. package/locales/pl-PL/file.json +1 -0
  37. package/locales/pl-PL/models.json +1 -1
  38. package/locales/pt-BR/chat.json +4 -4
  39. package/locales/pt-BR/file.json +1 -0
  40. package/locales/ru-RU/chat.json +4 -4
  41. package/locales/ru-RU/file.json +1 -0
  42. package/locales/ru-RU/models.json +1 -1
  43. package/locales/tr-TR/chat.json +4 -4
  44. package/locales/tr-TR/file.json +1 -0
  45. package/locales/tr-TR/models.json +1 -1
  46. package/locales/vi-VN/chat.json +4 -4
  47. package/locales/vi-VN/file.json +1 -0
  48. package/locales/vi-VN/models.json +1 -1
  49. package/locales/zh-CN/chat.json +4 -4
  50. package/locales/zh-CN/file.json +1 -0
  51. package/locales/zh-TW/chat.json +4 -4
  52. package/locales/zh-TW/file.json +1 -0
  53. package/locales/zh-TW/models.json +1 -1
  54. package/package.json +3 -2
  55. package/packages/const/src/file.ts +2 -0
  56. package/packages/obervability-otel/package.json +5 -5
  57. package/src/app/[variants]/(main)/settings/provider/features/ModelList/CreateNewModelModal/index.tsx +7 -0
  58. package/src/app/[variants]/(main)/settings/provider/features/ModelList/ModelConfigModal/index.tsx +7 -0
  59. package/src/features/FileManager/FileList/FileListItem/index.tsx +3 -2
  60. package/src/features/FileManager/FileList/MasonryFileItem/MasonryItemWrapper.tsx +1 -1
  61. package/src/features/FileManager/FileList/MasonryFileItem/index.tsx +2 -4
  62. package/src/features/FileManager/FileList/MasonrySkeleton.tsx +11 -5
  63. package/src/features/FileManager/FileList/ToolBar/MultiSelectActions.tsx +1 -1
  64. package/src/features/FileManager/FileList/index.tsx +51 -9
  65. package/src/locales/default/chat.ts +4 -4
  66. package/src/locales/default/file.ts +1 -0
  67. package/src/store/file/slices/fileManager/action.test.ts +136 -1
  68. package/src/store/file/slices/fileManager/action.ts +30 -8
  69. package/src/utils/unzipFile.test.ts +128 -0
  70. package/src/utils/unzipFile.ts +122 -0
  71. package/vitest.config.mts +1 -0
@@ -163,9 +163,9 @@
163
163
  "addMember": "Thêm thành viên",
164
164
  "allMembers": "Tất cả thành viên",
165
165
  "createGroup": "Tạo nhóm Agent",
166
- "noAvailableAgents": "Không có trợ nào để mời",
167
- "noSelectedAgents": "Chưa chọn trợ nào",
168
- "searchAgents": "Tìm trợ lý...",
166
+ "noAvailableAgents": "Không có Agent nào để mời",
167
+ "noSelectedAgents": "Chưa chọn Agent nào",
168
+ "searchAgents": "Tìm kiếm Agent...",
169
169
  "setInitialMembers": "Chọn thành viên nhóm"
170
170
  },
171
171
  "members": "Thành viên",
@@ -229,7 +229,7 @@
229
229
  "jumpToMessage": "Chuyển đến tin nhắn thứ {{index}}",
230
230
  "nextMessage": "Tin nhắn tiếp theo",
231
231
  "previousMessage": "Tin nhắn trước",
232
- "senderAssistant": "Trợ lý",
232
+ "senderAssistant": "Agent",
233
233
  "senderUser": "Bạn"
234
234
  },
235
235
  "newAgent": "Tạo trợ lý mới",
@@ -85,6 +85,7 @@
85
85
  "restTime": "Thời gian còn lại {{time}}"
86
86
  }
87
87
  },
88
+ "fileQueueInfo": "Đang tải lên {{count}} tệp đầu tiên, còn lại {{remaining}} tệp sẽ được xếp hàng để tải lên",
88
89
  "totalCount": "Tổng cộng {{count}} mục",
89
90
  "uploadStatus": {
90
91
  "error": "Lỗi tải lên",
@@ -3314,4 +3314,4 @@
3314
3314
  "zai/glm-4.5v": {
3315
3315
  "description": "GLM-4.5V được xây dựng trên mô hình nền tảng GLM-4.5-Air, kế thừa công nghệ đã được xác minh của GLM-4.1V-Thinking, đồng thời mở rộng hiệu quả với kiến trúc MoE 106 tỷ tham số mạnh mẽ."
3316
3316
  }
3317
- }
3317
+ }
@@ -163,9 +163,9 @@
163
163
  "addMember": "添加成员",
164
164
  "allMembers": "全体成员",
165
165
  "createGroup": "创建 Agent 团队",
166
- "noAvailableAgents": "没有可邀请的助手",
167
- "noSelectedAgents": "还未选择助手",
168
- "searchAgents": "搜索助手...",
166
+ "noAvailableAgents": "没有可邀请的 Agent",
167
+ "noSelectedAgents": "还未选择 Agent",
168
+ "searchAgents": "搜索 Agent...",
169
169
  "setInitialMembers": "选择团队成员"
170
170
  },
171
171
  "members": "Members",
@@ -229,7 +229,7 @@
229
229
  "jumpToMessage": "跳转至第 {{index}} 条消息",
230
230
  "nextMessage": "下一条消息",
231
231
  "previousMessage": "上一条消息",
232
- "senderAssistant": "助手",
232
+ "senderAssistant": "Agent",
233
233
  "senderUser": "你"
234
234
  },
235
235
  "newAgent": "新建助手",
@@ -85,6 +85,7 @@
85
85
  "restTime": "剩余 {{time}}"
86
86
  }
87
87
  },
88
+ "fileQueueInfo": "正在上传前 {{count}} 个文件,剩余 {{remaining}} 个文件将排队上传",
88
89
  "totalCount": "共 {{count}} 项",
89
90
  "uploadStatus": {
90
91
  "error": "上传出错",
@@ -163,9 +163,9 @@
163
163
  "addMember": "添加成員",
164
164
  "allMembers": "所有成員",
165
165
  "createGroup": "建立 Agent 團隊",
166
- "noAvailableAgents": "沒有可邀請的助理",
167
- "noSelectedAgents": "尚未選擇助理",
168
- "searchAgents": "搜尋助理...",
166
+ "noAvailableAgents": "沒有可邀請的 Agent",
167
+ "noSelectedAgents": "尚未選擇 Agent",
168
+ "searchAgents": "搜尋 Agent...",
169
169
  "setInitialMembers": "選擇團隊成員"
170
170
  },
171
171
  "members": "成員",
@@ -229,7 +229,7 @@
229
229
  "jumpToMessage": "跳轉至第 {{index}} 條訊息",
230
230
  "nextMessage": "下一條訊息",
231
231
  "previousMessage": "上一條訊息",
232
- "senderAssistant": "助理",
232
+ "senderAssistant": "Agent",
233
233
  "senderUser": "您"
234
234
  },
235
235
  "newAgent": "新建助手",
@@ -85,6 +85,7 @@
85
85
  "restTime": "剩餘 {{time}}"
86
86
  }
87
87
  },
88
+ "fileQueueInfo": "正在上傳前 {{count}} 個檔案,剩餘 {{remaining}} 個檔案將排隊上傳",
88
89
  "totalCount": "共 {{count}} 項",
89
90
  "uploadStatus": {
90
91
  "error": "上傳出錯",
@@ -3314,4 +3314,4 @@
3314
3314
  "zai/glm-4.5v": {
3315
3315
  "description": "GLM-4.5V 基於 GLM-4.5-Air 基礎模型構建,繼承了 GLM-4.1V-Thinking 的經過驗證的技術,同時透過強大的 1060 億參數 MoE 架構實現了有效的擴展。"
3316
3316
  }
3317
- }
3317
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.142.2",
3
+ "version": "1.142.3",
4
4
  "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -176,7 +176,7 @@
176
176
  "@neondatabase/serverless": "^1.0.2",
177
177
  "@next/third-parties": "^15.5.4",
178
178
  "@opentelemetry/exporter-jaeger": "^2.1.0",
179
- "@opentelemetry/winston-transport": "^0.17.0",
179
+ "@opentelemetry/winston-transport": "^0.18.0",
180
180
  "@react-pdf/renderer": "^4.3.0",
181
181
  "@react-spring/web": "^9.7.5",
182
182
  "@saintno/comfyui-sdk": "^0.2.48",
@@ -208,6 +208,7 @@
208
208
  "drizzle-zod": "^0.5.1",
209
209
  "epub2": "^3.0.2",
210
210
  "fast-deep-equal": "^3.1.3",
211
+ "fflate": "^0.8.2",
211
212
  "file-type": "^21.0.0",
212
213
  "framer-motion": "^12.23.24",
213
214
  "gpt-tokenizer": "^3.2.0",
@@ -6,3 +6,5 @@ export const FILE_UPLOAD_BLACKLIST = [
6
6
  'ehthumbs.db',
7
7
  'ehthumbs_vista.db',
8
8
  ];
9
+
10
+ export const MAX_UPLOAD_FILE_COUNT = 10;
@@ -7,15 +7,15 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "@opentelemetry/api": "^1.9.0",
10
- "@opentelemetry/auto-instrumentations-node": "^0.65.0",
10
+ "@opentelemetry/auto-instrumentations-node": "^0.66.0",
11
11
  "@opentelemetry/exporter-metrics-otlp-http": "^0.206.0",
12
- "@opentelemetry/exporter-trace-otlp-http": "^0.206.0",
12
+ "@opentelemetry/exporter-trace-otlp-http": "^0.207.0",
13
13
  "@opentelemetry/instrumentation": "^0.206.0",
14
- "@opentelemetry/instrumentation-http": "^0.205.0",
15
- "@opentelemetry/instrumentation-pg": "^0.59.0",
14
+ "@opentelemetry/instrumentation-http": "^0.207.0",
15
+ "@opentelemetry/instrumentation-pg": "^0.60.0",
16
16
  "@opentelemetry/resources": "^2.0.1",
17
17
  "@opentelemetry/sdk-metrics": "^2.0.1",
18
- "@opentelemetry/sdk-node": "^0.206.0",
18
+ "@opentelemetry/sdk-node": "^0.207.0",
19
19
  "@opentelemetry/sdk-trace-node": "^2.0.1",
20
20
  "@opentelemetry/semantic-conventions": "^1.36.0",
21
21
  "@vercel/otel": "^1.13.0",
@@ -64,6 +64,13 @@ const ModelConfigModal = memo<ModelConfigModalProps>(({ open, setOpen }) => {
64
64
  maskClosable
65
65
  onCancel={closeModal}
66
66
  open={open}
67
+ styles={{
68
+ content: {
69
+ display: 'flex',
70
+ flexDirection: 'column',
71
+ maxHeight: 'calc(100vh - 150px)',
72
+ },
73
+ }}
67
74
  title={t('providerModels.createNew.title')}
68
75
  zIndex={1251} // Select is 1150
69
76
  >
@@ -59,6 +59,13 @@ const ModelConfigModal = memo<ModelConfigModalProps>(({ id, open, setOpen }) =>
59
59
  maskClosable
60
60
  onCancel={closeModal}
61
61
  open={open}
62
+ styles={{
63
+ content: {
64
+ display: 'flex',
65
+ flexDirection: 'column',
66
+ maxHeight: 'calc(100vh - 150px)',
67
+ },
68
+ }}
62
69
  title={t('llm.customModelCards.modelConfig.modalTitle', { ns: 'setting' })}
63
70
  zIndex={1251} // Select is 1150
64
71
  >
@@ -79,7 +79,7 @@ const useStyles = createStyles(({ css, token, cx, isDarkMode }) => {
79
79
  interface FileRenderItemProps extends FileListItem {
80
80
  index: number;
81
81
  knowledgeBaseId?: string;
82
- onSelectedChange: (id: string, selected: boolean) => void;
82
+ onSelectedChange: (id: string, selected: boolean, shiftKey: boolean, index: number) => void;
83
83
  selected?: boolean;
84
84
  }
85
85
 
@@ -100,6 +100,7 @@ const FileRenderItem = memo<FileRenderItemProps>(
100
100
  chunkingStatus,
101
101
  onSelectedChange,
102
102
  knowledgeBaseId,
103
+ index,
103
104
  }) => {
104
105
  const { t } = useTranslation('components');
105
106
  const { styles, cx } = useStyles();
@@ -140,7 +141,7 @@ const FileRenderItem = memo<FileRenderItemProps>(
140
141
  onClick={(e) => {
141
142
  e.stopPropagation();
142
143
 
143
- onSelectedChange(id, !selected);
144
+ onSelectedChange(id, !selected, e.shiftKey, index);
144
145
  }}
145
146
  style={{ paddingInline: 4 }}
146
147
  >
@@ -21,7 +21,7 @@ const MasonryItemWrapper = memo<MasonryItemWrapperProps>(({ data: item, context
21
21
  }
22
22
 
23
23
  return (
24
- <div style={{ padding: '8px' }}>
24
+ <div style={{ padding: '8px 4px' }}>
25
25
  <MasonryFileItem
26
26
  knowledgeBaseId={context.knowledgeBaseId}
27
27
  onSelectedChange={(id, checked) => {
@@ -111,8 +111,6 @@ const useStyles = createStyles(({ css, token }) => ({
111
111
  inset-block-end: 8px;
112
112
  inset-inline-end: 8px;
113
113
 
114
- padding-block: 4px;
115
- padding-inline: 8px;
116
114
  border-radius: ${token.borderRadius}px;
117
115
 
118
116
  opacity: 0;
@@ -320,8 +318,8 @@ const MasonryFileItem = memo<MasonryFileItemProps>(
320
318
  });
321
319
  },
322
320
  {
323
- rootMargin: '50px', // Start loading slightly before entering viewport
324
- threshold: 0.1,
321
+ rootMargin: '200px', // Increased margin to load content earlier
322
+ threshold: 0.01, // Lower threshold for earlier triggering
325
323
  },
326
324
  );
327
325
 
@@ -26,6 +26,9 @@ const MasonrySkeleton = memo<MasonrySkeletonProps>(({ columnCount }) => {
26
26
  // Generate varying heights for more natural masonry look
27
27
  const heights = [180, 220, 200, 190, 240, 210, 200, 230, 180, 220, 210, 190];
28
28
 
29
+ // Calculate number of items based on viewport and column count
30
+ const itemCount = Math.min(columnCount * 3, 12);
31
+
29
32
  return (
30
33
  <div
31
34
  className={styles.grid}
@@ -33,18 +36,21 @@ const MasonrySkeleton = memo<MasonrySkeletonProps>(({ columnCount }) => {
33
36
  gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
34
37
  }}
35
38
  >
36
- {Array.from({ length: 12 }).map((_, index) => (
39
+ {Array.from({ length: itemCount }).map((_, index) => (
37
40
  <div className={styles.card} key={index}>
38
41
  <Skeleton
39
42
  active
43
+ avatar={false}
40
44
  paragraph={{
41
- rows: 3,
42
- width: ['100%', '80%', '60%'],
45
+ rows: 4,
46
+ width: ['100%', '90%', '70%', '50%'],
43
47
  }}
44
48
  style={{
45
- height: heights[index],
49
+ height: heights[index % heights.length],
50
+ }}
51
+ title={{
52
+ width: '100%',
46
53
  }}
47
- title={false}
48
54
  />
49
55
  </div>
50
56
  ))}
@@ -148,7 +148,7 @@ const MultiSelectActions = memo<MultiSelectActionsProps>(
148
148
  size={'small'}
149
149
  variant={'filled'}
150
150
  >
151
- {t('batchDelete', { ns: 'common' })}
151
+ {t('delete', { ns: 'common' })}
152
152
  </Button>
153
153
  </Flexbox>
154
154
  )}
@@ -51,10 +51,15 @@ const FileList = memo<FileListProps>(({ knowledgeBaseId, category }) => {
51
51
 
52
52
  const [selectFileIds, setSelectedFileIds] = useState<string[]>([]);
53
53
  const [viewConfig, setViewConfig] = useState({ showFilesInKnowledgeBase: false });
54
+ const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
55
+ const [isTransitioning, setIsTransitioning] = useState(false);
54
56
 
55
57
  const viewMode = useGlobalStore((s) => s.status.fileManagerViewMode || 'list') as ViewMode;
56
58
  const updateSystemStatus = useGlobalStore((s) => s.updateSystemStatus);
57
- const setViewMode = (mode: ViewMode) => updateSystemStatus({ fileManagerViewMode: mode });
59
+ const setViewMode = (mode: ViewMode) => {
60
+ setIsTransitioning(true);
61
+ updateSystemStatus({ fileManagerViewMode: mode });
62
+ };
58
63
 
59
64
  const [columnCount, setColumnCount] = useState(4);
60
65
 
@@ -105,6 +110,19 @@ const FileList = memo<FileListProps>(({ knowledgeBaseId, category }) => {
105
110
  ...viewConfig,
106
111
  });
107
112
 
113
+ // Handle view transition with a brief delay to show skeleton
114
+ React.useEffect(() => {
115
+ if (isTransitioning && data) {
116
+ // Use requestAnimationFrame to ensure smooth transition
117
+ requestAnimationFrame(() => {
118
+ const timer = setTimeout(() => {
119
+ setIsTransitioning(false);
120
+ }, 100);
121
+ return () => clearTimeout(timer);
122
+ });
123
+ }
124
+ }, [isTransitioning, viewMode, data]);
125
+
108
126
  useCheckTaskStatus(data);
109
127
 
110
128
  // Clean up selected files that no longer exist in the data
@@ -118,6 +136,13 @@ const FileList = memo<FileListProps>(({ knowledgeBaseId, category }) => {
118
136
  }
119
137
  }, [data]);
120
138
 
139
+ // Reset lastSelectedIndex when selection is cleared
140
+ React.useEffect(() => {
141
+ if (selectFileIds.length === 0) {
142
+ setLastSelectedIndex(null);
143
+ }
144
+ }, [selectFileIds.length]);
145
+
121
146
  // Memoize context object to avoid recreating on every render
122
147
  const masonryContext = useMemo(
123
148
  () => ({
@@ -161,7 +186,7 @@ const FileList = memo<FileListProps>(({ knowledgeBaseId, category }) => {
161
186
  </Flexbox>
162
187
  )}
163
188
  </Flexbox>
164
- {isLoading ? (
189
+ {isLoading || isTransitioning ? (
165
190
  viewMode === 'masonry' ? (
166
191
  <MasonrySkeleton columnCount={columnCount} />
167
192
  ) : (
@@ -184,13 +209,30 @@ const FileList = memo<FileListProps>(({ knowledgeBaseId, category }) => {
184
209
  index={index}
185
210
  key={item.id}
186
211
  knowledgeBaseId={knowledgeBaseId}
187
- onSelectedChange={(id, checked) => {
188
- setSelectedFileIds((prev) => {
189
- if (checked) {
190
- return [...prev, id];
191
- }
192
- return prev.filter((item) => item !== id);
193
- });
212
+ onSelectedChange={(id, checked, shiftKey, clickedIndex) => {
213
+ if (shiftKey && lastSelectedIndex !== null && selectFileIds.length > 0 && data) {
214
+ // Range selection with shift key
215
+ const start = Math.min(lastSelectedIndex, clickedIndex);
216
+ const end = Math.max(lastSelectedIndex, clickedIndex);
217
+ const rangeIds = data.slice(start, end + 1).map((item) => item.id);
218
+
219
+ setSelectedFileIds((prev) => {
220
+ // Create a Set for efficient lookup
221
+ const prevSet = new Set(prev);
222
+ // Add all items in range
223
+ rangeIds.forEach((rangeId) => prevSet.add(rangeId));
224
+ return Array.from(prevSet);
225
+ });
226
+ } else {
227
+ // Normal selection
228
+ setSelectedFileIds((prev) => {
229
+ if (checked) {
230
+ return [...prev, id];
231
+ }
232
+ return prev.filter((item) => item !== id);
233
+ });
234
+ }
235
+ setLastSelectedIndex(clickedIndex);
194
236
  }}
195
237
  selected={selectFileIds.includes(item.id)}
196
238
  {...item}
@@ -175,9 +175,9 @@ export default {
175
175
  addMember: '添加成员',
176
176
  allMembers: '全体成员',
177
177
  createGroup: '创建 Agent 团队',
178
- noAvailableAgents: '没有可邀请的助手',
179
- noSelectedAgents: '还未选择助手',
180
- searchAgents: '搜索助手...',
178
+ noAvailableAgents: '没有可邀请的 Agent',
179
+ noSelectedAgents: '还未选择 Agent',
180
+ searchAgents: '搜索 Agent...',
181
181
  setInitialMembers: '选择团队成员',
182
182
  },
183
183
 
@@ -247,7 +247,7 @@ export default {
247
247
  jumpToMessage: '跳转至第 {{index}} 条消息',
248
248
  nextMessage: '下一条消息',
249
249
  previousMessage: '上一条消息',
250
- senderAssistant: '助手',
250
+ senderAssistant: 'Agent',
251
251
  senderUser: '你',
252
252
  },
253
253
 
@@ -86,6 +86,7 @@ export default {
86
86
  restTime: '剩余 {{time}}',
87
87
  },
88
88
  },
89
+ fileQueueInfo: '正在上传前 {{count}} 个文件,剩余 {{remaining}} 个文件将排队上传',
89
90
  totalCount: '共 {{count}} 项',
90
91
  uploadStatus: {
91
92
  error: '上传出错',
@@ -2,17 +2,50 @@ import { act, renderHook, waitFor } from '@testing-library/react';
2
2
  import { mutate } from 'swr';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
 
5
- import { FILE_UPLOAD_BLACKLIST } from '@/const/file';
5
+ import { message } from '@/components/AntdStaticMethods';
6
+ import { FILE_UPLOAD_BLACKLIST, MAX_UPLOAD_FILE_COUNT } from '@/const/file';
6
7
  import { lambdaClient } from '@/libs/trpc/client';
7
8
  import { fileService } from '@/services/file';
8
9
  import { ragService } from '@/services/rag';
9
10
  import { FileListItem } from '@/types/files';
10
11
  import { UploadFileItem } from '@/types/files/upload';
12
+ import { unzipFile } from '@/utils/unzipFile';
11
13
 
12
14
  import { useFileStore as useStore } from '../../store';
13
15
 
14
16
  vi.mock('zustand/traditional');
15
17
 
18
+ // Mock i18next translation function
19
+ vi.mock('i18next', () => ({
20
+ t: (key: string, options?: any) => {
21
+ // Return a mock translation string that includes the options for verification
22
+ if (key === 'uploadDock.fileQueueInfo' && options?.count !== undefined) {
23
+ return `Uploading ${options.count} files, ${options.remaining} queued`;
24
+ }
25
+ return key;
26
+ },
27
+ }));
28
+
29
+ // Mock message
30
+ vi.mock('@/components/AntdStaticMethods', () => ({
31
+ message: {
32
+ info: vi.fn(),
33
+ warning: vi.fn(),
34
+ },
35
+ }));
36
+
37
+ // Mock unzipFile
38
+ vi.mock('@/utils/unzipFile', () => ({
39
+ unzipFile: vi.fn(),
40
+ }));
41
+
42
+ // Mock p-map to run sequentially for easier testing
43
+ vi.mock('p-map', () => ({
44
+ default: vi.fn(async (items, mapper) => {
45
+ return Promise.all(items.map(mapper));
46
+ }),
47
+ }));
48
+
16
49
  // Mock SWR
17
50
  vi.mock('swr', async () => {
18
51
  const actual = await vi.importActual('swr');
@@ -398,6 +431,108 @@ describe('FileManagerActions', () => {
398
431
  // Should not auto-parse when upload returns undefined
399
432
  expect(parseSpy).not.toHaveBeenCalled();
400
433
  });
434
+
435
+ it('should enforce file count limit and queue excess files', async () => {
436
+ const { result } = renderHook(() => useStore());
437
+
438
+ // Create more files than the limit
439
+ const totalFiles = MAX_UPLOAD_FILE_COUNT + 5;
440
+ const files = Array.from(
441
+ { length: totalFiles },
442
+ (_, i) => new File(['content'], `file-${i}.txt`, { type: 'text/plain' }),
443
+ );
444
+
445
+ vi.spyOn(result.current, 'uploadWithProgress').mockResolvedValue({
446
+ id: 'file-1',
447
+ url: 'http://example.com/file-1',
448
+ });
449
+ vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
450
+ vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
451
+ const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList');
452
+
453
+ await act(async () => {
454
+ await result.current.pushDockFileList(files);
455
+ });
456
+
457
+ // Should add all files to dock (not just first MAX_UPLOAD_FILE_COUNT)
458
+ expect(dispatchSpy).toHaveBeenCalledWith({
459
+ atStart: true,
460
+ files: expect.arrayContaining([
461
+ expect.objectContaining({ file: expect.any(File), status: 'pending' }),
462
+ ]),
463
+ type: 'addFiles',
464
+ });
465
+
466
+ // Verify all files were dispatched
467
+ const dispatchCall = dispatchSpy.mock.calls.find((call) => call[0].type === 'addFiles');
468
+ expect(dispatchCall?.[0]).toHaveProperty('files');
469
+ if (dispatchCall && 'files' in dispatchCall[0]) {
470
+ expect(dispatchCall[0].files).toHaveLength(totalFiles);
471
+ }
472
+ });
473
+
474
+ it('should extract ZIP files and upload contents', async () => {
475
+ const { result } = renderHook(() => useStore());
476
+
477
+ const zipFile = new File(['zip content'], 'archive.zip', { type: 'application/zip' });
478
+ const extractedFiles = [
479
+ new File(['file1'], 'file1.txt', { type: 'text/plain' }),
480
+ new File(['file2'], 'file2.txt', { type: 'text/plain' }),
481
+ ];
482
+
483
+ vi.mocked(unzipFile).mockResolvedValue(extractedFiles);
484
+ vi.spyOn(result.current, 'uploadWithProgress').mockResolvedValue({
485
+ id: 'file-1',
486
+ url: 'http://example.com/file-1',
487
+ });
488
+ vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
489
+ vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
490
+ const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList');
491
+
492
+ await act(async () => {
493
+ await result.current.pushDockFileList([zipFile]);
494
+ });
495
+
496
+ // Should extract ZIP file
497
+ expect(unzipFile).toHaveBeenCalledWith(zipFile);
498
+
499
+ // Should upload extracted files
500
+ expect(dispatchSpy).toHaveBeenCalledWith({
501
+ atStart: true,
502
+ files: extractedFiles.map((file) => ({ file, id: file.name, status: 'pending' })),
503
+ type: 'addFiles',
504
+ });
505
+ });
506
+
507
+ it('should handle ZIP extraction errors gracefully', async () => {
508
+ const { result } = renderHook(() => useStore());
509
+
510
+ const zipFile = new File(['zip content'], 'archive.zip', { type: 'application/zip' });
511
+
512
+ vi.mocked(unzipFile).mockRejectedValue(new Error('Extraction failed'));
513
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
514
+ vi.spyOn(result.current, 'uploadWithProgress').mockResolvedValue({
515
+ id: 'file-1',
516
+ url: 'http://example.com/file-1',
517
+ });
518
+ vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
519
+ vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
520
+ const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList');
521
+
522
+ await act(async () => {
523
+ await result.current.pushDockFileList([zipFile]);
524
+ });
525
+
526
+ // Should log error
527
+ expect(consoleErrorSpy).toHaveBeenCalled();
528
+
529
+ // Should fallback to uploading the ZIP file itself
530
+ expect(dispatchSpy).toHaveBeenCalledWith({
531
+ atStart: true,
532
+ files: [{ file: zipFile, id: zipFile.name, status: 'pending' }],
533
+ type: 'addFiles',
534
+ });
535
+ });
401
536
  });
402
537
 
403
538
  describe('reEmbeddingChunks', () => {