@lobehub/lobehub 2.0.0-next.54 → 2.0.0-next.55

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 (152) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/changelog/v1.json +9 -0
  3. package/locales/ar/common.json +1 -0
  4. package/locales/ar/file.json +85 -2
  5. package/locales/bg-BG/common.json +1 -0
  6. package/locales/bg-BG/file.json +85 -2
  7. package/locales/de-DE/common.json +1 -0
  8. package/locales/de-DE/file.json +85 -2
  9. package/locales/en-US/common.json +1 -0
  10. package/locales/en-US/file.json +85 -2
  11. package/locales/es-ES/common.json +1 -0
  12. package/locales/es-ES/file.json +85 -2
  13. package/locales/fa-IR/common.json +1 -0
  14. package/locales/fa-IR/file.json +85 -2
  15. package/locales/fr-FR/common.json +1 -0
  16. package/locales/fr-FR/file.json +85 -2
  17. package/locales/it-IT/common.json +1 -0
  18. package/locales/it-IT/file.json +85 -2
  19. package/locales/ja-JP/common.json +1 -0
  20. package/locales/ja-JP/file.json +85 -2
  21. package/locales/ko-KR/common.json +1 -0
  22. package/locales/ko-KR/file.json +85 -2
  23. package/locales/nl-NL/common.json +1 -0
  24. package/locales/nl-NL/file.json +85 -2
  25. package/locales/pl-PL/common.json +1 -0
  26. package/locales/pl-PL/file.json +85 -2
  27. package/locales/pt-BR/common.json +1 -0
  28. package/locales/pt-BR/file.json +85 -2
  29. package/locales/ru-RU/common.json +1 -0
  30. package/locales/ru-RU/file.json +85 -2
  31. package/locales/tr-TR/common.json +1 -0
  32. package/locales/tr-TR/file.json +85 -2
  33. package/locales/vi-VN/common.json +1 -0
  34. package/locales/vi-VN/file.json +85 -2
  35. package/locales/zh-CN/common.json +1 -0
  36. package/locales/zh-CN/file.json +85 -2
  37. package/locales/zh-TW/common.json +1 -0
  38. package/locales/zh-TW/file.json +85 -2
  39. package/package.json +1 -1
  40. package/packages/database/src/models/__tests__/file.test.ts +94 -29
  41. package/packages/database/src/models/file.ts +15 -4
  42. package/packages/database/src/repositories/knowledge/index.test.ts +300 -0
  43. package/packages/database/src/repositories/knowledge/index.ts +420 -0
  44. package/packages/model-bank/src/aiModels/aihubmix.ts +1 -0
  45. package/packages/model-bank/src/aiModels/google.ts +9 -5
  46. package/packages/model-bank/src/aiModels/openai.ts +2 -35
  47. package/packages/model-bank/src/aiModels/openrouter.ts +1 -0
  48. package/packages/model-bank/src/aiModels/vertexai.ts +2 -0
  49. package/packages/model-bank/src/types/aiModel.ts +15 -2
  50. package/packages/model-runtime/src/core/usageConverters/index.ts +1 -0
  51. package/packages/model-runtime/src/core/usageConverters/utils/resolveImageSinglePrice.ts +34 -0
  52. package/packages/types/src/document/index.ts +14 -2
  53. package/packages/types/src/files/index.ts +2 -0
  54. package/packages/types/src/files/list.ts +10 -0
  55. package/packages/types/src/llm.ts +1 -1
  56. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ModelSelect/ImageModelItem.tsx +93 -0
  57. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/{ModelSelect.tsx → ModelSelect/index.tsx} +17 -2
  58. package/src/app/[variants]/(main)/knowledge/KnowledgeRouter.tsx +2 -1
  59. package/src/app/[variants]/(main)/knowledge/components/KnowledgeBaseItem/index.tsx +0 -2
  60. package/src/app/[variants]/(main)/knowledge/hooks/useFileCategory.ts +6 -3
  61. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeBaseDetail/index.tsx +2 -2
  62. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeBaseDetail/menu/{MenuItems.tsx → CategoryMenu.tsx} +3 -3
  63. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeBaseDetail/menu/Menu.tsx +2 -2
  64. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeHome/index.tsx +40 -18
  65. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeHome/layout/Container.tsx +1 -1
  66. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeHome/menu/CategoryMenu.tsx +148 -0
  67. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeHome/menu/KnowledgeBase.tsx +20 -7
  68. package/src/components/FileIcon/index.tsx +3 -1
  69. package/src/features/ChatInput/ActionBar/Knowledge/index.tsx +2 -2
  70. package/src/features/FileSidePanel/index.tsx +1 -1
  71. package/src/features/KnowledgeBaseModal/AssignKnowledgeBase/Item/MasonryItem.tsx +80 -0
  72. package/src/features/KnowledgeBaseModal/AssignKnowledgeBase/Item/MasonryItemWrapper.tsx +27 -0
  73. package/src/features/KnowledgeBaseModal/AssignKnowledgeBase/List.tsx +104 -23
  74. package/src/features/KnowledgeBaseModal/AssignKnowledgeBase/MasonrySkeleton.tsx +62 -0
  75. package/src/features/KnowledgeBaseModal/AssignKnowledgeBase/index.tsx +3 -2
  76. package/src/features/KnowledgeBaseModal/CreateNew/CreateForm.tsx +1 -1
  77. package/src/features/KnowledgeManager/DocumentExplorer/DocumentActions.tsx +111 -0
  78. package/src/features/KnowledgeManager/DocumentExplorer/DocumentEditor.tsx +723 -0
  79. package/src/features/KnowledgeManager/DocumentExplorer/DocumentEditorPlaceholder.tsx +169 -0
  80. package/src/features/KnowledgeManager/DocumentExplorer/DocumentListItem.tsx +148 -0
  81. package/src/features/KnowledgeManager/DocumentExplorer/DocumentListSkeleton.tsx +39 -0
  82. package/src/features/KnowledgeManager/DocumentExplorer/NoteEditorModal.tsx +348 -0
  83. package/src/features/KnowledgeManager/DocumentExplorer/RenamePopover.tsx +163 -0
  84. package/src/features/KnowledgeManager/DocumentExplorer/index.tsx +318 -0
  85. package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/FileListItem/index.tsx +48 -9
  86. package/src/features/KnowledgeManager/FileExplorer/MasonryFileItem/DefaultFileItem.tsx +149 -0
  87. package/src/features/KnowledgeManager/FileExplorer/MasonryFileItem/ImageFileItem.tsx +245 -0
  88. package/src/features/KnowledgeManager/FileExplorer/MasonryFileItem/MarkdownFileItem.tsx +232 -0
  89. package/src/features/KnowledgeManager/FileExplorer/MasonryFileItem/NoteFileItem.tsx +230 -0
  90. package/src/features/KnowledgeManager/FileExplorer/MasonryFileItem/index.tsx +398 -0
  91. package/src/features/KnowledgeManager/FileExplorer/ToolBar/ViewSwitcher.tsx +45 -0
  92. package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/index.tsx +68 -16
  93. package/src/features/KnowledgeManager/Header/AddButton.tsx +107 -0
  94. package/src/features/KnowledgeManager/Header/NewNoteButton.tsx +33 -0
  95. package/src/features/{FileManager → KnowledgeManager}/Header/index.tsx +3 -9
  96. package/src/features/KnowledgeManager/Home/RecentDocumentCard.tsx +116 -0
  97. package/src/features/KnowledgeManager/Home/RecentDocuments.tsx +77 -0
  98. package/src/features/KnowledgeManager/Home/RecentFileCard.tsx +121 -0
  99. package/src/features/KnowledgeManager/Home/RecentFiles.tsx +73 -0
  100. package/src/features/KnowledgeManager/Home/RecentFilesSkeleton.tsx +83 -0
  101. package/src/features/KnowledgeManager/Home/UploadEntries.tsx +208 -0
  102. package/src/features/KnowledgeManager/Home/index.tsx +221 -0
  103. package/src/features/KnowledgeManager/index.tsx +75 -0
  104. package/src/features/Portal/FilePreview/Body/index.tsx +1 -1
  105. package/src/features/Portal/FilePreview/Header.tsx +1 -1
  106. package/src/locales/default/common.ts +1 -0
  107. package/src/locales/default/file.ts +85 -2
  108. package/src/server/routers/lambda/__tests__/file.test.ts +85 -6
  109. package/src/server/routers/lambda/document.ts +57 -0
  110. package/src/server/routers/lambda/file.ts +72 -0
  111. package/src/server/routers/lambda/knowledge.ts +94 -0
  112. package/src/server/services/document/index.ts +103 -0
  113. package/src/services/document/index.ts +44 -0
  114. package/src/services/file/index.ts +5 -3
  115. package/src/store/aiInfra/slices/aiProvider/__tests__/action.test.ts +125 -229
  116. package/src/store/aiInfra/slices/aiProvider/action.ts +113 -33
  117. package/src/store/file/initialState.ts +6 -1
  118. package/src/store/file/slices/chat/action.ts +3 -3
  119. package/src/store/file/slices/document/action.ts +359 -0
  120. package/src/store/file/slices/document/index.ts +3 -0
  121. package/src/store/file/slices/document/initialState.ts +22 -0
  122. package/src/store/file/slices/document/selectors.ts +25 -0
  123. package/src/store/file/slices/fileManager/action.test.ts +16 -9
  124. package/src/store/file/slices/fileManager/action.ts +11 -11
  125. package/src/store/file/store.ts +3 -0
  126. package/src/store/global/initialState.ts +3 -1
  127. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeHome/menu/FileMenu.tsx +0 -75
  128. package/src/features/FileManager/FileList/MasonryFileItem/index.tsx +0 -582
  129. package/src/features/FileManager/index.tsx +0 -36
  130. /package/src/features/{FileManager/FileList/ToolBar → KnowledgeBaseModal/AssignKnowledgeBase}/ViewSwitcher.tsx +0 -0
  131. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/ChunkList/ChunkItem.tsx +0 -0
  132. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/ChunkList/index.tsx +0 -0
  133. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/Content.tsx +0 -0
  134. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/Loading/index.tsx +0 -0
  135. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/SimilaritySearchList/Item.tsx +0 -0
  136. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/SimilaritySearchList/index.tsx +0 -0
  137. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/index.tsx +0 -0
  138. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/EmptyStatus.tsx +0 -0
  139. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/FileListItem/ChunkTag.tsx +0 -0
  140. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/FileListItem/DropdownMenu.tsx +0 -0
  141. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/FileSkeleton.tsx +0 -0
  142. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/MasonryFileItem/MasonryItemWrapper.tsx +0 -0
  143. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/MasonrySkeleton.tsx +0 -0
  144. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/ToolBar/Config.tsx +0 -0
  145. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/ToolBar/MultiSelectActions.tsx +0 -0
  146. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/ToolBar/index.tsx +0 -0
  147. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/useCheckTaskStatus.ts +0 -0
  148. /package/src/features/{FileManager → KnowledgeManager}/Header/FilesSearchBar.tsx +0 -0
  149. /package/src/features/{FileManager → KnowledgeManager}/Header/TogglePanelButton.tsx +0 -0
  150. /package/src/features/{FileManager → KnowledgeManager}/Header/UploadFileButton.tsx +0 -0
  151. /package/src/features/{FileManager → KnowledgeManager}/UploadDock/Item.tsx +0 -0
  152. /package/src/features/{FileManager → KnowledgeManager}/UploadDock/index.tsx +0 -0
@@ -0,0 +1,723 @@
1
+ 'use client';
2
+
3
+ import {
4
+ INSERT_HEADING_COMMAND,
5
+ INSERT_TABLE_COMMAND,
6
+ ReactCodePlugin,
7
+ ReactCodeblockPlugin,
8
+ ReactHRPlugin,
9
+ ReactImagePlugin,
10
+ ReactLinkHighlightPlugin,
11
+ ReactListPlugin,
12
+ ReactMathPlugin,
13
+ ReactTablePlugin,
14
+ } from '@lobehub/editor';
15
+ import { Editor, useEditor } from '@lobehub/editor/react';
16
+ import { ActionIcon, Button, Dropdown, Icon } from '@lobehub/ui';
17
+ import { useDebounceFn } from 'ahooks';
18
+ import { App } from 'antd';
19
+ import { useTheme } from 'antd-style';
20
+ import dayjs from 'dayjs';
21
+ import relativeTime from 'dayjs/plugin/relativeTime';
22
+ import {
23
+ FileText,
24
+ Heading1Icon,
25
+ Heading2Icon,
26
+ Heading3Icon,
27
+ Link2,
28
+ Loader2Icon,
29
+ MoreVertical,
30
+ SmilePlus,
31
+ Table2Icon,
32
+ Trash2,
33
+ } from 'lucide-react';
34
+ import dynamic from 'next/dynamic';
35
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
36
+ import { useTranslation } from 'react-i18next';
37
+ import { Flexbox } from 'react-layout-kit';
38
+
39
+ import { documentService } from '@/services/document';
40
+ import { useFileStore } from '@/store/file';
41
+ import { documentSelectors } from '@/store/file/slices/document/selectors';
42
+ import { useGlobalStore } from '@/store/global';
43
+ import { globalGeneralSelectors } from '@/store/global/selectors';
44
+ import { useUserStore } from '@/store/user';
45
+ import { userProfileSelectors } from '@/store/user/selectors';
46
+ import { DocumentSourceType, LobeDocument } from '@/types/document';
47
+
48
+ dayjs.extend(relativeTime);
49
+
50
+ const SAVE_THROTTLE_TIME = 3000; // ms
51
+ const RESET_DELAY = 100; // ms
52
+
53
+ const EmojiPicker = dynamic(() => import('@lobehub/ui/es/EmojiPicker'), { ssr: false });
54
+
55
+ interface DocumentEditorPanelProps {
56
+ documentId?: string;
57
+ knowledgeBaseId?: string;
58
+ onDelete?: () => void;
59
+ onDocumentIdChange?: (newId: string) => void;
60
+ onSave?: () => void;
61
+ }
62
+
63
+ const DocumentEditor = memo<DocumentEditorPanelProps>(
64
+ ({ documentId, knowledgeBaseId, onDocumentIdChange, onSave, onDelete }) => {
65
+ const { t } = useTranslation(['file', 'common']);
66
+ const theme = useTheme();
67
+ const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
68
+ const { message, modal } = App.useApp();
69
+ const username = useUserStore(userProfileSelectors.displayUserName);
70
+
71
+ const editor = useEditor();
72
+
73
+ const currentDocument = useFileStore(documentSelectors.getDocumentById(documentId));
74
+ const currentDocumentTitle = currentDocument?.title;
75
+ const currentDocumentEmoji = currentDocument?.metadata?.emoji;
76
+
77
+ const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
78
+ const [currentTitle, setCurrentTitle] = useState('');
79
+ const [currentEmoji, setCurrentEmoji] = useState<string | undefined>(undefined);
80
+ const [isHoveringTitle, setIsHoveringTitle] = useState(false);
81
+ const [showEmojiPicker, setShowEmojiPicker] = useState(false);
82
+ const [currentDocId, setCurrentDocId] = useState<string | undefined>(documentId);
83
+ const [lastUpdatedTime, setLastUpdatedTime] = useState<Date | null>(null);
84
+ const [wordCount, setWordCount] = useState(0);
85
+
86
+ const refreshFileList = useFileStore((s) => s.refreshFileList);
87
+ const updateDocumentOptimistically = useFileStore((s) => s.updateDocumentOptimistically);
88
+ const replaceTempDocumentWithReal = useFileStore((s) => s.replaceTempDocumentWithReal);
89
+ const removeDocument = useFileStore((s) => s.removeDocument);
90
+
91
+ const isInitialLoadRef = useRef(false);
92
+
93
+ // Helper function to calculate word count from text
94
+ const calculateWordCount = useCallback((text: string) => {
95
+ return text.trim().split(/\s+/).filter(Boolean).length;
96
+ }, []);
97
+
98
+ // Helper function to extract content from pages array
99
+ const extractContentFromPages = useCallback((pages?: Array<{ pageContent: string }>) => {
100
+ if (!pages || pages.length === 0) return null;
101
+ return pages.map((page) => page.pageContent).join('\n\n');
102
+ }, []);
103
+
104
+ // Sync title and emoji when document data changes (e.g., from rename)
105
+ useEffect(() => {
106
+ if (currentDocumentTitle !== undefined && currentDocumentTitle !== currentTitle) {
107
+ setCurrentTitle(currentDocumentTitle);
108
+ }
109
+ if (currentDocumentEmoji !== currentEmoji) {
110
+ setCurrentEmoji(currentDocumentEmoji);
111
+ }
112
+ }, [currentDocumentTitle, currentDocumentEmoji]);
113
+
114
+ // Load document content when documentId changes
115
+ useEffect(() => {
116
+ // Reset initial load flag when switching documents
117
+ isInitialLoadRef.current = true;
118
+
119
+ if (documentId && editor) {
120
+ setShowEmojiPicker(false);
121
+ setCurrentEmoji(currentDocumentEmoji);
122
+ setLastUpdatedTime(null);
123
+
124
+ // Check if this is an optimistic temp document
125
+ if (currentDocument && documentId.startsWith('temp-document-')) {
126
+ console.log('[DocumentEditor] Using optimistic document from currentDocument');
127
+ setCurrentTitle(currentDocument.title || 'Untitled Document');
128
+ // Start with empty editor for new documents
129
+ editor.cleanDocument();
130
+ setWordCount(0);
131
+ // Reset flag after cleanDocument (no onChange should fire for cleanDocument)
132
+ setTimeout(() => {
133
+ isInitialLoadRef.current = false;
134
+ }, RESET_DELAY);
135
+ return;
136
+ }
137
+
138
+ if (currentDocument?.editorData && Object.keys(currentDocument.editorData).length > 0) {
139
+ setCurrentTitle(currentDocumentTitle || '');
140
+ isInitialLoadRef.current = true;
141
+
142
+ console.log('[DocumentEditor] Setting editor data', currentDocument.editorData);
143
+
144
+ editor.setDocument('json', JSON.stringify(currentDocument.editorData));
145
+ // Calculate word count from content
146
+ const textContent = currentDocument.content || '';
147
+ setWordCount(calculateWordCount(textContent));
148
+ setTimeout(() => {
149
+ isInitialLoadRef.current = false;
150
+ }, RESET_DELAY);
151
+ return;
152
+ } else if (currentDocument?.pages && editor) {
153
+ const pagesContent = extractContentFromPages(currentDocument.pages);
154
+ if (pagesContent) {
155
+ console.log('[DocumentEditor] Using pages content as fallback');
156
+ setCurrentTitle(currentDocumentTitle || '');
157
+ isInitialLoadRef.current = true;
158
+ editor.setDocument('markdown', pagesContent);
159
+ // Calculate word count from pages content
160
+ setWordCount(calculateWordCount(pagesContent));
161
+ setTimeout(() => {
162
+ isInitialLoadRef.current = false;
163
+ }, RESET_DELAY);
164
+ return;
165
+ }
166
+ } else {
167
+ // Reset editor
168
+ editor.cleanDocument();
169
+ setWordCount(0);
170
+ isInitialLoadRef.current = false;
171
+ return;
172
+ }
173
+ }
174
+ }, [
175
+ documentId,
176
+ currentDocument,
177
+ currentDocumentTitle,
178
+ currentDocumentEmoji,
179
+ editor,
180
+ calculateWordCount,
181
+ extractContentFromPages,
182
+ ]);
183
+
184
+ // Auto-save function
185
+ const performSave = useCallback(async () => {
186
+ if (!editor) return;
187
+
188
+ const editorData = editor.getDocument('json');
189
+ const textContent = (editor.getDocument('markdown') as unknown as string) || '';
190
+
191
+ // Don't save if content is empty
192
+ if (!textContent || textContent.trim() === '') {
193
+ return;
194
+ }
195
+
196
+ // Store focus state before saving
197
+ // Check if the editor's root element or any of its descendants has focus
198
+ const editorElement = editor.getRootElement();
199
+ const hadFocus = editorElement?.contains(document.activeElement) ?? false;
200
+
201
+ setSaveStatus('saving');
202
+
203
+ try {
204
+ if (currentDocId && !currentDocId.startsWith('temp-document-')) {
205
+ // Update existing document with optimistic update (including metadata for emoji)
206
+ await updateDocumentOptimistically(currentDocId, {
207
+ content: textContent,
208
+ editorData: structuredClone(editorData),
209
+ metadata: currentEmoji
210
+ ? {
211
+ emoji: currentEmoji,
212
+ }
213
+ : {
214
+ emoji: undefined, // Explicitly set to undefined to remove emoji
215
+ },
216
+ title: currentTitle,
217
+ updatedAt: new Date(),
218
+ });
219
+
220
+ // Restore focus if editor had it before save
221
+ if (hadFocus) {
222
+ // Use setTimeout to ensure focus is restored after any re-renders
223
+ setTimeout(() => {
224
+ editor.focus();
225
+ }, 0);
226
+ }
227
+ } else {
228
+ // Create new document (either no ID or temp ID)
229
+ const now = Date.now();
230
+ const timestamp = new Date(now).toLocaleString('en-US', {
231
+ day: '2-digit',
232
+ hour: '2-digit',
233
+ minute: '2-digit',
234
+ month: 'short',
235
+ year: 'numeric',
236
+ });
237
+ const title = currentTitle || `Document - ${timestamp}`;
238
+
239
+ const newDoc = await documentService.createDocument({
240
+ content: textContent,
241
+ editorData: JSON.stringify(editorData),
242
+ fileType: 'custom/document',
243
+ knowledgeBaseId,
244
+ metadata: currentEmoji
245
+ ? {
246
+ createdAt: now,
247
+ emoji: currentEmoji,
248
+ }
249
+ : {
250
+ createdAt: now,
251
+ },
252
+ title,
253
+ });
254
+
255
+ // Create the real document object for optimistic update
256
+ const realDocument: LobeDocument = {
257
+ content: textContent,
258
+ createdAt: new Date(now),
259
+ editorData: structuredClone(editorData) || null,
260
+ fileType: 'custom/document' as const,
261
+ filename: title,
262
+ id: newDoc.id,
263
+ metadata: currentEmoji
264
+ ? {
265
+ createdAt: now,
266
+ emoji: currentEmoji,
267
+ }
268
+ : {
269
+ createdAt: now,
270
+ },
271
+ source: 'document',
272
+ sourceType: DocumentSourceType.EDITOR,
273
+ title,
274
+ totalCharCount: textContent.length,
275
+ totalLineCount: 0,
276
+ updatedAt: new Date(now),
277
+ };
278
+
279
+ // Replace temp document with real document (smooth UX, no flicker)
280
+ if (currentDocId?.startsWith('temp-document-')) {
281
+ replaceTempDocumentWithReal(currentDocId, realDocument);
282
+ }
283
+
284
+ // Update state and notify parent
285
+ setCurrentDocId(newDoc.id);
286
+ onDocumentIdChange?.(newDoc.id);
287
+
288
+ // Refresh in background to sync with server
289
+ refreshFileList();
290
+
291
+ // Restore focus if editor had it before save
292
+ if (hadFocus) {
293
+ setTimeout(() => {
294
+ editor.focus();
295
+ }, 0);
296
+ }
297
+ }
298
+
299
+ setSaveStatus('saved');
300
+ // Update last updated time
301
+ setLastUpdatedTime(new Date());
302
+
303
+ onSave?.();
304
+ } catch {
305
+ setSaveStatus('idle');
306
+ }
307
+ }, [
308
+ editor,
309
+ currentDocId,
310
+ currentTitle,
311
+ currentEmoji,
312
+ knowledgeBaseId,
313
+ refreshFileList,
314
+ updateDocumentOptimistically,
315
+ onSave,
316
+ onDocumentIdChange,
317
+ replaceTempDocumentWithReal,
318
+ ]);
319
+
320
+ // Handle content change - auto-save after debounce (with skip initial load)
321
+ const handleContentChangeInternal = useCallback(() => {
322
+ // Skip if we're in the initial load phase
323
+ if (isInitialLoadRef.current) {
324
+ console.log('[DocumentEditor] Skipping onChange during initial load');
325
+ return;
326
+ }
327
+
328
+ console.log('[DocumentEditor] Content changed, triggering auto-save');
329
+
330
+ // Update word count from editor
331
+ if (editor) {
332
+ try {
333
+ const textContent = (editor.getDocument('text') as unknown as string) || '';
334
+ setWordCount(calculateWordCount(textContent));
335
+ } catch (error) {
336
+ console.error('Failed to update word count:', error);
337
+ }
338
+ }
339
+
340
+ performSave();
341
+ }, [performSave, editor, calculateWordCount]);
342
+
343
+ const { run: handleContentChange } = useDebounceFn(handleContentChangeInternal, {
344
+ wait: SAVE_THROTTLE_TIME,
345
+ });
346
+
347
+ // Debounced save for title/emoji changes (no initial load check needed)
348
+ const { run: debouncedSave } = useDebounceFn(performSave, {
349
+ wait: SAVE_THROTTLE_TIME,
350
+ });
351
+
352
+ // Update currentDocId when documentId prop changes
353
+ useEffect(() => {
354
+ setCurrentDocId(documentId);
355
+ }, [documentId]);
356
+
357
+ // Clean up when closing
358
+ useEffect(() => {
359
+ return () => {
360
+ editor?.cleanDocument();
361
+ };
362
+ }, [editor]);
363
+
364
+ // Handle Cmd+S / Ctrl+S keyboard shortcut
365
+ useEffect(() => {
366
+ const handleKeyDown = (e: KeyboardEvent) => {
367
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
368
+ e.preventDefault();
369
+ message.info(t('documentEditor.autoSaveMessage'));
370
+ }
371
+ };
372
+
373
+ document.addEventListener('keydown', handleKeyDown);
374
+ return () => {
375
+ document.removeEventListener('keydown', handleKeyDown);
376
+ };
377
+ }, [t]);
378
+
379
+ // Handle delete document
380
+ const handleDelete = useCallback(async () => {
381
+ if (!currentDocId) return;
382
+
383
+ modal.confirm({
384
+ cancelText: t('cancel', { ns: 'common' }),
385
+ content: t('documentEditor.deleteConfirm.content'),
386
+ okButtonProps: { danger: true },
387
+ okText: t('delete', { ns: 'common' }),
388
+ onOk: async () => {
389
+ try {
390
+ await removeDocument(currentDocId);
391
+ message.success(t('documentEditor.deleteSuccess'));
392
+ onDelete?.();
393
+ } catch (error) {
394
+ console.error('Failed to delete document:', error);
395
+ message.error(t('documentEditor.deleteError'));
396
+ }
397
+ },
398
+ title: t('documentEditor.deleteConfirm.title'),
399
+ });
400
+ }, [currentDocId, modal, removeDocument, message, onDelete, t]);
401
+
402
+ // Menu items for the three-dot menu
403
+ const menuItems = useMemo(
404
+ () => [
405
+ {
406
+ icon: <Icon icon={Link2} />,
407
+ key: 'copy-link',
408
+ label: t('documentEditor.menu.copyLink'),
409
+ onClick: () => {
410
+ if (currentDocId) {
411
+ const url = `${window.location.origin}${window.location.pathname}`;
412
+ navigator.clipboard.writeText(url);
413
+ message.success(t('documentEditor.linkCopied'));
414
+ }
415
+ },
416
+ },
417
+ {
418
+ danger: true,
419
+ icon: <Icon icon={Trash2} />,
420
+ key: 'delete',
421
+ label: t('delete', { ns: 'common' }),
422
+ onClick: handleDelete,
423
+ },
424
+ // {
425
+ // type: 'divider' as const,
426
+ // },
427
+ // {
428
+ // icon: <Icon icon={Download} />,
429
+ // key: 'export',
430
+ // label: t('documentEditor.menu.exportDocument'),
431
+ // onClick: () => {
432
+ // // TODO: Implement export functionality
433
+ // console.log('Export clicked');
434
+ // },
435
+ // },
436
+ // {
437
+ // icon: <Icon icon={Upload} />,
438
+ // key: 'import',
439
+ // label: t('documentEditor.menu.importDocument'),
440
+ // onClick: () => {
441
+ // // TODO: Implement import functionality
442
+ // console.log('Import clicked');
443
+ // },
444
+ // },
445
+ {
446
+ type: 'divider' as const,
447
+ },
448
+ {
449
+ disabled: true,
450
+ key: 'document-info',
451
+ label: (
452
+ <div style={{ color: theme.colorTextTertiary, fontSize: 12, lineHeight: 1.6 }}>
453
+ <div>{t('documentEditor.wordCount', { wordCount })}</div>
454
+ {/* <div>{t('documentEditor.editedBy', { name: username })}</div> */}
455
+ <div>
456
+ {lastUpdatedTime
457
+ ? t('documentEditor.editedAt', {
458
+ time: dayjs(lastUpdatedTime).format('MMMM D, YYYY [at] h:mm A'),
459
+ })
460
+ : ''}
461
+ </div>
462
+ </div>
463
+ ),
464
+ },
465
+ ],
466
+ [theme, wordCount, username, lastUpdatedTime, handleDelete, currentDocId, message, t],
467
+ );
468
+
469
+ return (
470
+ <Flexbox height={'100%'} style={{ background: theme.colorBgContainer }}>
471
+ {/* Header */}
472
+ <Flexbox
473
+ align="center"
474
+ direction="horizontal"
475
+ gap={8}
476
+ paddingBlock={8}
477
+ paddingInline={16}
478
+ style={{
479
+ background: theme.colorBgContainer,
480
+ }}
481
+ >
482
+ {/* Icon */}
483
+ {currentEmoji ? (
484
+ <span style={{ fontSize: 20, lineHeight: 1 }}>{currentEmoji}</span>
485
+ ) : (
486
+ <Icon icon={FileText} size={20} style={{ color: theme.colorTextSecondary }} />
487
+ )}
488
+
489
+ {/* Title */}
490
+ <Flexbox
491
+ flex={1}
492
+ style={{
493
+ color: theme.colorText,
494
+ fontSize: 14,
495
+ fontWeight: 500,
496
+ overflow: 'hidden',
497
+ textOverflow: 'ellipsis',
498
+ whiteSpace: 'nowrap',
499
+ }}
500
+ >
501
+ {currentTitle || t('documentEditor.titlePlaceholder')}
502
+ </Flexbox>
503
+
504
+ {/* Save Status Indicator */}
505
+ {saveStatus === 'saving' && (
506
+ <Flexbox>
507
+ <Icon icon={Loader2Icon} spin />
508
+ </Flexbox>
509
+ )}
510
+
511
+ {/* Last Updated Time */}
512
+ {lastUpdatedTime && (
513
+ <span
514
+ style={{
515
+ color: theme.colorTextTertiary,
516
+ fontSize: 12,
517
+ whiteSpace: 'nowrap',
518
+ }}
519
+ >
520
+ {t('documentEditor.editedAt', { time: dayjs(lastUpdatedTime).fromNow() })}
521
+ </span>
522
+ )}
523
+
524
+ {/* Pin action */}
525
+ {/* <ActionIcon
526
+ icon={Pin}
527
+ onClick={() => {
528
+ // TODO: Implement pin functionality
529
+ console.log('Pin clicked');
530
+ }}
531
+ size={15.5}
532
+ style={{ color: theme.colorText }}
533
+ /> */}
534
+
535
+ {/* Three-dot menu */}
536
+ <Dropdown
537
+ menu={{
538
+ items: menuItems,
539
+ style: { minWidth: 200 },
540
+ }}
541
+ placement="bottomRight"
542
+ trigger={['click']}
543
+ >
544
+ <ActionIcon icon={MoreVertical} size={15.5} style={{ color: theme.colorText }} />
545
+ </Dropdown>
546
+ </Flexbox>
547
+
548
+ {/* Editor with title */}
549
+ <Flexbox flex={1} style={{ overflowY: 'auto' }}>
550
+ <Flexbox
551
+ paddingBlock={36}
552
+ style={{
553
+ margin: '0 auto',
554
+ maxWidth: 900,
555
+ paddingLeft: 32,
556
+ paddingRight: 48,
557
+ width: '100%',
558
+ }}
559
+ >
560
+ {/* Emoji and Title */}
561
+ <Flexbox
562
+ onMouseEnter={() => setIsHoveringTitle(true)}
563
+ onMouseLeave={() => setIsHoveringTitle(false)}
564
+ style={{ marginBottom: 24 }}
565
+ >
566
+ {/* Emoji picker above Choose Icon button */}
567
+ {(currentEmoji || showEmojiPicker) && (
568
+ <Flexbox style={{ marginBottom: 4 }}>
569
+ <EmojiPicker
570
+ allowDelete
571
+ locale={locale}
572
+ onChange={(emoji) => {
573
+ setCurrentEmoji(emoji);
574
+ setShowEmojiPicker(false);
575
+ debouncedSave();
576
+ }}
577
+ onDelete={() => {
578
+ setCurrentEmoji(undefined);
579
+ setShowEmojiPicker(false);
580
+ debouncedSave();
581
+ }}
582
+ onOpenChange={(open) => {
583
+ setShowEmojiPicker(open);
584
+ }}
585
+ open={showEmojiPicker}
586
+ size={80}
587
+ style={{
588
+ fontSize: 80,
589
+ transform: 'translateX(-6px)',
590
+ }}
591
+ title={t('documentEditor.chooseIcon')}
592
+ value={currentEmoji}
593
+ />
594
+ </Flexbox>
595
+ )}
596
+
597
+ {/* Choose Icon button - only shown when no emoji */}
598
+ <Flexbox style={{ marginBottom: 12 }}>
599
+ <Button
600
+ icon={<Icon icon={SmilePlus} />}
601
+ onClick={() => {
602
+ setCurrentEmoji('📄');
603
+ setShowEmojiPicker(true);
604
+ }}
605
+ size="small"
606
+ style={{
607
+ opacity: isHoveringTitle && !currentEmoji && !showEmojiPicker ? 1 : 0,
608
+ transform: 'translateX(-6px)',
609
+ transition: `opacity ${theme.motionDurationMid} ${theme.motionEaseInOut}`,
610
+ width: 'fit-content',
611
+ }}
612
+ type="text"
613
+ >
614
+ {t('documentEditor.chooseIcon')}
615
+ </Button>
616
+ </Flexbox>
617
+
618
+ {/* Title Input */}
619
+ <Flexbox align="center" direction="horizontal" gap={8}>
620
+ <input
621
+ onChange={(e) => {
622
+ setCurrentTitle(e.target.value);
623
+ debouncedSave();
624
+ }}
625
+ onKeyDown={(e) => {
626
+ if (e.key === 'Enter') {
627
+ e.preventDefault();
628
+ // Save immediately and focus on the editor
629
+ performSave().then(() => {
630
+ editor?.focus();
631
+ });
632
+ }
633
+ }}
634
+ placeholder={t('documentEditor.titlePlaceholder')}
635
+ style={{
636
+ background: 'transparent',
637
+ border: 'none',
638
+ color: theme.colorText,
639
+ flex: 1,
640
+ fontSize: 40,
641
+ fontWeight: 700,
642
+ lineHeight: 1.2,
643
+ outline: 'none',
644
+ }}
645
+ value={currentTitle}
646
+ />
647
+ </Flexbox>
648
+ </Flexbox>
649
+
650
+ <div
651
+ onClick={() => editor?.focus()}
652
+ style={{
653
+ cursor: 'text',
654
+ flex: 1,
655
+ minHeight: '400px',
656
+ }}
657
+ >
658
+ <Editor
659
+ content={''}
660
+ editor={editor}
661
+ onTextChange={handleContentChange}
662
+ placeholder={t('documentEditor.editorPlaceholder')}
663
+ plugins={[
664
+ ReactListPlugin,
665
+ ReactCodePlugin,
666
+ ReactCodeblockPlugin,
667
+ ReactHRPlugin,
668
+ ReactLinkHighlightPlugin,
669
+ ReactTablePlugin,
670
+ ReactMathPlugin,
671
+ ReactImagePlugin,
672
+ ]}
673
+ slashOption={{
674
+ items: [
675
+ {
676
+ icon: Heading1Icon,
677
+ key: 'h1',
678
+ label: 'Heading 1',
679
+ onSelect: (editor) => {
680
+ editor.dispatchCommand(INSERT_HEADING_COMMAND, { tag: 'h1' });
681
+ },
682
+ },
683
+ {
684
+ icon: Heading2Icon,
685
+ key: 'h2',
686
+ label: 'Heading 2',
687
+ onSelect: (editor) => {
688
+ editor.dispatchCommand(INSERT_HEADING_COMMAND, { tag: 'h2' });
689
+ },
690
+ },
691
+ {
692
+ icon: Heading3Icon,
693
+ key: 'h3',
694
+ label: 'Heading 3',
695
+ onSelect: (editor) => {
696
+ editor.dispatchCommand(INSERT_HEADING_COMMAND, { tag: 'h3' });
697
+ },
698
+ },
699
+ {
700
+ icon: Table2Icon,
701
+ key: 'table',
702
+ label: 'Table',
703
+ onSelect: (editor) => {
704
+ editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: '3', rows: '3' });
705
+ },
706
+ },
707
+ ],
708
+ }}
709
+ style={{
710
+ minHeight: '400px',
711
+ paddingBottom: '200px',
712
+ }}
713
+ type={'text'}
714
+ />
715
+ </div>
716
+ </Flexbox>
717
+ </Flexbox>
718
+ </Flexbox>
719
+ );
720
+ },
721
+ );
722
+
723
+ export default DocumentEditor;