mirror of https://github.com/kortix-ai/suna.git
1149 lines
49 KiB
TypeScript
1149 lines
49 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useRef } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { KBDeleteConfirmDialog } from './kb-delete-confirm-dialog';
|
|
import { FileUploadModal } from './file-upload-modal';
|
|
import { EditSummaryModal } from './edit-summary-modal';
|
|
import { createClient } from '@/lib/supabase/client';
|
|
import { getSandboxFileContent } from '@/lib/api';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { FileNameValidator } from '@/lib/validation';
|
|
import {
|
|
FolderIcon,
|
|
FileIcon,
|
|
PlusIcon,
|
|
UploadIcon,
|
|
TrashIcon,
|
|
FolderPlusIcon,
|
|
ChevronDownIcon,
|
|
ChevronRightIcon,
|
|
MoreVerticalIcon
|
|
} from 'lucide-react';
|
|
import { KnowledgeBasePageHeader } from './knowledge-base-header';
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensors,
|
|
useSensor,
|
|
DragEndEvent,
|
|
DragOverlay,
|
|
DragStartEvent,
|
|
useDroppable,
|
|
} from '@dnd-kit/core';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
verticalListSortingStrategy,
|
|
useSortable,
|
|
} from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { SharedTreeItem, FileDragOverlay } from '@/components/knowledge-base/shared-kb-tree';
|
|
import { KBFilePreviewModal } from './kb-file-preview-modal';
|
|
import { TextEntryModal } from './text-entry-modal';
|
|
|
|
// Get backend URL from environment variables
|
|
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
|
|
|
// Helper function to get file extension and type
|
|
const getFileTypeInfo = (filename: string) => {
|
|
const extension = filename.split('.').pop()?.toLowerCase() || '';
|
|
const fileType = extension.toUpperCase();
|
|
|
|
// Define color scheme based on file type
|
|
const getTypeColor = (ext: string) => {
|
|
switch (ext) {
|
|
case 'pdf': return 'bg-red-100 text-red-700 border-red-200';
|
|
case 'doc':
|
|
case 'docx': return 'bg-blue-100 text-blue-700 border-blue-200';
|
|
case 'ppt':
|
|
case 'pptx': return 'bg-orange-100 text-orange-700 border-orange-200';
|
|
case 'xls':
|
|
case 'xlsx': return 'bg-green-100 text-green-700 border-green-200';
|
|
case 'txt': return 'bg-gray-100 text-gray-700 border-gray-200';
|
|
case 'jpg':
|
|
case 'jpeg':
|
|
case 'png':
|
|
case 'gif': return 'bg-purple-100 text-purple-700 border-purple-200';
|
|
default: return 'bg-slate-100 text-slate-700 border-slate-200';
|
|
}
|
|
};
|
|
|
|
return {
|
|
extension: fileType,
|
|
colorClass: getTypeColor(extension)
|
|
};
|
|
};
|
|
|
|
// Helper function to format date
|
|
const formatDate = (dateString: string) => {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now.getTime() - date.getTime());
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays === 1) return 'Today';
|
|
if (diffDays === 2) return 'Yesterday';
|
|
if (diffDays <= 7) return `${diffDays} days ago`;
|
|
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
};
|
|
|
|
interface Folder {
|
|
folder_id: string;
|
|
name: string;
|
|
description?: string;
|
|
entry_count: number;
|
|
created_at: string;
|
|
}
|
|
|
|
interface Entry {
|
|
entry_id: string;
|
|
filename: string;
|
|
summary: string;
|
|
file_size: number;
|
|
created_at: string;
|
|
}
|
|
|
|
interface TreeItem {
|
|
id: string;
|
|
type: 'folder' | 'file';
|
|
name: string;
|
|
parentId?: string;
|
|
data?: Folder | Entry;
|
|
children?: TreeItem[];
|
|
expanded?: boolean;
|
|
}
|
|
|
|
// Hooks for API calls
|
|
const useKnowledgeFolders = () => {
|
|
const [folders, setFolders] = useState<Folder[]>([]);
|
|
const [recentFiles, setRecentFiles] = useState<Entry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const fetchFolders = async () => {
|
|
try {
|
|
const supabase = createClient();
|
|
|
|
// Fetch folders directly using Supabase client with RLS
|
|
const { data: foldersData, error: foldersError } = await supabase
|
|
.from('knowledge_base_folders')
|
|
.select('folder_id, name, description, created_at')
|
|
.order('created_at', { ascending: false });
|
|
|
|
if (foldersError) {
|
|
console.error('Supabase error fetching folders:', foldersError);
|
|
return;
|
|
}
|
|
|
|
// Fetch recent files (last 6 files across all folders)
|
|
const { data: recentFilesData, error: recentError } = await supabase
|
|
.from('knowledge_base_entries')
|
|
.select('entry_id, filename, summary, file_size, created_at, folder_id')
|
|
.order('created_at', { ascending: false })
|
|
.limit(6);
|
|
|
|
if (recentError) {
|
|
console.error('Supabase error fetching recent files:', recentError);
|
|
} else {
|
|
setRecentFiles(recentFilesData || []);
|
|
}
|
|
|
|
// Get entry counts for each folder
|
|
const foldersWithCounts = await Promise.all(
|
|
foldersData.map(async (folder) => {
|
|
const { count, error: countError } = await supabase
|
|
.from('knowledge_base_entries')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('folder_id', folder.folder_id);
|
|
|
|
if (countError) {
|
|
console.error('Error counting entries:', countError);
|
|
}
|
|
|
|
return {
|
|
folder_id: folder.folder_id,
|
|
name: folder.name,
|
|
description: folder.description,
|
|
entry_count: count || 0,
|
|
created_at: folder.created_at
|
|
};
|
|
})
|
|
);
|
|
|
|
setFolders(foldersWithCounts);
|
|
} catch (error) {
|
|
console.error('Failed to fetch folders:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
fetchFolders();
|
|
}, []);
|
|
|
|
return { folders, recentFiles, loading, refetch: fetchFolders };
|
|
};
|
|
|
|
export function KnowledgeBasePage() {
|
|
const [treeData, setTreeData] = useState<TreeItem[]>([]);
|
|
const [folderEntries, setFolderEntries] = useState<{ [folderId: string]: Entry[] }>({});
|
|
const [loadingFolders, setLoadingFolders] = useState<{ [folderId: string]: boolean }>({});
|
|
const [movingFiles, setMovingFiles] = useState<{ [fileId: string]: boolean }>({});
|
|
const [selectedItem, setSelectedItem] = useState<TreeItem | null>(null);
|
|
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
|
const [editingName, setEditingName] = useState('');
|
|
const [validationError, setValidationError] = useState<string | null>(null);
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
const editInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Delete confirmation state
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
|
isOpen: boolean;
|
|
item: { id: string; name: string; type: 'folder' | 'file' } | null;
|
|
isDeleting: boolean;
|
|
}>({
|
|
isOpen: false,
|
|
item: null,
|
|
isDeleting: false,
|
|
});
|
|
|
|
// Upload status state for native file drops
|
|
const [uploadStatus, setUploadStatus] = useState<{
|
|
[folderId: string]: {
|
|
isUploading: boolean;
|
|
progress: number;
|
|
currentFile?: string;
|
|
totalFiles?: number;
|
|
completedFiles?: number;
|
|
};
|
|
}>({});
|
|
|
|
// Edit summary modal state
|
|
const [editSummaryModal, setEditSummaryModal] = useState<{
|
|
isOpen: boolean;
|
|
fileId: string;
|
|
fileName: string;
|
|
currentSummary: string;
|
|
}>({
|
|
isOpen: false,
|
|
fileId: '',
|
|
fileName: '',
|
|
currentSummary: '',
|
|
});
|
|
|
|
// File preview modal state
|
|
const [filePreviewModal, setFilePreviewModal] = useState<{
|
|
isOpen: boolean;
|
|
file: Entry | null;
|
|
}>({
|
|
isOpen: false,
|
|
file: null,
|
|
});
|
|
|
|
const { folders, recentFiles, loading: foldersLoading, refetch: refetchFolders } = useKnowledgeFolders();
|
|
|
|
const handleFileSelect = (item: TreeItem) => {
|
|
if (item.type === 'file' && item.data && 'entry_id' in item.data) {
|
|
setFilePreviewModal({
|
|
isOpen: true,
|
|
file: item.data,
|
|
});
|
|
} else {
|
|
setSelectedItem(item);
|
|
}
|
|
};
|
|
|
|
// DND Sensors
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
})
|
|
);
|
|
|
|
// Build tree structure
|
|
React.useEffect(() => {
|
|
const buildTree = () => {
|
|
const tree: TreeItem[] = folders.map(folder => {
|
|
// Preserve existing expanded state
|
|
const existingFolder = treeData.find(item => item.id === folder.folder_id);
|
|
const isExpanded = existingFolder?.expanded || false;
|
|
|
|
return {
|
|
id: folder.folder_id,
|
|
type: 'folder' as const,
|
|
name: folder.name,
|
|
data: folder,
|
|
children: folderEntries[folder.folder_id]?.map(entry => ({
|
|
id: entry.entry_id,
|
|
type: 'file' as const,
|
|
name: entry.filename,
|
|
parentId: folder.folder_id,
|
|
data: entry,
|
|
})) || [],
|
|
expanded: isExpanded,
|
|
};
|
|
});
|
|
setTreeData(tree);
|
|
};
|
|
|
|
buildTree();
|
|
}, [folders, folderEntries]);
|
|
|
|
const handleCreateFolder = async () => {
|
|
try {
|
|
const supabase = createClient();
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
if (!session?.access_token) {
|
|
toast.error('Authentication error');
|
|
return;
|
|
}
|
|
|
|
// Create folder using API - backend will handle unique naming
|
|
const response = await fetch(`${API_URL}/knowledge-base/folders`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${session.access_token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
name: 'Untitled Folder'
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const newFolder = await response.json();
|
|
toast.success('Folder created successfully');
|
|
refetchFolders();
|
|
// Start editing the new folder immediately
|
|
setTimeout(() => {
|
|
setEditingFolder(newFolder.folder_id);
|
|
setEditingName(newFolder.name);
|
|
}, 100);
|
|
} else {
|
|
const errorData = await response.json().catch(() => null);
|
|
toast.error(errorData?.detail || 'Failed to create folder');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating folder:', error);
|
|
toast.error('Failed to create folder');
|
|
}
|
|
};
|
|
|
|
const handleStartEdit = (folderId: string, currentName: string) => {
|
|
setEditingFolder(folderId);
|
|
setEditingName(currentName);
|
|
setValidationError(null); // Clear any previous validation errors
|
|
setTimeout(() => {
|
|
editInputRef.current?.focus();
|
|
editInputRef.current?.select();
|
|
}, 0);
|
|
};
|
|
|
|
const handleEditChange = (newName: string) => {
|
|
setEditingName(newName);
|
|
|
|
// Real-time validation
|
|
const existingNames = folders
|
|
.map(f => f.name)
|
|
.filter(name => name !== folders.find(f => f.folder_id === editingFolder)?.name);
|
|
|
|
const nameValidation = FileNameValidator.validateName(newName, 'folder');
|
|
const hasConflict = nameValidation.isValid && FileNameValidator.checkNameConflict(newName, existingNames);
|
|
const isValid = nameValidation.isValid && !hasConflict;
|
|
const errorMessage = hasConflict
|
|
? 'A folder with this name already exists'
|
|
: FileNameValidator.getFriendlyErrorMessage(newName, 'folder');
|
|
|
|
setValidationError(isValid ? null : errorMessage);
|
|
};
|
|
|
|
const handleFinishEdit = async () => {
|
|
if (!editingFolder || !editingName.trim()) {
|
|
setEditingFolder(null);
|
|
return;
|
|
}
|
|
|
|
const trimmedName = editingName.trim();
|
|
|
|
// Validate the name
|
|
const existingNames = folders.map(f => f.name).filter(name => name !== folders.find(f => f.folder_id === editingFolder)?.name);
|
|
const nameValidation = FileNameValidator.validateName(trimmedName, 'folder');
|
|
const hasConflict = nameValidation.isValid && FileNameValidator.checkNameConflict(trimmedName, existingNames);
|
|
const isValid = nameValidation.isValid && !hasConflict;
|
|
|
|
if (!isValid) {
|
|
const errorMessage = hasConflict
|
|
? 'A folder with this name already exists'
|
|
: FileNameValidator.getFriendlyErrorMessage(trimmedName, 'folder');
|
|
toast.error(errorMessage);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const supabase = createClient();
|
|
|
|
// Update folder name directly using Supabase client
|
|
const { error } = await supabase
|
|
.from('knowledge_base_folders')
|
|
.update({ name: trimmedName })
|
|
.eq('folder_id', editingFolder);
|
|
|
|
if (error) {
|
|
console.error('Supabase error:', error);
|
|
// Check if it's a unique constraint error
|
|
if (error.message?.includes('duplicate') || error.code === '23505') {
|
|
toast.error('A folder with this name already exists');
|
|
} else {
|
|
toast.error('Failed to rename folder');
|
|
}
|
|
} else {
|
|
toast.success('Folder renamed successfully');
|
|
refetchFolders();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error renaming folder:', error);
|
|
toast.error('Failed to rename folder');
|
|
}
|
|
|
|
setEditingFolder(null);
|
|
setEditingName('');
|
|
setValidationError(null);
|
|
};
|
|
|
|
const handleEditSummary = (fileId: string, fileName: string, currentSummary: string) => {
|
|
setEditSummaryModal({
|
|
isOpen: true,
|
|
fileId,
|
|
fileName,
|
|
currentSummary,
|
|
});
|
|
};
|
|
|
|
const handleSaveSummary = async (newSummary: string) => {
|
|
try {
|
|
const supabase = createClient();
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
if (!session?.access_token) {
|
|
throw new Error('No session found');
|
|
}
|
|
|
|
const response = await fetch(`${API_URL}/knowledge-base/entries/${editSummaryModal.fileId}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Authorization': `Bearer ${session.access_token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
summary: newSummary
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast.success('Summary updated successfully');
|
|
// Refresh the folder entries to show updated summary
|
|
const fileItem = treeData.flatMap(folder => folder.children || []).find(file => file.id === editSummaryModal.fileId);
|
|
if (fileItem?.parentId) {
|
|
await fetchFolderEntries(fileItem.parentId);
|
|
}
|
|
refetchFolders();
|
|
} else {
|
|
const errorData = await response.json().catch(() => null);
|
|
toast.error(errorData?.detail || 'Failed to update summary');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating summary:', error);
|
|
toast.error('Failed to update summary');
|
|
}
|
|
};
|
|
|
|
const handleMoveFile = async (fileId: string, targetFolderId: string) => {
|
|
// Set moving state
|
|
setMovingFiles(prev => ({ ...prev, [fileId]: true }));
|
|
|
|
try {
|
|
const supabase = createClient();
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
if (!session?.access_token) {
|
|
throw new Error('No session found');
|
|
}
|
|
|
|
const response = await fetch(`${API_URL}/knowledge-base/entries/${fileId}/move`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${session.access_token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
folder_id: targetFolderId
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast.success('File moved successfully');
|
|
refetchFolders();
|
|
// Refresh folder entries for both source and target folders
|
|
const movedItem = treeData.flatMap(folder => folder.children || []).find(file => file.id === fileId);
|
|
if (movedItem) {
|
|
await fetchFolderEntries(movedItem.parentId!);
|
|
await fetchFolderEntries(targetFolderId);
|
|
}
|
|
} else {
|
|
toast.error('Failed to move file');
|
|
}
|
|
} catch (error) {
|
|
toast.error('Failed to move file');
|
|
} finally {
|
|
// Clear moving state
|
|
setMovingFiles(prev => ({ ...prev, [fileId]: false }));
|
|
}
|
|
};
|
|
|
|
const handleEditKeyPress = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
handleFinishEdit();
|
|
} else if (e.key === 'Escape') {
|
|
setEditingFolder(null);
|
|
setEditingName('');
|
|
setValidationError(null);
|
|
}
|
|
};
|
|
|
|
const fetchFolderEntries = async (folderId: string) => {
|
|
// Set loading state
|
|
setLoadingFolders(prev => ({ ...prev, [folderId]: true }));
|
|
|
|
try {
|
|
const supabase = createClient();
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
if (!session?.access_token) {
|
|
throw new Error('No session found');
|
|
}
|
|
|
|
const response = await fetch(`${API_URL}/knowledge-base/folders/${folderId}/entries`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${session.access_token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setFolderEntries(prev => ({ ...prev, [folderId]: data }));
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch entries:', error);
|
|
} finally {
|
|
// Clear loading state
|
|
setLoadingFolders(prev => ({ ...prev, [folderId]: false }));
|
|
}
|
|
};
|
|
|
|
const handleExpand = async (folderId: string) => {
|
|
const folder = treeData.find(item => item.id === folderId);
|
|
const isCurrentlyExpanded = folder?.expanded;
|
|
|
|
setTreeData(prev =>
|
|
prev.map(item =>
|
|
item.id === folderId
|
|
? { ...item, expanded: !item.expanded }
|
|
: item
|
|
)
|
|
);
|
|
|
|
// Fetch entries if expanding and not already loaded
|
|
if (folder && !isCurrentlyExpanded && !folderEntries[folderId]) {
|
|
await fetchFolderEntries(folderId);
|
|
}
|
|
|
|
// Clear loading state if collapsing
|
|
if (isCurrentlyExpanded) {
|
|
setLoadingFolders(prev => ({ ...prev, [folderId]: false }));
|
|
}
|
|
};
|
|
|
|
const handleDelete = (id: string, type: 'folder' | 'file') => {
|
|
const item = treeData.flatMap(folder => [folder, ...(folder.children || [])])
|
|
.find(item => item.id === id);
|
|
|
|
if (!item) return;
|
|
|
|
setDeleteConfirm({
|
|
isOpen: true,
|
|
item: { id, name: item.name, type },
|
|
isDeleting: false,
|
|
});
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
if (!deleteConfirm.item) return;
|
|
|
|
const { id, type } = deleteConfirm.item;
|
|
|
|
setDeleteConfirm(prev => ({ ...prev, isDeleting: true }));
|
|
|
|
try {
|
|
const supabase = createClient();
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
if (!session?.access_token) {
|
|
throw new Error('No session found');
|
|
}
|
|
|
|
const endpoint = type === 'folder'
|
|
? `${API_URL}/knowledge-base/folders/${id}`
|
|
: `${API_URL}/knowledge-base/entries/${id}`;
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${session.access_token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast.success(`${type === 'folder' ? 'Folder' : 'File'} deleted`);
|
|
|
|
// Always refetch folders to update counts and structure
|
|
refetchFolders();
|
|
|
|
if (type === 'folder') {
|
|
if (selectedItem?.id === id) {
|
|
setSelectedItem(null);
|
|
}
|
|
} else {
|
|
// Also reload folder entries for immediate UI update
|
|
const parentFolder = treeData.find(folder =>
|
|
folder.children?.some(child => child.id === id)
|
|
);
|
|
if (parentFolder) {
|
|
await fetchFolderEntries(parentFolder.id);
|
|
}
|
|
}
|
|
} else {
|
|
toast.error(`Failed to delete ${type}`);
|
|
}
|
|
} catch (error) {
|
|
toast.error(`Failed to delete ${type}`);
|
|
} finally {
|
|
setDeleteConfirm({
|
|
isOpen: false,
|
|
item: null,
|
|
isDeleting: false,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|
console.log('Drag started:', event.active.id, event.active.data.current);
|
|
setActiveId(event.active.id as string);
|
|
};
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
console.log('Drag ended:', { activeId: active.id, overId: over?.id });
|
|
|
|
if (!over || active.id === over.id) {
|
|
setActiveId(null);
|
|
return;
|
|
}
|
|
|
|
// Check if this is an external file from file browser
|
|
if (active.data.current?.type === 'external-file') {
|
|
const fileData = active.data.current.file;
|
|
const overItemId = over.id.toString().replace('droppable-', '');
|
|
const overItem = treeData.find(item => item.id === overItemId);
|
|
|
|
if (overItem?.type === 'folder') {
|
|
handleExternalFileDrop(fileData, overItem.id);
|
|
}
|
|
setActiveId(null);
|
|
return;
|
|
}
|
|
|
|
// Handle internal DND - get the actual item IDs
|
|
const activeItemId = active.id.toString();
|
|
const overItemId = over.id.toString().replace('droppable-', ''); // Remove droppable prefix if present
|
|
|
|
const activeItem = treeData.flatMap(folder => [folder, ...(folder.children || [])]).find(item => item.id === activeItemId);
|
|
const overItem = treeData.flatMap(folder => [folder, ...(folder.children || [])]).find(item => item.id === overItemId);
|
|
|
|
if (!activeItem || !overItem) {
|
|
setActiveId(null);
|
|
return;
|
|
}
|
|
|
|
// File to folder: Move file to different folder
|
|
if (activeItem.type === 'file' && overItem.type === 'folder') {
|
|
console.log('Moving file', activeItem.id, 'to folder', overItem.id);
|
|
handleMoveFile(activeItem.id, overItem.id);
|
|
}
|
|
// Block all other drag operations - no folder reordering, no file reordering
|
|
else {
|
|
console.log('Blocked drag operation:', activeItem.type, 'to', overItem.type);
|
|
}
|
|
|
|
setActiveId(null);
|
|
};
|
|
|
|
const handleExternalFileDrop = async (fileData: any, folderId: string) => {
|
|
// This would be used for cross-component DND if both file browser and KB tree were in the same DND context
|
|
// For now, we use the "Add to Knowledge Base" button in file browser as a simpler solution
|
|
toast.info('Cross-component DND not fully implemented - use "Add to Knowledge Base" button in file browser');
|
|
};
|
|
|
|
const handleNativeFileDrop = async (files: FileList, folderId: string) => {
|
|
try {
|
|
const supabase = createClient();
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
if (!session?.access_token) {
|
|
throw new Error('No session found');
|
|
}
|
|
|
|
const fileArray = Array.from(files);
|
|
const totalFiles = fileArray.length;
|
|
|
|
// Initialize upload status
|
|
setUploadStatus(prev => ({
|
|
...prev,
|
|
[folderId]: {
|
|
isUploading: true,
|
|
progress: 0,
|
|
totalFiles,
|
|
completedFiles: 0,
|
|
currentFile: fileArray[0]?.name
|
|
}
|
|
}));
|
|
|
|
// Upload files one by one
|
|
let successCount = 0;
|
|
let limitErrorShown = false; // Track if we've shown the limit error
|
|
|
|
for (let i = 0; i < fileArray.length; i++) {
|
|
const file = fileArray[i];
|
|
|
|
// Validate filename before upload
|
|
const validation = FileNameValidator.validateName(file.name, 'file');
|
|
if (!validation.isValid) {
|
|
toast.error(`Invalid filename "${file.name}": ${FileNameValidator.getFriendlyErrorMessage(file.name, 'file')}`);
|
|
continue;
|
|
}
|
|
|
|
// Update current file status
|
|
setUploadStatus(prev => ({
|
|
...prev,
|
|
[folderId]: {
|
|
...prev[folderId],
|
|
currentFile: file.name,
|
|
progress: (i / totalFiles) * 100
|
|
}
|
|
}));
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const response = await fetch(`${API_URL}/knowledge-base/folders/${folderId}/upload`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${session.access_token}`,
|
|
},
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
successCount++;
|
|
|
|
// Show info about filename changes
|
|
if (result.filename_changed) {
|
|
toast.info(`File "${result.original_filename}" was renamed to "${result.final_filename}" to avoid conflicts`);
|
|
}
|
|
} else {
|
|
// Handle specific error cases
|
|
if (response.status === 413) {
|
|
// Only show one limit error per upload session
|
|
if (!limitErrorShown) {
|
|
try {
|
|
const errorData = await response.json();
|
|
toast.error(`Knowledge base limit exceeded: ${errorData.detail || 'Total file size limit (50MB) exceeded'}`);
|
|
} catch {
|
|
toast.error('Knowledge base limit exceeded: Total file size limit (50MB) exceeded');
|
|
}
|
|
limitErrorShown = true;
|
|
}
|
|
// Don't log console error for limit exceeded
|
|
} else if (response.status === 400) {
|
|
// Validation errors
|
|
try {
|
|
const errorData = await response.json();
|
|
toast.error(`Failed to upload ${file.name}: ${errorData.detail}`);
|
|
} catch {
|
|
toast.error(`Failed to upload ${file.name}: Invalid file`);
|
|
}
|
|
} else {
|
|
toast.error(`Failed to upload ${file.name}: Error ${response.status}`);
|
|
console.error(`Failed to upload ${file.name}:`, response.status);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
toast.error(`Error uploading ${file.name}`);
|
|
console.error(`Error uploading ${file.name}:`, error);
|
|
}
|
|
|
|
// Update completed count
|
|
setUploadStatus(prev => ({
|
|
...prev,
|
|
[folderId]: {
|
|
...prev[folderId],
|
|
completedFiles: i + 1,
|
|
progress: ((i + 1) / totalFiles) * 100
|
|
}
|
|
}));
|
|
}
|
|
|
|
// Clear upload status after a short delay
|
|
setTimeout(() => {
|
|
setUploadStatus(prev => {
|
|
const newStatus = { ...prev };
|
|
delete newStatus[folderId];
|
|
return newStatus;
|
|
});
|
|
}, 3000);
|
|
|
|
if (successCount === totalFiles) {
|
|
toast.success(`Successfully uploaded ${successCount} file(s)`);
|
|
} else if (successCount > 0) {
|
|
toast.success(`Uploaded ${successCount} of ${totalFiles} files`);
|
|
} else {
|
|
toast.error('Failed to upload files');
|
|
}
|
|
|
|
// Refresh the folder contents
|
|
refetchFolders();
|
|
// Also refresh the specific folder's entries to show new files immediately
|
|
await fetchFolderEntries(folderId);
|
|
|
|
} catch (error) {
|
|
console.error('Error uploading files:', error);
|
|
toast.error('Failed to upload files');
|
|
|
|
// Clear upload status on error
|
|
setUploadStatus(prev => {
|
|
const newStatus = { ...prev };
|
|
delete newStatus[folderId];
|
|
return newStatus;
|
|
});
|
|
}
|
|
};
|
|
|
|
if (foldersLoading) {
|
|
return (
|
|
<div className="h-screen flex flex-col bg-background">
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="max-w-7xl mx-auto p-6">
|
|
{/* Header Skeleton */}
|
|
<div className="mb-8">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<Skeleton className="h-9 w-48 mb-2" />
|
|
<Skeleton className="h-5 w-96" />
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Skeleton className="h-10 w-32" />
|
|
<Skeleton className="h-10 w-32" />
|
|
</div>
|
|
</div>
|
|
<Skeleton className="h-10 w-80" />
|
|
</div>
|
|
|
|
{/* Content Skeleton */}
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-6 w-32" />
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-16 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="min-h-screen">
|
|
<div className="container mx-auto max-w-7xl px-4 py-8">
|
|
<KnowledgeBasePageHeader />
|
|
</div>
|
|
<div className="container mx-auto max-w-7xl px-4 py-2">
|
|
<div className="w-full min-h-[calc(100vh-300px)]">
|
|
{/* Header Section */}
|
|
<div className="flex justify-between items-start mb-8">
|
|
<div className="space-y-1">
|
|
<h2 className="text-xl font-semibold text-foreground">Knowledge Base</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Organize documents and files for AI agents to search and reference
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Button variant="outline" onClick={handleCreateFolder}>
|
|
<FolderPlusIcon className="h-4 w-4 mr-2" />
|
|
New Folder
|
|
</Button>
|
|
<TextEntryModal
|
|
folders={folders}
|
|
onUploadComplete={refetchFolders}
|
|
/>
|
|
<FileUploadModal
|
|
folders={folders}
|
|
onUploadComplete={refetchFolders}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div
|
|
className="space-y-8"
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
// Only prevent default - don't show any message since folders handle their own drops
|
|
}}
|
|
>
|
|
{/* Recent Creations Section */}
|
|
{recentFiles.length > 0 && (
|
|
<div className="mb-8">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="text-lg font-medium text-foreground">
|
|
Recently Added
|
|
</h3>
|
|
<span className="text-xs text-muted-foreground">
|
|
{recentFiles.length} files
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 mb-8">
|
|
{recentFiles.slice(0, 6).map((file) => {
|
|
const fileInfo = getFileTypeInfo(file.filename);
|
|
return (
|
|
<div
|
|
key={file.entry_id}
|
|
className="group cursor-pointer"
|
|
onClick={() => setFilePreviewModal({
|
|
isOpen: true,
|
|
file: file,
|
|
})}
|
|
>
|
|
<div className="relative bg-muted/20 border border-border/50 rounded-lg p-4 transition-all duration-200 hover:bg-muted/30 hover:border-border">
|
|
<div className="flex flex-col items-center space-y-3">
|
|
<div className="relative">
|
|
<div className="w-12 h-12 bg-muted/80 rounded-lg flex items-center justify-center">
|
|
<FileIcon className="h-6 w-6 text-foreground/60" />
|
|
</div>
|
|
<div className="absolute -bottom-1 -right-1 bg-background border border-border rounded px-1 py-0.5">
|
|
<span className="text-[8px] font-medium text-muted-foreground uppercase">
|
|
{fileInfo.extension.slice(0, 3)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-center space-y-1 w-full">
|
|
<p className="text-xs font-medium text-foreground truncate" title={file.filename}>
|
|
{file.filename.length > 12 ? `${file.filename.slice(0, 12)}...` : file.filename}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatDate(file.created_at)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex items-center justify-between mb-6 mt-8">
|
|
<h3 className="text-lg font-medium text-foreground">
|
|
All Folders
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{treeData.length === 0 ? (
|
|
<div className="text-center py-20">
|
|
<div className="mx-auto max-w-md">
|
|
<div className="relative mb-8">
|
|
<div className="mx-auto w-20 h-20 bg-muted rounded-xl flex items-center justify-center border border-border/50">
|
|
<FolderIcon className="h-10 w-10 text-muted-foreground" />
|
|
</div>
|
|
<div className="absolute -top-2 -right-2 w-8 h-8 bg-background border border-border rounded-full flex items-center justify-center">
|
|
<PlusIcon className="h-4 w-4 text-foreground" />
|
|
</div>
|
|
</div>
|
|
<h3 className="text-xl font-semibold mb-3 text-foreground">Start Building Your Knowledge Base</h3>
|
|
<p className="text-muted-foreground mb-8 leading-relaxed">
|
|
Create folders to organize documents, PDFs, and files that your AI agents can search and reference during conversations.
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8 text-left">
|
|
<div className="bg-muted/20 border border-border/50 rounded-lg p-4">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-8 h-8 bg-muted rounded-lg flex items-center justify-center">
|
|
<FileIcon className="h-4 w-4 text-foreground/70" />
|
|
</div>
|
|
<h4 className="text-sm font-semibold">Smart Search</h4>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">Agents can intelligently search across all your documents</p>
|
|
</div>
|
|
|
|
<div className="bg-muted/20 border border-border/50 rounded-lg p-4">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-8 h-8 bg-muted rounded-lg flex items-center justify-center">
|
|
<FolderIcon className="h-4 w-4 text-foreground/70" />
|
|
</div>
|
|
<h4 className="text-sm font-semibold">Organized</h4>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">Keep files organized by topic, project, or purpose</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Button onClick={handleCreateFolder} size="lg">
|
|
<FolderPlusIcon className="h-4 w-4 mr-2" />
|
|
Create Your First Folder
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={[]} // No sorting - only drag files to folders
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="space-y-3">
|
|
{treeData.map((item) => (
|
|
<SharedTreeItem
|
|
key={item.id}
|
|
item={item}
|
|
onExpand={handleExpand}
|
|
onSelect={handleFileSelect}
|
|
enableDnd={true}
|
|
enableActions={true}
|
|
enableEdit={true}
|
|
onDelete={handleDelete}
|
|
onEditSummary={handleEditSummary}
|
|
editingFolder={editingFolder}
|
|
editingName={editingName}
|
|
onStartEdit={handleStartEdit}
|
|
onFinishEdit={handleFinishEdit}
|
|
onEditChange={handleEditChange}
|
|
onEditKeyPress={handleEditKeyPress}
|
|
editInputRef={editInputRef}
|
|
onNativeFileDrop={handleNativeFileDrop}
|
|
uploadStatus={uploadStatus[item.id]}
|
|
validationError={editingFolder === item.id ? validationError : null}
|
|
isLoadingEntries={loadingFolders[item.id]}
|
|
movingFiles={movingFiles}
|
|
/>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
|
|
<DragOverlay>
|
|
{activeId ? (() => {
|
|
// Find the active item in the tree data
|
|
const findActiveItem = (items: any[]): any => {
|
|
for (const item of items) {
|
|
if (item.id === activeId) return item;
|
|
if (item.children) {
|
|
const found = findActiveItem(item.children);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const activeItem = findActiveItem(treeData);
|
|
|
|
if (activeItem?.type === 'file') {
|
|
return <FileDragOverlay item={activeItem} />;
|
|
} else {
|
|
return (
|
|
<div className="bg-background border rounded-lg p-3">
|
|
<div className="flex items-center gap-2">
|
|
<FolderIcon className="h-4 w-4 text-blue-500" />
|
|
<span className="font-medium text-sm">
|
|
{activeItem?.name}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
})() : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
{/* Modals */}
|
|
<KBDeleteConfirmDialog
|
|
isOpen={deleteConfirm.isOpen}
|
|
onClose={() => setDeleteConfirm({ isOpen: false, item: null, isDeleting: false })}
|
|
onConfirm={confirmDelete}
|
|
itemName={deleteConfirm.item?.name || ''}
|
|
itemType={deleteConfirm.item?.type || 'file'}
|
|
isDeleting={deleteConfirm.isDeleting}
|
|
/>
|
|
|
|
<EditSummaryModal
|
|
isOpen={editSummaryModal.isOpen}
|
|
onClose={() => setEditSummaryModal({ isOpen: false, fileId: '', fileName: '', currentSummary: '' })}
|
|
fileName={editSummaryModal.fileName}
|
|
currentSummary={editSummaryModal.currentSummary}
|
|
onSave={handleSaveSummary}
|
|
/>
|
|
|
|
{filePreviewModal.file && (
|
|
<KBFilePreviewModal
|
|
isOpen={filePreviewModal.isOpen}
|
|
onClose={() => setFilePreviewModal({ isOpen: false, file: null })}
|
|
file={filePreviewModal.file}
|
|
onEditSummary={handleEditSummary}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |