diff --git a/frontend/src/components/ContentManager.tsx b/frontend/src/components/ContentManager.tsx new file mode 100644 index 0000000..d287b81 --- /dev/null +++ b/frontend/src/components/ContentManager.tsx @@ -0,0 +1,91 @@ +import React, { JSX, useCallback } from 'react'; +import { Box } from '@mui/material'; +import { VectorVisualizer } from 'components/VectorVisualizer'; +import { DocumentManager } from 'components/DocumentManager'; + +const ContentManager = (): JSX.Element => { + const [filenames, setFilenames] = React.useState([]); + const [editPrompt, setEditPrompt] = React.useState(''); + const [prompt, setPrompt] = React.useState(''); + const [filenameFilter, setFilenameFilter] = React.useState([]); + + const setFilter = useCallback( + (newFilter: string) => { + if (newFilter !== editPrompt) { + console.log(`Setting edit prompt to: ${newFilter}`); + setEditPrompt(newFilter); + } + if (newFilter !== prompt) { + console.log(`Setting prompt to: ${newFilter}`); + setPrompt(newFilter); + } + if (newFilter === '' && filenames.length > 0) { + console.log('Clearing filename filter'); + setFilenames([]); + } + }, + [editPrompt, prompt, filenames.length] + ); + + const onDocumentSelect = useCallback( + (document: { filename: string } | null): void => { + if (document) { + console.log(`Document selected: ${document.filename}`); + setFilenameFilter([document.filename]); + } else if (filenames.length > 0) { + console.log('No document selected, clearing filename filter'); + setFilenameFilter([]); + } + }, + [setFilenameFilter, filenames] + ); + + return ( + + { + editPrompt !== newPrompt && setEditPrompt(newPrompt); + }} + onQueryResult={(newPrompt: string, newFilenames: string[]) => { + if (newPrompt !== prompt) { + console.log(`Setting prompt to: ${newPrompt}`); + setPrompt(newPrompt); + } + let update = filenames.length !== newFilenames.length; + if (!update) { + for (let i = 0; i < filenames.length; i++) { + if (filenames[i] !== newFilenames[i]) { + update = true; + break; + } + } + } + if (update) { + console.log(`Updating filenames from ${filenames} to ${newFilenames}`); + setFilenames(newFilenames); + } + }} + /> + + + ); +}; + +export { ContentManager }; diff --git a/frontend/src/components/DeleteConfirmation.tsx b/frontend/src/components/DeleteConfirmation.tsx index a39c53d..6311b2b 100644 --- a/frontend/src/components/DeleteConfirmation.tsx +++ b/frontend/src/components/DeleteConfirmation.tsx @@ -8,11 +8,11 @@ import { DialogTitle, Button, useMediaQuery, - Tooltip, SxProps, } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import ResetIcon from '@mui/icons-material/History'; +import DeleteIcon from '@mui/icons-material/Delete'; interface DeleteConfirmationProps { // Legacy props for backward compatibility (uncontrolled mode) @@ -38,7 +38,7 @@ interface DeleteConfirmationProps { title?: string; message?: string; icon?: React.ReactNode; - + size?: 'small' | 'medium' | 'large'; // Optional props for button customization in controlled mode hideButton?: boolean; confirmButtonText?: string; @@ -66,8 +66,9 @@ const DeleteConfirmation = (props: DeleteConfirmationProps): JSX.Element => { hideButton = false, confirmButtonText, cancelButtonText = 'Cancel', + size = 'large', sx, - icon = , + icon = props.action === 'reset' ? : , } = props; // Internal state for uncontrolled mode @@ -116,27 +117,22 @@ const DeleteConfirmation = (props: DeleteConfirmationProps): JSX.Element => { <> {/* Only show button if not hidden (for controlled mode) */} {!hideButton && ( - - - {' '} - {/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */} - { - e.stopPropagation(); - e.preventDefault(); - handleClickOpen(); - }} - color={color || 'inherit'} - sx={{ display: 'flex', margin: 'auto 0px', ...sx }} - size="large" - edge="start" - disabled={disabled} - > - {icon} - - - + { + e.stopPropagation(); + e.preventDefault(); + handleClickOpen(); + }} + title={label ? `${capitalizeFirstLetter(action)} ${label}` : 'Reset'} + color={color || 'default'} + sx={{ display: 'flex', margin: 'auto 0px', ...sx }} + size={size} + edge="start" + disabled={disabled} + > + {icon} + )} { - const { filepath } = props; - - const [document, setDocument] = useState(''); - - // Get the markdown - useEffect(() => { - if (!filepath) { - return; - } - const fetchDocument = async (): Promise => { - try { - const response = await fetch(filepath, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - if (!response.ok) { - throw Error(`${filepath} not found.`); - } - const data = await response.text(); - setDocument(data); - } catch (error) { - console.error('Error obtaining Docs content information:', error); - setDocument(`${filepath} not found.`); - } - }; - - fetchDocument(); - }, [document, setDocument, filepath]); - - return ( - <> - - - ); -}; - -export { Document }; diff --git a/frontend/src/components/DocumentList.tsx b/frontend/src/components/DocumentList.tsx new file mode 100644 index 0000000..a42edee --- /dev/null +++ b/frontend/src/components/DocumentList.tsx @@ -0,0 +1,463 @@ +import React, { JSX, useState, useMemo } from 'react'; +import { Edit, Visibility, ArrowUpward, ArrowDownward } from '@mui/icons-material'; +import { + Box, + Chip, + Dialog, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Theme, + Typography, + useMediaQuery, + useTheme, +} from '@mui/material'; +import { useAuth } from 'hooks/AuthContext'; +import * as Types from 'types/types'; +import { useAppState } from 'hooks/GlobalContext'; +import { DeleteConfirmation } from './DeleteConfirmation'; +import { DocumentView } from './DocumentView'; + +interface DocumentListProps { + documents: Types.Document[]; + setDocuments: (documents: Types.Document[]) => void; + setSelectedDocument?: (document: Types.Document | null) => void; + selectedDocument?: Types.Document | null; +} + +// Format file size +const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +const getFileTypeColor = (theme: Theme, type: string): string => { + switch (type) { + case 'pdf': + return theme.palette.primary.main; + case 'docx': + return theme.palette.secondary.main; + case 'txt': + return theme.palette.success.main; + case 'md': + return theme.palette.warning.main; + default: + return theme.palette.primary.main; + } +}; + +type SortField = 'name' | 'date' | 'size' | 'type' | 'rag'; + +const DocumentList = (props: DocumentListProps): JSX.Element => { + const theme = useTheme(); + const { setSnack } = useAppState(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const { apiClient } = useAuth(); + const { documents, setDocuments, setSelectedDocument, selectedDocument } = props; + const [documentView, setDocumentView] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [sortField, setSortField] = useState('date'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + + // Sort documents + const sortedDocuments = useMemo(() => { + if (!sortField) return documents; + + return [...documents].sort((a, b) => { + let aValue: string | number; + let bValue: string | number; + + switch (sortField) { + case 'name': + aValue = a.filename.toLowerCase(); + bValue = b.filename.toLowerCase(); + break; + case 'date': + aValue = (a.updatedAt || a.uploadDate)?.getTime() || 0; + bValue = (b.updatedAt || b.uploadDate)?.getTime() || 0; + break; + case 'size': + aValue = a.size; + bValue = b.size; + break; + case 'type': + aValue = a.type.toLowerCase(); + bValue = b.type.toLowerCase(); + break; + case 'rag': + aValue = a.options?.includeInRag ? 1 : 0; + bValue = b.options?.includeInRag ? 1 : 0; + break; + default: + return 0; + } + + if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1; + if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + }, [documents, sortField, sortDirection]); + + // Handle sorting + const handleSort = (field: SortField): void => { + if (sortField === field) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortDirection('asc'); + } + }; + + // Render sort icon + const renderSortIcon = (field: SortField): JSX.Element | null => { + if (sortField !== field) return null; + return sortDirection === 'asc' ? ( + + ) : ( + + ); + }; + + // Start rename process + const viewDocument = (document: Types.Document, edit = false): void => { + console.log('Starting rename for document:', document, document.filename); + setDocumentView(document); + setIsEditing(edit); + }; + + // Handle document deletion + const handleDeleteDocument = async (document: Types.Document): Promise => { + try { + // Call API to delete document + await apiClient.deleteCandidateDocument(document); + + setDocuments(documents.filter(doc => doc.id !== document.id)); + setSnack('Document deleted successfully', 'success'); + + // Close content view if this document was being viewed + if (selectedDocument?.id === document.id) { + setSelectedDocument && setSelectedDocument(null); + } + } catch (error) { + console.log(error); + } + }; + + // Handle RAG flag toggle + const handleRAGToggle = async ( + document: Types.Document, + includeInRag: boolean + ): Promise => { + try { + document.options = { includeInRag }; + // Call API to update RAG flag + await apiClient.updateCandidateDocument(document); + + setDocuments( + documents.map(doc => (doc.id === document.id ? { ...doc, options: { includeInRag } } : doc)) + ); + setSnack(`Document ${includeInRag ? 'included in' : 'excluded from'} RAG`, 'success'); + } catch (error) { + setSnack('Failed to update RAG setting', 'error'); + } + }; + + return ( + + + + + + handleSort('name')} + > + + Name + {renderSortIcon('name')} + + + {!isMobile && ( + handleSort('type')} + > + + Type + {renderSortIcon('type')} + + + )} + {!isMobile && ( + handleSort('size')} + > + Size + {renderSortIcon('size')} + + )} + {!isMobile && ( + handleSort('date')} + > + + Date + {renderSortIcon('date')} + + + )} + handleSort('rag')} + > + + RAG + {renderSortIcon('rag')} + + + + Actions + + + + + {sortedDocuments.map(doc => ( + { + setSelectedDocument && + setSelectedDocument(selectedDocument?.id === doc.id ? null : doc); + }} + > + + + + {doc.filename} + + {isMobile && ( + + {doc.type.toUpperCase()} + + )} + {isMobile && ( + + + {formatFileSize(doc.size)} •{' '} + {(doc?.updatedAt || doc?.uploadDate)?.toLocaleDateString()} + + + )} + + + {!isMobile && ( + + + {doc.type.toUpperCase()} + + + )} + {!isMobile && ( + + + {formatFileSize(doc.size)} + + + )} + {!isMobile && ( + + + {(doc?.updatedAt || doc?.uploadDate)?.toLocaleDateString()} + + + )} + e.stopPropagation()}> + { + e.stopPropagation(); + handleRAGToggle(doc, !doc.options?.includeInRag); + }} + sx={{ + height: 20, + fontSize: '0.65rem', + cursor: 'pointer', + '&:hover': { + opacity: 0.8, + }, + }} + /> + + e.stopPropagation()}> + + { + e.stopPropagation(); + viewDocument(doc); + }} + title="View content" + sx={{ p: 0.5 }} + > + + + { + e.stopPropagation(); + viewDocument(doc, true); + }} + title="Rename" + sx={{ p: 0.5 }} + > + + + { + handleDeleteDocument(doc); + }} + // color="primary" + sx={{ minWidth: 'auto', maxHeight: 'min-content' }} + size="small" + action="delete" + label="this document" + title="Delete document" + message={`Are you sure you want to delete this document? This action cannot be undone.`} + /> + + + + ))} + +
+
+ + {/* Rename Dialog */} + { + setDocumentView(null); + }} + maxWidth="sm" + fullWidth + > + {documentView && ( + { + setDocumentView(null); + setIsEditing(false); + setDocuments([...documents]); + }} + onCancel={() => { + setDocumentView(null); + }} + /> + )} + +
+ ); +}; + +export { DocumentList }; diff --git a/frontend/src/components/DocumentManager.tsx b/frontend/src/components/DocumentManager.tsx index cf83d24..92a480f 100644 --- a/frontend/src/components/DocumentManager.tsx +++ b/frontend/src/components/DocumentManager.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, JSX } from 'react'; +import React, { useState, useEffect, JSX, useRef } from 'react'; import { Box, Button, @@ -7,30 +7,18 @@ import { Typography, Card, CardContent, - List, - ListItem, - ListItemText, - ListItemSecondaryAction, IconButton, - Switch, - FormControlLabel, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - TextField, - Chip, - Divider, Paper, } from '@mui/material'; +import ClearIcon from '@mui/icons-material/Clear'; import { styled } from '@mui/material/styles'; -import { CloudUpload, Edit, Delete, Visibility, Close } from '@mui/icons-material'; +import { CloudUpload, Close } from '@mui/icons-material'; import { useTheme } from '@mui/material/styles'; import { useAuth } from 'hooks/AuthContext'; import * as Types from 'types/types'; -import { BackstoryElementProps } from './BackstoryTab'; import { useAppState } from 'hooks/GlobalContext'; +import { DocumentList } from './DocumentList'; const VisuallyHiddenInput = styled('input')({ clip: 'rect(0 0 0 0)', @@ -44,23 +32,57 @@ const VisuallyHiddenInput = styled('input')({ width: 1, }); -const DocumentManager = (_props: BackstoryElementProps): JSX.Element => { +interface DocumentManagerProps { + filter?: string; + onDocumentSelect?: (document: Types.Document | null) => void; + setFilter?: (filter: string) => void; + filenames?: string[]; +} + +const DocumentManager = (props: DocumentManagerProps): JSX.Element => { + const { filter = '', filenames = [], setFilter, onDocumentSelect } = props; const theme = useTheme(); const { setSnack } = useAppState(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const { user, apiClient } = useAuth(); const [documents, setDocuments] = useState([]); + const [filteredDocuments, setFilteredDocuments] = useState([]); const [selectedDocument, setSelectedDocument] = useState(null); const [documentContent, setDocumentContent] = useState(''); const [isViewingContent, setIsViewingContent] = useState(false); - const [editingDocument, setEditingDocument] = useState(null); - const [editingName, setEditingName] = useState(''); - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); // Check if user is a candidate const candidate = user?.userType === 'candidate' ? (user as Types.Candidate) : null; + useEffect(() => { + onDocumentSelect && onDocumentSelect(selectedDocument); + }, [selectedDocument, onDocumentSelect]); + + const prevDepsRef = useRef({ documents, filenames }); + + useEffect(() => { + const prev = prevDepsRef.current; + + // Check if the actual content changed + const shouldUpdate = + documents.length !== prev.documents.length || + filenames.length !== prev.filenames.length || + documents.some((doc, i) => doc.filename !== prev.documents[i]?.filename) || + filenames.some((name, i) => name !== prev.filenames[i]); + + if (shouldUpdate) { + prevDepsRef.current = { documents, filenames }; + + if (filenames.length > 0) { + const filtered = documents.filter(doc => filenames.includes(doc.filename)); + setFilteredDocuments(filtered); + } else { + setFilteredDocuments(documents); + } + } + }, [documents, filenames]); + // Load documents on component mount useEffect(() => { const loadDocuments = async (): Promise => { @@ -131,114 +153,25 @@ const DocumentManager = (_props: BackstoryElementProps): JSX.Element => { } }; - // Handle document deletion - const handleDeleteDocument = async (document: Types.Document): Promise => { - try { - // Call API to delete document - await apiClient.deleteCandidateDocument(document); + const updateDocuments = (updatedDocs: Types.Document[]): void => { + // Find documents that were deleted (in filteredDocuments but not in updatedDocs) + const deletedDocIds = filteredDocuments + .filter(doc => !updatedDocs.some(updated => updated.id === doc.id)) + .map(doc => doc.id); - setDocuments(prev => prev.filter(doc => doc.id !== document.id)); - setSnack('Document deleted successfully', 'success'); + // Update the main documents array: + // 1. Remove any deleted documents + // 2. Update any modified documents + const updatedDocuments = documents + .filter(doc => !deletedDocIds.includes(doc.id)) // Remove deleted docs + .map(doc => { + // Check if this document was modified in updatedDocs + const modifiedDoc = updatedDocs.find(updated => updated.id === doc.id); + return modifiedDoc || doc; // Use modified version if available, otherwise keep original + }); - // Close content view if this document was being viewed - if (selectedDocument?.id === document.id) { - setIsViewingContent(false); - setSelectedDocument(null); - setDocumentContent(''); - } - } catch (error) { - setSnack('Failed to delete document', 'error'); - } - }; - - // Handle RAG flag toggle - const handleRAGToggle = async ( - document: Types.Document, - includeInRag: boolean - ): Promise => { - try { - document.options = { includeInRag }; - // Call API to update RAG flag - await apiClient.updateCandidateDocument(document); - - setDocuments(prev => - prev.map(doc => (doc.id === document.id ? { ...doc, includeInRag } : doc)) - ); - setSnack(`Document ${includeInRag ? 'included in' : 'excluded from'} RAG`, 'success'); - } catch (error) { - setSnack('Failed to update RAG setting', 'error'); - } - }; - - // Handle document rename - const handleRenameDocument = async (document: Types.Document, newName: string): Promise => { - if (!newName.trim()) { - setSnack('Document name cannot be empty', 'error'); - return; - } - - try { - // Call API to rename document - document.filename = newName; - await apiClient.updateCandidateDocument(document); - - setDocuments(prev => - prev.map(doc => (doc.id === document.id ? { ...doc, filename: newName.trim() } : doc)) - ); - setSnack('Document renamed successfully', 'success'); - setIsRenameDialogOpen(false); - setEditingDocument(null); - setEditingName(''); - } catch (error) { - setSnack('Failed to rename document', 'error'); - } - }; - - // Handle document content viewing - const handleViewDocument = async (document: Types.Document): Promise => { - try { - setSelectedDocument(document); - setIsViewingContent(true); - - // Call API to get document content - const result = await apiClient.getCandidateDocumentText(document); - setDocumentContent(result.content); - } catch (error) { - setSnack('Failed to load document content', 'error'); - setIsViewingContent(false); - } - }; - - // Start rename process - const startRename = (document: Types.Document, currentName: string): void => { - setEditingDocument(document); - setEditingName(currentName); - setIsRenameDialogOpen(true); - }; - - // Format file size - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - }; - - // Get file type color - const getFileTypeColor = (type: string): 'primary' | 'secondary' | 'success' | 'warning' => { - switch (type) { - case 'pdf': - return 'primary'; - case 'docx': - return 'secondary'; - case 'txt': - return 'success'; - case 'md': - return 'warning'; - default: - return 'primary'; - } + setDocuments(updatedDocuments); + setFilteredDocuments(updatedDocs); // Update filtered docs to match what child returned }; if (!candidate) { @@ -246,250 +179,139 @@ const DocumentManager = (_props: BackstoryElementProps): JSX.Element => { } return ( - <> - - - Documents - - + + )} + + + + {documents.length === 0 ? ( + + No additional documents uploaded + + ) : ( + + )} + + + {/* Document Content Viewer */} + {isViewingContent && ( - {documents.length === 0 ? ( - + Document Content + { + setIsViewingContent(false); + setSelectedDocument(null); + setDocumentContent(''); }} > - No additional documents uploaded - - ) : ( - - {documents.map((doc, index) => ( - - {index > 0 && } - - - - {doc.filename} - - - {doc.options?.includeInRag && ( - - )} - - } - secondary={ - - - {formatFileSize(doc.size)} • {doc?.uploadDate?.toLocaleDateString()} - - - { - handleRAGToggle(doc, e.target.checked); - }} - size="small" - /> - } - label={Include in RAG} - /> - - - } - /> - - - { - handleViewDocument(doc); - }} - title="View content" - > - - - { - startRename(doc, doc.filename); - }} - title="Rename" - > - - - { - handleDeleteDocument(doc); - }} - title="Delete" - color="error" - > - - - - - - - ))} - - )} + + + + +
+                  {documentContent || 'Loading content...'}
+                
+
- - {/* Document Content Viewer */} - {isViewingContent && ( - - - - - Document Content - { - setIsViewingContent(false); - setSelectedDocument(null); - setDocumentContent(''); - }} - > - - - - -
-                    {documentContent || 'Loading content...'}
-                  
-
-
-
-
- )} - - {/* Rename Dialog */} - { - setIsRenameDialogOpen(false); - }} - maxWidth="sm" - fullWidth - > - Rename Document - - { - setEditingName(e.target.value); - }} - onKeyUp={(e): void => { - if (e.key === 'Enter' && editingDocument) { - handleRenameDocument(editingDocument, editingName); - } - }} - /> - - - - - - -
- + )} + ); }; diff --git a/frontend/src/components/DocumentView.tsx b/frontend/src/components/DocumentView.tsx new file mode 100644 index 0000000..f9ed832 --- /dev/null +++ b/frontend/src/components/DocumentView.tsx @@ -0,0 +1,143 @@ +import React, { useState, useEffect, JSX } from 'react'; +import { BackstoryElementProps } from './BackstoryTab'; +import * as Types from 'types/types'; +import { Box, Button, TextField } from '@mui/material'; +import { useAuth } from 'hooks/AuthContext'; +import { useAppState } from 'hooks/GlobalContext'; +import { Scrollable } from './Scrollable'; + +interface DocumentViewProps extends BackstoryElementProps { + document: Types.Document; + edit?: boolean; + onSave?: (document: Types.Document) => void; + onCancel?: () => void; +} + +const DocumentView = (props: DocumentViewProps): JSX.Element => { + const { apiClient } = useAuth(); + const { setSnack } = useAppState(); + const { document, edit = false, onSave, onCancel } = props; + const [editingName, setEditingName] = useState(document.filename); + const [content, setContent] = useState(''); + const [editContent, setEditContent] = useState(''); + + useEffect(() => { + if (!document) { + return; + } + const fetchDocument = async (): Promise => { + try { + const response: Types.DocumentContentResponse = await apiClient.getCandidateDocumentText( + document + ); + setContent(response.content || ''); + setEditContent(response.content || ''); + } catch (error) { + console.error('Error obtaining Docs content information:', error); + setContent(`${document.filename} not found.`); + } + }; + + fetchDocument(); + }, [document, setContent]); + + // Handle document rename + const handleDocumentUpdate = async (): Promise => { + if (!editingName.trim()) { + setSnack('Document name cannot be empty', 'error'); + return; + } + if (!editContent.trim()) { + setSnack('Document content cannot be empty. Delete instead.', 'error'); + return; + } + + try { + // Call API to rename document + document.filename = editingName; + let result: Types.Document; + if (editContent !== content) { + result = await apiClient.updateCandidateDocument(document, editContent); + } else { + result = await apiClient.updateCandidateDocument(document); + } + onSave && onSave(result); + setContent(editContent); + setSnack('Document updated successfully', 'success'); + } catch (error) { + setSnack('Failed to udpate document', 'error'); + } + }; + + return ( + + { + edit && setEditingName(e.target.value); + }} + onKeyUp={(e): void => { + if (e.key === 'Enter') { + handleDocumentUpdate(); + } + }} + sx={{ pointerEvents: edit ? 'auto' : 'none' }} + /> + + { + edit && setEditContent(e.target.value); + }} + sx={{ pointerEvents: edit ? 'auto' : 'none' }} + multiline + /> + + + {edit && ( + + )} + + + + ); +}; + +export { DocumentView }; diff --git a/frontend/src/components/VectorVisualizer.tsx b/frontend/src/components/VectorVisualizer.tsx index f5f207e..7e408e2 100644 --- a/frontend/src/components/VectorVisualizer.tsx +++ b/frontend/src/components/VectorVisualizer.tsx @@ -28,6 +28,10 @@ import { useNavigate } from 'react-router-dom'; interface VectorVisualizerProps extends BackstoryPageProps { inline?: boolean; rag?: Types.ChromaDBGetResponse; + query?: string; + filenameFilter?: string[]; + setQuery?: (query: string) => void; + onQueryResult?: (query: string, filenames: string[]) => void; } // interface Metadata { @@ -191,10 +195,19 @@ type Node = { const VectorVisualizer: React.FC = (props: VectorVisualizerProps) => { const { user, apiClient } = useAuth(); - const { rag, inline, sx } = props; + const { rag, inline, sx, onQueryResult, setQuery, query, filenameFilter = [] } = props; const { setSnack } = useAppState(); const [plotData, setPlotData] = useState(null); - const [newQuery, setNewQuery] = useState(''); + + // Determine if component is controlled or uncontrolled + const isControlled = query !== undefined; + + // Internal state for uncontrolled mode + const [internalQuery, setInternalQuery] = useState(''); + + // Input field value - use prop in controlled mode, internal state in uncontrolled mode + const inputValue = isControlled ? query : internalQuery; + const [querySet, setQuerySet] = useState(rag || emptyQuerySet); const [result, setResult] = useState(null); const [view2D, setView2D] = useState(true); @@ -209,6 +222,21 @@ const VectorVisualizer: React.FC = (props: VectorVisualiz const candidate: Types.Candidate | null = user?.userType === 'candidate' ? (user as Types.Candidate) : null; + // Sync internal state with query prop when it changes (for controlled mode) + useEffect(() => { + if (isControlled) { + if (query !== internalQuery) { + setInternalQuery(query); + if (query) { + sendQuery(query); + } else { + /* Clear the query to reset the visualization */ + setQuerySet(rag || emptyQuerySet); + } + } + } + }, [query, isControlled]); + /* Force resize of Plotly as it tends to not be the correct size if it is initially rendered * off screen (eg., the VectorVisualizer is not on the tab the app loads to) */ useEffect(() => { @@ -309,6 +337,15 @@ const VectorVisualizer: React.FC = (props: VectorVisualiz * query is for any item that is in the querySet */ full.ids.forEach((id, index) => { + // Skip items that don't match the filename filter + if (filenameFilter.length > 0) { + console.log(`Checking if ${full.metadatas[index]?.sourceFile} is in ${filenameFilter}`); + const sourceFile = full.metadatas[index]?.sourceFile; + if (!sourceFile || !filenameFilter.includes(sourceFile)) { + return; // Skip this item + } + } + const foundIndex = querySet.ids.indexOf(id); /* Update metadata to hold the doc content and id */ full.metadatas[index].id = id; @@ -419,24 +456,43 @@ const VectorVisualizer: React.FC = (props: VectorVisualiz } setPlotData(data); - }, [result, querySet, view2D]); + }, [result, querySet, view2D, filenameFilter]); - const handleKeyPress = (event: React.KeyboardEvent): void => { - if (event.key === 'Enter') { - sendQuery(newQuery); + const handleInputChange = (newValue: string): void => { + if (isControlled) { + // In controlled mode, always call onQuery to let parent handle the change + setQuery?.(newValue); + } else { + // In uncontrolled mode, update internal state + setInternalQuery(newValue); } }; - const sendQuery = async (query: string): Promise => { - if (!query.trim()) return; - setNewQuery(''); + const handleKeyPress = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter') { + sendQuery(inputValue); + } + }; + + const sendQuery = async (queryText: string): Promise => { + if (!queryText.trim()) return; try { - const result = await apiClient.getCandidateSimilarContent(query); - console.log(result); + const result = await apiClient.getCandidateSimilarContent(queryText); setQuerySet(result); + const uniqueSourceFiles = result.metadatas + .map(x => x.sourceFile) + .filter((sourceFile, index, array) => array.indexOf(sourceFile) === index); + + // Always call onQuery when a search is performed + onQueryResult?.(queryText, uniqueSourceFiles || []); + + // Only clear input in uncontrolled mode + if (!isControlled) { + setInternalQuery(''); + } } catch (error) { - const msg = `Error obtaining similar content to ${query}.`; + const msg = `Error obtaining similar content to ${queryText}.`; setSnack(msg, 'error'); } }; @@ -599,7 +655,6 @@ The scatter graph shows the query in N-dimensional space, mapped to ${ }} /> - - - {!inline && querySet.query !== undefined && querySet.query !== '' && ( - - {querySet.query !== undefined && querySet.query !== '' && `Query: ${querySet.query}`} - {querySet.ids.length === 0 && 'Enter query below to perform a similarity search.'} - + Query: {querySet.query} + )} - {!inline && ( { - setNewQuery(e.target.value); + handleInputChange(e.target.value); }} onKeyDown={handleKeyPress} placeholder="Enter query to find related documents..." @@ -803,14 +855,14 @@ The scatter graph shows the query in N-dimensional space, mapped to ${ sx={{ m: 1 }} variant="contained" onClick={(): void => { - sendQuery(newQuery); + sendQuery(inputValue); }} > - )} + )}{' '} ); diff --git a/frontend/src/config/navigationConfig.tsx b/frontend/src/config/navigationConfig.tsx index 60bb529..b3b5466 100644 --- a/frontend/src/config/navigationConfig.tsx +++ b/frontend/src/config/navigationConfig.tsx @@ -17,18 +17,17 @@ import { JobAnalysisPage } from 'pages/JobAnalysisPage'; import { GenerateCandidate } from 'pages/GenerateCandidate'; import { LoginPage } from 'pages/LoginPage'; import { EmailVerificationPage } from 'components/EmailVerificationComponents'; -import { Box, Typography } from '@mui/material'; +import { Typography } from '@mui/material'; import { CandidateDashboard } from 'pages/candidate/Dashboard'; import { NavigationConfig, NavigationItem } from 'types/navigation'; import { HowItWorks } from 'pages/HowItWorks'; import { CandidateProfile } from 'pages/candidate/Profile'; import { Settings } from 'pages/candidate/Settings'; -import { VectorVisualizer } from 'components/VectorVisualizer'; -import { DocumentManager } from 'components/DocumentManager'; import { useAuth } from 'hooks/AuthContext'; import { useNavigate } from 'react-router-dom'; import { JobsViewPage } from 'pages/JobsViewPage'; import { ResumeViewer } from 'components/ui/ResumeViewer'; +import { ContentManager } from 'components/ContentManager'; const LogoutPage = (): JSX.Element => { const { logout } = useAuth(); @@ -150,12 +149,7 @@ export const navigationConfig: NavigationConfig = { label: 'Content', icon: , path: '/candidate/documents', - component: ( - - - - - ), + component: , userTypes: ['candidate'], userMenuGroup: 'profile', showInNavigation: false, diff --git a/frontend/src/pages/DocsPage.tsx b/frontend/src/pages/DocsPage.tsx deleted file mode 100644 index c43ff9c..0000000 --- a/frontend/src/pages/DocsPage.tsx +++ /dev/null @@ -1,508 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useNavigate, useLocation, useParams } from 'react-router-dom'; -import { - Box, - Drawer, - AppBar, - Toolbar, - IconButton, - Typography, - List, - ListItem, - ListItemButton, - ListItemIcon, - ListItemText, - Paper, - Grid, - Card, - CardContent, - CardActionArea, - useTheme, - useMediaQuery, -} from '@mui/material'; -import MenuIcon from '@mui/icons-material/Menu'; -import PersonIcon from '@mui/icons-material/Person'; -import CloseIcon from '@mui/icons-material/Close'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import DescriptionIcon from '@mui/icons-material/Description'; -import CodeIcon from '@mui/icons-material/Code'; -import LayersIcon from '@mui/icons-material/Layers'; -import DashboardIcon from '@mui/icons-material/Dashboard'; -import PaletteIcon from '@mui/icons-material/Palette'; -import AnalyticsIcon from '@mui/icons-material/Analytics'; -import ViewQuiltIcon from '@mui/icons-material/ViewQuilt'; - -import { Document } from '../components/Document'; -import { BackstoryPageProps } from '../components/BackstoryTab'; -import { BackstoryUIOverviewPage } from 'documents/BackstoryUIOverviewPage'; -import { BackstoryAppAnalysisPage } from 'documents/BackstoryAppAnalysisPage'; -import { BackstoryThemeVisualizerPage } from 'documents/BackstoryThemeVisualizerPage'; -import { UserManagement } from 'documents/UserManagement'; -import { MockupPage } from 'documents/MockupPage'; -import { useAppState } from 'hooks/GlobalContext'; - -// Sidebar navigation component using MUI components -const Sidebar: React.FC<{ - currentPage: string; - onDocumentSelect: (docName: string, open: boolean) => void; - onClose?: () => void; - isMobile: boolean; -}> = ({ currentPage, onDocumentSelect, onClose, isMobile }) => { - const navigate = useNavigate(); - - // Document definitions - - const handleItemClick = (route: string) => { - onDocumentSelect(route, true); - if (isMobile && onClose) { - onClose(); - } - }; - - return ( - - - - Documentation - - {isMobile && onClose && ( - - - - )} - - - - - {documents.map((doc, index) => ( - - (doc.route ? handleItemClick(doc.route) : navigate('/'))} - selected={currentPage === doc.route} - sx={{ - borderRadius: 1, - mb: 0.5, - }} - > - - {getDocumentIcon(doc.title)} - - - - - ))} - - - - ); -}; - -const getDocumentIcon = (title: string): React.ReactNode => { - const item = documents.find(d => d.title.toLocaleLowerCase() === title.toLocaleLowerCase()); - if (!item) { - throw Error(`${title} does not exist in documents`); - } - return item.icon || ; -}; - -type DocType = { - title: string; - route: string | null; - description: string; - icon?: React.ReactNode; -}; - -const documents: DocType[] = [ - { - title: 'Backstory', - route: null, - description: 'Backstory', - icon: , - }, - { - title: 'About', - route: 'about', - description: 'General information about the application and its purpose', - icon: , - }, - { - title: 'BETA', - route: 'beta', - description: 'Details about the current beta version and upcoming features', - icon: , - }, - { - title: 'Resume Generation Architecture', - route: 'resume-generation', - description: 'Technical overview of how resumes are processed and generated', - icon: , - }, - { - title: 'Application Architecture', - route: 'about-app', - description: 'System design and technical stack information', - icon: , - }, - { - title: 'Authentication Architecture', - route: 'authentication.md', - description: 'Complete authentication architecture', - icon: , - }, - { - title: 'UI Overview', - route: 'ui-overview', - description: 'Guide to the user interface components and interactions', - icon: , - }, - { - title: 'UI Mockup', - route: 'ui-mockup', - description: 'Visual previews of interfaces and layout concepts', - icon: , - }, - { - title: 'Theme Visualizer', - route: 'theme-visualizer', - description: 'Explore and customize application themes and visual styles', - icon: , - }, - { - title: 'App Analysis', - route: 'app-analysis', - description: 'Statistics and performance metrics of the application', - icon: , - }, - { - title: 'Text Mockups', - route: 'backstory-ui-mockups', - description: 'Early text mockups of many of the interaction points.', - }, - { - title: 'User Management', - route: 'user-management', - description: 'User management.', - icon: , - }, - { - title: 'Type Safety', - route: 'type-safety', - description: 'Overview of front/back-end type synchronization.', - icon: , - }, -]; - -const documentFromRoute = (route: string): DocType | null => { - const index = documents.findIndex(v => v.route === route); - if (index === -1) { - return null; - } - return documents[index]; -}; - -// Helper function to get document title from route -const documentTitleFromRoute = (route: string): string => { - const doc = documentFromRoute(route); - if (doc === null) { - return 'Documentation'; - } - return doc.title; -}; - -const DocsPage = (props: BackstoryPageProps) => { - const { setSnack } = useAppState(); - const navigate = useNavigate(); - const location = useLocation(); - const { paramPage = '' } = useParams(); - const [page, setPage] = useState(paramPage); - const [drawerOpen, setDrawerOpen] = useState(false); - - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('md')); - - // Track location changes - useEffect(() => { - const parts = location.pathname.split('/'); - if (parts.length > 2) { - setPage(parts[2]); - } else { - setPage(''); - } - }, [location]); - - // Close drawer when changing to desktop view - useEffect(() => { - if (!isMobile) { - setDrawerOpen(false); - } - }, [isMobile]); - - // Handle document navigation - const onDocumentExpand = (docName: string, open: boolean) => { - console.log('Document expanded:', { docName, open, location }); - if (open) { - const parts = location.pathname.split('/'); - if (docName === 'backstory') { - navigate('/'); - return; - } - if (parts.length > 2) { - const basePath = parts.slice(0, -1).join('/'); - navigate(`${basePath}/${docName}`); - } else { - navigate(docName); - } - } else { - const basePath = location.pathname.split('/').slice(0, -1).join('/'); - navigate(`${basePath}`); - } - }; - - // Toggle mobile drawer - const toggleDrawer = () => { - setDrawerOpen(!drawerOpen); - }; - - // Close the drawer - const closeDrawer = () => { - setDrawerOpen(false); - }; - - interface DocViewProps { - page: string; - } - const DocView = (props: DocViewProps) => { - const { page = 'about' } = props; - const title = documentTitleFromRoute(page); - const icon = getDocumentIcon(title); - - return ( - - - - {icon} - {title} - - {page && } - - - ); - }; - - // Render the appropriate content based on current page - function renderContent() { - switch (page) { - case 'ui-overview': - return ; - case 'theme-visualizer': - return ( - - - - ); - case 'app-analysis': - return ; - case 'ui-mockup': - return ; - case 'user-management': - return ; - default: - if (documentFromRoute(page)) { - return ; - } - // Document grid for landing page - return ( - - - - Documentation - - - Select a document from the sidebar to view detailed technical information about the - application. - - - - {documents.map((doc, index) => { - if (doc.route === null) return <>; - return ( - - - - doc.route ? onDocumentExpand(doc.route, true) : navigate('/') - } - > - - - {getDocumentIcon(doc.title)} - - {doc.title} - - - - {doc.description} - - - - - - ); - })} - - - ); - } - } - - // Calculate drawer width - const drawerWidth = 240; - - return ( - - {/* Mobile App Bar */} - {isMobile && ( - - - - - - - {page ? documentTitleFromRoute(page) : 'Documentation'} - - - - )} - - {/* Navigation drawer */} - - {/* Mobile drawer (temporary) */} - {isMobile ? ( - - - - ) : ( - // Desktop drawer (permanent) - - - - )} - - - {/* Main content */} - - {renderContent()} - - - ); -}; - -export { DocsPage }; diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index eab3a3f..07685f6 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -1654,19 +1654,20 @@ class ApiClient { ); } - async updateCandidateDocument(document: Types.Document): Promise { + async updateCandidateDocument(document: Types.Document, content = ''): Promise { const request: Types.DocumentUpdateRequest = { filename: document.filename, options: document.options, }; + if (content) { + request.content = content; + } const response = await this.fetchWithErrorHandling(`/candidates/documents/${document.id}`, { method: 'PATCH', body: JSON.stringify(formatApiRequest(request)), }); - const result = await handleApiResponse(response); - - return result; + return await handleApiResponse(response); } async deleteCandidateDocument(document: Types.Document): Promise { diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index fdbcea7..713de34 100644 --- a/frontend/src/types/types.ts +++ b/frontend/src/types/types.ts @@ -1,6 +1,6 @@ // Generated TypeScript types from Pydantic models // Source: src/backend/models.py -// Generated on: 2025-07-17T19:28:19.417396 +// Generated on: 2025-07-18T22:57:32.251542 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ @@ -506,6 +506,7 @@ export interface Document { type: "pdf" | "docx" | "txt" | "markdown" | "image"; size: number; uploadDate?: Date; + updatedAt?: Date; options?: DocumentOptions; ragChunks?: number; } @@ -543,6 +544,7 @@ export interface DocumentOptions { export interface DocumentUpdateRequest { filename?: string; + content?: string; options?: DocumentOptions; } @@ -1527,7 +1529,7 @@ export function convertDataSourceConfigurationFromApi(data: any): DataSourceConf } /** * Convert Document from API response - * Date fields: uploadDate + * Date fields: uploadDate, updatedAt */ export function convertDocumentFromApi(data: any): Document { if (!data) return data; @@ -1536,6 +1538,8 @@ export function convertDocumentFromApi(data: any): Document { ...data, // Convert uploadDate from ISO string to Date uploadDate: data.uploadDate ? new Date(data.uploadDate) : undefined, + // Convert updatedAt from ISO string to Date + updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined, }; } /** diff --git a/src/backend/models.py b/src/backend/models.py index 0dc988e..d2d613d 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -666,10 +666,17 @@ class Document(BaseModel): type: DocumentType size: int upload_date: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("uploadDate")) + updated_at: Optional[datetime] = Field(default=None, alias=str("updatedAt")) options: DocumentOptions = Field(default_factory=lambda: DocumentOptions(), alias=str("options")) rag_chunks: Optional[int] = Field(default=0, alias=str("ragChunks")) model_config = ConfigDict(populate_by_name=True) + @model_validator(mode="after") + def set_updated_at_default(self): + if self.updated_at is None: + self.updated_at = self.upload_date + return self + class DocumentContentResponse(BaseModel): document_id: str = Field(..., alias=str("documentId")) @@ -688,6 +695,7 @@ class DocumentListResponse(BaseModel): class DocumentUpdateRequest(BaseModel): filename: Optional[str] = None + content: Optional[str] = None options: Optional[DocumentOptions] = None model_config = ConfigDict(populate_by_name=True) diff --git a/src/backend/rag/rag.py b/src/backend/rag/rag.py index 1d28213..af00335 100644 --- a/src/backend/rag/rag.py +++ b/src/backend/rag/rag.py @@ -427,10 +427,13 @@ class ChromaDBFileWatcher(FileSystemEventHandler): def prepare_metadata(self, meta: Dict[str, Any], buffer=defines.chunk_buffer) -> str | None: source_file = meta.get("source_file") + if not source_file: + logging.warning("⚠️ No source_file in metadata, cannot prepare content.") + return None try: - source_file = meta["source_file"] path_parts = source_file.split(os.sep) file_name = path_parts[-1] + # Strip file path from metadata meta["source_file"] = file_name with open(source_file, "r") as file: lines = file.readlines() @@ -440,7 +443,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler): end = min(meta["lines"], meta["line_end"] + buffer) meta["chunk_end"] = end return "".join(lines[start:end]) - except: + except Exception: logging.warning(f"⚠️ Unable to open {source_file}") return None diff --git a/src/backend/routes/candidates.py b/src/backend/routes/candidates.py index 644c379..bf3177d 100644 --- a/src/backend/routes/candidates.py +++ b/src/backend/routes/candidates.py @@ -892,38 +892,33 @@ async def update_document( content=create_error_response("FORBIDDEN", "Cannot update another candidate's document"), ) update_options = updates.options if updates.options else DocumentOptions() + rag_dir = os.path.join(defines.user_dir, candidate.username, "rag-content") + file_dir = os.path.join(defines.user_dir, candidate.username, "files") + os.makedirs(rag_dir, exist_ok=True) + os.makedirs(file_dir, exist_ok=True) + rag_path = os.path.join(rag_dir, document.original_name) + file_path = os.path.join(file_dir, document.original_name) + + if update_options.include_in_rag: + src = pathlib.Path(file_path) + dst = pathlib.Path(rag_path) + else: + src = pathlib.Path(rag_path) + dst = pathlib.Path(file_path) + if document.options.include_in_rag != update_options.include_in_rag: # If RAG status is changing, we need to handle file movement - rag_dir = os.path.join(defines.user_dir, candidate.username, "rag-content") - file_dir = os.path.join(defines.user_dir, candidate.username, "files") - os.makedirs(rag_dir, exist_ok=True) - os.makedirs(file_dir, exist_ok=True) - rag_path = os.path.join(rag_dir, document.original_name) - file_path = os.path.join(file_dir, document.original_name) - - if update_options.include_in_rag: + # Move to new directory + src.rename(dst) + logger.info( + f"📁 Moved file to {'RAG directory' if update_options.include_in_rag else 'regular files directory'}" + ) + if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT: src = pathlib.Path(file_path) - dst = pathlib.Path(rag_path) - # Move to RAG directory - src.rename(dst) - logger.info("📁 Moved file to RAG directory") - if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT: - src = pathlib.Path(file_path) - src_as_md = src.with_suffix(".md") - if src_as_md.exists(): - dst = pathlib.Path(rag_path).with_suffix(".md") - src_as_md.rename(dst) - else: - src = pathlib.Path(rag_path) - dst = pathlib.Path(file_path) - # Move to regular files directory - src.rename(dst) - logger.info("📁 Moved file to regular files directory") - if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT: - src_as_md = src.with_suffix(".md") - if src_as_md.exists(): - dst = pathlib.Path(file_path).with_suffix(".md") - src_as_md.rename(dst) + src_as_md = src.with_suffix(".md") + if src_as_md.exists(): + dst = pathlib.Path(rag_path).with_suffix(".md") + src_as_md.rename(dst) # Apply updates update_dict = {} @@ -931,6 +926,33 @@ async def update_document( update_dict["filename"] = updates.filename.strip() if update_options.include_in_rag is not None: update_dict["include_in_rag"] = update_options.include_in_rag + if updates.content is not None: + if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT: + logger.warning("⚠️ Content updates are not supported for non-text documents") + return JSONResponse( + status_code=400, content=create_error_response("INVALID_UPDATE", "Content updates not allowed") + ) + if not updates.content.strip(): + logger.warning("⚠️ Content update provided is empty") + return JSONResponse( + status_code=400, content=create_error_response("INVALID_UPDATE", "Content cannot be empty") + ) + if not os.path.exists(dst): + logger.error(f"❌ File not found for content update: {dst}") + return JSONResponse( + status_code=404, + content=create_error_response("FILE_NOT_FOUND", "File not found for content update"), + ) + # Write new content to file + try: + with open(dst, "w", encoding="utf-8") as f: + f.write(updates.content.strip()) + logger.info(f"📄 Updated content for document {document_id}") + except Exception as e: + logger.error(f"❌ Failed to write updated content to file: {e}") + return JSONResponse( + status_code=500, content=create_error_response("WRITE_ERROR", "Failed to write updated content") + ) if not update_dict: return JSONResponse( @@ -1130,6 +1152,14 @@ async def post_candidate_vectors(dimensions: int = Body(...), current_user=Depen results = {"ids": [], "metadatas": [], "documents": [], "embeddings": [], "size": 0} return create_success_response(results) + for metadata in collection.metadatas: + source_file = metadata.get("source_file") + if not source_file: + continue + path_parts = source_file.split(os.sep) + file_name = path_parts[-1] + metadata["source_file"] = file_name + result = { "ids": collection.ids, "metadatas": collection.metadatas,