mirror of https://github.com/kortix-ai/suna.git
Merge pull request #1705 from kortix-ai/feat/new-knowledge-base-and-sematic-search
feat: add file preview in kb
This commit is contained in:
commit
bc6176fffd
|
@ -174,6 +174,9 @@ api_router.include_router(email_api.router)
|
|||
from knowledge_base import api as knowledge_base_api
|
||||
api_router.include_router(knowledge_base_api.router)
|
||||
|
||||
from core.knowledge_base import api as core_knowledge_base_api
|
||||
api_router.include_router(core_knowledge_base_api.router)
|
||||
|
||||
api_router.include_router(triggers_api.router)
|
||||
|
||||
from core.pipedream import api as pipedream_api
|
||||
|
|
|
@ -58,6 +58,16 @@ class FolderResponse(BaseModel):
|
|||
entry_count: int
|
||||
created_at: str
|
||||
|
||||
async def get_user_account_id(client, user_id: str) -> str:
|
||||
"""Get account_id for a user from the account_user table"""
|
||||
account_user_result = await client.schema('basejump').from_('account_user').select('account_id').eq('user_id', user_id).execute()
|
||||
|
||||
if not account_user_result.data or len(account_user_result.data) == 0:
|
||||
raise HTTPException(status_code=404, detail="User account not found")
|
||||
|
||||
return account_user_result.data[0]['account_id']
|
||||
|
||||
|
||||
@router.get("/folders", response_model=List[FolderResponse])
|
||||
async def get_folders(
|
||||
user_id: str = Depends(verify_and_get_user_id_from_jwt)
|
||||
|
@ -67,11 +77,7 @@ async def get_folders(
|
|||
client = await db.client
|
||||
|
||||
# Get current account_id from user
|
||||
user_result = await client.from_("profiles").select("account_id").eq("id", user_id).single().execute()
|
||||
if not user_result.data:
|
||||
raise HTTPException(status_code=404, detail="User profile not found")
|
||||
|
||||
account_id = user_result.data["account_id"]
|
||||
account_id = await get_user_account_id(client, user_id)
|
||||
|
||||
# Get folders for this account
|
||||
result = await client.from_("knowledge_base_folders").select("*").eq("account_id", account_id).order("created_at", desc=True).execute()
|
||||
|
@ -104,11 +110,7 @@ async def create_folder(
|
|||
client = await db.client
|
||||
|
||||
# Get current account_id from user
|
||||
user_result = await client.from_("profiles").select("account_id").eq("id", user_id).single().execute()
|
||||
if not user_result.data:
|
||||
raise HTTPException(status_code=404, detail="User profile not found")
|
||||
|
||||
account_id = user_result.data["account_id"]
|
||||
account_id = await get_user_account_id(client, user_id)
|
||||
|
||||
# Create folder
|
||||
result = await client.from_("knowledge_base_folders").insert({
|
||||
|
@ -155,8 +157,8 @@ async def update_folder(
|
|||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
# Get current account_id from user
|
||||
user_result = await client.from_("profiles").select("account_id").eq("id", user_id).single().execute()
|
||||
if not user_result.data or user_result.data["account_id"] != folder_result.data["account_id"]:
|
||||
user_account_id = await get_user_account_id(client, user_id)
|
||||
if user_account_id != folder_result.data["account_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Update folder
|
||||
|
@ -198,8 +200,8 @@ async def get_folder_entries(
|
|||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
# Get current account_id from user
|
||||
user_result = await client.from_("profiles").select("account_id").eq("id", user_id).single().execute()
|
||||
if not user_result.data or user_result.data["account_id"] != folder_result.data["account_id"]:
|
||||
user_account_id = await get_user_account_id(client, user_id)
|
||||
if user_account_id != folder_result.data["account_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Get entries
|
||||
|
@ -775,3 +777,46 @@ async def update_agent_assignments(
|
|||
logger.error(f"Error updating agent assignments for {agent_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Failed to update agent assignments")
|
||||
|
||||
|
||||
@router.get("/entries/{entry_id}/download")
|
||||
async def download_file(
|
||||
entry_id: str,
|
||||
user_id: str = Depends(verify_and_get_user_id_from_jwt)
|
||||
):
|
||||
"""Download the actual file content from S3"""
|
||||
try:
|
||||
client = await db.client
|
||||
|
||||
# Get the entry from knowledge_base_entries table
|
||||
result = await client.from_("knowledge_base_entries").select("file_path, filename, mime_type, account_id").eq("entry_id", entry_id).single().execute()
|
||||
|
||||
if not result.data:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
entry = result.data
|
||||
|
||||
# Verify user has access to this entry (check account_id)
|
||||
user_account_id = await get_user_account_id(client, user_id)
|
||||
if user_account_id != entry['account_id']:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Get file content from S3
|
||||
file_response = await client.storage.from_('file-uploads').download(entry['file_path'])
|
||||
|
||||
if not file_response:
|
||||
raise HTTPException(status_code=404, detail="File content not found in storage")
|
||||
|
||||
# For text files, return as text
|
||||
if entry['mime_type'] and entry['mime_type'].startswith('text/'):
|
||||
return {"content": file_response.decode('utf-8'), "is_binary": False}
|
||||
else:
|
||||
# For binary files (including PDFs), return base64 encoded content
|
||||
import base64
|
||||
return {"content": base64.b64encode(file_response).decode('utf-8'), "is_binary": True}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading file {entry_id}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Failed to download file")
|
||||
|
||||
|
|
|
@ -0,0 +1,200 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { File } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { PdfRenderer } from '@/components/thread/preview-renderers/pdf-renderer';
|
||||
import { HtmlRenderer } from '@/components/thread/preview-renderers/html-renderer';
|
||||
import { MarkdownRenderer } from '@/components/thread/preview-renderers/file-preview-markdown-renderer';
|
||||
|
||||
interface KBFilePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
file: {
|
||||
entry_id: string;
|
||||
filename: string;
|
||||
summary: string;
|
||||
file_size: number;
|
||||
created_at: string;
|
||||
};
|
||||
onEditSummary: (fileId: string, fileName: string, summary: string) => void;
|
||||
}
|
||||
|
||||
export function KBFilePreviewModal({ isOpen, onClose, file, onEditSummary }: KBFilePreviewModalProps) {
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [binaryBlobUrl, setBinaryBlobUrl] = useState<string | null>(null);
|
||||
|
||||
// File type detection
|
||||
const filename = file.filename;
|
||||
const extension = filename.split('.').pop()?.toLowerCase() || '';
|
||||
const isPdf = extension === 'pdf';
|
||||
const isMarkdown = ['md', 'markdown'].includes(extension);
|
||||
const isHtml = ['html', 'htm'].includes(extension);
|
||||
const isCode = ['js', 'jsx', 'ts', 'tsx', 'py', 'java', 'c', 'cpp', 'css', 'json', 'xml', 'yaml', 'yml'].includes(extension);
|
||||
|
||||
// Renderer mapping like file attachments
|
||||
const rendererMap = {
|
||||
'html': HtmlRenderer,
|
||||
'htm': HtmlRenderer,
|
||||
'md': MarkdownRenderer,
|
||||
'markdown': MarkdownRenderer,
|
||||
};
|
||||
|
||||
// Load file content when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && file.entry_id) {
|
||||
loadFileContent();
|
||||
} else {
|
||||
setFileContent(null);
|
||||
if (binaryBlobUrl) {
|
||||
URL.revokeObjectURL(binaryBlobUrl);
|
||||
}
|
||||
setBinaryBlobUrl(null);
|
||||
}
|
||||
}, [isOpen, file.entry_id]);
|
||||
|
||||
// Cleanup blob URL on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (binaryBlobUrl) {
|
||||
URL.revokeObjectURL(binaryBlobUrl);
|
||||
}
|
||||
};
|
||||
}, [binaryBlobUrl]);
|
||||
|
||||
const loadFileContent = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
if (!session?.access_token) {
|
||||
throw new Error('No session found');
|
||||
}
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
console.log('Loading file content for entry_id:', file.entry_id);
|
||||
|
||||
const response = await fetch(`${API_URL}/knowledge-base/entries/${file.entry_id}/download`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Download response status:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('Download result:', result);
|
||||
|
||||
if (result.is_binary) {
|
||||
// For binary files (PDFs), create blob URL like file attachments do
|
||||
const binaryString = atob(result.content);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Create blob and blob URL like useImageContent does
|
||||
const blob = new Blob([bytes], {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setBinaryBlobUrl(blobUrl);
|
||||
setFileContent(null);
|
||||
} else {
|
||||
setFileContent(result.content);
|
||||
setBinaryBlobUrl(null);
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error('Download failed:', response.status, errorText);
|
||||
setFileContent(`Error loading file: ${response.status} ${errorText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load file content:', error);
|
||||
setFileContent(`Error loading file: ${error}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>File Preview</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col h-[600px]">
|
||||
{/* File preview - exactly like file browser */}
|
||||
<div className="border rounded-xl overflow-hidden flex flex-col flex-1">
|
||||
<div className="p-2 bg-muted text-sm font-medium border-b">
|
||||
{file.filename}
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2 p-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : isPdf && binaryBlobUrl ? (
|
||||
<div className="w-full h-full">
|
||||
<PdfRenderer url={binaryBlobUrl} className="w-full h-full" />
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<div className="p-2 h-full">
|
||||
{(() => {
|
||||
// Use appropriate renderer based on file type
|
||||
const Renderer = rendererMap[extension as keyof typeof rendererMap];
|
||||
|
||||
if (Renderer) {
|
||||
return (
|
||||
<Renderer
|
||||
content={fileContent}
|
||||
previewUrl=""
|
||||
className="h-full w-full"
|
||||
/>
|
||||
);
|
||||
} else if (isCode) {
|
||||
// For code files, show with syntax highlighting
|
||||
return (
|
||||
<pre className="text-xs whitespace-pre-wrap bg-muted/30 p-3 rounded-md overflow-auto h-full">
|
||||
<code className={`language-${extension}`}>
|
||||
{fileContent}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
} else {
|
||||
// Default text rendering
|
||||
return (
|
||||
<pre className="text-xs whitespace-pre-wrap">{fileContent}</pre>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<File className="h-8 w-8 mb-2" />
|
||||
<p>Select a file to preview</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -50,6 +50,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { SharedTreeItem, FileDragOverlay } from '@/components/knowledge-base/shared-kb-tree';
|
||||
import { KBFilePreviewModal } from './kb-file-preview-modal';
|
||||
|
||||
// Get backend URL from environment variables
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
@ -245,8 +246,28 @@ export function KnowledgeBasePage() {
|
|||
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),
|
||||
|
@ -456,7 +477,7 @@ export function KnowledgeBasePage() {
|
|||
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();
|
||||
|
@ -509,7 +530,7 @@ export function KnowledgeBasePage() {
|
|||
const fetchFolderEntries = async (folderId: string) => {
|
||||
// Set loading state
|
||||
setLoadingFolders(prev => ({ ...prev, [folderId]: true }));
|
||||
|
||||
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
@ -873,239 +894,251 @@ export function KnowledgeBasePage() {
|
|||
}
|
||||
|
||||
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="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>
|
||||
<FileUploadModal
|
||||
folders={folders}
|
||||
onUploadComplete={refetchFolders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={handleCreateFolder}>
|
||||
<FolderPlusIcon className="h-4 w-4 mr-2" />
|
||||
New Folder
|
||||
</Button>
|
||||
<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={() => handleEditSummary(file.entry_id, file.filename, file.summary)}
|
||||
>
|
||||
<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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{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={setSelectedItem}
|
||||
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}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
})() : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<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>
|
||||
<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>
|
||||
{/* 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}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -83,7 +83,7 @@ interface SharedTreeItemProps {
|
|||
|
||||
// Loading state for folder expansion
|
||||
isLoadingEntries?: boolean;
|
||||
|
||||
|
||||
// Moving state for files
|
||||
isMoving?: boolean;
|
||||
movingFiles?: { [fileId: string]: boolean };
|
||||
|
@ -218,7 +218,7 @@ export function SharedTreeItem({
|
|||
? 'bg-primary/5 border-primary/20 border-dashed'
|
||||
: 'hover:bg-muted/30 hover:border-border/50'
|
||||
}`}
|
||||
|
||||
|
||||
onClick={() => onExpand(item.id)}
|
||||
onDragOver={handleNativeDragOver}
|
||||
onDragLeave={handleNativeDragLeave}
|
||||
|
@ -399,11 +399,10 @@ export function SharedTreeItem({
|
|||
/* File Row - Using div instead of button to avoid nesting */
|
||||
<div
|
||||
ref={combinedRef}
|
||||
className={`group flex items-center w-full text-sm h-auto px-4 py-3 rounded-lg transition-all duration-200 border border-transparent ${
|
||||
itemIsMoving
|
||||
? 'opacity-60 cursor-not-allowed bg-muted/30'
|
||||
className={`group flex items-center w-full text-sm h-auto px-4 py-3 rounded-lg transition-all duration-200 border border-transparent ${itemIsMoving
|
||||
? 'opacity-60 cursor-not-allowed bg-muted/30'
|
||||
: 'hover:bg-muted/30 hover:border-border/50 cursor-pointer'
|
||||
} ${isDragging ? 'opacity-50' : ''}`}
|
||||
} ${isDragging ? 'opacity-50' : ''}`}
|
||||
style={{
|
||||
paddingLeft: ``,
|
||||
...style
|
||||
|
@ -411,13 +410,9 @@ export function SharedTreeItem({
|
|||
onClick={() => {
|
||||
// Don't allow clicks when moving
|
||||
if (itemIsMoving) return;
|
||||
|
||||
// Trigger summary editing on file click
|
||||
if (onEditSummary) {
|
||||
onEditSummary(item.id, item.name, item.data?.summary || '');
|
||||
} else {
|
||||
onSelect(item);
|
||||
}
|
||||
|
||||
// Trigger file preview on file click
|
||||
onSelect(item);
|
||||
}}
|
||||
>
|
||||
{/* Drag Handle - Only visible on hover and only when DND is enabled for files */}
|
||||
|
@ -452,7 +447,7 @@ export function SharedTreeItem({
|
|||
</span>
|
||||
<span className="text-xs text-muted-foreground/50">•</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Click to edit summary
|
||||
Click to preview file
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
Loading…
Reference in New Issue