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,
|
||||
Button,
|
||||
useMediaQuery,
|
||||
Tooltip,
|
||||
SxProps,
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import ResetIcon from '@mui/icons-material/History';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
|
||||
interface DeleteConfirmationProps {
|
||||
// Legacy props for backward compatibility (uncontrolled mode)
|
||||
@ -38,7 +38,7 @@ interface DeleteConfirmationProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
icon?: React.ReactNode;
|
||||
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
// Optional props for button customization in controlled mode
|
||||
hideButton?: boolean;
|
||||
confirmButtonText?: string;
|
||||
@ -66,8 +66,9 @@ const DeleteConfirmation = (props: DeleteConfirmationProps): JSX.Element => {
|
||||
hideButton = false,
|
||||
confirmButtonText,
|
||||
cancelButtonText = 'Cancel',
|
||||
size = 'large',
|
||||
sx,
|
||||
icon = <ResetIcon />,
|
||||
icon = props.action === 'reset' ? <ResetIcon /> : <DeleteIcon />,
|
||||
} = props;
|
||||
|
||||
// Internal state for uncontrolled mode
|
||||
@ -116,10 +117,6 @@ const DeleteConfirmation = (props: DeleteConfirmationProps): JSX.Element => {
|
||||
<>
|
||||
{/* Only show button if not hidden (for controlled mode) */}
|
||||
{!hideButton && (
|
||||
<Tooltip title={label ? `${capitalizeFirstLetter(action)} ${label}` : 'Reset'}>
|
||||
<span style={{ display: 'flex' }}>
|
||||
{' '}
|
||||
{/* This span is used to wrap the IconButton to ensure Tooltip works even when disabled */}
|
||||
<IconButton
|
||||
aria-label={action}
|
||||
onClick={(e): void => {
|
||||
@ -127,16 +124,15 @@ const DeleteConfirmation = (props: DeleteConfirmationProps): JSX.Element => {
|
||||
e.preventDefault();
|
||||
handleClickOpen();
|
||||
}}
|
||||
color={color || 'inherit'}
|
||||
title={label ? `${capitalizeFirstLetter(action)} ${label}` : 'Reset'}
|
||||
color={color || 'default'}
|
||||
sx={{ display: 'flex', margin: 'auto 0px', ...sx }}
|
||||
size="large"
|
||||
size={size}
|
||||
edge="start"
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<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 {
|
||||
Box,
|
||||
Button,
|
||||
@ -7,30 +7,18 @@ import {
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Chip,
|
||||
Divider,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { CloudUpload, Edit, Delete, Visibility, Close } from '@mui/icons-material';
|
||||
import { CloudUpload, Close } from '@mui/icons-material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import * as Types from 'types/types';
|
||||
import { BackstoryElementProps } from './BackstoryTab';
|
||||
import { useAppState } from 'hooks/GlobalContext';
|
||||
import { DocumentList } from './DocumentList';
|
||||
|
||||
const VisuallyHiddenInput = styled('input')({
|
||||
clip: 'rect(0 0 0 0)',
|
||||
@ -44,23 +32,57 @@ const VisuallyHiddenInput = styled('input')({
|
||||
width: 1,
|
||||
});
|
||||
|
||||
const DocumentManager = (_props: BackstoryElementProps): JSX.Element => {
|
||||
interface DocumentManagerProps {
|
||||
filter?: string;
|
||||
onDocumentSelect?: (document: Types.Document | null) => void;
|
||||
setFilter?: (filter: string) => void;
|
||||
filenames?: string[];
|
||||
}
|
||||
|
||||
const DocumentManager = (props: DocumentManagerProps): JSX.Element => {
|
||||
const { filter = '', filenames = [], setFilter, onDocumentSelect } = props;
|
||||
const theme = useTheme();
|
||||
const { setSnack } = useAppState();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const { user, apiClient } = useAuth();
|
||||
|
||||
const [documents, setDocuments] = useState<Types.Document[]>([]);
|
||||
const [filteredDocuments, setFilteredDocuments] = useState<Types.Document[]>([]);
|
||||
const [selectedDocument, setSelectedDocument] = useState<Types.Document | null>(null);
|
||||
const [documentContent, setDocumentContent] = useState<string>('');
|
||||
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
|
||||
const candidate = user?.userType === 'candidate' ? (user as Types.Candidate) : null;
|
||||
|
||||
useEffect(() => {
|
||||
onDocumentSelect && onDocumentSelect(selectedDocument);
|
||||
}, [selectedDocument, onDocumentSelect]);
|
||||
|
||||
const prevDepsRef = useRef({ documents, filenames });
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevDepsRef.current;
|
||||
|
||||
// Check if the actual content changed
|
||||
const shouldUpdate =
|
||||
documents.length !== prev.documents.length ||
|
||||
filenames.length !== prev.filenames.length ||
|
||||
documents.some((doc, i) => doc.filename !== prev.documents[i]?.filename) ||
|
||||
filenames.some((name, i) => name !== prev.filenames[i]);
|
||||
|
||||
if (shouldUpdate) {
|
||||
prevDepsRef.current = { documents, filenames };
|
||||
|
||||
if (filenames.length > 0) {
|
||||
const filtered = documents.filter(doc => filenames.includes(doc.filename));
|
||||
setFilteredDocuments(filtered);
|
||||
} else {
|
||||
setFilteredDocuments(documents);
|
||||
}
|
||||
}
|
||||
}, [documents, filenames]);
|
||||
|
||||
// Load documents on component mount
|
||||
useEffect(() => {
|
||||
const loadDocuments = async (): Promise<void> => {
|
||||
@ -131,114 +153,25 @@ const DocumentManager = (_props: BackstoryElementProps): JSX.Element => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle document deletion
|
||||
const handleDeleteDocument = async (document: Types.Document): Promise<void> => {
|
||||
try {
|
||||
// Call API to delete document
|
||||
await apiClient.deleteCandidateDocument(document);
|
||||
const updateDocuments = (updatedDocs: Types.Document[]): void => {
|
||||
// Find documents that were deleted (in filteredDocuments but not in updatedDocs)
|
||||
const deletedDocIds = filteredDocuments
|
||||
.filter(doc => !updatedDocs.some(updated => updated.id === doc.id))
|
||||
.map(doc => doc.id);
|
||||
|
||||
setDocuments(prev => prev.filter(doc => doc.id !== document.id));
|
||||
setSnack('Document deleted successfully', 'success');
|
||||
// Update the main documents array:
|
||||
// 1. Remove any deleted documents
|
||||
// 2. Update any modified documents
|
||||
const updatedDocuments = documents
|
||||
.filter(doc => !deletedDocIds.includes(doc.id)) // Remove deleted docs
|
||||
.map(doc => {
|
||||
// Check if this document was modified in updatedDocs
|
||||
const modifiedDoc = updatedDocs.find(updated => updated.id === doc.id);
|
||||
return modifiedDoc || doc; // Use modified version if available, otherwise keep original
|
||||
});
|
||||
|
||||
// Close content view if this document was being viewed
|
||||
if (selectedDocument?.id === document.id) {
|
||||
setIsViewingContent(false);
|
||||
setSelectedDocument(null);
|
||||
setDocumentContent('');
|
||||
}
|
||||
} catch (error) {
|
||||
setSnack('Failed to delete document', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle RAG flag toggle
|
||||
const handleRAGToggle = async (
|
||||
document: Types.Document,
|
||||
includeInRag: boolean
|
||||
): Promise<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';
|
||||
}
|
||||
setDocuments(updatedDocuments);
|
||||
setFilteredDocuments(updatedDocs); // Update filtered docs to match what child returned
|
||||
};
|
||||
|
||||
if (!candidate) {
|
||||
@ -246,24 +179,53 @@ const DocumentManager = (_props: BackstoryElementProps): JSX.Element => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={{ xs: 1, sm: 3 }} sx={{ maxWidth: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
gap: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
m: 0,
|
||||
p: 1,
|
||||
width: '100%',
|
||||
verticalAlign: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Documents</Typography>
|
||||
{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
|
||||
component="label"
|
||||
variant="contained"
|
||||
startIcon={<CloudUpload />}
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
sx={{ justifySelf: 'flex-end', ml: 'auto' }}
|
||||
>
|
||||
Upload Document
|
||||
<VisuallyHiddenInput
|
||||
@ -274,9 +236,7 @@ const DocumentManager = (_props: BackstoryElementProps): JSX.Element => {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
|
||||
<Box>
|
||||
{documents.length === 0 ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
@ -290,105 +250,16 @@ const DocumentManager = (_props: BackstoryElementProps): JSX.Element => {
|
||||
No additional documents uploaded
|
||||
</Typography>
|
||||
) : (
|
||||
<List sx={{ width: '100%' }}>
|
||||
{documents.map((doc, index) => (
|
||||
<React.Fragment key={doc.id}>
|
||||
{index > 0 && <Divider />}
|
||||
<ListItem sx={{ px: 0 }}>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
<DocumentList
|
||||
{...{
|
||||
documents: filteredDocuments,
|
||||
setDocuments: updateDocuments,
|
||||
selectedDocument,
|
||||
setSelectedDocument,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
wordBreak: 'break-word',
|
||||
fontSize: { xs: '0.9rem', sm: '1rem' },
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Document Content Viewer */}
|
||||
{isViewingContent && (
|
||||
@ -440,56 +311,7 @@ const DocumentManager = (_props: BackstoryElementProps): JSX.Element => {
|
||||
</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>
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
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 {
|
||||
inline?: boolean;
|
||||
rag?: Types.ChromaDBGetResponse;
|
||||
query?: string;
|
||||
filenameFilter?: string[];
|
||||
setQuery?: (query: string) => void;
|
||||
onQueryResult?: (query: string, filenames: string[]) => void;
|
||||
}
|
||||
|
||||
// interface Metadata {
|
||||
@ -191,10 +195,19 @@ type Node = {
|
||||
|
||||
const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualizerProps) => {
|
||||
const { user, apiClient } = useAuth();
|
||||
const { rag, inline, sx } = props;
|
||||
const { rag, inline, sx, onQueryResult, setQuery, query, filenameFilter = [] } = props;
|
||||
const { setSnack } = useAppState();
|
||||
const [plotData, setPlotData] = useState<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 [result, setResult] = useState<Types.ChromaDBGetResponse | null>(null);
|
||||
const [view2D, setView2D] = useState<boolean>(true);
|
||||
@ -209,6 +222,21 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
|
||||
const candidate: Types.Candidate | null =
|
||||
user?.userType === 'candidate' ? (user as Types.Candidate) : null;
|
||||
|
||||
// Sync internal state with query prop when it changes (for controlled mode)
|
||||
useEffect(() => {
|
||||
if (isControlled) {
|
||||
if (query !== internalQuery) {
|
||||
setInternalQuery(query);
|
||||
if (query) {
|
||||
sendQuery(query);
|
||||
} else {
|
||||
/* Clear the query to reset the visualization */
|
||||
setQuerySet(rag || emptyQuerySet);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [query, isControlled]);
|
||||
|
||||
/* Force resize of Plotly as it tends to not be the correct size if it is initially rendered
|
||||
* off screen (eg., the VectorVisualizer is not on the tab the app loads to) */
|
||||
useEffect(() => {
|
||||
@ -309,6 +337,15 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
|
||||
* query is for any item that is in the querySet
|
||||
*/
|
||||
full.ids.forEach((id, index) => {
|
||||
// Skip items that don't match the filename filter
|
||||
if (filenameFilter.length > 0) {
|
||||
console.log(`Checking if ${full.metadatas[index]?.sourceFile} is in ${filenameFilter}`);
|
||||
const sourceFile = full.metadatas[index]?.sourceFile;
|
||||
if (!sourceFile || !filenameFilter.includes(sourceFile)) {
|
||||
return; // Skip this item
|
||||
}
|
||||
}
|
||||
|
||||
const foundIndex = querySet.ids.indexOf(id);
|
||||
/* Update metadata to hold the doc content and id */
|
||||
full.metadatas[index].id = id;
|
||||
@ -419,24 +456,43 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
|
||||
}
|
||||
|
||||
setPlotData(data);
|
||||
}, [result, querySet, view2D]);
|
||||
}, [result, querySet, view2D, filenameFilter]);
|
||||
|
||||
const handleKeyPress = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
sendQuery(newQuery);
|
||||
const handleInputChange = (newValue: string): void => {
|
||||
if (isControlled) {
|
||||
// In controlled mode, always call onQuery to let parent handle the change
|
||||
setQuery?.(newValue);
|
||||
} else {
|
||||
// In uncontrolled mode, update internal state
|
||||
setInternalQuery(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const sendQuery = async (query: string): Promise<void> => {
|
||||
if (!query.trim()) return;
|
||||
setNewQuery('');
|
||||
const handleKeyPress = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
sendQuery(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
const sendQuery = async (queryText: string): Promise<void> => {
|
||||
if (!queryText.trim()) return;
|
||||
|
||||
try {
|
||||
const result = await apiClient.getCandidateSimilarContent(query);
|
||||
console.log(result);
|
||||
const result = await apiClient.getCandidateSimilarContent(queryText);
|
||||
setQuerySet(result);
|
||||
const uniqueSourceFiles = result.metadatas
|
||||
.map(x => x.sourceFile)
|
||||
.filter((sourceFile, index, array) => array.indexOf(sourceFile) === index);
|
||||
|
||||
// Always call onQuery when a search is performed
|
||||
onQueryResult?.(queryText, uniqueSourceFiles || []);
|
||||
|
||||
// Only clear input in uncontrolled mode
|
||||
if (!isControlled) {
|
||||
setInternalQuery('');
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = `Error obtaining similar content to ${query}.`;
|
||||
const msg = `Error obtaining similar content to ${queryText}.`;
|
||||
setSnack(msg, 'error');
|
||||
}
|
||||
};
|
||||
@ -599,7 +655,6 @@ The scatter graph shows the query in N-dimensional space, mapped to ${
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@ -763,9 +818,8 @@ The scatter graph shows the query in N-dimensional space, mapped to ${
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{!inline && querySet.query !== undefined && querySet.query !== '' && (
|
||||
<Paper
|
||||
{!isControlled && !inline && querySet.query !== undefined && querySet.query !== '' && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@ -779,20 +833,18 @@ The scatter graph shows the query in N-dimensional space, mapped to ${
|
||||
pb: 0,
|
||||
}}
|
||||
>
|
||||
{querySet.query !== undefined && querySet.query !== '' && `Query: ${querySet.query}`}
|
||||
{querySet.ids.length === 0 && 'Enter query below to perform a similarity search.'}
|
||||
</Paper>
|
||||
Query: {querySet.query}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!inline && (
|
||||
<Box className="Query" sx={{ display: 'flex', flexDirection: 'row', p: 1 }}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
type="text"
|
||||
value={newQuery}
|
||||
value={inputValue}
|
||||
onChange={(e): void => {
|
||||
setNewQuery(e.target.value);
|
||||
handleInputChange(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder="Enter query to find related documents..."
|
||||
@ -803,14 +855,14 @@ The scatter graph shows the query in N-dimensional space, mapped to ${
|
||||
sx={{ m: 1 }}
|
||||
variant="contained"
|
||||
onClick={(): void => {
|
||||
sendQuery(newQuery);
|
||||
sendQuery(inputValue);
|
||||
}}
|
||||
>
|
||||
<SendIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
)}{' '}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
@ -17,18 +17,17 @@ import { JobAnalysisPage } from 'pages/JobAnalysisPage';
|
||||
import { GenerateCandidate } from 'pages/GenerateCandidate';
|
||||
import { LoginPage } from 'pages/LoginPage';
|
||||
import { EmailVerificationPage } from 'components/EmailVerificationComponents';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { Typography } from '@mui/material';
|
||||
import { CandidateDashboard } from 'pages/candidate/Dashboard';
|
||||
import { NavigationConfig, NavigationItem } from 'types/navigation';
|
||||
import { HowItWorks } from 'pages/HowItWorks';
|
||||
import { CandidateProfile } from 'pages/candidate/Profile';
|
||||
import { Settings } from 'pages/candidate/Settings';
|
||||
import { VectorVisualizer } from 'components/VectorVisualizer';
|
||||
import { DocumentManager } from 'components/DocumentManager';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { JobsViewPage } from 'pages/JobsViewPage';
|
||||
import { ResumeViewer } from 'components/ui/ResumeViewer';
|
||||
import { ContentManager } from 'components/ContentManager';
|
||||
|
||||
const LogoutPage = (): JSX.Element => {
|
||||
const { logout } = useAuth();
|
||||
@ -150,12 +149,7 @@ export const navigationConfig: NavigationConfig = {
|
||||
label: 'Content',
|
||||
icon: <BubbleChart />,
|
||||
path: '/candidate/documents',
|
||||
component: (
|
||||
<Box sx={{ display: 'flex', width: '100%', flexDirection: 'column' }}>
|
||||
<VectorVisualizer />
|
||||
<DocumentManager />
|
||||
</Box>
|
||||
),
|
||||
component: <ContentManager />,
|
||||
userTypes: ['candidate'],
|
||||
userMenuGroup: 'profile',
|
||||
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 = {
|
||||
filename: document.filename,
|
||||
options: document.options,
|
||||
};
|
||||
if (content) {
|
||||
request.content = content;
|
||||
}
|
||||
const response = await this.fetchWithErrorHandling(`/candidates/documents/${document.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(formatApiRequest(request)),
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<Types.Document>(response);
|
||||
|
||||
return result;
|
||||
return await handleApiResponse<Types.Document>(response);
|
||||
}
|
||||
|
||||
async deleteCandidateDocument(document: Types.Document): Promise<boolean> {
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Generated TypeScript types from Pydantic models
|
||||
// Source: src/backend/models.py
|
||||
// Generated on: 2025-07-17T19:28:19.417396
|
||||
// Generated on: 2025-07-18T22:57:32.251542
|
||||
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||
|
||||
// ============================
|
||||
@ -506,6 +506,7 @@ export interface Document {
|
||||
type: "pdf" | "docx" | "txt" | "markdown" | "image";
|
||||
size: number;
|
||||
uploadDate?: Date;
|
||||
updatedAt?: Date;
|
||||
options?: DocumentOptions;
|
||||
ragChunks?: number;
|
||||
}
|
||||
@ -543,6 +544,7 @@ export interface DocumentOptions {
|
||||
|
||||
export interface DocumentUpdateRequest {
|
||||
filename?: string;
|
||||
content?: string;
|
||||
options?: DocumentOptions;
|
||||
}
|
||||
|
||||
@ -1527,7 +1529,7 @@ export function convertDataSourceConfigurationFromApi(data: any): DataSourceConf
|
||||
}
|
||||
/**
|
||||
* Convert Document from API response
|
||||
* Date fields: uploadDate
|
||||
* Date fields: uploadDate, updatedAt
|
||||
*/
|
||||
export function convertDocumentFromApi(data: any): Document {
|
||||
if (!data) return data;
|
||||
@ -1536,6 +1538,8 @@ export function convertDocumentFromApi(data: any): Document {
|
||||
...data,
|
||||
// Convert uploadDate from ISO string to Date
|
||||
uploadDate: data.uploadDate ? new Date(data.uploadDate) : undefined,
|
||||
// Convert updatedAt from ISO string to Date
|
||||
updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined,
|
||||
};
|
||||
}
|
||||
/**
|
||||
|
@ -666,10 +666,17 @@ class Document(BaseModel):
|
||||
type: DocumentType
|
||||
size: int
|
||||
upload_date: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("uploadDate"))
|
||||
updated_at: Optional[datetime] = Field(default=None, alias=str("updatedAt"))
|
||||
options: DocumentOptions = Field(default_factory=lambda: DocumentOptions(), alias=str("options"))
|
||||
rag_chunks: Optional[int] = Field(default=0, alias=str("ragChunks"))
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def set_updated_at_default(self):
|
||||
if self.updated_at is None:
|
||||
self.updated_at = self.upload_date
|
||||
return self
|
||||
|
||||
|
||||
class DocumentContentResponse(BaseModel):
|
||||
document_id: str = Field(..., alias=str("documentId"))
|
||||
@ -688,6 +695,7 @@ class DocumentListResponse(BaseModel):
|
||||
|
||||
class DocumentUpdateRequest(BaseModel):
|
||||
filename: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
options: Optional[DocumentOptions] = None
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
@ -427,10 +427,13 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
|
||||
|
||||
def prepare_metadata(self, meta: Dict[str, Any], buffer=defines.chunk_buffer) -> str | None:
|
||||
source_file = meta.get("source_file")
|
||||
if not source_file:
|
||||
logging.warning("⚠️ No source_file in metadata, cannot prepare content.")
|
||||
return None
|
||||
try:
|
||||
source_file = meta["source_file"]
|
||||
path_parts = source_file.split(os.sep)
|
||||
file_name = path_parts[-1]
|
||||
# Strip file path from metadata
|
||||
meta["source_file"] = file_name
|
||||
with open(source_file, "r") as file:
|
||||
lines = file.readlines()
|
||||
@ -440,7 +443,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
|
||||
end = min(meta["lines"], meta["line_end"] + buffer)
|
||||
meta["chunk_end"] = end
|
||||
return "".join(lines[start:end])
|
||||
except:
|
||||
except Exception:
|
||||
logging.warning(f"⚠️ Unable to open {source_file}")
|
||||
return None
|
||||
|
||||
|
@ -892,8 +892,6 @@ async def update_document(
|
||||
content=create_error_response("FORBIDDEN", "Cannot update another candidate's document"),
|
||||
)
|
||||
update_options = updates.options if updates.options else DocumentOptions()
|
||||
if document.options.include_in_rag != update_options.include_in_rag:
|
||||
# If RAG status is changing, we need to handle file movement
|
||||
rag_dir = os.path.join(defines.user_dir, candidate.username, "rag-content")
|
||||
file_dir = os.path.join(defines.user_dir, candidate.username, "files")
|
||||
os.makedirs(rag_dir, exist_ok=True)
|
||||
@ -904,26 +902,23 @@ async def update_document(
|
||||
if update_options.include_in_rag:
|
||||
src = pathlib.Path(file_path)
|
||||
dst = pathlib.Path(rag_path)
|
||||
# Move to RAG directory
|
||||
else:
|
||||
src = pathlib.Path(rag_path)
|
||||
dst = pathlib.Path(file_path)
|
||||
|
||||
if document.options.include_in_rag != update_options.include_in_rag:
|
||||
# If RAG status is changing, we need to handle file movement
|
||||
# Move to new directory
|
||||
src.rename(dst)
|
||||
logger.info("📁 Moved file to RAG directory")
|
||||
logger.info(
|
||||
f"📁 Moved file to {'RAG directory' if update_options.include_in_rag else 'regular files directory'}"
|
||||
)
|
||||
if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT:
|
||||
src = pathlib.Path(file_path)
|
||||
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
|
||||
update_dict = {}
|
||||
@ -931,6 +926,33 @@ async def update_document(
|
||||
update_dict["filename"] = updates.filename.strip()
|
||||
if update_options.include_in_rag is not None:
|
||||
update_dict["include_in_rag"] = update_options.include_in_rag
|
||||
if updates.content is not None:
|
||||
if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT:
|
||||
logger.warning("⚠️ Content updates are not supported for non-text documents")
|
||||
return JSONResponse(
|
||||
status_code=400, content=create_error_response("INVALID_UPDATE", "Content updates not allowed")
|
||||
)
|
||||
if not updates.content.strip():
|
||||
logger.warning("⚠️ Content update provided is empty")
|
||||
return JSONResponse(
|
||||
status_code=400, content=create_error_response("INVALID_UPDATE", "Content cannot be empty")
|
||||
)
|
||||
if not os.path.exists(dst):
|
||||
logger.error(f"❌ File not found for content update: {dst}")
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=create_error_response("FILE_NOT_FOUND", "File not found for content update"),
|
||||
)
|
||||
# Write new content to file
|
||||
try:
|
||||
with open(dst, "w", encoding="utf-8") as f:
|
||||
f.write(updates.content.strip())
|
||||
logger.info(f"📄 Updated content for document {document_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to write updated content to file: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500, content=create_error_response("WRITE_ERROR", "Failed to write updated content")
|
||||
)
|
||||
|
||||
if not update_dict:
|
||||
return JSONResponse(
|
||||
@ -1130,6 +1152,14 @@ async def post_candidate_vectors(dimensions: int = Body(...), current_user=Depen
|
||||
results = {"ids": [], "metadatas": [], "documents": [], "embeddings": [], "size": 0}
|
||||
return create_success_response(results)
|
||||
|
||||
for metadata in collection.metadatas:
|
||||
source_file = metadata.get("source_file")
|
||||
if not source_file:
|
||||
continue
|
||||
path_parts = source_file.split(os.sep)
|
||||
file_name = path_parts[-1]
|
||||
metadata["source_file"] = file_name
|
||||
|
||||
result = {
|
||||
"ids": collection.ids,
|
||||
"metadatas": collection.metadatas,
|
||||
|
Loading…
x
Reference in New Issue
Block a user