diff --git a/backend/api.py b/backend/api.py index 2b038550..126f4630 100644 --- a/backend/api.py +++ b/backend/api.py @@ -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 diff --git a/backend/core/knowledge_base/api.py b/backend/core/knowledge_base/api.py index 3ee55b61..5d7d950c 100644 --- a/backend/core/knowledge_base/api.py +++ b/backend/core/knowledge_base/api.py @@ -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") + diff --git a/frontend/src/components/knowledge-base/kb-file-preview-modal.tsx b/frontend/src/components/knowledge-base/kb-file-preview-modal.tsx new file mode 100644 index 00000000..dc5d1e4e --- /dev/null +++ b/frontend/src/components/knowledge-base/kb-file-preview-modal.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(false); + const [binaryBlobUrl, setBinaryBlobUrl] = useState(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 ( + + + + File Preview + + +
+ {/* File preview - exactly like file browser */} +
+
+ {file.filename} +
+
+ {isLoading ? ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ ) : isPdf && binaryBlobUrl ? ( +
+ +
+ ) : fileContent ? ( +
+ {(() => { + // Use appropriate renderer based on file type + const Renderer = rendererMap[extension as keyof typeof rendererMap]; + + if (Renderer) { + return ( + + ); + } else if (isCode) { + // For code files, show with syntax highlighting + return ( +
+                                                    
+                                                        {fileContent}
+                                                    
+                                                
+ ); + } else { + // Default text rendering + return ( +
{fileContent}
+ ); + } + })()} +
+ ) : ( +
+ +

Select a file to preview

+
+ )} +
+
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/knowledge-base/knowledge-base-page.tsx b/frontend/src/components/knowledge-base/knowledge-base-page.tsx index 9c5b2035..10741389 100644 --- a/frontend/src/components/knowledge-base/knowledge-base-page.tsx +++ b/frontend/src/components/knowledge-base/knowledge-base-page.tsx @@ -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 ( -
-
-
- -
-
-
- {/* Header Section */} -
-
-

Knowledge Base

-

- Organize documents and files for AI agents to search and reference -

+
+
+
+ +
+
+
+ {/* Header Section */} +
+
+

Knowledge Base

+

+ Organize documents and files for AI agents to search and reference +

+
+
+ + +
-
- - -
-
- {/* Main Content */} -
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 && ( -
-
-

- Recently Added -

- - {recentFiles.length} files - -
-
- {recentFiles.slice(0, 6).map((file) => { - const fileInfo = getFileTypeInfo(file.filename); - return ( -
handleEditSummary(file.entry_id, file.filename, file.summary)} - > -
-
-
-
- -
-
- - {fileInfo.extension.slice(0, 3)} - -
-
-
-

- {file.filename.length > 12 ? `${file.filename.slice(0, 12)}...` : file.filename} -

-

- {formatDate(file.created_at)} -

-
-
-
-
- ); - })} -
-
-

- All Folders -

-
+ {/* Main Content */} +
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 && ( +
+
+

+ Recently Added +

+ + {recentFiles.length} files +
- )} - - {treeData.length === 0 ? ( -
-
-
-
- -
-
- -
-
-

Start Building Your Knowledge Base

-

- Create folders to organize documents, PDFs, and files that your AI agents can search and reference during conversations. -

- -
-
-
-
- -
-

Smart Search

-
-

Agents can intelligently search across all your documents

-
- -
-
-
- -
-

Organized

-
-

Keep files organized by topic, project, or purpose

-
-
- - -
-
- ) : ( -
- - -
- {treeData.map((item) => ( - - ))} -
-
- - - {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 ; - } else { - return ( -
-
- - - {activeItem?.name} +
+ {recentFiles.slice(0, 6).map((file) => { + const fileInfo = getFileTypeInfo(file.filename); + return ( +
setFilePreviewModal({ + isOpen: true, + file: file, + })} + > +
+
+
+
+ +
+
+ + {fileInfo.extension.slice(0, 3)}
- ); - } - })() : null} - - +
+

+ {file.filename.length > 12 ? `${file.filename.slice(0, 12)}...` : file.filename} +

+

+ {formatDate(file.created_at)} +

+
+
+
+
+ ); + })}
- )} -
+
+

+ All Folders +

+
+
+ )} + + {treeData.length === 0 ? ( +
+
+
+
+ +
+
+ +
+
+

Start Building Your Knowledge Base

+

+ Create folders to organize documents, PDFs, and files that your AI agents can search and reference during conversations. +

+ +
+
+
+
+ +
+

Smart Search

+
+

Agents can intelligently search across all your documents

+
+ +
+
+
+ +
+

Organized

+
+

Keep files organized by topic, project, or purpose

+
+
+ + +
+
+ ) : ( +
+ + +
+ {treeData.map((item) => ( + + ))} +
+
+ + + {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 ; + } else { + return ( +
+
+ + + {activeItem?.name} + +
+
+ ); + } + })() : null} +
+
+
+ )}
-
-
- {/* Modals */} - setDeleteConfirm({ isOpen: false, item: null, isDeleting: false })} - onConfirm={confirmDelete} - itemName={deleteConfirm.item?.name || ''} - itemType={deleteConfirm.item?.type || 'file'} - isDeleting={deleteConfirm.isDeleting} - /> +
+
+
+ {/* Modals */} + setDeleteConfirm({ isOpen: false, item: null, isDeleting: false })} + onConfirm={confirmDelete} + itemName={deleteConfirm.item?.name || ''} + itemType={deleteConfirm.item?.type || 'file'} + isDeleting={deleteConfirm.isDeleting} + /> - setEditSummaryModal({ isOpen: false, fileId: '', fileName: '', currentSummary: '' })} - fileName={editSummaryModal.fileName} - currentSummary={editSummaryModal.currentSummary} - onSave={handleSaveSummary} - /> + setEditSummaryModal({ isOpen: false, fileId: '', fileName: '', currentSummary: '' })} + fileName={editSummaryModal.fileName} + currentSummary={editSummaryModal.currentSummary} + onSave={handleSaveSummary} + /> + + {filePreviewModal.file && ( + setFilePreviewModal({ isOpen: false, file: null })} + file={filePreviewModal.file} + onEditSummary={handleEditSummary} + /> + )} +
-
); } \ No newline at end of file diff --git a/frontend/src/components/knowledge-base/shared-kb-tree.tsx b/frontend/src/components/knowledge-base/shared-kb-tree.tsx index c764fd9e..f60546f9 100644 --- a/frontend/src/components/knowledge-base/shared-kb-tree.tsx +++ b/frontend/src/components/knowledge-base/shared-kb-tree.tsx @@ -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 */
{ // 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({ - Click to edit summary + Click to preview file )}