@lobehub/lobehub 2.0.0-next.267 → 2.0.0-next.268

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 (126) hide show
  1. package/.cursor/rules/microcopy-cn.mdc +75 -63
  2. package/.cursor/rules/microcopy-en.mdc +4 -8
  3. package/CHANGELOG.md +25 -0
  4. package/README.md +8 -8
  5. package/README.zh-CN.md +8 -8
  6. package/apps/desktop/src/main/locales/default/common.ts +2 -2
  7. package/changelog/v1.json +5 -0
  8. package/docs/development/database-schema.dbml +4 -0
  9. package/e2e/CLAUDE.md +9 -8
  10. package/e2e/cucumber.config.js +1 -0
  11. package/e2e/src/features/page/README.md +118 -0
  12. package/e2e/src/features/page/crud.feature +62 -0
  13. package/e2e/src/features/page/editor-content.feature +93 -0
  14. package/e2e/src/features/page/editor-meta.feature +60 -0
  15. package/e2e/src/steps/agent/conversation.steps.ts +4 -4
  16. package/e2e/src/steps/home/sidebarAgent.steps.ts +91 -94
  17. package/e2e/src/steps/home/sidebarGroup.steps.ts +4 -4
  18. package/e2e/src/steps/hooks.ts +2 -0
  19. package/e2e/src/steps/page/editor-content.steps.ts +344 -0
  20. package/e2e/src/steps/page/editor-meta.steps.ts +410 -0
  21. package/e2e/src/steps/page/page-crud.steps.ts +363 -0
  22. package/e2e/src/support/world.ts +12 -0
  23. package/locales/ar/file.json +2 -0
  24. package/locales/bg-BG/file.json +2 -0
  25. package/locales/de-DE/file.json +2 -0
  26. package/locales/en-US/auth.json +1 -1
  27. package/locales/en-US/file.json +2 -0
  28. package/locales/en-US/metadata.json +2 -2
  29. package/locales/es-ES/file.json +2 -0
  30. package/locales/fa-IR/file.json +2 -0
  31. package/locales/fr-FR/file.json +2 -0
  32. package/locales/it-IT/file.json +2 -0
  33. package/locales/ja-JP/file.json +2 -0
  34. package/locales/ko-KR/file.json +2 -0
  35. package/locales/nl-NL/file.json +2 -0
  36. package/locales/pl-PL/file.json +2 -0
  37. package/locales/pt-BR/file.json +2 -0
  38. package/locales/ru-RU/file.json +2 -0
  39. package/locales/tr-TR/file.json +2 -0
  40. package/locales/vi-VN/file.json +2 -0
  41. package/locales/zh-CN/file.json +2 -0
  42. package/locales/zh-TW/file.json +2 -0
  43. package/package.json +1 -1
  44. package/packages/builtin-agents/src/agents/agent-builder/index.ts +1 -1
  45. package/packages/builtin-agents/src/agents/group-agent-builder/index.ts +1 -1
  46. package/packages/builtin-agents/src/agents/page-agent/index.ts +1 -1
  47. package/packages/const/src/settings/group.ts +0 -10
  48. package/packages/database/migrations/0068_update_group_data.sql +4 -0
  49. package/packages/database/migrations/meta/0068_snapshot.json +9588 -0
  50. package/packages/database/migrations/meta/_journal.json +7 -0
  51. package/packages/database/src/models/__tests__/chatGroup.test.ts +5 -7
  52. package/packages/database/src/models/__tests__/knowledgeBase.test.ts +185 -0
  53. package/packages/database/src/models/knowledgeBase.ts +67 -3
  54. package/packages/database/src/repositories/agentGroup/index.test.ts +23 -29
  55. package/packages/database/src/repositories/agentGroup/index.ts +4 -9
  56. package/packages/database/src/repositories/knowledge/index.ts +3 -3
  57. package/packages/database/src/schemas/chatGroup.ts +4 -3
  58. package/packages/database/src/types/chatGroup.ts +0 -7
  59. package/packages/types/src/agentGroup/index.ts +30 -9
  60. package/src/app/[variants]/(main)/home/_layout/Body/Agent/ModalProvider.tsx +9 -32
  61. package/src/app/[variants]/(main)/home/_layout/hooks/useCreateMenuItems.tsx +3 -37
  62. package/src/app/[variants]/(main)/home/_layout/hooks/useSessionGroupMenuItems.tsx +7 -53
  63. package/src/app/[variants]/(main)/home/features/RecentPage/List.tsx +2 -1
  64. package/src/app/[variants]/(main)/resource/features/DndContextWrapper.tsx +1 -1
  65. package/src/app/[variants]/(main)/resource/library/_layout/Sidebar.tsx +2 -2
  66. package/src/app/[variants]/(main)/resource/library/features/LibraryMenu.tsx +2 -2
  67. package/src/app/[variants]/(mobile)/chat/settings/features/SettingButton.tsx +2 -12
  68. package/src/components/ChatGroupWizard/ChatGroupWizard.tsx +5 -27
  69. package/src/components/DragUpload/index.tsx +24 -27
  70. package/src/components/MemberSelectionModal/MemberSelectionModal.tsx +2 -11
  71. package/src/features/CommandMenu/useCommandMenu.ts +4 -14
  72. package/src/features/ResourceManager/components/Editor/index.tsx +2 -3
  73. package/src/features/ResourceManager/components/Explorer/Header/index.tsx +13 -17
  74. package/src/features/ResourceManager/components/Explorer/ItemDropdown/useFileItemDropdown.tsx +1 -1
  75. package/src/features/ResourceManager/components/Explorer/ListView/ListItem/TruncatedFileName.tsx +130 -0
  76. package/src/features/ResourceManager/components/Explorer/ListView/ListItem/index.tsx +36 -4
  77. package/src/features/ResourceManager/components/Explorer/ListView/Skeleton.tsx +4 -3
  78. package/src/features/ResourceManager/components/Explorer/ListView/index.tsx +58 -2
  79. package/src/features/ResourceManager/components/Explorer/MasonryView/index.tsx +58 -6
  80. package/src/features/ResourceManager/components/Explorer/MoveToFolderModal.tsx +2 -5
  81. package/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx +9 -5
  82. package/src/features/ResourceManager/components/Explorer/index.tsx +11 -56
  83. package/src/features/ResourceManager/components/Header/AddButton.tsx +5 -6
  84. package/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx +382 -0
  85. package/src/features/ResourceManager/components/LibraryHierarchy/index.tsx +396 -0
  86. package/src/features/ResourceManager/components/LibraryHierarchy/styles.ts +19 -0
  87. package/src/features/ResourceManager/components/LibraryHierarchy/treeState.ts +178 -0
  88. package/src/features/ResourceManager/components/LibraryHierarchy/types.ts +10 -0
  89. package/src/features/ResourceManager/index.tsx +3 -0
  90. package/src/layout/GlobalProvider/GroupWizardProvider.tsx +6 -29
  91. package/src/locales/default/auth.ts +1 -1
  92. package/src/locales/default/file.ts +2 -0
  93. package/src/locales/default/metadata.ts +2 -2
  94. package/src/server/modules/AgentRuntime/AgentRuntimeCoordinator.ts +30 -30
  95. package/src/server/modules/AgentRuntime/AgentStateManager.ts +23 -23
  96. package/src/server/modules/AgentRuntime/InMemoryAgentStateManager.ts +16 -16
  97. package/src/server/modules/AgentRuntime/InMemoryStreamEventManager.ts +13 -13
  98. package/src/server/modules/AgentRuntime/RuntimeExecutors.ts +2 -2
  99. package/src/server/modules/AgentRuntime/StreamEventManager.ts +18 -18
  100. package/src/server/modules/AgentRuntime/types.ts +21 -21
  101. package/src/server/routers/lambda/__tests__/agentGroup.test.ts +8 -8
  102. package/src/server/routers/lambda/agentGroup.ts +10 -12
  103. package/src/server/services/document/index.ts +1 -0
  104. package/src/store/agentGroup/slices/curd.test.ts +4 -4
  105. package/src/store/file/slices/fileManager/action.ts +12 -4
  106. package/src/store/home/slices/homeInput/action.ts +0 -3
  107. package/src/store/session/slices/session/action.ts +5 -9
  108. package/src/app/[variants]/(mobile)/chat/settings/features/AgentTeamSettings/index.tsx +0 -95
  109. package/src/features/GroupChatSettings/AgentCard.tsx +0 -154
  110. package/src/features/GroupChatSettings/AgentTeamChatSettings.tsx +0 -179
  111. package/src/features/GroupChatSettings/AgentTeamMembersSettings.tsx +0 -244
  112. package/src/features/GroupChatSettings/AgentTeamMetaSettings.tsx +0 -94
  113. package/src/features/GroupChatSettings/AgentTeamSettings.tsx +0 -54
  114. package/src/features/GroupChatSettings/GroupCategory/index.tsx +0 -30
  115. package/src/features/GroupChatSettings/GroupCategory/useGroupCategory.tsx +0 -42
  116. package/src/features/GroupChatSettings/GroupChatSettingsProvider.tsx +0 -19
  117. package/src/features/GroupChatSettings/HostMemberCard.tsx +0 -113
  118. package/src/features/GroupChatSettings/StoreUpdater.tsx +0 -34
  119. package/src/features/GroupChatSettings/hooks/useGroupChatSettings.ts +0 -25
  120. package/src/features/GroupChatSettings/index.ts +0 -16
  121. package/src/features/GroupChatSettings/store/action.ts +0 -105
  122. package/src/features/GroupChatSettings/store/index.ts +0 -18
  123. package/src/features/GroupChatSettings/store/initialState.ts +0 -23
  124. package/src/features/GroupChatSettings/store/selectors.ts +0 -13
  125. package/src/features/ResourceManager/components/Tree/index.tsx +0 -883
  126. /package/src/features/ResourceManager/components/{Tree → LibraryHierarchy}/TreeSkeleton.tsx +0 -0
