Added improved RAG content editor
This commit is contained in:
parent
a0e83d3cfb
commit
7b392409ca
91
frontend/src/components/ContentManager.tsx
Normal file
91
frontend/src/components/ContentManager.tsx
Normal 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 };
|
@ -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
|
||||||
|
@ -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 };
|
|
463
frontend/src/components/DocumentList.tsx
Normal file
463
frontend/src/components/DocumentList.tsx
Normal 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 };
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
143
frontend/src/components/DocumentView.tsx
Normal file
143
frontend/src/components/DocumentView.tsx
Normal 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 };
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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,
|
||||||
|
@ -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 };
|
|
@ -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> {
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user