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:
kubet 2025-09-23 17:51:42 +02:00 committed by GitHub
commit bc6176fffd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 529 additions and 253 deletions

View File

@ -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

View File

@ -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")

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
)}