@@ -1,883 +0,0 @@
1
- 'use client';
2
-
3
- import { CaretDownFilled, LoadingOutlined } from '@ant-design/icons';
4
- import { ActionIcon, Block, Flexbox, Icon, showContextMenu } from '@lobehub/ui';
5
- import { App, Input } from 'antd';
6
- import { createStaticStyles, cx } from 'antd-style';
7
- import { FileText, FolderIcon, FolderOpenIcon } from 'lucide-react';
8
- import * as motion from 'motion/react-m';
9
- import React, { memo, useCallback, useMemo, useReducer, useRef, useState } from 'react';
10
- import { useNavigate } from 'react-router-dom';
11
- import { VList } from 'virtua';
12
-
13
- import {
14
- getTransparentDragImage,
15
- useDragActive,
16
- useDragState,
17
- } from '@/app/[variants]/(main)/resource/features/DndContextWrapper';
18
- import { useFolderPath } from '@/app/[variants]/(main)/resource/features/hooks/useFolderPath';
19
- import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
20
- import FileIcon from '@/components/FileIcon';
21
- import { fileService } from '@/services/file';
22
- import { useFileStore } from '@/store/file';
23
- import type { ResourceItem } from '@/types/resource';
24
-
25
- import { useFileItemDropdown } from '../Explorer/ItemDropdown/useFileItemDropdown';
26
- import TreeSkeleton from './TreeSkeleton';
27
-
28
- // Module-level state to persist expansion across re-renders
29
- const treeState = new Map<
30
- string,
31
- {
32
- expandedFolders: Set<string>;
33
- folderChildrenCache: Map<string, any[]>;
34
- loadedFolders: Set<string>;
35
- loadingFolders: Set<string>;
36
- }
37
- >();
38
-
39
- const TREE_REFRESH_EVENT = 'resource-tree-refresh';
40
-
41
- const emitTreeRefresh = (knowledgeBaseId: string) => {
42
- if (typeof window === 'undefined') return;
43
- window.dispatchEvent(
44
- new CustomEvent(TREE_REFRESH_EVENT, {
45
- detail: { knowledgeBaseId },
46
- }),
47
- );
48
- };
49
-
50
- const getTreeState = (knowledgeBaseId: string) => {
51
- if (!treeState.has(knowledgeBaseId)) {
52
- treeState.set(knowledgeBaseId, {
53
- expandedFolders: new Set(),
54
- folderChildrenCache: new Map(),
55
- loadedFolders: new Set(),
56
- loadingFolders: new Set(),
57
- });
58
- }
59
- return treeState.get(knowledgeBaseId)!;
60
- };
61
-
62
- /**
63
- * Clear and reload all expanded folders
64
- * This should be called along with file store's refreshFileList()
65
- * Simpler approach: reload all expanded folders to avoid ID vs slug issues
66
- */
67
- export const clearTreeFolderCache = async (knowledgeBaseId: string) => {
68
- const state = treeState.get(knowledgeBaseId);
69
- if (!state) return;
70
-
71
- const { resourceList } = useFileStore.getState();
72
-
73
- const resolveParentId = (key: string | null | undefined) => {
74
- if (!key) return null;
75
- // Prefer id match
76
- const byId = resourceList.find(
77
- (item) => item.knowledgeBaseId === knowledgeBaseId && item.id === key,
78
- );
79
- if (byId) return byId.id;
80
- // Fallback to slug match
81
- const bySlug = resourceList.find(
82
- (item) => item.knowledgeBaseId === knowledgeBaseId && item.slug === key,
83
- );
84
- return bySlug?.id ?? key;
85
- };
86
-
87
- const buildChildrenFromStore = (parentKey: string | null) => {
88
- const parentId = resolveParentId(parentKey);
89
- return resourceList
90
- .filter(
91
- (item) =>
92
- item.knowledgeBaseId === knowledgeBaseId &&
93
- (item.parentId ?? null) === (parentId ?? null),
94
- )
95
- .map<ResourceItem>((item) => item)
96
- .map((item) => ({
97
- fileType: item.fileType,
98
- id: item.id,
99
- isFolder: item.fileType === 'custom/folder',
100
- name: item.name,
101
- slug: item.slug,
102
- sourceType: item.sourceType,
103
- url: item.url || '',
104
- }))
105
- .sort((a, b) => {
106
- if (a.isFolder && !b.isFolder) return -1;
107
- if (!a.isFolder && b.isFolder) return 1;
108
- return a.name.localeCompare(b.name);
109
- });
110
- };
111
-
112
- // Get list of all currently expanded folders before clearing
113
- const expandedFoldersList = Array.from(state.expandedFolders);
114
-
115
- // Clear all caches
116
- state.folderChildrenCache.clear();
117
- state.loadedFolders.clear();
118
-
119
- // Reload each expanded folder
120
- for (const folderKey of expandedFoldersList) {
121
- // Prefer local store (explorer data) to avoid stale remote state
122
- const localChildren = buildChildrenFromStore(folderKey);
123
- if (localChildren.length > 0) {
124
- state.folderChildrenCache.set(folderKey, localChildren);
125
- state.loadedFolders.add(folderKey);
126
- continue;
127
- }
128
-
129
- // Fallback to remote fetch if store has no data (e.g., initial load)
130
- try {
131
- const response = await fileService.getKnowledgeItems({
132
- knowledgeBaseId,
133
- parentId: folderKey,
134
- showFilesInKnowledgeBase: false,
135
- });
136
-
137
- if (response?.items) {
138
- const childItems = response.items.map((item) => ({
139
- fileType: item.fileType,
140
- id: item.id,
141
- isFolder: item.fileType === 'custom/folder',
142
- name: item.name,
143
- slug: item.slug,
144
- sourceType: item.sourceType,
145
- url: item.url,
146
- }));
147
-
148
- // Sort children: folders first, then files
149
- const sortedChildren = childItems.sort((a, b) => {
150
- if (a.isFolder && !b.isFolder) return -1;
151
- if (!a.isFolder && b.isFolder) return 1;
152
- return a.name.localeCompare(b.name);
153
- });
154
-
155
- state.folderChildrenCache.set(folderKey, sortedChildren);
156
- state.loadedFolders.add(folderKey);
157
- }
158
- } catch (error) {
159
- console.error(`Failed to reload folder ${folderKey}:`, error);
160
- }
161
- }
162
-
163
- // Revalidate SWR caches for root and expanded folders to keep list and tree in sync
164
- try {
165
- const { mutate } = await import('swr');
166
- const revalidateFolder = (parentId: string | null) =>
167
- mutate(
168
- [
169
- 'useFetchKnowledgeItems',
170
- {
171
- knowledgeBaseId,
172
- parentId,
173
- showFilesInKnowledgeBase: false,
174
- },
175
- ],
176
- undefined,
177
- { revalidate: true },
178
- );
179
-
180
- await Promise.all([
181
- revalidateFolder(null),
182
- ...expandedFoldersList.map((folderKey) => revalidateFolder(folderKey)),
183
- ]);
184
- } catch (error) {
185
- console.error('Failed to revalidate tree SWR cache:', error);
186
- }
187
-
188
- emitTreeRefresh(knowledgeBaseId);
189
- };
190
-
191
- const styles = createStaticStyles(({ css, cssVar }) => ({
192
- dragging: css`
193
- will-change: transform;
194
- opacity: 0.5;
195
- `,
196
- fileItemDragOver: css`
197
- color: ${cssVar.colorBgElevated} !important;
198
- background-color: ${cssVar.colorText} !important;
199
-
200
- * {
201
- color: ${cssVar.colorBgElevated} !important;
202
- }
203
- `,
204
- treeItem: css`
205
- cursor: pointer;
206
- `,
207
- }));
208
-
209
- interface TreeItem {
210
- children?: TreeItem[];
211
- fileType: string;
212
- id: string;
213
- isFolder: boolean;
214
- name: string;
215
- slug?: string | null;
216
- sourceType?: string;
217
- url: string;
218
- }
219
-
220
- // Row component for folder / file tree (virtualized by flattening visible nodes)
221
- const FileTreeRow = memo<{
222
- expandedFolders: Set<string>;
223
- folderChildrenCache: Map<string, TreeItem[]>;
224
- item: TreeItem;
225
- level?: number;
226
- loadingFolders: Set<string>;
227
- onLoadFolder: (_: string) => Promise<void>;
228
- onToggleFolder: (_: string) => void;
229
- selectedKey: string | null;
230
- updateKey?: number;
231
- }>(
232
- ({
233
- item,
234
- level = 0,
235
- expandedFolders,
236
- loadingFolders,
237
- onToggleFolder,
238
- onLoadFolder,
239
- selectedKey,
240
-
241
- folderChildrenCache,
242
- }) => {
243
- const navigate = useNavigate();
244
- const { currentFolderSlug } = useFolderPath();
245
- const { message } = App.useApp();
246
-
247
- const [setMode, setCurrentViewItemId, libraryId] = useResourceManagerStore((s) => [
248
- s.setMode,
249
- s.setCurrentViewItemId,
250
- s.libraryId,
251
- ]);
252
-
253
- const renameFolder = useFileStore((s) => s.renameFolder);
254
-
255
- const [isRenaming, setIsRenaming] = useState(false);
256
- const [renamingValue, setRenamingValue] = useState(item.name);
257
- const inputRef = useRef<any>(null);
258
-
259
- // Memoize computed values that don't change frequently
260
- const { itemKey } = useMemo(
261
- () => ({
262
- itemKey: item.slug || item.id,
263
- }),
264
- [item.slug, item.id],
265
- );
266
-
267
- const handleRenameStart = useCallback(() => {
268
- setIsRenaming(true);
269
- setRenamingValue(item.name);
270
- // Focus input after render
271
- setTimeout(() => {
272
- inputRef.current?.focus();
273
- inputRef.current?.select();
274
- }, 0);
275
- }, [item.name]);
276
-
277
- const handleRenameConfirm = useCallback(async () => {
278
- if (!renamingValue.trim()) {
279
- message.error('Folder name cannot be empty');
280
- return;
281
- }
282
-
283
- if (renamingValue.trim() === item.name) {
284
- setIsRenaming(false);
285
- return;
286
- }
287
-
288
- try {
289
- await renameFolder(item.id, renamingValue.trim());
290
- if (libraryId) {
291
- await clearTreeFolderCache(libraryId);
292
- }
293
- message.success('Renamed successfully');
294
- setIsRenaming(false);
295
- } catch (error) {
296
- console.error('Rename error:', error);
297
- message.error('Rename failed');
298
- }
299
- }, [item.id, item.name, libraryId, renamingValue, renameFolder, message]);
300
-
301
- const handleRenameCancel = useCallback(() => {
302
- setIsRenaming(false);
303
- setRenamingValue(item.name);
304
- }, [item.name]);
305
-
306
- const { menuItems } = useFileItemDropdown({
307
- fileType: item.fileType,
308
- filename: item.name,
309
- id: item.id,
310
- libraryId,
311
- onRenameStart: item.isFolder ? handleRenameStart : undefined,
312
- sourceType: item.sourceType,
313
- url: item.url,
314
- });
315
-
316
- const isDragActive = useDragActive();
317
- const { setCurrentDrag } = useDragState();
318
- const [isDragging, setIsDragging] = useState(false);
319
- const [isOver, setIsOver] = useState(false);
320
-
321
- // Memoize drag data to prevent recreation
322
- const dragData = useMemo(
323
- () => ({
324
- fileType: item.fileType,
325
- isFolder: item.isFolder,
326
- name: item.name,
327
- sourceType: item.sourceType,
328
- }),
329
- [item.fileType, item.isFolder, item.name, item.sourceType],
330
- );
331
-
332
- // Native HTML5 drag event handlers
333
- const handleDragStart = useCallback(
334
- (e: React.DragEvent) => {
335
- setIsDragging(true);
336
- setCurrentDrag({
337
- data: dragData,
338
- id: item.id,
339
- type: item.isFolder ? 'folder' : 'file',
340
- });
341
-
342
- // Set drag image to be transparent (we use custom overlay)
343
- const img = getTransparentDragImage();
344
- if (img) {
345
- e.dataTransfer.setDragImage(img, 0, 0);
346
- }
347
- e.dataTransfer.effectAllowed = 'move';
348
- },
349
- [dragData, item.id, item.isFolder, setCurrentDrag],
350
- );
351
-
352
- const handleDragEnd = useCallback(() => {
353
- setIsDragging(false);
354
- }, []);
355
-
356
- const handleDragOver = useCallback(
357
- (e: React.DragEvent) => {
358
- if (!item.isFolder || !isDragActive) return;
359
-
360
- e.preventDefault();
361
- e.stopPropagation();
362
- setIsOver(true);
363
- },
364
- [item.isFolder, isDragActive],
365
- );
366
-
367
- const handleDragLeave = useCallback(() => {
368
- setIsOver(false);
369
- }, []);
370
-
371
- const handleDrop = useCallback(() => {
372
- // Clear the highlight after drop
373
- setIsOver(false);
374
- }, []);
375
-
376
- const handleItemClick = useCallback(() => {
377
- // Open file modal using slug-based routing
378
- const currentPath = currentFolderSlug
379
- ? `/resource/library/${libraryId}/${currentFolderSlug}`
380
- : `/resource/library/${libraryId}`;
381
-
382
- setCurrentViewItemId(itemKey);
383
- navigate(`${currentPath}?file=${itemKey}`);
384
-
385
- if (itemKey.startsWith('doc')) {
386
- setMode('page');
387
- } else {
388
- // Set mode to 'file' immediately to prevent flickering to list view
389
- setMode('editor');
390
- }
391
- }, [itemKey, currentFolderSlug, libraryId, navigate, setMode, setCurrentViewItemId]);
392
-
393
- const handleFolderClick = useCallback(
394
- (folderId: string, folderSlug?: string | null) => {
395
- const navKey = folderSlug || folderId;
396
- navigate(`/resource/library/${libraryId}/${navKey}`);
397
-
398
- setMode('explorer');
399
- },
400
- [libraryId, navigate],
401
- );
402
-
403
- if (item.isFolder) {
404
- const isExpanded = expandedFolders.has(itemKey);
405
- const isActive = selectedKey === itemKey;
406
- const isLoading = loadingFolders.has(itemKey);
407
-
408
- const handleToggle = async () => {
409
- // Toggle folder expansion
410
- onToggleFolder(itemKey);
411
-
412
- // Only load if not already cached
413
- if (!isExpanded && !folderChildrenCache.has(itemKey)) {
414
- await onLoadFolder(itemKey);
415
- }
416
- };
417
-
418
- return (
419
- <Flexbox gap={2}>
420
- <Block
421
- align={'center'}
422
- className={cx(
423
- styles.treeItem,
424
- isOver && styles.fileItemDragOver,
425
- isDragging && styles.dragging,
426
- )}
427
- clickable
428
- data-drop-target-id={item.id}
429
- data-is-folder={String(item.isFolder)}
430
- draggable
431
- gap={8}
432
- height={36}
433
- horizontal
434
- onClick={() => handleFolderClick(item.id, item.slug)}
435
- onContextMenu={(e) => {
436
- e.preventDefault();
437
- showContextMenu(menuItems());
438
- }}
439
- onDragEnd={handleDragEnd}
440
- onDragLeave={handleDragLeave}
441
- onDragOver={handleDragOver}
442
- onDragStart={handleDragStart}
443
- onDrop={handleDrop}
444
- paddingInline={4}
445
- style={{
446
- paddingInlineStart: level * 12 + 4,
447
- }}
448
- variant={isActive ? 'filled' : 'borderless'}
449
- >
450
- {isLoading ? (
451
- <ActionIcon icon={LoadingOutlined as any} size={'small'} spin style={{ width: 20 }} />
452
- ) : (
453
- <motion.div
454
- animate={{ rotate: isExpanded ? 0 : -90 }}
455
- initial={false}
456
- transition={{ duration: 0.2, ease: 'easeInOut' }}
457
- >
458
- <ActionIcon
459
- icon={CaretDownFilled as any}
460
- onClick={(e) => {
461
- e.stopPropagation();
462
- handleToggle();
463
- }}
464
- size={'small'}
465
- style={{ width: 20 }}
466
- />
467
- </motion.div>
468
- )}
469
- <Flexbox
470
- align={'center'}
471
- flex={1}
472
- gap={8}
473
- horizontal
474
- style={{ minHeight: 28, minWidth: 0, overflow: 'hidden' }}
475
- >
476
- <Icon icon={isExpanded ? FolderOpenIcon : FolderIcon} size={18} />
477
- {isRenaming ? (
478
- <Input
479
- onBlur={handleRenameConfirm}
480
- onChange={(e) => setRenamingValue(e.target.value)}
481
- onClick={(e) => e.stopPropagation()}
482
- onKeyDown={(e) => {
483
- if (e.key === 'Enter') {
484
- e.preventDefault();
485
- handleRenameConfirm();
486
- } else if (e.key === 'Escape') {
487
- e.preventDefault();
488
- handleRenameCancel();
489
- }
490
- }}
491
- onPointerDown={(e) => e.stopPropagation()}
492
- ref={inputRef}
493
- size="small"
494
- style={{ flex: 1 }}
495
- value={renamingValue}
496
- />
497
- ) : (
498
- <span
499
- style={{
500
- flex: 1,
501
- overflow: 'hidden',
502
- textOverflow: 'ellipsis',
503
- whiteSpace: 'nowrap',
504
- }}
505
- >
506
- {item.name}
507
- </span>
508
- )}
509
- </Flexbox>
510
- </Block>
511
- </Flexbox>
512
- );
513
- }
514
-
515
- // Render as file
516
- const isActive = selectedKey === itemKey;
517
- return (
518
- <Flexbox gap={2}>
519
- <Block
520
- align={'center'}
521
- className={cx(styles.treeItem, isDragging && styles.dragging)}
522
- clickable
523
- data-drop-target-id={item.id}
524
- data-is-folder={false}
525
- draggable
526
- gap={8}
527
- height={36}
528
- horizontal
529
- onClick={handleItemClick}
530
- onContextMenu={(e) => {
531
- e.preventDefault();
532
- showContextMenu(menuItems());
533
- }}
534
- onDragEnd={handleDragEnd}
535
- onDragStart={handleDragStart}
536
- paddingInline={4}
537
- style={{
538
- paddingInlineStart: level * 12 + 4,
539
- }}
540
- variant={isActive ? 'filled' : 'borderless'}
541
- >
542
- <div style={{ width: 20 }} />
543
- <Flexbox
544
- align={'center'}
545
- flex={1}
546
- gap={8}
547
- horizontal
548
- style={{ minHeight: 28, minWidth: 0, overflow: 'hidden' }}
549
- >
550
- {item.sourceType === 'document' ? (
551
- <Icon icon={FileText} size={18} />
552
- ) : (
553
- <FileIcon fileName={item.name} fileType={item.fileType} size={18} />
554
- )}
555
- <span
556
- style={{
557
- flex: 1,
558
- overflow: 'hidden',
559
- textOverflow: 'ellipsis',
560
- whiteSpace: 'nowrap',
561
- }}
562
- >
563
- {item.name}
564
- </span>
565
- </Flexbox>
566
- </Block>
567
- </Flexbox>
568
- );
569
- },
570
- );
571
-
572
- FileTreeRow.displayName = 'FileTreeRow';
573
-
574
- /**
575
- * As a sidebar along with the Explorer to work
576
- */
577
- const FileTree = memo(() => {
578
- const { currentFolderSlug } = useFolderPath();
579
-
580
- const [useFetchKnowledgeItems, useFetchFolderBreadcrumb, useFetchKnowledgeItem] = useFileStore(
581
- (s) => [s.useFetchKnowledgeItems, s.useFetchFolderBreadcrumb, s.useFetchKnowledgeItem],
582
- );
583
-
584
- const [libraryId, currentViewItemId] = useResourceManagerStore((s) => [
585
- s.libraryId,
586
- s.currentViewItemId,
587
- ]);
588
-
589
- // Force re-render when tree state changes
590
- const [updateKey, forceUpdate] = useReducer((x) => x + 1, 0);
591
-
592
- // Get the persisted state for this knowledge base
593
- const state = React.useMemo(() => getTreeState(libraryId || ''), [libraryId]);
594
- const { expandedFolders, folderChildrenCache, loadingFolders } = state;
595
-
596
- // Fetch breadcrumb for current folder
597
- const { data: folderBreadcrumb } = useFetchFolderBreadcrumb(currentFolderSlug);
598
-
599
- // Fetch current file when viewing a file
600
- const { data: currentFile } = useFetchKnowledgeItem(currentViewItemId);
601
-
602
- // Track parent folder key for file selection - stored in a ref to avoid hook order issues
603
- const parentFolderKeyRef = React.useRef<string | null>(null);
604
-
605
- // Fetch root level data using SWR
606
- const { data: rootData, isLoading } = useFetchKnowledgeItems({
607
- knowledgeBaseId: libraryId,
608
- parentId: null,
609
- showFilesInKnowledgeBase: false,
610
- });
611
-
612
- // Sort items: folders first, then files
613
- const sortItems = useCallback(<T extends TreeItem>(items: T[]): T[] => {
614
- return [...items].sort((a, b) => {
615
- // Folders first
616
- if (a.isFolder && !b.isFolder) return -1;
617
- if (!a.isFolder && b.isFolder) return 1;
618
- // Then alphabetically by name
619
- return a.name.localeCompare(b.name);
620
- });
621
- }, []);
622
-
623
- // Convert root data to tree items
624
- const items: TreeItem[] = React.useMemo(() => {
625
- if (!rootData) return [];
626
-
627
- const mappedItems: TreeItem[] = rootData.map((item) => ({
628
- fileType: item.fileType,
629
- id: item.id,
630
- isFolder: item.fileType === 'custom/folder',
631
- name: item.name,
632
- slug: item.slug,
633
- sourceType: item.sourceType,
634
- url: item.url,
635
- }));
636
-
637
- return sortItems(mappedItems);
638
- }, [rootData, sortItems, updateKey]);
639
-
640
- const visibleNodes = React.useMemo(() => {
641
- interface VisibleNode {
642
- item: TreeItem;
643
- key: string;
644
- level: number;
645
- }
646
-
647
- const result: VisibleNode[] = [];
648
-
649
- const walk = (nodes: TreeItem[], level: number) => {
650
- for (const node of nodes) {
651
- const key = node.slug || node.id;
652
-
653
- result.push({ item: node, key, level });
654
-
655
- if (!node.isFolder) continue;
656
- if (!expandedFolders.has(key)) continue;
657
-
658
- const children = folderChildrenCache.get(key);
659
- if (!children || children.length === 0) continue;
660
-
661
- walk(children, level + 1);
662
- }
663
- };
664
-
665
- walk(items, 0);
666
-
667
- return result;
668
- // NOTE: expandedFolders / folderChildrenCache are mutated in-place, so rely on updateKey for recompute
669
- }, [items, expandedFolders, folderChildrenCache, updateKey]);
670
-
671
- const handleLoadFolder = useCallback(
672
- async (folderId: string) => {
673
- // Set loading state
674
- state.loadingFolders.add(folderId);
675
- forceUpdate();
676
-
677
- try {
678
- // Use SWR mutate to trigger a fetch that will be cached and shared with FileExplorer
679
- const { mutate: swrMutate } = await import('swr');
680
- const response = await swrMutate(
681
- [
682
- 'useFetchKnowledgeItems',
683
- {
684
- knowledgeBaseId: libraryId,
685
- parentId: folderId,
686
- showFilesInKnowledgeBase: false,
687
- },
688
- ],
689
- () =>
690
- fileService.getKnowledgeItems({
691
- knowledgeBaseId: libraryId,
692
- parentId: folderId,
693
- showFilesInKnowledgeBase: false,
694
- }),
695
- {
696
- revalidate: false, // Don't revalidate immediately after mutation
697
- },
698
- );
699
-
700
- if (!response || !response.items) {
701
- console.error('Failed to load folder contents: no data returned');
702
- return;
703
- }
704
-
705
- const childItems: TreeItem[] = response.items.map((item) => ({
706
- fileType: item.fileType,
707
- id: item.id,
708
- isFolder: item.fileType === 'custom/folder',
709
- name: item.name,
710
- slug: item.slug,
711
- sourceType: item.sourceType,
712
- url: item.url,
713
- }));
714
-
715
- // Sort children: folders first, then files
716
- const sortedChildren = sortItems(childItems);
717
-
718
- // Store children in cache
719
- state.folderChildrenCache.set(folderId, sortedChildren);
720
- state.loadedFolders.add(folderId);
721
- } catch (error) {
722
- console.error('Failed to load folder contents:', error);
723
- } finally {
724
- // Clear loading state
725
- state.loadingFolders.delete(folderId);
726
- // Trigger re-render
727
- forceUpdate();
728
- }
729
- },
730
- [libraryId, sortItems, state, forceUpdate],
731
- );
732
-
733
- const handleToggleFolder = useCallback(
734
- (folderId: string) => {
735
- if (state.expandedFolders.has(folderId)) {
736
- state.expandedFolders.delete(folderId);
737
- } else {
738
- state.expandedFolders.add(folderId);
739
- }
740
- // Trigger re-render
741
- forceUpdate();
742
- },
743
- [state, forceUpdate],
744
- );
745
-
746
- // Reset parent folder key when switching libraries
747
- React.useEffect(() => {
748
- parentFolderKeyRef.current = null;
749
- }, [libraryId]);
750
-
751
- // Listen for external tree refresh events (triggered when cache is cleared)
752
- React.useEffect(() => {
753
- if (typeof window === 'undefined') return;
754
-
755
- const handleTreeRefresh = (event: Event) => {
756
- const detail = (event as CustomEvent<{ knowledgeBaseId?: string }>).detail;
757
- if (detail?.knowledgeBaseId && libraryId && detail.knowledgeBaseId !== libraryId) return;
758
- forceUpdate();
759
- };
760
-
761
- window.addEventListener(TREE_REFRESH_EVENT, handleTreeRefresh);
762
- return () => {
763
- window.removeEventListener(TREE_REFRESH_EVENT, handleTreeRefresh);
764
- };
765
- }, [libraryId, forceUpdate]);
766
-
767
- // Auto-expand folders when navigating to a folder in Explorer
768
- React.useEffect(() => {
769
- if (!folderBreadcrumb || folderBreadcrumb.length === 0) return;
770
-
771
- let hasChanges = false;
772
-
773
- // Expand all folders in the breadcrumb path
774
- for (const crumb of folderBreadcrumb) {
775
- const key = crumb.slug || crumb.id;
776
- if (!state.expandedFolders.has(key)) {
777
- state.expandedFolders.add(key);
778
- hasChanges = true;
779
- }
780
-
781
- // Load folder contents if not already loaded
782
- if (!state.loadedFolders.has(key) && !state.loadingFolders.has(key)) {
783
- handleLoadFolder(key);
784
- }
785
- }
786
-
787
- if (hasChanges) {
788
- forceUpdate();
789
- }
790
- }, [folderBreadcrumb, state, forceUpdate, handleLoadFolder]);
791
-
792
- // Auto-expand parent folder when viewing a file
793
- React.useEffect(() => {
794
- if (!currentFile || !currentViewItemId) {
795
- parentFolderKeyRef.current = null;
796
- return;
797
- }
798
-
799
- // If the file has a parent folder, expand the path to it
800
- if (currentFile.parentId) {
801
- // Fetch the parent folder's breadcrumb to get the full path
802
- const fetchParentPath = async () => {
803
- try {
804
- const parentBreadcrumb = await fileService.getFolderBreadcrumb(currentFile.parentId!);
805
-
806
- if (!parentBreadcrumb || parentBreadcrumb.length === 0) return;
807
-
808
- let hasChanges = false;
809
-
810
- // The last item in breadcrumb is the immediate parent folder
811
- const parentFolder = parentBreadcrumb.at(-1)!;
812
- const parentKey = parentFolder.slug || parentFolder.id;
813
- parentFolderKeyRef.current = parentKey;
814
-
815
- // Expand all folders in the parent's breadcrumb path
816
- for (const crumb of parentBreadcrumb) {
817
- const key = crumb.slug || crumb.id;
818
- if (!state.expandedFolders.has(key)) {
819
- state.expandedFolders.add(key);
820
- hasChanges = true;
821
- }
822
-
823
- // Load folder contents if not already loaded
824
- if (!state.loadedFolders.has(key) && !state.loadingFolders.has(key)) {
825
- handleLoadFolder(key);
826
- }
827
- }
828
-
829
- if (hasChanges) {
830
- forceUpdate();
831
- }
832
- } catch (error) {
833
- console.error('Failed to fetch parent folder breadcrumb:', error);
834
- }
835
- };
836
-
837
- fetchParentPath();
838
- } else {
839
- parentFolderKeyRef.current = null;
840
- }
841
- }, [currentFile, currentViewItemId, state, forceUpdate, handleLoadFolder]);
842
-
843
- if (isLoading) {
844
- return <TreeSkeleton />;
845
- }
846
-
847
- // Determine which item should be highlighted
848
- // If viewing a file, highlight its parent folder
849
- // Otherwise, highlight the current folder
850
- const selectedKey =
851
- currentViewItemId && parentFolderKeyRef.current
852
- ? parentFolderKeyRef.current
853
- : currentFolderSlug;
854
-
855
- return (
856
- <Flexbox paddingInline={4} style={{ height: '100%' }}>
857
- <VList
858
- bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
859
- style={{ height: '100%' }}
860
- >
861
- {visibleNodes.map(({ item, key, level }) => (
862
- <div key={key} style={{ paddingBottom: 2 }}>
863
- <FileTreeRow
864
- expandedFolders={expandedFolders}
865
- folderChildrenCache={folderChildrenCache}
866
- item={item}
867
- level={level}
868
- loadingFolders={loadingFolders}
869
- onLoadFolder={handleLoadFolder}
870
- onToggleFolder={handleToggleFolder}
871
- selectedKey={selectedKey}
872
- updateKey={updateKey}
873
- />
874
- </div>
875
- ))}
876
- </VList>
877
- </Flexbox>
878
- );
879
- });
880
-
881
- FileTree.displayName = 'FileTree';
882
-
883
- export default FileTree;