Added improved RAG content editor

This commit is contained in:
James Ketr 2025-07-18 16:00:52 -07:00
parent a0e83d3cfb
commit 7b392409ca
14 changed files with 1061 additions and 1010 deletions

View File

@ -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<string[]>([]);
const [editPrompt, setEditPrompt] = React.useState<string>('');
const [prompt, setPrompt] = React.useState<string>('');
const [filenameFilter, setFilenameFilter] = React.useState<string[]>([]);
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 (
<Box
sx={{
display: 'flex',
width: '100%',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
>
<VectorVisualizer
filenameFilter={filenameFilter}
query={editPrompt}
setQuery={(newPrompt: string) => {
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);
}
}}
/>
<DocumentManager
{...{
onDocumentSelect,
filter: prompt,
setFilter,
filenames,
}}
/>
</Box>
);
};
export { ContentManager };

View File

@ -8,11 +8,11 @@ import {
DialogTitle, DialogTitle,
Button, Button,
useMediaQuery, useMediaQuery,
Tooltip,
SxProps, SxProps,
} from '@mui/material'; } from '@mui/material';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import ResetIcon from '@mui/icons-material/History'; import ResetIcon from '@mui/icons-material/History';
import DeleteIcon from '@mui/icons-material/Delete';
interface DeleteConfirmationProps { interface DeleteConfirmationProps {
// Legacy props for backward compatibility (uncontrolled mode) // Legacy props for backward compatibility (uncontrolled mode)
@ -38,7 +38,7 @@ interface DeleteConfirmationProps {
title?: string; title?: string;
message?: string; message?: string;
icon?: React.ReactNode; icon?: React.ReactNode;
size?: 'small' | 'medium' | 'large';
// Optional props for button customization in controlled mode // Optional props for button customization in controlled mode
hideButton?: boolean; hideButton?: boolean;
confirmButtonText?: string; confirmButtonText?: string;
@ -66,8 +66,9 @@ const DeleteConfirmation = (props: DeleteConfirmationProps): JSX.Element => {
hideButton = false, hideButton = false,
confirmButtonText, confirmButtonText,
cancelButtonText = 'Cancel', cancelButtonText = 'Cancel',
size = 'large',
sx, sx,
icon = <ResetIcon />, icon = props.action === 'reset' ? <ResetIcon /> : <DeleteIcon />,
} = props; } = props;
// Internal state for uncontrolled mode // Internal state for uncontrolled mode
@ -116,27 +117,22 @@ const DeleteConfirmation = (props: DeleteConfirmationProps): JSX.Element => {
<> <>
{/* Only show button if not hidden (for controlled mode) */} {/* Only show button if not hidden (for controlled mode) */}
{!hideButton && ( {!hideButton && (
<Tooltip title={label ? `${capitalizeFirstLetter(action)} ${label}` : 'Reset'}> <IconButton
<span style={{ display: 'flex' }}> aria-label={action}
{' '} onClick={(e): void => {
{/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */} e.stopPropagation();
<IconButton e.preventDefault();
aria-label={action} handleClickOpen();
onClick={(e): void => { }}
e.stopPropagation(); title={label ? `${capitalizeFirstLetter(action)} ${label}` : 'Reset'}
e.preventDefault(); color={color || 'default'}
handleClickOpen(); sx={{ display: 'flex', margin: 'auto 0px', ...sx }}
}} size={size}
color={color || 'inherit'} edge="start"
sx={{ display: 'flex', margin: 'auto 0px', ...sx }} disabled={disabled}
size="large" >
edge="start" {icon}
disabled={disabled} </IconButton>
>
{icon}
</IconButton>
</span>
</Tooltip>
)} )}
<Dialog <Dialog

View File

@ -1,48 +0,0 @@
import React, { useState, useEffect, JSX } from 'react';
import { BackstoryElementProps } from './BackstoryTab';
import { StyledMarkdown } from './StyledMarkdown';
interface DocumentProps extends BackstoryElementProps {
filepath?: string;
}
const Document = (props: DocumentProps): JSX.Element => {
const { filepath } = props;
const [document, setDocument] = useState<string>('');
// Get the markdown
useEffect(() => {
if (!filepath) {
return;
}
const fetchDocument = async (): Promise<void> => {
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 (
<>
<StyledMarkdown content={document} />
</>
);
};
export { Document };

View File

@ -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<Types.Document | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [sortField, setSortField] = useState<SortField | null>('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' ? (
<ArrowUpward fontSize="small" sx={{ ml: 0.5 }} />
) : (
<ArrowDownward fontSize="small" sx={{ ml: 0.5 }} />
);
};
// 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<void> => {
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<void> => {
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 (
<Box
className="DocumentList"
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
p: 0,
m: 0,
overflow: 'hidden',
width: '100%',
height: '100%',
position: 'relative',
}}
>
<TableContainer>
<Table size="small" sx={{ '& .MuiTableCell-root': { py: 0.5 } }}>
<TableHead>
<TableRow>
<TableCell
sx={{
fontWeight: 600,
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleSort('name')}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
Name
{renderSortIcon('name')}
</Box>
</TableCell>
{!isMobile && (
<TableCell
sx={{
fontWeight: 600,
width: '80px',
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleSort('type')}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
Type
{renderSortIcon('type')}
</Box>
</TableCell>
)}
{!isMobile && (
<TableCell
sx={{
fontWeight: 600,
width: '80px',
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleSort('size')}
>
Size
{renderSortIcon('size')}
</TableCell>
)}
{!isMobile && (
<TableCell
sx={{
fontWeight: 600,
width: '100px',
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleSort('date')}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
Date
{renderSortIcon('date')}
</Box>
</TableCell>
)}
<TableCell
sx={{
fontWeight: 600,
width: '80px',
textAlign: 'center',
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleSort('rag')}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
RAG
{renderSortIcon('rag')}
</Box>
</TableCell>
<TableCell sx={{ fontWeight: 600, width: '120px', textAlign: 'center' }}>
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedDocuments.map(doc => (
<TableRow
key={doc.id}
hover
sx={{
backgroundColor:
selectedDocument?.id === doc.id ? 'action.selected' : 'transparent',
'&:hover': {
backgroundColor:
selectedDocument?.id === doc.id
? 'rgba(0, 0, 0, 0.25) !important' // Slightly darker when selected + hover
: 'action.hover !important',
},
cursor: 'pointer',
}}
onClick={() => {
setSelectedDocument &&
setSelectedDocument(selectedDocument?.id === doc.id ? null : doc);
}}
>
<TableCell sx={{ py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography
variant="body2"
sx={{
wordBreak: 'break-word',
fontSize: '0.875rem',
minWidth: 0,
flex: 1,
}}
>
{doc.filename}
</Typography>
{isMobile && (
<Box
sx={{
display: 'flex',
width: 'fit-content',
border: `1px solid ${getFileTypeColor(theme, doc.type)}`,
height: 20,
px: 1,
fontSize: '0.65rem',
alignItems: 'center',
justifyContent: 'center',
}}
>
{doc.type.toUpperCase()}
</Box>
)}
{isMobile && (
<Box sx={{ width: '100%', mt: 0.5 }}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.7rem' }}
>
{formatFileSize(doc.size)} {' '}
{(doc?.updatedAt || doc?.uploadDate)?.toLocaleDateString()}
</Typography>
</Box>
)}
</Box>
</TableCell>
{!isMobile && (
<TableCell sx={{ py: 1 }}>
<Box
sx={{
display: 'flex',
width: 'fit-content',
border: `1px solid ${getFileTypeColor(theme, doc.type)}`,
height: 20,
px: 1,
fontSize: '0.65rem',
alignItems: 'center',
justifyContent: 'center',
}}
>
{doc.type.toUpperCase()}
</Box>
</TableCell>
)}
{!isMobile && (
<TableCell sx={{ py: 1 }}>
<Typography variant="caption" color="text.secondary">
{formatFileSize(doc.size)}
</Typography>
</TableCell>
)}
{!isMobile && (
<TableCell sx={{ py: 1 }}>
<Typography variant="caption" color="text.secondary">
{(doc?.updatedAt || doc?.uploadDate)?.toLocaleDateString()}
</Typography>
</TableCell>
)}
<TableCell sx={{ py: 1, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<Chip
label="RAG"
size="small"
color="success"
variant={doc.options?.includeInRag ? 'filled' : 'outlined'}
onClick={e => {
e.stopPropagation();
handleRAGToggle(doc, !doc.options?.includeInRag);
}}
sx={{
height: 20,
fontSize: '0.65rem',
cursor: 'pointer',
'&:hover': {
opacity: 0.8,
},
}}
/>
</TableCell>
<TableCell sx={{ py: 1, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 0.25 }}>
<IconButton
size="small"
onClick={(e): void => {
e.stopPropagation();
viewDocument(doc);
}}
title="View content"
sx={{ p: 0.5 }}
>
<Visibility fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={(e): void => {
e.stopPropagation();
viewDocument(doc, true);
}}
title="Rename"
sx={{ p: 0.5 }}
>
<Edit fontSize="small" />
</IconButton>
<DeleteConfirmation
onDelete={(): void => {
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.`}
/>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* Rename Dialog */}
<Dialog
open={documentView !== null}
onClose={(): void => {
setDocumentView(null);
}}
maxWidth="sm"
fullWidth
>
{documentView && (
<DocumentView
document={documentView}
edit={isEditing}
onSave={() => {
setDocumentView(null);
setIsEditing(false);
setDocuments([...documents]);
}}
onCancel={() => {
setDocumentView(null);
}}
/>
)}
</Dialog>
</Box>
);
};
export { DocumentList };

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, JSX } from 'react'; import React, { useState, useEffect, JSX, useRef } from 'react';
import { import {
Box, Box,
Button, Button,
@ -7,30 +7,18 @@ import {
Typography, Typography,
Card, Card,
CardContent, CardContent,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton, IconButton,
Switch,
FormControlLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Chip,
Divider,
Paper, Paper,
} from '@mui/material'; } from '@mui/material';
import ClearIcon from '@mui/icons-material/Clear';
import { styled } from '@mui/material/styles'; 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 { useTheme } from '@mui/material/styles';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import * as Types from 'types/types'; import * as Types from 'types/types';
import { BackstoryElementProps } from './BackstoryTab';
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from 'hooks/GlobalContext';
import { DocumentList } from './DocumentList';
const VisuallyHiddenInput = styled('input')({ const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)', clip: 'rect(0 0 0 0)',
@ -44,23 +32,57 @@ const VisuallyHiddenInput = styled('input')({
width: 1, 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 theme = useTheme();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { user, apiClient } = useAuth(); const { user, apiClient } = useAuth();
const [documents, setDocuments] = useState<Types.Document[]>([]); const [documents, setDocuments] = useState<Types.Document[]>([]);
const [filteredDocuments, setFilteredDocuments] = useState<Types.Document[]>([]);
const [selectedDocument, setSelectedDocument] = useState<Types.Document | null>(null); const [selectedDocument, setSelectedDocument] = useState<Types.Document | null>(null);
const [documentContent, setDocumentContent] = useState<string>(''); const [documentContent, setDocumentContent] = useState<string>('');
const [isViewingContent, setIsViewingContent] = useState(false); const [isViewingContent, setIsViewingContent] = useState(false);
const [editingDocument, setEditingDocument] = useState<Types.Document | null>(null);
const [editingName, setEditingName] = useState('');
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
// Check if user is a candidate // Check if user is a candidate
const candidate = user?.userType === 'candidate' ? (user as Types.Candidate) : null; 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 // Load documents on component mount
useEffect(() => { useEffect(() => {
const loadDocuments = async (): Promise<void> => { const loadDocuments = async (): Promise<void> => {
@ -131,114 +153,25 @@ const DocumentManager = (_props: BackstoryElementProps): JSX.Element => {
} }
}; };
// Handle document deletion const updateDocuments = (updatedDocs: Types.Document[]): void => {
const handleDeleteDocument = async (document: Types.Document): Promise<void> => { // Find documents that were deleted (in filteredDocuments but not in updatedDocs)
try { const deletedDocIds = filteredDocuments
// Call API to delete document .filter(doc => !updatedDocs.some(updated => updated.id === doc.id))
await apiClient.deleteCandidateDocument(document); .map(doc => doc.id);
setDocuments(prev => prev.filter(doc => doc.id !== document.id)); // Update the main documents array:
setSnack('Document deleted successfully', 'success'); // 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 setDocuments(updatedDocuments);
if (selectedDocument?.id === document.id) { setFilteredDocuments(updatedDocs); // Update filtered docs to match what child returned
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<void> => {
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<void> => {
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<void> => {
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';
}
}; };
if (!candidate) { if (!candidate) {
@ -246,250 +179,139 @@ const DocumentManager = (_props: BackstoryElementProps): JSX.Element => {
} }
return ( return (
<> <Box
<Grid container spacing={{ xs: 1, sm: 3 }} sx={{ maxWidth: '100%' }}> sx={{
<Box display: 'flex',
sx={{ position: 'relative',
display: 'flex', flexDirection: 'column',
justifyContent: 'space-between', flexGrow: 1,
alignItems: 'center', gap: 2,
mb: 2, overflow: 'hidden',
width: '100%', }}
verticalAlign: 'center', >
}} <Box
> sx={{
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Documents</Typography> display: 'flex',
<Button justifyContent: 'flex-start',
component="label" alignItems: 'center',
variant="contained" m: 0,
startIcon={<CloudUpload />} p: 1,
size={isMobile ? 'small' : 'medium'} width: '100%',
> verticalAlign: 'center',
Upload Document gap: 1,
<VisuallyHiddenInput }}
type="file" >
accept=".txt,.md,.docx,.pdf" <Typography variant={isMobile ? 'subtitle2' : 'h6'}>Documents</Typography>
onChange={handleDocumentUpload} {filter && (
<>
<Typography variant={isMobile ? 'caption' : 'body2'} color="text.secondary">
RAG filter: {filter}
</Typography>
<ClearIcon
sx={{
height: 16,
cursor: 'pointer',
userSelect: 'none',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => {
setFilter && setFilter('');
}}
/> />
</Button> </>
</Box> )}
<Button
component="label"
variant="contained"
startIcon={<CloudUpload />}
size={isMobile ? 'small' : 'medium'}
sx={{ justifySelf: 'flex-end', ml: 'auto' }}
>
Upload Document
<VisuallyHiddenInput
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleDocumentUpload}
/>
</Button>
</Box>
<Box>
{documents.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: { xs: '0.8rem', sm: '0.875rem' },
textAlign: 'center',
py: 3,
}}
>
No additional documents uploaded
</Typography>
) : (
<DocumentList
{...{
documents: filteredDocuments,
setDocuments: updateDocuments,
selectedDocument,
setSelectedDocument,
}}
/>
)}
</Box>
{/* Document Content Viewer */}
{isViewingContent && (
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<Card variant="outlined"> <Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}> <CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
{documents.length === 0 ? ( <Box
<Typography sx={{
variant="body2" display: 'flex',
color="text.secondary" justifyContent: 'space-between',
sx={{ alignItems: 'center',
fontSize: { xs: '0.8rem', sm: '0.875rem' }, mb: 2,
textAlign: 'center', }}
py: 3, >
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Document Content</Typography>
<IconButton
size="small"
onClick={(): void => {
setIsViewingContent(false);
setSelectedDocument(null);
setDocumentContent('');
}} }}
> >
No additional documents uploaded <Close />
</Typography> </IconButton>
) : ( </Box>
<List sx={{ width: '100%' }}> <Paper
{documents.map((doc, index) => ( variant="outlined"
<React.Fragment key={doc.id}> sx={{
{index > 0 && <Divider />} p: 2,
<ListItem sx={{ px: 0 }}> maxHeight: 400,
<ListItemText overflow: 'auto',
primary={ backgroundColor: 'grey.50',
<Box }}
sx={{ >
display: 'flex', <pre
alignItems: 'center', style={{
gap: 1, margin: 0,
flexWrap: 'wrap', fontFamily: 'monospace',
}} fontSize: isMobile ? '0.75rem' : '0.875rem',
> whiteSpace: 'pre-wrap',
<Typography wordBreak: 'break-word',
variant="body1" }}
sx={{ >
wordBreak: 'break-word', {documentContent || 'Loading content...'}
fontSize: { xs: '0.9rem', sm: '1rem' }, </pre>
}} </Paper>
>
{doc.filename}
</Typography>
<Chip
label={doc.type.toUpperCase()}
size="small"
color={getFileTypeColor(doc.type)}
/>
{doc.options?.includeInRag && (
<Chip label="RAG" size="small" color="success" variant="outlined" />
)}
</Box>
}
secondary={
<Box sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{formatFileSize(doc.size)} {doc?.uploadDate?.toLocaleDateString()}
</Typography>
<Box sx={{ mt: 1 }}>
<FormControlLabel
control={
<Switch
checked={doc.options?.includeInRag}
onChange={(e): void => {
handleRAGToggle(doc, e.target.checked);
}}
size="small"
/>
}
label={<Typography variant="caption">Include in RAG</Typography>}
/>
</Box>
</Box>
}
/>
<ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
edge="end"
size="small"
onClick={(): void => {
handleViewDocument(doc);
}}
title="View content"
>
<Visibility />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={(): void => {
startRename(doc, doc.filename);
}}
title="Rename"
>
<Edit />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={(): void => {
handleDeleteDocument(doc);
}}
title="Delete"
color="error"
>
<Delete />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
</React.Fragment>
))}
</List>
)}
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
)}
{/* Document Content Viewer */} </Box>
{isViewingContent && (
<Grid size={{ xs: 12 }}>
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Document Content</Typography>
<IconButton
size="small"
onClick={(): void => {
setIsViewingContent(false);
setSelectedDocument(null);
setDocumentContent('');
}}
>
<Close />
</IconButton>
</Box>
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: 400,
overflow: 'auto',
backgroundColor: 'grey.50',
}}
>
<pre
style={{
margin: 0,
fontFamily: 'monospace',
fontSize: isMobile ? '0.75rem' : '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{documentContent || 'Loading content...'}
</pre>
</Paper>
</CardContent>
</Card>
</Grid>
)}
{/* Rename Dialog */}
<Dialog
open={isRenameDialogOpen}
onClose={(): void => {
setIsRenameDialogOpen(false);
}}
maxWidth="sm"
fullWidth
>
<DialogTitle>Rename Document</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Document Name"
fullWidth
variant="outlined"
value={editingName}
onChange={(e): void => {
setEditingName(e.target.value);
}}
onKeyUp={(e): void => {
if (e.key === 'Enter' && editingDocument) {
handleRenameDocument(editingDocument, editingName);
}
}}
/>
</DialogContent>
<DialogActions>
<Button
onClick={(): void => {
setIsRenameDialogOpen(false);
}}
>
Cancel
</Button>
<Button
onClick={(): void => {
editingDocument && handleRenameDocument(editingDocument, editingName);
}}
variant="contained"
disabled={!editingName.trim()}
>
Rename
</Button>
</DialogActions>
</Dialog>
</Grid>
</>
); );
}; };

View File

@ -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<string>(document.filename);
const [content, setContent] = useState<string>('');
const [editContent, setEditContent] = useState<string>('');
useEffect(() => {
if (!document) {
return;
}
const fetchDocument = async (): Promise<void> => {
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<void> => {
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 (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
padding: 2,
position: 'relative',
overflow: 'hidden',
}}
>
<TextField
autoFocus
margin="dense"
label="Document Name"
fullWidth
variant="outlined"
value={editingName}
onChange={(e): void => {
edit && setEditingName(e.target.value);
}}
onKeyUp={(e): void => {
if (e.key === 'Enter') {
handleDocumentUpdate();
}
}}
sx={{ pointerEvents: edit ? 'auto' : 'none' }}
/>
<Scrollable sx={{ width: '100%', height: 'calc(100% - 64px)', overflowY: 'auto' }}>
<TextField
margin="dense"
label="Content"
fullWidth
variant="outlined"
value={editContent}
onChange={(e): void => {
edit && setEditContent(e.target.value);
}}
sx={{ pointerEvents: edit ? 'auto' : 'none' }}
multiline
/>
</Scrollable>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2, direction: 'row' }}>
{edit && (
<Button
onClick={(): void => {
handleDocumentUpdate();
}}
variant="contained"
disabled={
!editingName.trim() ||
!editContent.trim() ||
(editingName === document.filename && editContent === content)
}
>
Save
</Button>
)}
<Button
onClick={(): void => {
onCancel && onCancel();
}}
>
{edit && (editingName !== document.filename || editContent !== content)
? 'Cancel'
: 'Close'}
</Button>
</Box>
</Box>
);
};
export { DocumentView };

View File

@ -28,6 +28,10 @@ import { useNavigate } from 'react-router-dom';
interface VectorVisualizerProps extends BackstoryPageProps { interface VectorVisualizerProps extends BackstoryPageProps {
inline?: boolean; inline?: boolean;
rag?: Types.ChromaDBGetResponse; rag?: Types.ChromaDBGetResponse;
query?: string;
filenameFilter?: string[];
setQuery?: (query: string) => void;
onQueryResult?: (query: string, filenames: string[]) => void;
} }
// interface Metadata { // interface Metadata {
@ -191,10 +195,19 @@ type Node = {
const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualizerProps) => { const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualizerProps) => {
const { user, apiClient } = useAuth(); const { user, apiClient } = useAuth();
const { rag, inline, sx } = props; const { rag, inline, sx, onQueryResult, setQuery, query, filenameFilter = [] } = props;
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [plotData, setPlotData] = useState<PlotData[] | null>(null); const [plotData, setPlotData] = useState<PlotData[] | null>(null);
const [newQuery, setNewQuery] = useState<string>('');
// Determine if component is controlled or uncontrolled
const isControlled = query !== undefined;
// Internal state for uncontrolled mode
const [internalQuery, setInternalQuery] = useState<string>('');
// Input field value - use prop in controlled mode, internal state in uncontrolled mode
const inputValue = isControlled ? query : internalQuery;
const [querySet, setQuerySet] = useState<Types.ChromaDBGetResponse>(rag || emptyQuerySet); const [querySet, setQuerySet] = useState<Types.ChromaDBGetResponse>(rag || emptyQuerySet);
const [result, setResult] = useState<Types.ChromaDBGetResponse | null>(null); const [result, setResult] = useState<Types.ChromaDBGetResponse | null>(null);
const [view2D, setView2D] = useState<boolean>(true); const [view2D, setView2D] = useState<boolean>(true);
@ -209,6 +222,21 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
const candidate: Types.Candidate | null = const candidate: Types.Candidate | null =
user?.userType === 'candidate' ? (user as 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 /* 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) */ * off screen (eg., the VectorVisualizer is not on the tab the app loads to) */
useEffect(() => { useEffect(() => {
@ -309,6 +337,15 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
* query is for any item that is in the querySet * query is for any item that is in the querySet
*/ */
full.ids.forEach((id, index) => { 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); const foundIndex = querySet.ids.indexOf(id);
/* Update metadata to hold the doc content and id */ /* Update metadata to hold the doc content and id */
full.metadatas[index].id = id; full.metadatas[index].id = id;
@ -419,24 +456,43 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
} }
setPlotData(data); setPlotData(data);
}, [result, querySet, view2D]); }, [result, querySet, view2D, filenameFilter]);
const handleKeyPress = (event: React.KeyboardEvent<HTMLDivElement>): void => { const handleInputChange = (newValue: string): void => {
if (event.key === 'Enter') { if (isControlled) {
sendQuery(newQuery); // 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<void> => { const handleKeyPress = (event: React.KeyboardEvent<HTMLDivElement>): void => {
if (!query.trim()) return; if (event.key === 'Enter') {
setNewQuery(''); sendQuery(inputValue);
}
};
const sendQuery = async (queryText: string): Promise<void> => {
if (!queryText.trim()) return;
try { try {
const result = await apiClient.getCandidateSimilarContent(query); const result = await apiClient.getCandidateSimilarContent(queryText);
console.log(result);
setQuerySet(result); 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) { } catch (error) {
const msg = `Error obtaining similar content to ${query}.`; const msg = `Error obtaining similar content to ${queryText}.`;
setSnack(msg, 'error'); setSnack(msg, 'error');
} }
}; };
@ -599,7 +655,6 @@ The scatter graph shows the query in N-dimensional space, mapped to ${
}} }}
/> />
</Paper> </Paper>
<Paper <Paper
sx={{ sx={{
display: 'flex', display: 'flex',
@ -763,9 +818,8 @@ The scatter graph shows the query in N-dimensional space, mapped to ${
)} )}
</Box> </Box>
</Paper> </Paper>
{!isControlled && !inline && querySet.query !== undefined && querySet.query !== '' && (
{!inline && querySet.query !== undefined && querySet.query !== '' && ( <Box
<Paper
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -779,20 +833,18 @@ The scatter graph shows the query in N-dimensional space, mapped to ${
pb: 0, pb: 0,
}} }}
> >
{querySet.query !== undefined && querySet.query !== '' && `Query: ${querySet.query}`} Query: {querySet.query}
{querySet.ids.length === 0 && 'Enter query below to perform a similarity search.'} </Box>
</Paper>
)} )}
{!inline && ( {!inline && (
<Box className="Query" sx={{ display: 'flex', flexDirection: 'row', p: 1 }}> <Box className="Query" sx={{ display: 'flex', flexDirection: 'row', p: 1 }}>
<TextField <TextField
variant="outlined" variant="outlined"
fullWidth fullWidth
type="text" type="text"
value={newQuery} value={inputValue}
onChange={(e): void => { onChange={(e): void => {
setNewQuery(e.target.value); handleInputChange(e.target.value);
}} }}
onKeyDown={handleKeyPress} onKeyDown={handleKeyPress}
placeholder="Enter query to find related documents..." 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 }} sx={{ m: 1 }}
variant="contained" variant="contained"
onClick={(): void => { onClick={(): void => {
sendQuery(newQuery); sendQuery(inputValue);
}} }}
> >
<SendIcon /> <SendIcon />
</Button> </Button>
</Tooltip> </Tooltip>
</Box> </Box>
)} )}{' '}
</Box> </Box>
</Box> </Box>
); );

View File

@ -17,18 +17,17 @@ import { JobAnalysisPage } from 'pages/JobAnalysisPage';
import { GenerateCandidate } from 'pages/GenerateCandidate'; import { GenerateCandidate } from 'pages/GenerateCandidate';
import { LoginPage } from 'pages/LoginPage'; import { LoginPage } from 'pages/LoginPage';
import { EmailVerificationPage } from 'components/EmailVerificationComponents'; import { EmailVerificationPage } from 'components/EmailVerificationComponents';
import { Box, Typography } from '@mui/material'; import { Typography } from '@mui/material';
import { CandidateDashboard } from 'pages/candidate/Dashboard'; import { CandidateDashboard } from 'pages/candidate/Dashboard';
import { NavigationConfig, NavigationItem } from 'types/navigation'; import { NavigationConfig, NavigationItem } from 'types/navigation';
import { HowItWorks } from 'pages/HowItWorks'; import { HowItWorks } from 'pages/HowItWorks';
import { CandidateProfile } from 'pages/candidate/Profile'; import { CandidateProfile } from 'pages/candidate/Profile';
import { Settings } from 'pages/candidate/Settings'; import { Settings } from 'pages/candidate/Settings';
import { VectorVisualizer } from 'components/VectorVisualizer';
import { DocumentManager } from 'components/DocumentManager';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { JobsViewPage } from 'pages/JobsViewPage'; import { JobsViewPage } from 'pages/JobsViewPage';
import { ResumeViewer } from 'components/ui/ResumeViewer'; import { ResumeViewer } from 'components/ui/ResumeViewer';
import { ContentManager } from 'components/ContentManager';
const LogoutPage = (): JSX.Element => { const LogoutPage = (): JSX.Element => {
const { logout } = useAuth(); const { logout } = useAuth();
@ -150,12 +149,7 @@ export const navigationConfig: NavigationConfig = {
label: 'Content', label: 'Content',
icon: <BubbleChart />, icon: <BubbleChart />,
path: '/candidate/documents', path: '/candidate/documents',
component: ( component: <ContentManager />,
<Box sx={{ display: 'flex', width: '100%', flexDirection: 'column' }}>
<VectorVisualizer />
<DocumentManager />
</Box>
),
userTypes: ['candidate'], userTypes: ['candidate'],
userMenuGroup: 'profile', userMenuGroup: 'profile',
showInNavigation: false, showInNavigation: false,

View File

@ -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 (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box
sx={{
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: 1,
borderColor: 'divider',
}}
>
<Typography variant="h6" component="h2" fontWeight="bold">
Documentation
</Typography>
{isMobile && onClose && (
<IconButton onClick={onClose} size="small" aria-label="Close navigation">
<CloseIcon />
</IconButton>
)}
</Box>
<Box
sx={{
flexGrow: 1,
overflow: 'auto',
p: 1,
}}
>
<List>
{documents.map((doc, index) => (
<ListItem key={index} disablePadding>
<ListItemButton
onClick={() => (doc.route ? handleItemClick(doc.route) : navigate('/'))}
selected={currentPage === doc.route}
sx={{
borderRadius: 1,
mb: 0.5,
}}
>
<ListItemIcon
sx={{
color: currentPage === doc.route ? 'primary.main' : 'text.secondary',
minWidth: 40,
}}
>
{getDocumentIcon(doc.title)}
</ListItemIcon>
<ListItemText
primary={doc.title}
slotProps={{
primary: {
fontWeight: currentPage === doc.route ? 'medium' : 'regular',
},
}}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</Box>
);
};
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 || <ViewQuiltIcon />;
};
type DocType = {
title: string;
route: string | null;
description: string;
icon?: React.ReactNode;
};
const documents: DocType[] = [
{
title: 'Backstory',
route: null,
description: 'Backstory',
icon: <ArrowBackIcon />,
},
{
title: 'About',
route: 'about',
description: 'General information about the application and its purpose',
icon: <DescriptionIcon />,
},
{
title: 'BETA',
route: 'beta',
description: 'Details about the current beta version and upcoming features',
icon: <CodeIcon />,
},
{
title: 'Resume Generation Architecture',
route: 'resume-generation',
description: 'Technical overview of how resumes are processed and generated',
icon: <LayersIcon />,
},
{
title: 'Application Architecture',
route: 'about-app',
description: 'System design and technical stack information',
icon: <LayersIcon />,
},
{
title: 'Authentication Architecture',
route: 'authentication.md',
description: 'Complete authentication architecture',
icon: <LayersIcon />,
},
{
title: 'UI Overview',
route: 'ui-overview',
description: 'Guide to the user interface components and interactions',
icon: <DashboardIcon />,
},
{
title: 'UI Mockup',
route: 'ui-mockup',
description: 'Visual previews of interfaces and layout concepts',
icon: <DashboardIcon />,
},
{
title: 'Theme Visualizer',
route: 'theme-visualizer',
description: 'Explore and customize application themes and visual styles',
icon: <PaletteIcon />,
},
{
title: 'App Analysis',
route: 'app-analysis',
description: 'Statistics and performance metrics of the application',
icon: <AnalyticsIcon />,
},
{
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: <PersonIcon />,
},
{
title: 'Type Safety',
route: 'type-safety',
description: 'Overview of front/back-end type synchronization.',
icon: <CodeIcon />,
},
];
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<string>(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 (
<Card>
<CardContent>
<Box
sx={{
color: 'inherit',
fontSize: '1.75rem',
fontWeight: 'bold',
display: 'flex',
flexDirection: 'row',
gap: 1,
alignItems: 'center',
mr: 1.5,
}}
>
{icon}
{title}
</Box>
{page && <Document filepath={`/docs/${page}.md`} />}
</CardContent>
</Card>
);
};
// Render the appropriate content based on current page
function renderContent() {
switch (page) {
case 'ui-overview':
return <BackstoryUIOverviewPage />;
case 'theme-visualizer':
return (
<Paper sx={{ m: 0, p: 1 }}>
<BackstoryThemeVisualizerPage />
</Paper>
);
case 'app-analysis':
return <BackstoryAppAnalysisPage />;
case 'ui-mockup':
return <MockupPage />;
case 'user-management':
return <UserManagement />;
default:
if (documentFromRoute(page)) {
return <DocView page={page} />;
}
// Document grid for landing page
return (
<Paper sx={{ p: 1 }} elevation={0}>
<Box sx={{ mb: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
Documentation
</Typography>
<Typography variant="body1" color="text.secondary">
Select a document from the sidebar to view detailed technical information about the
application.
</Typography>
</Box>
<Grid container spacing={1}>
{documents.map((doc, index) => {
if (doc.route === null) return <></>;
return (
<Grid sx={{ minWidth: '164px' }} size={{ xs: 12, sm: 6, md: 4 }} key={index}>
<Card sx={{ minHeight: '180px' }}>
<CardActionArea
onClick={() =>
doc.route ? onDocumentExpand(doc.route, true) : navigate('/')
}
>
<CardContent
sx={{
display: 'flex',
flexDirection: 'column',
m: 0,
p: 1,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
verticalAlign: 'top',
}}
>
{getDocumentIcon(doc.title)}
<Typography variant="h3" sx={{ m: '0 !important' }}>
{doc.title}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{doc.description}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</Grid>
);
})}
</Grid>
</Paper>
);
}
}
// Calculate drawer width
const drawerWidth = 240;
return (
<Box sx={{ display: 'flex', height: '100%' }}>
{/* Mobile App Bar */}
{isMobile && (
<AppBar
position="fixed"
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
display: { md: 'none' },
}}
elevation={0}
color="default"
>
<Toolbar>
<IconButton aria-label="open drawer" edge="start" onClick={toggleDrawer} sx={{ mr: 2 }}>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ color: 'white' }}>
{page ? documentTitleFromRoute(page) : 'Documentation'}
</Typography>
</Toolbar>
</AppBar>
)}
{/* Navigation drawer */}
<Box
component="nav"
sx={{
width: { md: drawerWidth },
flexShrink: { md: 0 },
}}
>
{/* Mobile drawer (temporary) */}
{isMobile ? (
<Drawer
variant="temporary"
open={drawerOpen}
onClose={closeDrawer}
ModalProps={{
keepMounted: true, // Better open performance on mobile
}}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: drawerWidth,
},
}}
>
<Sidebar
currentPage={page}
onDocumentSelect={onDocumentExpand}
onClose={closeDrawer}
isMobile={true}
/>
</Drawer>
) : (
// Desktop drawer (permanent)
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: drawerWidth,
position: 'relative',
height: '100%',
},
}}
open
>
<Sidebar currentPage={page} onDocumentSelect={onDocumentExpand} isMobile={false} />
</Drawer>
)}
</Box>
{/* Main content */}
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { md: `calc(100% - ${drawerWidth}px)` },
pt: isMobile ? { xs: 8, sm: 9 } : 3, // Add padding top on mobile to account for AppBar
height: '100%',
overflow: 'auto',
}}
>
{renderContent()}
</Box>
</Box>
);
};
export { DocsPage };

View File

@ -1654,19 +1654,20 @@ class ApiClient {
); );
} }
async updateCandidateDocument(document: Types.Document): Promise<Types.Document> { async updateCandidateDocument(document: Types.Document, content = ''): Promise<Types.Document> {
const request: Types.DocumentUpdateRequest = { const request: Types.DocumentUpdateRequest = {
filename: document.filename, filename: document.filename,
options: document.options, options: document.options,
}; };
if (content) {
request.content = content;
}
const response = await this.fetchWithErrorHandling(`/candidates/documents/${document.id}`, { const response = await this.fetchWithErrorHandling(`/candidates/documents/${document.id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(formatApiRequest(request)), body: JSON.stringify(formatApiRequest(request)),
}); });
const result = await handleApiResponse<Types.Document>(response); return await handleApiResponse<Types.Document>(response);
return result;
} }
async deleteCandidateDocument(document: Types.Document): Promise<boolean> { async deleteCandidateDocument(document: Types.Document): Promise<boolean> {

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models // Generated TypeScript types from Pydantic models
// Source: src/backend/models.py // 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 // DO NOT EDIT MANUALLY - This file is auto-generated
// ============================ // ============================
@ -506,6 +506,7 @@ export interface Document {
type: "pdf" | "docx" | "txt" | "markdown" | "image"; type: "pdf" | "docx" | "txt" | "markdown" | "image";
size: number; size: number;
uploadDate?: Date; uploadDate?: Date;
updatedAt?: Date;
options?: DocumentOptions; options?: DocumentOptions;
ragChunks?: number; ragChunks?: number;
} }
@ -543,6 +544,7 @@ export interface DocumentOptions {
export interface DocumentUpdateRequest { export interface DocumentUpdateRequest {
filename?: string; filename?: string;
content?: string;
options?: DocumentOptions; options?: DocumentOptions;
} }
@ -1527,7 +1529,7 @@ export function convertDataSourceConfigurationFromApi(data: any): DataSourceConf
} }
/** /**
* Convert Document from API response * Convert Document from API response
* Date fields: uploadDate * Date fields: uploadDate, updatedAt
*/ */
export function convertDocumentFromApi(data: any): Document { export function convertDocumentFromApi(data: any): Document {
if (!data) return data; if (!data) return data;
@ -1536,6 +1538,8 @@ export function convertDocumentFromApi(data: any): Document {
...data, ...data,
// Convert uploadDate from ISO string to Date // Convert uploadDate from ISO string to Date
uploadDate: data.uploadDate ? new Date(data.uploadDate) : undefined, uploadDate: data.uploadDate ? new Date(data.uploadDate) : undefined,
// Convert updatedAt from ISO string to Date
updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined,
}; };
} }
/** /**

View File

@ -666,10 +666,17 @@ class Document(BaseModel):
type: DocumentType type: DocumentType
size: int size: int
upload_date: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("uploadDate")) 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")) options: DocumentOptions = Field(default_factory=lambda: DocumentOptions(), alias=str("options"))
rag_chunks: Optional[int] = Field(default=0, alias=str("ragChunks")) rag_chunks: Optional[int] = Field(default=0, alias=str("ragChunks"))
model_config = ConfigDict(populate_by_name=True) 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): class DocumentContentResponse(BaseModel):
document_id: str = Field(..., alias=str("documentId")) document_id: str = Field(..., alias=str("documentId"))
@ -688,6 +695,7 @@ class DocumentListResponse(BaseModel):
class DocumentUpdateRequest(BaseModel): class DocumentUpdateRequest(BaseModel):
filename: Optional[str] = None filename: Optional[str] = None
content: Optional[str] = None
options: Optional[DocumentOptions] = None options: Optional[DocumentOptions] = None
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)

View File

@ -427,10 +427,13 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
def prepare_metadata(self, meta: Dict[str, Any], buffer=defines.chunk_buffer) -> str | None: def prepare_metadata(self, meta: Dict[str, Any], buffer=defines.chunk_buffer) -> str | None:
source_file = meta.get("source_file") source_file = meta.get("source_file")
if not source_file:
logging.warning("⚠️ No source_file in metadata, cannot prepare content.")
return None
try: try:
source_file = meta["source_file"]
path_parts = source_file.split(os.sep) path_parts = source_file.split(os.sep)
file_name = path_parts[-1] file_name = path_parts[-1]
# Strip file path from metadata
meta["source_file"] = file_name meta["source_file"] = file_name
with open(source_file, "r") as file: with open(source_file, "r") as file:
lines = file.readlines() lines = file.readlines()
@ -440,7 +443,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
end = min(meta["lines"], meta["line_end"] + buffer) end = min(meta["lines"], meta["line_end"] + buffer)
meta["chunk_end"] = end meta["chunk_end"] = end
return "".join(lines[start:end]) return "".join(lines[start:end])
except: except Exception:
logging.warning(f"⚠️ Unable to open {source_file}") logging.warning(f"⚠️ Unable to open {source_file}")
return None return None

View File

@ -892,38 +892,33 @@ async def update_document(
content=create_error_response("FORBIDDEN", "Cannot update another candidate's document"), content=create_error_response("FORBIDDEN", "Cannot update another candidate's document"),
) )
update_options = updates.options if updates.options else DocumentOptions() 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 document.options.include_in_rag != update_options.include_in_rag:
# If RAG status is changing, we need to handle file movement # If RAG status is changing, we need to handle file movement
rag_dir = os.path.join(defines.user_dir, candidate.username, "rag-content") # Move to new directory
file_dir = os.path.join(defines.user_dir, candidate.username, "files") src.rename(dst)
os.makedirs(rag_dir, exist_ok=True) logger.info(
os.makedirs(file_dir, exist_ok=True) f"📁 Moved file to {'RAG directory' if update_options.include_in_rag else 'regular files directory'}"
rag_path = os.path.join(rag_dir, document.original_name) )
file_path = os.path.join(file_dir, document.original_name) if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT:
if update_options.include_in_rag:
src = pathlib.Path(file_path) src = pathlib.Path(file_path)
dst = pathlib.Path(rag_path) src_as_md = src.with_suffix(".md")
# Move to RAG directory if src_as_md.exists():
src.rename(dst) dst = pathlib.Path(rag_path).with_suffix(".md")
logger.info("📁 Moved file to RAG directory") src_as_md.rename(dst)
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)
# Apply updates # Apply updates
update_dict = {} update_dict = {}
@ -931,6 +926,33 @@ async def update_document(
update_dict["filename"] = updates.filename.strip() update_dict["filename"] = updates.filename.strip()
if update_options.include_in_rag is not None: if update_options.include_in_rag is not None:
update_dict["include_in_rag"] = update_options.include_in_rag 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: if not update_dict:
return JSONResponse( return JSONResponse(
@ -1130,6 +1152,14 @@ async def post_candidate_vectors(dimensions: int = Body(...), current_user=Depen
results = {"ids": [], "metadatas": [], "documents": [], "embeddings": [], "size": 0} results = {"ids": [], "metadatas": [], "documents": [], "embeddings": [], "size": 0}
return create_success_response(results) 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 = { result = {
"ids": collection.ids, "ids": collection.ids,
"metadatas": collection.metadatas, "metadatas": collection.metadatas,