(feat:nested source view) file tree, chunks display

This commit is contained in:
ManishMadan2882
2025-07-15 19:15:40 +05:30
parent 1d9af05e9e
commit 8a7806ab2d
9 changed files with 452 additions and 358 deletions

View File

@@ -38,13 +38,20 @@ const endpoints = {
UPDATE_TOOL_STATUS: '/api/update_tool_status',
UPDATE_TOOL: '/api/update_tool',
DELETE_TOOL: '/api/delete_tool',
GET_CHUNKS: (docId: string, page: number, per_page: number) =>
`/api/get_chunks?id=${docId}&page=${page}&per_page=${per_page}`,
GET_CHUNKS: (
docId: string,
page: number,
per_page: number,
path?: string,
) =>
`/api/get_chunks?id=${docId}&page=${page}&per_page=${per_page}${path ? `&path=${encodeURIComponent(path)}` : ''}`,
ADD_CHUNK: '/api/add_chunk',
DELETE_CHUNK: (docId: string, chunkId: string) =>
`/api/delete_chunk?id=${docId}&chunk_id=${chunkId}`,
UPDATE_CHUNK: '/api/update_chunk',
STORE_ATTACHMENT: '/api/store_attachment',
DIRECTORY_STRUCTURE: (docId: string) =>
`/api/directory_structure?id=${docId}`,
},
CONVERSATION: {
ANSWER: '/api/answer',

View File

@@ -86,8 +86,9 @@ const userService = {
page: number,
perPage: number,
token: string | null,
path?: string,
): Promise<any> =>
apiClient.get(endpoints.USER.GET_CHUNKS(docId, page, perPage), token),
apiClient.get(endpoints.USER.GET_CHUNKS(docId, page, perPage, path), token),
addChunk: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.ADD_CHUNK, data, token),
deleteChunk: (
@@ -98,6 +99,8 @@ const userService = {
apiClient.delete(endpoints.USER.DELETE_CHUNK(docId, chunkId), token),
updateChunk: (data: any, token: string | null): Promise<any> =>
apiClient.put(endpoints.USER.UPDATE_CHUNK, data, token),
getDirectoryStructure: (docId: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.DIRECTORY_STRUCTURE(docId), token),
};
export default userService;

View File

@@ -0,0 +1,3 @@
<svg width="13" height="17" viewBox="0 0 13 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 1.94971C0 0.983707 0.784 0.199707 1.75 0.199707H8.336C8.8 0.199707 9.245 0.383707 9.573 0.712707L12.487 3.62671C12.816 3.95471 13 4.39971 13 4.86371V14.4497C13 14.9138 12.8156 15.359 12.4874 15.6871C12.1592 16.0153 11.7141 16.1997 11.25 16.1997H1.75C1.28587 16.1997 0.840752 16.0153 0.512563 15.6871C0.184375 15.359 0 14.9138 0 14.4497V1.94971ZM1.75 1.69971C1.6837 1.69971 1.62011 1.72605 1.57322 1.77293C1.52634 1.81981 1.5 1.8834 1.5 1.94971V14.4497C1.5 14.5877 1.612 14.6997 1.75 14.6997H11.25C11.3163 14.6997 11.3799 14.6734 11.4268 14.6265C11.4737 14.5796 11.5 14.516 11.5 14.4497V6.19971H8.75C8.28587 6.19971 7.84075 6.01533 7.51256 5.68714C7.18437 5.35896 7 4.91384 7 4.44971V1.69971H1.75ZM8.5 1.76171V4.44971C8.5 4.58771 8.612 4.69971 8.75 4.69971H11.438L11.427 4.68671L8.513 1.77271L8.5 1.76171Z" fill="#59636E"/>
</svg>

After

Width:  |  Height:  |  Size: 938 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="15" viewBox="0 0 16 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.75 0.599915C1.28587 0.599915 0.840752 0.784289 0.512563 1.11248C0.184374 1.44067 0 1.88579 0 2.34991L0 12.8499C0 13.8159 0.784 14.5999 1.75 14.5999H14.25C14.7141 14.5999 15.1592 14.4155 15.4874 14.0874C15.8156 13.7592 16 13.314 16 12.8499V4.34991C16 3.88579 15.8156 3.44067 15.4874 3.11248C15.1592 2.78429 14.7141 2.59991 14.25 2.59991H7.5C7.46119 2.59991 7.42291 2.59088 7.3882 2.57352C7.35348 2.55616 7.32329 2.53096 7.3 2.49991L6.4 1.29991C6.07 0.859915 5.55 0.599915 5 0.599915H1.75Z" fill="#A382E7"/>
</svg>

After

Width:  |  Height:  |  Size: 621 B

View File

@@ -0,0 +1,3 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.25 5.25H12.25L10.5 3.5H5.25C4.2875 3.5 3.50875 4.2875 3.50875 5.25L3.5 15.75C3.5 16.7125 4.2875 17.5 5.25 17.5H19.25C20.2125 17.5 21 16.7125 21 15.75V7C21 6.0375 20.2125 5.25 19.25 5.25ZM19.25 15.75H5.25V5.25H9.77375L11.5238 7H19.25V15.75ZM17.5 10.5H7V8.75H17.5V10.5ZM14 14H7V12.25H14V14Z" fill="#949494"/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

View File

@@ -0,0 +1,273 @@
import React, { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { selectToken } from '../preferences/preferenceSlice';
import { useDarkTheme, useLoaderState } from '../hooks';
import userService from '../api/services/userService';
import ArrowLeft from '../assets/arrow-left.svg';
import NoFilesIcon from '../assets/no-files.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import Spinner from '../components/Spinner';
import Input from '../components/Input';
import ChunkModal from '../modals/ChunkModal';
import { ActiveState } from '../models/misc';
import { ChunkType } from '../settings/types';
interface DocumentChunksProps {
documentId: string;
documentName?: string;
handleGoBack: () => void;
showHeader?: boolean;
path?: string;
}
const DocumentChunks: React.FC<DocumentChunksProps> = ({
documentId,
documentName,
handleGoBack,
showHeader = true,
path,
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [isDarkTheme] = useDarkTheme();
const [paginatedChunks, setPaginatedChunks] = useState<ChunkType[]>([]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(5);
const [totalChunks, setTotalChunks] = useState(0);
const [loading, setLoading] = useLoaderState(true);
const [searchTerm, setSearchTerm] = useState<string>('');
const [addModal, setAddModal] = useState<ActiveState>('INACTIVE');
const [editModal, setEditModal] = useState<{
state: ActiveState;
chunk: ChunkType | null;
}>({ state: 'INACTIVE', chunk: null });
const fetchChunks = () => {
setLoading(true);
try {
userService
.getDocumentChunks(documentId, page, perPage, token, path)
.then((response) => {
if (!response.ok) {
setLoading(false);
setPaginatedChunks([]);
throw new Error('Failed to fetch chunks data');
}
return response.json();
})
.then((data) => {
setPage(data.page);
setPerPage(data.per_page);
setTotalChunks(data.total);
setPaginatedChunks(data.chunks);
setLoading(false);
});
} catch (e) {
console.log(e);
setLoading(false);
}
};
const handleAddChunk = (title: string, text: string) => {
try {
userService
.addChunk(
{
id: documentId,
text: text,
metadata: {
title: title,
},
},
token,
)
.then((response) => {
if (!response.ok) {
throw new Error('Failed to add chunk');
}
fetchChunks();
});
} catch (e) {
console.log(e);
}
};
const handleUpdateChunk = (title: string, text: string, chunk: ChunkType) => {
try {
userService
.updateChunk(
{
id: documentId,
chunk_id: chunk.doc_id,
text: text,
metadata: {
title: title,
},
},
token,
)
.then((response) => {
if (!response.ok) {
throw new Error('Failed to update chunk');
}
fetchChunks();
});
} catch (e) {
console.log(e);
}
};
const handleDeleteChunk = (chunk: ChunkType) => {
try {
userService
.deleteChunk(documentId, chunk.doc_id, token)
.then((response) => {
if (!response.ok) {
throw new Error('Failed to delete chunk');
}
setEditModal({ state: 'INACTIVE', chunk: null });
fetchChunks();
});
} catch (e) {
console.log(e);
}
};
useEffect(() => {
fetchChunks();
}, [page, perPage]);
return (
<div className="mt-8 flex flex-col">
{showHeader && (
<div className="text-eerie-black dark:text-bright-gray mb-3 flex items-center gap-3 text-sm">
<button
className="rounded-full border p-3 text-sm text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
onClick={handleGoBack}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<p className="mt-px">{t('settings.documents.backToAll')}</p>
</div>
)}
<div className="my-3 flex items-center justify-between gap-1">
<div className="text-eerie-black dark:text-bright-gray flex w-full items-center gap-2 sm:w-auto">
<p className="hidden text-2xl font-semibold sm:flex">{`${totalChunks} ${t('settings.documents.chunks')}`}</p>
<label htmlFor="chunk-search-input" className="sr-only">
{t('settings.documents.searchPlaceholder')}
</label>
<Input
maxLength={256}
placeholder={t('settings.documents.searchPlaceholder')}
name="chunk-search-input"
type="text"
id="chunk-search-input"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
borderVariant="thin"
/>
</div>
<button
className="bg-purple-30 hover:bg-violets-are-blue flex h-[32px] min-w-[108px] items-center justify-center rounded-full px-4 text-sm whitespace-normal text-white"
title={t('settings.documents.addNew')}
onClick={() => setAddModal('ACTIVE')}
>
{t('settings.documents.addNew')}
</button>
</div>
{loading ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="col-span-2 mt-24 flex h-32 items-center justify-center lg:col-span-3">
<Spinner />
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{paginatedChunks.filter((chunk) => {
if (!chunk.metadata?.title) return true;
return chunk.metadata.title
.toLowerCase()
.includes(searchTerm.toLowerCase());
}).length === 0 ? (
<div className="col-span-2 mt-24 text-center text-gray-500 lg:col-span-3 dark:text-gray-400">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt={t('settings.documents.noChunksAlt')}
className="mx-auto mb-2 h-24 w-24"
/>
{t('settings.documents.noChunks')}
</div>
) : (
paginatedChunks
.filter((chunk) => {
if (!chunk.metadata?.title) return true;
return chunk.metadata.title
.toLowerCase()
.includes(searchTerm.toLowerCase());
})
.map((chunk, index) => (
<div
key={index}
className="border-silver dark:border-silver/40 relative flex h-56 w-full flex-col justify-between rounded-2xl border p-6"
>
<div className="w-full">
<div className="flex w-full items-center justify-between">
<button
aria-label={'edit'}
onClick={() => {
setEditModal({
state: 'ACTIVE',
chunk: chunk,
});
}}
className="text-left"
>
<h3 className="text-eerie-black dark:text-bright-gray line-clamp-2 text-base font-semibold">
{chunk.metadata?.title ||
t('settings.documents.untitled')}
</h3>
</button>
</div>
<div className="mt-2 h-[80px] overflow-hidden">
<p className="text-eerie-black dark:text-bright-gray line-clamp-4 text-sm">
{chunk.text}
</p>
</div>
</div>
</div>
))
)}
</div>
)}
<ChunkModal
type="ADD"
modalState={addModal}
setModalState={setAddModal}
handleSubmit={handleAddChunk}
/>
{editModal.chunk && (
<ChunkModal
type="EDIT"
modalState={editModal.state}
setModalState={(state) =>
setEditModal((prev) => ({ ...prev, state }))
}
handleSubmit={(title, text) => {
handleUpdateChunk(title, text, editModal.chunk as ChunkType);
}}
originalText={editModal.chunk?.text ?? ''}
originalTitle={editModal.chunk?.metadata?.title ?? ''}
handleDelete={() => {
handleDeleteChunk(editModal.chunk as ChunkType);
}}
/>
)}
</div>
);
};
export default DocumentChunks;

View File

@@ -1,5 +1,8 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import DocumentChunks from './DocumentChunks';
import ContextMenu, { MenuOption } from './ContextMenu';
import userService from '../api/services/userService';
import FileIcon from '../assets/file.svg';
import FolderIcon from '../assets/folder.svg';
@@ -8,9 +11,6 @@ import ThreeDots from '../assets/three-dots.svg';
import EyeView from '../assets/eye-view.svg';
import OutlineSource from '../assets/outline-source.svg';
import Trash from '../assets/red-trash.svg';
import Spinner from './Spinner';
import { useTranslation } from 'react-i18next';
import ContextMenu, { MenuOption } from './ContextMenu';
interface FileNode {
type?: string;
@@ -45,6 +45,18 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
const menuRefs = useRef<{
[key: string]: React.RefObject<HTMLDivElement | null>;
}>({});
const [selectedFile, setSelectedFile] = useState<{
id: string;
name: string;
} | null>(null);
const handleFileClick = (fileName: string) => {
const fullPath = [...currentPath, fileName].join('/');
setSelectedFile({
id: fullPath,
name: fileName,
});
};
useEffect(() => {
const fetchDirectoryStructure = async () => {
@@ -129,7 +141,11 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
setActiveMenuId(itemId);
};
const getActionOptions = (name: string, isFile: boolean): MenuOption[] => {
const getActionOptions = (
name: string,
isFile: boolean,
itemId: string,
): MenuOption[] => {
const options: MenuOption[] = [];
if (isFile) {
@@ -138,8 +154,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
label: t('settings.documents.view'),
onClick: (event: React.SyntheticEvent) => {
event.stopPropagation();
console.log('View file:', name);
// View file action will be implemented later
handleFileClick(name);
},
iconWidth: 18,
iconHeight: 18,
@@ -195,32 +210,89 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
</div>
);
};
const calculateDirectoryStats = (
structure: DirectoryStructure,
): { totalSize: number; totalTokens: number } => {
let totalSize = 0;
let totalTokens = 0;
Object.entries(structure).forEach(([_, node]) => {
if (node.type) {
// It's a file
totalSize += node.size_bytes || 0;
totalTokens += node.token_count || 0;
} else {
// It's a directory, recurse
const stats = calculateDirectoryStats(node);
totalSize += stats.totalSize;
totalTokens += stats.totalTokens;
}
});
return { totalSize, totalTokens };
};
const renderFileTree = (structure: DirectoryStructure): React.ReactNode[] => {
// Separate directories and files
const entries = Object.entries(structure);
const directories = entries.filter(([_, node]) => !node.type);
const files = entries.filter(([_, node]) => node.type);
// Create parent directory row
const parentRow =
currentPath.length > 0
? [
<tr
key="parent-dir"
className="cursor-pointer border-b border-[#D1D9E0] hover:bg-[#ECEEEF] dark:border-[#6A6A6A] dark:hover:bg-[#27282D]"
onClick={navigateUp}
>
<td className="px-4 py-2">
<div className="flex items-center">
<img
src={FolderIcon}
alt="Parent folder"
className="mr-2 h-4 w-4"
/>
<span className="text-sm dark:text-[#E0E0E0]">..</span>
</div>
</td>
<td className="px-4 py-2 text-sm dark:text-[#E0E0E0]">-</td>
<td className="px-4 py-2 text-sm dark:text-[#E0E0E0]">-</td>
<td className="w-10 px-4 py-2 text-sm"></td>
</tr>,
]
: [];
// Render directories first, then files
return [
...parentRow,
...directories.map(([name, node]) => {
const itemId = `dir-${name}`;
const menuRef = getMenuRef(itemId);
const dirStats = calculateDirectoryStats(node as DirectoryStructure);
return (
<tr
key={itemId}
className="border-b border-[#D1D9E0] dark:border-[#6A6A6A]"
className="cursor-pointer border-b border-[#D1D9E0] hover:bg-[#ECEEEF] dark:border-[#6A6A6A] dark:hover:bg-[#27282D]"
onClick={() => navigateToDirectory(name)}
>
<td className="px-4 py-2">
<div
className="flex cursor-pointer items-center"
onClick={() => navigateToDirectory(name)}
>
<div className="flex items-center">
<img src={FolderIcon} alt="Folder" className="mr-2 h-4 w-4" />
<span className="text-sm">{name}</span>
<span className="text-sm dark:text-[#E0E0E0]">{name}</span>
</div>
</td>
<td className="px-4 py-2 text-sm">-</td>
<td className="px-4 py-2 text-sm">-</td>
<td className="px-4 py-2 text-sm dark:text-[#E0E0E0]">
{dirStats.totalTokens > 0
? dirStats.totalTokens.toLocaleString()
: '-'}
</td>
<td className="px-4 py-2 text-sm dark:text-[#E0E0E0]">
{dirStats.totalSize > 0 ? formatBytes(dirStats.totalSize) : '-'}
</td>
<td className="w-10 px-4 py-2 text-sm">
<div ref={menuRef} className="relative">
<button
@@ -239,7 +311,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
setIsOpen={(isOpen) =>
setActiveMenuId(isOpen ? itemId : null)
}
options={getActionOptions(name, false)}
options={getActionOptions(name, false, itemId)}
anchorRef={menuRef}
position="bottom-left"
offset={{ x: 0, y: 8 }}
@@ -256,18 +328,19 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
return (
<tr
key={itemId}
className="border-b border-[#D1D9E0] dark:border-[#6A6A6A]"
className="cursor-pointer border-b border-[#D1D9E0] hover:bg-[#ECEEEF] dark:border-[#6A6A6A] dark:hover:bg-[#27282D]"
onClick={() => handleFileClick(name)}
>
<td className="px-4 py-2">
<div className="flex items-center">
<img src={FileIcon} alt="File" className="mr-2 h-4 w-4" />
<span className="text-sm">{name}</span>
<span className="text-sm dark:text-[#E0E0E0]">{name}</span>
</div>
</td>
<td className="px-4 py-2 text-sm">
<td className="px-4 py-2 text-sm dark:text-[#E0E0E0]">
{node.token_count?.toLocaleString() || '-'}
</td>
<td className="px-4 py-2 text-sm">
<td className="px-4 py-2 text-sm dark:text-[#E0E0E0]">
{node.size_bytes ? formatBytes(node.size_bytes) : '-'}
</td>
<td className="w-10 px-4 py-2 text-sm">
@@ -288,7 +361,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
setIsOpen={(isOpen) =>
setActiveMenuId(isOpen ? itemId : null)
}
options={getActionOptions(name, true)}
options={getActionOptions(name, true, itemId)}
anchorRef={menuRef}
position="bottom-left"
offset={{ x: 0, y: 8 }}
@@ -300,57 +373,47 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
}),
];
};
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner />
</div>
);
}
if (error) {
return <div className="p-4 text-center text-red-500">{error}</div>;
}
if (!directoryStructure) {
return (
<div className="p-4 text-center text-gray-500">
No directory structure available
</div>
);
}
const currentDirectory = getCurrentDirectory();
return (
<div className="w-full">
<div className="mb-4">{renderPathNavigation()}</div>
<>
{selectedFile ? (
<DocumentChunks
documentId={docId}
documentName={selectedFile.name}
handleGoBack={() => setSelectedFile(null)}
path={selectedFile.id}
/>
) : (
<div className="mt-8 flex flex-col">
<div className="mb-4">{renderPathNavigation()}</div>
<div className="overflow-x-auto rounded-[6px] border border-[#D1D9E0] dark:border-[#6A6A6A]">
<table className="min-w-full table-fixed bg-white dark:bg-gray-900">
<thead className="bg-gray-100 dark:bg-gray-800">
<tr className="border-b border-[#D1D9E0] dark:border-[#6A6A6A]">
<th className="w-3/5 px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Name
</th>
<th className="w-1/5 px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Tokens
</th>
<th className="w-1/5 px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Size
</th>
<th className="w-[60px] px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="[&>tr:last-child]:border-b-0">
{renderFileTree(currentDirectory)}
</tbody>
</table>
</div>
</div>
<div className="overflow-x-auto rounded-[6px] border border-[#D1D9E0] dark:border-[#6A6A6A]">
<table className="min-w-full table-fixed bg-transparent">
<thead className="bg-gray-100 dark:bg-[#27282D]">
<tr className="border-b border-[#D1D9E0] dark:border-[#6A6A6A]">
<th className="w-3/5 px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-[#59636E]">
Name
</th>
<th className="w-1/5 px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-[#59636E]">
Tokens
</th>
<th className="w-1/5 px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-[#59636E]">
Size
</th>
<th className="w-[60px] px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-[#59636E]">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="[&>tr:last-child]:border-b-0">
{renderFileTree(currentDirectory)}
</tbody>
</table>
</div>
</div>
)}
</>
);
};

View File

@@ -12,6 +12,7 @@ export type Doc = {
type?: string;
retriever?: string;
syncFrequency?: string;
isNested?: boolean;
};
export type GetDocsResponse = {

View File

@@ -3,11 +3,10 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import userService from '../api/services/userService';
import ArrowLeft from '../assets/arrow-left.svg';
import Edit from '../assets/edit.svg';
import EyeView from '../assets/eye-view.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import Trash from '../assets/red-trash.svg';
import SyncIcon from '../assets/sync.svg';
import ThreeDots from '../assets/three-dots.svg';
@@ -16,9 +15,7 @@ import Pagination from '../components/DocumentPagination';
import DropdownMenu from '../components/DropdownMenu';
import Input from '../components/Input';
import SkeletonLoader from '../components/SkeletonLoader';
import Spinner from '../components/Spinner';
import { useDarkTheme, useLoaderState } from '../hooks';
import ChunkModal from '../modals/ChunkModal';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState, Doc, DocumentsProps } from '../models/misc';
import { getDocs, getDocsWithPagination } from '../preferences/preferenceApi';
@@ -29,7 +26,8 @@ import {
} from '../preferences/preferenceSlice';
import Upload from '../upload/Upload';
import { formatDate } from '../utils/dateTimeUtils';
import { ChunkType } from './types';
import FileTreeComponent from '../components/FileTreeComponent';
import DocumentChunks from '../components/DocumentChunks';
const formatTokens = (tokens: number): string => {
const roundToTwoDecimals = (num: number): string => {
@@ -52,6 +50,7 @@ export default function Documents({
handleDeleteDocument,
}: DocumentsProps) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
const dispatch = useDispatch();
const token = useSelector(selectToken);
@@ -119,7 +118,7 @@ export default function Documents({
{ label: t('settings.documents.syncFrequency.weekly'), value: 'weekly' },
{ label: t('settings.documents.syncFrequency.monthly'), value: 'monthly' },
];
const [showDocumentChunks, setShowDocumentChunks] = useState<Doc>();
const [documentToView, setDocumentToView] = useState<Doc>();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [syncMenuState, setSyncMenuState] = useState<{
isOpen: boolean;
@@ -234,7 +233,7 @@ export default function Documents({
icon: EyeView,
label: t('settings.documents.view'),
onClick: () => {
setShowDocumentChunks(document);
setDocumentToView(document);
},
iconWidth: 18,
iconHeight: 18,
@@ -276,13 +275,23 @@ export default function Documents({
refreshDocs(undefined, 1, rowsPerPage);
}, [searchTerm]);
return showDocumentChunks ? (
<DocumentChunks
document={showDocumentChunks}
handleGoBack={() => {
setShowDocumentChunks(undefined);
}}
/>
return documentToView ? (
<div className="mt-8 flex flex-col">
{documentToView.isNested ? (
<FileTreeComponent
docId={documentToView.id || ''}
sourceName={documentToView.name}
onBackToDocuments={() => setDocumentToView(undefined)}
/>
) : (
<DocumentChunks
documentId={documentToView.id || ''}
documentName={documentToView.name}
handleGoBack={() => setDocumentToView(undefined)}
showHeader={false}
/>
)}
</div>
) : (
<div className="mt-8 flex w-full max-w-full flex-col overflow-hidden">
<div className="relative flex grow flex-col">
@@ -327,7 +336,7 @@ export default function Documents({
) : !currentDocuments?.length ? (
<div className="flex flex-col items-center justify-center py-12">
<img
src={NoFilesIcon}
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt={t('settings.documents.noData')}
className="mx-auto mb-6 h-32 w-32"
/>
@@ -491,274 +500,3 @@ export default function Documents({
</div>
);
}
function DocumentChunks({
document,
handleGoBack,
}: {
document: Doc;
handleGoBack: () => void;
}) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [isDarkTheme] = useDarkTheme();
const [paginatedChunks, setPaginatedChunks] = useState<ChunkType[]>([]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(5);
const [totalChunks, setTotalChunks] = useState(0);
const [loading, setLoading] = useLoaderState(true);
const [searchTerm, setSearchTerm] = useState<string>('');
const [addModal, setAddModal] = useState<ActiveState>('INACTIVE');
const [editModal, setEditModal] = useState<{
state: ActiveState;
chunk: ChunkType | null;
}>({ state: 'INACTIVE', chunk: null });
const fetchChunks = () => {
setLoading(true);
try {
userService
.getDocumentChunks(document.id ?? '', page, perPage, token)
.then((response) => {
if (!response.ok) {
setLoading(false);
setPaginatedChunks([]);
throw new Error('Failed to fetch chunks data');
}
return response.json();
})
.then((data) => {
setPage(data.page);
setPerPage(data.per_page);
setTotalChunks(data.total);
setPaginatedChunks(data.chunks);
setLoading(false);
});
} catch (e) {
console.log(e);
setLoading(false);
}
};
const handleAddChunk = (title: string, text: string) => {
try {
userService
.addChunk(
{
id: document.id ?? '',
text: text,
metadata: {
title: title,
},
},
token,
)
.then((response) => {
if (!response.ok) {
throw new Error('Failed to add chunk');
}
fetchChunks();
});
} catch (e) {
console.log(e);
}
};
const handleUpdateChunk = (title: string, text: string, chunk: ChunkType) => {
try {
userService
.updateChunk(
{
id: document.id ?? '',
chunk_id: chunk.doc_id,
text: text,
metadata: {
title: title,
},
},
token,
)
.then((response) => {
if (!response.ok) {
throw new Error('Failed to update chunk');
}
fetchChunks();
});
} catch (e) {
console.log(e);
}
};
const handleDeleteChunk = (chunk: ChunkType) => {
try {
userService
.deleteChunk(document.id ?? '', chunk.doc_id, token)
.then((response) => {
if (!response.ok) {
throw new Error('Failed to delete chunk');
}
setEditModal({ state: 'INACTIVE', chunk: null });
fetchChunks();
});
} catch (e) {
console.log(e);
}
};
React.useEffect(() => {
fetchChunks();
}, [page, perPage]);
return (
<div className="mt-8 flex flex-col">
<div className="text-eerie-black dark:text-bright-gray mb-3 flex items-center gap-3 text-sm">
<button
className="rounded-full border p-3 text-sm text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
onClick={handleGoBack}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<p className="mt-px">{t('settings.documents.backToAll')}</p>
</div>
<div className="my-3 flex items-center justify-between gap-1">
<div className="text-eerie-black dark:text-bright-gray flex w-full items-center gap-2 sm:w-auto">
<p className="hidden text-2xl font-semibold sm:flex">{`${totalChunks} ${t('settings.documents.chunks')}`}</p>
<label htmlFor="chunk-search-input" className="sr-only">
{t('settings.documents.searchPlaceholder')}
</label>
<Input
maxLength={256}
placeholder={t('settings.documents.searchPlaceholder')}
name="chunk-search-input"
type="text"
id="chunk-search-input"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
borderVariant="thin"
/>
</div>
<button
className="bg-purple-30 hover:bg-violets-are-blue flex h-[32px] min-w-[108px] items-center justify-center rounded-full px-4 text-sm whitespace-normal text-white"
title={t('settings.documents.addNew')}
onClick={() => setAddModal('ACTIVE')}
>
{t('settings.documents.addNew')}
</button>
</div>
{loading ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="col-span-2 mt-24 flex h-32 items-center justify-center lg:col-span-3">
<Spinner />
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{paginatedChunks.filter((chunk) => {
if (!chunk.metadata?.title) return true;
return chunk.metadata.title
.toLowerCase()
.includes(searchTerm.toLowerCase());
}).length === 0 ? (
<div className="col-span-2 mt-24 text-center text-gray-500 lg:col-span-3 dark:text-gray-400">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt={t('settings.documents.noChunksAlt')}
className="mx-auto mb-2 h-24 w-24"
/>
{t('settings.documents.noChunks')}
</div>
) : (
paginatedChunks
.filter((chunk) => {
if (!chunk.metadata?.title) return true;
return chunk.metadata.title
.toLowerCase()
.includes(searchTerm.toLowerCase());
})
.map((chunk, index) => (
<div
key={index}
className="border-silver dark:border-silver/40 relative flex h-56 w-full flex-col justify-between rounded-2xl border p-6"
>
<div className="w-full">
<div className="flex w-full items-center justify-between">
<button
aria-label={'edit'}
onClick={() => {
setEditModal({
state: 'ACTIVE',
chunk: chunk,
});
}}
className="absolute top-3 right-3 h-4 w-4 cursor-pointer"
>
<img
alt={'edit'}
src={Edit}
className="opacity-60 hover:opacity-100"
/>
</button>
</div>
<div className="mt-[9px]">
<p className="ellipsis-text text-eerie-black h-12 text-sm leading-relaxed font-semibold break-words dark:text-[#EEEEEE]">
{chunk.metadata?.title ?? 'Untitled'}
</p>
<p className="mt-1 h-[110px] overflow-y-auto pr-1 text-[13px] leading-relaxed break-words text-gray-600 dark:text-gray-400">
{chunk.text}
</p>
</div>
</div>
</div>
))
)}
</div>
)}
{!loading &&
paginatedChunks.filter((chunk) => {
if (!chunk.metadata?.title) return true;
return chunk.metadata.title
.toLowerCase()
.includes(searchTerm.toLowerCase());
}).length !== 0 && (
<div className="mt-10 flex w-full items-center justify-center">
<Pagination
currentPage={page}
totalPages={Math.ceil(totalChunks / perPage)}
rowsPerPage={perPage}
onPageChange={(page) => {
setPage(page);
}}
onRowsPerPageChange={(rows) => {
setPerPage(rows);
setPage(1);
}}
/>
</div>
)}
<ChunkModal
type="ADD"
modalState={addModal}
setModalState={setAddModal}
handleSubmit={handleAddChunk}
/>
{editModal.chunk && (
<ChunkModal
type="EDIT"
modalState={editModal.state}
setModalState={(state) =>
setEditModal((prev) => ({ ...prev, state }))
}
handleSubmit={(title, text) => {
handleUpdateChunk(title, text, editModal.chunk as ChunkType);
}}
originalText={editModal.chunk?.text ?? ''}
originalTitle={editModal.chunk?.metadata?.title ?? ''}
handleDelete={() => {
handleDeleteChunk(editModal.chunk as ChunkType);
}}
/>
)}
</div>
);
}