@lobehub/lobehub 2.0.0-next.210 → 2.0.0-next.212

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/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.212](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.211...v2.0.0-next.212)
6
+
7
+ <sup>Released on **2026-01-05**</sup>
8
+
9
+ #### ♻ Code Refactoring
10
+
11
+ - **redis**: Disable automatic deserialization in upstash provider.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### Code refactoring
19
+
20
+ - **redis**: Disable automatic deserialization in upstash provider, closes [#11210](https://github.com/lobehub/lobe-chat/issues/11210) ([eb5c76c](https://github.com/lobehub/lobe-chat/commit/eb5c76c))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
30
+ ## [Version 2.0.0-next.211](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.210...v2.0.0-next.211)
31
+
32
+ <sup>Released on **2026-01-05**</sup>
33
+
34
+ #### 🐛 Bug Fixes
35
+
36
+ - **misc**: Add lost like button in discover detail page.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### What's fixed
44
+
45
+ - **misc**: Add lost like button in discover detail page, closes [#11182](https://github.com/lobehub/lobe-chat/issues/11182) ([41215d4](https://github.com/lobehub/lobe-chat/commit/41215d4))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ## [Version 2.0.0-next.210](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.209...v2.0.0-next.210)
6
56
 
7
57
  <sup>Released on **2026-01-04**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "children": {},
4
+ "date": "2026-01-05",
5
+ "version": "2.0.0-next.212"
6
+ },
7
+ {
8
+ "children": {
9
+ "fixes": [
10
+ "Add lost like button in discover detail page."
11
+ ]
12
+ },
13
+ "date": "2026-01-05",
14
+ "version": "2.0.0-next.211"
15
+ },
2
16
  {
3
17
  "children": {},
4
18
  "date": "2026-01-04",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.210",
3
+ "version": "2.0.0-next.212",
4
4
  "description": "LobeHub - an open-source,comprehensive AI Agent 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",
@@ -43,8 +43,8 @@ export const GET = async (_req: Request, segmentData: { params: Params }) => {
43
43
 
44
44
  const cacheKey = buildCacheKey(id);
45
45
  if (redisClient) {
46
- // Upstash Redis auto-deserializes JSON, so cached is already an object
47
- const cached = (await redisClient.get(cacheKey)) as CachedFileData | null;
46
+ const cachedStr = await redisClient.get(cacheKey);
47
+ const cached = cachedStr ? (JSON.parse(cachedStr) as CachedFileData) : null;
48
48
  if (cached?.redirectUrl) {
49
49
  log('Cache hit for file: %s', id);
50
50
  return Response.redirect(cached.redirectUrl, 302);
@@ -158,7 +158,7 @@ export const GET = async (req: NextRequest, context: RouteContext) => {
158
158
  // Follow queries
159
159
  case 'follow-status': {
160
160
  const targetUserId = Number(segments[1]);
161
- if (!accessToken) {
161
+ if (!accessToken && !trustedClientToken) {
162
162
  return NextResponse.json({ isFollowing: false, isMutual: false });
163
163
  }
164
164
  const result = await market.follows.checkFollowStatus(targetUserId);
@@ -193,7 +193,7 @@ export const GET = async (req: NextRequest, context: RouteContext) => {
193
193
  case 'favorite-status': {
194
194
  const targetType = segments[1] as 'agent' | 'plugin';
195
195
  const targetIdOrIdentifier = segments[2];
196
- if (!accessToken) {
196
+ if (!accessToken && !trustedClientToken) {
197
197
  return NextResponse.json({ isFavorited: false });
198
198
  }
199
199
  // SDK accepts both number (targetId) and string (identifier)
@@ -236,7 +236,7 @@ export const GET = async (req: NextRequest, context: RouteContext) => {
236
236
  case 'like-status': {
237
237
  const targetType = segments[1] as 'agent' | 'plugin';
238
238
  const targetIdOrIdentifier = segments[2];
239
- if (!accessToken) {
239
+ if (!accessToken && !trustedClientToken) {
240
240
  return NextResponse.json({ isLiked: false });
241
241
  }
242
242
  const isNumeric = /^\d+$/.test(targetIdOrIdentifier);
@@ -13,7 +13,14 @@ import {
13
13
  } from '@lobehub/ui';
14
14
  import { App } from 'antd';
15
15
  import { createStaticStyles, cssVar, useResponsive } from 'antd-style';
16
- import { BookTextIcon, BookmarkCheckIcon, BookmarkIcon, CoinsIcon, DotIcon } from 'lucide-react';
16
+ import {
17
+ BookTextIcon,
18
+ BookmarkCheckIcon,
19
+ BookmarkIcon,
20
+ CoinsIcon,
21
+ DotIcon,
22
+ HeartIcon,
23
+ } from 'lucide-react';
17
24
  import qs from 'query-string';
18
25
  import { memo, useState } from 'react';
19
26
  import { useTranslation } from 'react-i18next';
@@ -54,8 +61,7 @@ const Header = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
54
61
  const { mobile = isMobile } = useResponsive();
55
62
  const { isAuthenticated, signIn, session } = useMarketAuth();
56
63
  const [favoriteLoading, setFavoriteLoading] = useState(false);
57
- // TODO: enable like feature
58
- // const [likeLoading, setLikeLoading] = useState(false);
64
+ const [likeLoading, setLikeLoading] = useState(false);
59
65
 
60
66
  // Set access token for social service
61
67
  if (session?.accessToken) {
@@ -71,13 +77,13 @@ const Header = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
71
77
 
72
78
  const isFavorited = favoriteStatus?.isFavorited ?? false;
73
79
 
74
- // TODO: enable like feature
75
- // const { data: likeStatus, mutate: mutateLike } = useSWR(
76
- // identifier && isAuthenticated ? ['like-status', 'agent', identifier] : null,
77
- // () => socialService.checkLikeStatus('agent', identifier!),
78
- // { revalidateOnFocus: false },
79
- // );
80
- // const isLiked = likeStatus?.isLiked ?? false;
80
+ // Fetch like status
81
+ const { data: likeStatus, mutate: mutateLike } = useSWR(
82
+ identifier && isAuthenticated ? ['like-status', 'agent', identifier] : null,
83
+ () => socialService.checkLikeStatus('agent', identifier!),
84
+ { revalidateOnFocus: false },
85
+ );
86
+ const isLiked = likeStatus?.isLiked ?? false;
81
87
 
82
88
  const handleFavoriteClick = async () => {
83
89
  if (!isAuthenticated) {
@@ -105,30 +111,29 @@ const Header = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
105
111
  }
106
112
  };
107
113
 
108
- // TODO: enable like feature
109
- // const handleLikeClick = async () => {
110
- // if (!isAuthenticated) {
111
- // await signIn();
112
- // return;
113
- // }
114
- // if (!identifier) return;
115
- // setLikeLoading(true);
116
- // try {
117
- // if (isLiked) {
118
- // await socialService.unlike('agent', identifier);
119
- // message.success(t('assistant.unlikeSuccess'));
120
- // } else {
121
- // await socialService.like('agent', identifier);
122
- // message.success(t('assistant.likeSuccess'));
123
- // }
124
- // await mutateLike();
125
- // } catch (error) {
126
- // console.error('Like action failed:', error);
127
- // message.error(t('assistant.likeFailed'));
128
- // } finally {
129
- // setLikeLoading(false);
130
- // }
131
- // };
114
+ const handleLikeClick = async () => {
115
+ if (!isAuthenticated) {
116
+ await signIn();
117
+ return;
118
+ }
119
+ if (!identifier) return;
120
+ setLikeLoading(true);
121
+ try {
122
+ if (isLiked) {
123
+ await socialService.unlike('agent', identifier);
124
+ message.success(t('assistant.unlikeSuccess'));
125
+ } else {
126
+ await socialService.like('agent', identifier);
127
+ message.success(t('assistant.likeSuccess'));
128
+ }
129
+ await mutateLike();
130
+ } catch (error) {
131
+ console.error('Like action failed:', error);
132
+ message.error(t('assistant.likeFailed'));
133
+ } finally {
134
+ setLikeLoading(false);
135
+ }
136
+ };
132
137
 
133
138
  const categories = useCategory();
134
139
  const cate = categories.find((c) => c.key === category);
@@ -186,6 +191,14 @@ const Header = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
186
191
  {title}
187
192
  </Text>
188
193
  </Flexbox>
194
+ <Tooltip title={isLiked ? t('assistant.unlike') : t('assistant.like')}>
195
+ <ActionIcon
196
+ icon={HeartIcon}
197
+ loading={likeLoading}
198
+ onClick={handleLikeClick}
199
+ style={isLiked ? { color: '#ff4d4f' } : undefined}
200
+ />
201
+ </Tooltip>
189
202
  <Tooltip title={isFavorited ? t('assistant.unfavorite') : t('assistant.favorite')}>
190
203
  <ActionIcon
191
204
  icon={isFavorited ? BookmarkCheckIcon : BookmarkIcon}
@@ -1,10 +1,9 @@
1
1
  'use client';
2
2
 
3
- import { useDroppable } from '@dnd-kit/core';
4
3
  import { Center, type DropdownItem, DropdownMenu, Flexbox, Skeleton, Text } from '@lobehub/ui';
5
4
  import { createStaticStyles, cx } from 'antd-style';
6
5
  import { ChevronsUpDown } from 'lucide-react';
7
- import { memo, useCallback, useMemo } from 'react';
6
+ import { type DragEvent, memo, useCallback, useMemo, useState } from 'react';
8
7
  import { useNavigate } from 'react-router-dom';
9
8
 
10
9
  import { useDragActive } from '@/app/[variants]/(main)/resource/features/DndContextWrapper';
@@ -48,24 +47,11 @@ const Head = memo<{ id: string }>(({ id }) => {
48
47
  const name = useKnowledgeBaseStore(knowledgeBaseSelectors.getKnowledgeBaseNameById(id));
49
48
  const setMode = useResourceManagerStore((s) => s.setMode);
50
49
  const isDragActive = useDragActive();
50
+ const [isDropZoneActive, setIsDropZoneActive] = useState(false);
51
51
 
52
52
  const useFetchKnowledgeBaseList = useKnowledgeBaseStore((s) => s.useFetchKnowledgeBaseList);
53
53
  const { data: libraries } = useFetchKnowledgeBaseList();
54
54
 
55
- // Special droppable ID for root folder - matches the pattern expected by DndContextWrapper
56
- const ROOT_DROP_ID = `__root__:${id}`;
57
-
58
- const { setNodeRef, isOver } = useDroppable({
59
- data: {
60
- fileType: 'custom/folder',
61
- isFolder: true,
62
- name: 'Root',
63
- targetId: null,
64
- },
65
- disabled: !isDragActive,
66
- id: ROOT_DROP_ID,
67
- });
68
-
69
55
  const handleClick = useCallback(() => {
70
56
  navigate(`/resource/library/${id}`);
71
57
  setMode('explorer');
@@ -79,6 +65,25 @@ const Head = memo<{ id: string }>(({ id }) => {
79
65
  [navigate, setMode],
80
66
  );
81
67
 
68
+ // Native HTML5 drag-and-drop handlers for root directory drop
69
+ const handleDragOver = useCallback(
70
+ (e: DragEvent<HTMLDivElement>) => {
71
+ if (!isDragActive) return;
72
+ e.preventDefault();
73
+ e.stopPropagation();
74
+ setIsDropZoneActive(true);
75
+ },
76
+ [isDragActive],
77
+ );
78
+
79
+ const handleDragLeave = useCallback(() => {
80
+ setIsDropZoneActive(false);
81
+ }, []);
82
+
83
+ const handleDrop = useCallback(() => {
84
+ setIsDropZoneActive(false);
85
+ }, []);
86
+
82
87
  const menuItems = useMemo<DropdownItem[]>(() => {
83
88
  if (!libraries) return [];
84
89
 
@@ -98,12 +103,17 @@ const Head = memo<{ id: string }>(({ id }) => {
98
103
  return (
99
104
  <Flexbox
100
105
  align={'center'}
101
- className={cx(styles.clickableHeader, isOver && styles.dropZoneActive)}
106
+ className={cx(styles.clickableHeader, isDropZoneActive && styles.dropZoneActive)}
107
+ data-drop-target-id="root"
108
+ data-is-folder="true"
109
+ data-root-drop="true"
102
110
  gap={8}
103
111
  horizontal
112
+ onDragLeave={handleDragLeave}
113
+ onDragOver={handleDragOver}
114
+ onDrop={handleDrop}
104
115
  paddingBlock={6}
105
116
  paddingInline={'12px 14px'}
106
- ref={setNodeRef}
107
117
  >
108
118
  <Center style={{ minWidth: 24 }} width={24}>
109
119
  <RepoIcon />
@@ -78,6 +78,9 @@ const ListView = memo(() => {
78
78
  const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
79
79
  const isDragActive = useDragActive();
80
80
  const [isDropZoneActive, setIsDropZoneActive] = useState(false);
81
+ const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
82
+ const autoScrollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
83
+ const containerRef = useRef<HTMLDivElement>(null);
81
84
 
82
85
  const { currentFolderSlug } = useFolderPath();
83
86
  const { data: folderBreadcrumb } = useResourceManagerFetchFolderBreadcrumb(currentFolderSlug);
@@ -174,6 +177,18 @@ const ListView = memo(() => {
174
177
  }
175
178
  }, [fileListHasMore, loadMoreKnowledgeItems, isLoadingMore]);
176
179
 
180
+ // Clear auto-scroll timers
181
+ const clearScrollTimers = useCallback(() => {
182
+ if (scrollTimerRef.current) {
183
+ clearTimeout(scrollTimerRef.current);
184
+ scrollTimerRef.current = null;
185
+ }
186
+ if (autoScrollIntervalRef.current) {
187
+ clearInterval(autoScrollIntervalRef.current);
188
+ autoScrollIntervalRef.current = null;
189
+ }
190
+ }, []);
191
+
177
192
  // Drop zone handlers for dragging to blank space
178
193
  const handleDropZoneDragOver = useCallback(
179
194
  (e: DragEvent) => {
@@ -187,11 +202,57 @@ const ListView = memo(() => {
187
202
 
188
203
  const handleDropZoneDragLeave = useCallback(() => {
189
204
  setIsDropZoneActive(false);
190
- }, []);
205
+ clearScrollTimers();
206
+ }, [clearScrollTimers]);
191
207
 
192
208
  const handleDropZoneDrop = useCallback(() => {
193
209
  setIsDropZoneActive(false);
194
- }, []);
210
+ clearScrollTimers();
211
+ }, [clearScrollTimers]);
212
+
213
+ // Handle auto-scroll during drag
214
+ const handleDragMove = useCallback(
215
+ (e: DragEvent<HTMLDivElement>) => {
216
+ if (!isDragActive || !containerRef.current) return;
217
+
218
+ const container = containerRef.current;
219
+ const rect = container.getBoundingClientRect();
220
+ const mouseY = e.clientY;
221
+ const bottomThreshold = 200; // pixels from bottom edge
222
+ const distanceFromBottom = rect.bottom - mouseY;
223
+
224
+ // Check if mouse is near the bottom edge
225
+ if (distanceFromBottom > 0 && distanceFromBottom <= bottomThreshold) {
226
+ // If not already started, start the 2-second timer
227
+ if (!scrollTimerRef.current && !autoScrollIntervalRef.current) {
228
+ scrollTimerRef.current = setTimeout(() => {
229
+ // After 2 seconds, start auto-scrolling
230
+ autoScrollIntervalRef.current = setInterval(() => {
231
+ virtuosoRef.current?.scrollBy({ top: 50 });
232
+ }, 100); // Scroll every 100ms for smooth scrolling
233
+ scrollTimerRef.current = null;
234
+ }, 2000);
235
+ }
236
+ } else {
237
+ // Mouse moved away from bottom edge, clear timers
238
+ clearScrollTimers();
239
+ }
240
+ },
241
+ [isDragActive, clearScrollTimers],
242
+ );
243
+
244
+ // Clean up timers when drag ends or component unmounts
245
+ useEffect(() => {
246
+ if (!isDragActive) {
247
+ clearScrollTimers();
248
+ }
249
+ }, [isDragActive, clearScrollTimers]);
250
+
251
+ useEffect(() => {
252
+ return () => {
253
+ clearScrollTimers();
254
+ };
255
+ }, [clearScrollTimers]);
195
256
 
196
257
  return (
197
258
  <Flexbox height={'100%'}>
@@ -227,8 +288,12 @@ const ListView = memo(() => {
227
288
  data-drop-target-id={currentFolderId || undefined}
228
289
  data-is-folder="true"
229
290
  onDragLeave={handleDropZoneDragLeave}
230
- onDragOver={handleDropZoneDragOver}
291
+ onDragOver={(e) => {
292
+ handleDropZoneDragOver(e);
293
+ handleDragMove(e);
294
+ }}
231
295
  onDrop={handleDropZoneDrop}
296
+ ref={containerRef}
232
297
  style={{ flex: 1, overflow: 'hidden', position: 'relative' }}
233
298
  >
234
299
  <Virtuoso
@@ -7,7 +7,6 @@ import MasonryFileItem from '.';
7
7
  interface MasonryItemWrapperProps {
8
8
  context: {
9
9
  knowledgeBaseId?: string;
10
- openFile?: (id: string) => void;
11
10
  selectFileIds: string[];
12
11
  setSelectedFileIds: (ids: string[]) => void;
13
12
  };
@@ -25,7 +24,6 @@ const MasonryItemWrapper = memo<MasonryItemWrapperProps>(({ data: item, context
25
24
  <div style={{ padding: '8px 4px' }}>
26
25
  <MasonryFileItem
27
26
  knowledgeBaseId={context.knowledgeBaseId}
28
- onOpen={context.openFile}
29
27
  onSelectedChange={(id, checked) => {
30
28
  if (checked) {
31
29
  context.setSelectedFileIds([...context.selectFileIds, id]);
@@ -6,106 +6,134 @@ import { cssVar } from 'antd-style';
6
6
  import { type UIEvent, memo, useCallback, useMemo, useState } from 'react';
7
7
  import { useTranslation } from 'react-i18next';
8
8
 
9
- import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
10
- import { type FileListItem } from '@/types/files';
9
+ import { useFolderPath } from '@/app/[variants]/(main)/resource/features/hooks/useFolderPath';
10
+ import {
11
+ useResourceManagerFetchKnowledgeItems,
12
+ useResourceManagerStore,
13
+ } from '@/app/[variants]/(main)/resource/features/store';
14
+ import { sortFileList } from '@/app/[variants]/(main)/resource/features/store/selectors';
11
15
 
12
16
  import { useMasonryColumnCount } from '../useMasonryColumnCount';
13
17
  import MasonryItemWrapper from './MasonryFileItem/MasonryItemWrapper';
14
18
 
15
- interface MasonryViewProps {
16
- data: FileListItem[] | undefined;
17
- hasMore: boolean;
18
- isMasonryReady: boolean;
19
- loadMore: () => Promise<void>;
20
- onOpenFile?: (id: string) => void;
21
- selectFileIds: string[];
22
- setSelectedFileIds: (ids: string[]) => void;
23
- }
19
+ const MasonryView = memo(() => {
20
+ // Access all state from Resource Manager store
21
+ const [
22
+ libraryId,
23
+ category,
24
+ searchQuery,
25
+ selectedFileIds,
26
+ setSelectedFileIds,
27
+ loadMoreKnowledgeItems,
28
+ fileListHasMore,
29
+ isMasonryReady,
30
+ sorter,
31
+ sortType,
32
+ ] = useResourceManagerStore((s) => [
33
+ s.libraryId,
34
+ s.category,
35
+ s.searchQuery,
36
+ s.selectedFileIds,
37
+ s.setSelectedFileIds,
38
+ s.loadMoreKnowledgeItems,
39
+ s.fileListHasMore,
40
+ s.isMasonryReady,
41
+ s.sorter,
42
+ s.sortType,
43
+ ]);
24
44
 
25
- const MasonryView = memo<MasonryViewProps>(
26
- ({ data, hasMore, isMasonryReady, loadMore, onOpenFile, selectFileIds, setSelectedFileIds }) => {
27
- const { t } = useTranslation('file');
28
- const columnCount = useMasonryColumnCount();
29
- const [isLoadingMore, setIsLoadingMore] = useState(false);
45
+ const { t } = useTranslation('file');
46
+ const columnCount = useMasonryColumnCount();
47
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
30
48
 
31
- const libraryId = useResourceManagerStore((s) => s.libraryId);
49
+ const { currentFolderSlug } = useFolderPath();
32
50
 
33
- const masonryContext = useMemo(
34
- () => ({
35
- knowledgeBaseId: libraryId,
36
- openFile: onOpenFile,
37
- selectFileIds,
38
- setSelectedFileIds,
39
- }),
40
- [onOpenFile, libraryId, selectFileIds, setSelectedFileIds],
41
- );
51
+ // Fetch data with SWR
52
+ const { data: rawData } = useResourceManagerFetchKnowledgeItems({
53
+ category,
54
+ knowledgeBaseId: libraryId,
55
+ parentId: currentFolderSlug || null,
56
+ q: searchQuery ?? undefined,
57
+ showFilesInKnowledgeBase: false,
58
+ });
42
59
 
43
- // Handle automatic load more when scrolling to bottom
44
- const handleLoadMore = useCallback(async () => {
45
- if (!hasMore || isLoadingMore) return;
60
+ // Sort data using current sort settings
61
+ const data = sortFileList(rawData, sorter, sortType);
46
62
 
47
- setIsLoadingMore(true);
48
- try {
49
- await loadMore();
50
- } finally {
51
- setIsLoadingMore(false);
52
- }
53
- }, [hasMore, loadMore, isLoadingMore]);
63
+ const masonryContext = useMemo(
64
+ () => ({
65
+ knowledgeBaseId: libraryId,
66
+ selectFileIds: selectedFileIds,
67
+ setSelectedFileIds,
68
+ }),
69
+ [libraryId, selectedFileIds, setSelectedFileIds],
70
+ );
71
+
72
+ // Handle automatic load more when scrolling to bottom
73
+ const handleLoadMore = useCallback(async () => {
74
+ if (!fileListHasMore || isLoadingMore) return;
54
75
 
55
- // Handle scroll event to detect when near bottom
56
- const handleScroll = useCallback(
57
- (e: UIEvent<HTMLDivElement>) => {
58
- const target = e.currentTarget;
59
- const scrollTop = target.scrollTop;
60
- const scrollHeight = target.scrollHeight;
61
- const clientHeight = target.clientHeight;
76
+ setIsLoadingMore(true);
77
+ try {
78
+ await loadMoreKnowledgeItems();
79
+ } finally {
80
+ setIsLoadingMore(false);
81
+ }
82
+ }, [fileListHasMore, loadMoreKnowledgeItems, isLoadingMore]);
62
83
 
63
- // Trigger load when within 300px of bottom
64
- if (scrollHeight - scrollTop - clientHeight < 300) {
65
- handleLoadMore();
66
- }
67
- },
68
- [handleLoadMore],
69
- );
84
+ // Handle scroll event to detect when near bottom
85
+ const handleScroll = useCallback(
86
+ (e: UIEvent<HTMLDivElement>) => {
87
+ const target = e.currentTarget;
88
+ const scrollTop = target.scrollTop;
89
+ const scrollHeight = target.scrollHeight;
90
+ const clientHeight = target.clientHeight;
91
+
92
+ // Trigger load when within 300px of bottom
93
+ if (scrollHeight - scrollTop - clientHeight < 300) {
94
+ handleLoadMore();
95
+ }
96
+ },
97
+ [handleLoadMore],
98
+ );
70
99
 
71
- return (
72
- <div
73
- onScroll={handleScroll}
74
- style={{
75
- flex: 1,
76
- height: '100%',
77
- opacity: isMasonryReady ? 1 : 0,
78
- overflowY: 'auto',
79
- transition: 'opacity 0.2s ease-in-out',
80
- }}
81
- >
82
- <div style={{ paddingBlockEnd: 24, paddingBlockStart: 12, paddingInline: 24 }}>
83
- <VirtuosoMasonry
84
- ItemContent={MasonryItemWrapper}
85
- columnCount={columnCount}
86
- context={masonryContext}
87
- data={data || []}
100
+ return (
101
+ <div
102
+ onScroll={handleScroll}
103
+ style={{
104
+ flex: 1,
105
+ height: '100%',
106
+ opacity: isMasonryReady ? 1 : 0,
107
+ overflowY: 'auto',
108
+ transition: 'opacity 0.2s ease-in-out',
109
+ }}
110
+ >
111
+ <div style={{ paddingBlockEnd: 24, paddingBlockStart: 12, paddingInline: 24 }}>
112
+ <VirtuosoMasonry
113
+ ItemContent={MasonryItemWrapper}
114
+ columnCount={columnCount}
115
+ context={masonryContext}
116
+ data={data || []}
117
+ style={{
118
+ gap: '16px',
119
+ overflow: 'hidden',
120
+ }}
121
+ />
122
+ {isLoadingMore && (
123
+ <Center
88
124
  style={{
89
- gap: '16px',
90
- overflow: 'hidden',
125
+ color: cssVar.colorTextDescription,
126
+ fontSize: 14,
127
+ marginBlockStart: 16,
128
+ minHeight: 40,
91
129
  }}
92
- />
93
- {isLoadingMore && (
94
- <Center
95
- style={{
96
- color: cssVar.colorTextDescription,
97
- fontSize: 14,
98
- marginBlockStart: 16,
99
- minHeight: 40,
100
- }}
101
- >
102
- {t('loading', { defaultValue: 'Loading...' })}
103
- </Center>
104
- )}
105
- </div>
130
+ >
131
+ {t('loading', { defaultValue: 'Loading...' })}
132
+ </Center>
133
+ )}
106
134
  </div>
107
- );
108
- },
109
- );
135
+ </div>
136
+ );
137
+ });
110
138
 
111
139
  export default MasonryView;
@@ -11,6 +11,8 @@ import { memo, useMemo } from 'react';
11
11
  import { useTranslation } from 'react-i18next';
12
12
 
13
13
  import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
14
+ import RepoIcon from '@/components/LibIcon';
15
+ import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
14
16
 
15
17
  import ActionIconWithChevron from './ActionIconWithChevron';
16
18
 
@@ -30,10 +32,18 @@ interface BatchActionsDropdownProps {
30
32
 
31
33
  const BatchActionsDropdown = memo<BatchActionsDropdownProps>(
32
34
  ({ selectCount, onActionClick, disabled }) => {
33
- const { t } = useTranslation(['components', 'common', 'file']);
35
+ const { t } = useTranslation(['components', 'common', 'file', 'knowledgeBase']);
34
36
  const { modal, message } = App.useApp();
35
37
 
36
- const libraryId = useResourceManagerStore((s) => s.libraryId);
38
+ const [libraryId, selectedFileIds] = useResourceManagerStore((s) => [
39
+ s.libraryId,
40
+ s.selectedFileIds,
41
+ ]);
42
+ const [useFetchKnowledgeBaseList, addFilesToKnowledgeBase] = useKnowledgeBaseStore((s) => [
43
+ s.useFetchKnowledgeBaseList,
44
+ s.addFilesToKnowledgeBase,
45
+ ]);
46
+ const { data: knowledgeBases } = useFetchKnowledgeBaseList();
37
47
 
38
48
  const menuItems = useMemo<DropdownItem[]>(() => {
39
49
  const items: DropdownItem[] = [];
@@ -60,44 +70,64 @@ const BatchActionsDropdown = memo<BatchActionsDropdownProps>(
60
70
  return items;
61
71
  }
62
72
 
73
+ // Filter out current knowledge base and create submenu items
74
+ const availableKnowledgeBases = (knowledgeBases || []).filter((kb) => kb.id !== libraryId);
75
+
76
+ const addToKnowledgeBaseSubmenu: DropdownItem[] = availableKnowledgeBases.map((kb) => ({
77
+ icon: <RepoIcon />,
78
+ key: `add-to-kb-${kb.id}`,
79
+ label: <span style={{ marginLeft: 8 }}>{kb.name}</span>,
80
+ onClick: async () => {
81
+ try {
82
+ await addFilesToKnowledgeBase(kb.id, selectedFileIds);
83
+ message.success(
84
+ t('addToKnowledgeBase.addSuccess', {
85
+ count: selectCount,
86
+ ns: 'knowledgeBase',
87
+ }),
88
+ );
89
+ } catch (e) {
90
+ console.error(e);
91
+ message.error(t('addToKnowledgeBase.error', { ns: 'knowledgeBase' }));
92
+ }
93
+ },
94
+ }));
95
+
63
96
  if (libraryId) {
64
- items.push(
65
- {
66
- icon: <Icon icon={BookMinusIcon} />,
67
- key: 'removeFromKnowledgeBase',
68
- label: t('FileManager.actions.removeFromKnowledgeBase'),
69
- onClick: () => {
70
- modal.confirm({
71
- okButtonProps: {
72
- danger: true,
73
- },
74
- onOk: async () => {
75
- await onActionClick('removeFromKnowledgeBase');
76
- message.success(t('FileManager.actions.removeFromKnowledgeBaseSuccess'));
77
- },
78
- title: t('FileManager.actions.confirmRemoveFromKnowledgeBase', {
79
- count: selectCount,
80
- }),
81
- });
82
- },
97
+ items.push({
98
+ icon: <Icon icon={BookMinusIcon} />,
99
+ key: 'removeFromKnowledgeBase',
100
+ label: t('FileManager.actions.removeFromKnowledgeBase'),
101
+ onClick: () => {
102
+ modal.confirm({
103
+ okButtonProps: {
104
+ danger: true,
105
+ },
106
+ onOk: async () => {
107
+ await onActionClick('removeFromKnowledgeBase');
108
+ message.success(t('FileManager.actions.removeFromKnowledgeBaseSuccess'));
109
+ },
110
+ title: t('FileManager.actions.confirmRemoveFromKnowledgeBase', {
111
+ count: selectCount,
112
+ }),
113
+ });
83
114
  },
84
- {
115
+ });
116
+
117
+ if (availableKnowledgeBases.length > 0) {
118
+ items.push({
119
+ children: addToKnowledgeBaseSubmenu as any,
85
120
  icon: <Icon icon={BookPlusIcon} />,
86
121
  key: 'addToOtherKnowledgeBase',
87
122
  label: t('FileManager.actions.addToOtherKnowledgeBase'),
88
- onClick: () => {
89
- onActionClick('addToOtherKnowledgeBase');
90
- },
91
- },
92
- );
93
- } else {
123
+ });
124
+ }
125
+ } else if (availableKnowledgeBases.length > 0) {
94
126
  items.push({
127
+ children: addToKnowledgeBaseSubmenu as any,
95
128
  icon: <Icon icon={BookPlusIcon} />,
96
129
  key: 'addToKnowledgeBase',
97
130
  label: t('FileManager.actions.addToKnowledgeBase'),
98
- onClick: () => {
99
- onActionClick('addToKnowledgeBase');
100
- },
101
131
  });
102
132
  }
103
133
 
@@ -134,7 +164,17 @@ const BatchActionsDropdown = memo<BatchActionsDropdownProps>(
134
164
  );
135
165
 
136
166
  return items;
137
- }, [libraryId, selectCount, onActionClick, t, modal, message]);
167
+ }, [
168
+ libraryId,
169
+ selectCount,
170
+ selectedFileIds,
171
+ onActionClick,
172
+ addFilesToKnowledgeBase,
173
+ t,
174
+ modal,
175
+ message,
176
+ knowledgeBases,
177
+ ]);
138
178
 
139
179
  return (
140
180
  <DropdownMenu items={menuItems} placement="bottomLeft" triggerProps={{ disabled }}>
@@ -41,10 +41,7 @@ const ResourceExplorer = memo(() => {
41
41
  isTransitioning,
42
42
  isMasonryReady,
43
43
  searchQuery,
44
- selectedFileIds,
45
44
  setSelectedFileIds,
46
- loadMoreKnowledgeItems,
47
- fileListHasMore,
48
45
  sorter,
49
46
  sortType,
50
47
  ] = useResourceManagerStore((s) => [
@@ -54,10 +51,7 @@ const ResourceExplorer = memo(() => {
54
51
  s.isTransitioning,
55
52
  s.isMasonryReady,
56
53
  s.searchQuery,
57
- s.selectedFileIds,
58
54
  s.setSelectedFileIds,
59
- s.loadMoreKnowledgeItems,
60
- s.fileListHasMore,
61
55
  s.sorter,
62
56
  s.sortType,
63
57
  ]);
@@ -114,14 +108,7 @@ const ResourceExplorer = memo(() => {
114
108
  ) : viewMode === 'list' ? (
115
109
  <ListView />
116
110
  ) : (
117
- <MasonryView
118
- data={data}
119
- hasMore={fileListHasMore}
120
- isMasonryReady={isMasonryReady}
121
- loadMore={loadMoreKnowledgeItems}
122
- selectFileIds={selectedFileIds}
123
- setSelectedFileIds={setSelectedFileIds}
124
- />
111
+ <MasonryView />
125
112
  )}
126
113
  </Flexbox>
127
114
  );
@@ -64,10 +64,7 @@ const enabledSSOProviders = parseSSOProviders(authEnv.AUTH_SSO_PROVIDERS);
64
64
  const { socialProviders, genericOAuthProviders } = initBetterAuthSSOProviders();
65
65
 
66
66
  async function customEmailValidator(email: string): Promise<boolean> {
67
- if (ENABLE_BUSINESS_FEATURES && !(await businessEmailValidator(email))) {
68
- return false;
69
- }
70
- return validateEmail(email);
67
+ return ENABLE_BUSINESS_FEATURES ? businessEmailValidator(email) : validateEmail(email);
71
68
  }
72
69
 
73
70
  interface CustomBetterAuthOptions {
@@ -25,7 +25,10 @@ export class UpstashRedisProvider implements BaseRedisProvider {
25
25
  constructor(options: UpstashConfig | RedisConfigNodejs) {
26
26
  const { prefix, ...clientOptions } = options as UpstashConfig & RedisConfigNodejs;
27
27
  this.prefix = prefix ? `${prefix}:` : '';
28
- this.client = new Redis(clientOptions as RedisConfigNodejs);
28
+ this.client = new Redis({
29
+ ...clientOptions,
30
+ automaticDeserialization: false,
31
+ } as RedisConfigNodejs);
29
32
  }
30
33
 
31
34
  /**