| | <script lang="ts"> |
| | import Fuse from 'fuse.js'; |
| | import { toast } from 'svelte-sonner'; |
| | import { v4 as uuidv4 } from 'uuid'; |
| | |
| | import { onMount, getContext, onDestroy, tick } from 'svelte'; |
| | const i18n = getContext('i18n'); |
| | |
| | import { goto } from '$app/navigation'; |
| | import { page } from '$app/stores'; |
| | import { mobile, showSidebar, knowledge as _knowledge } from '$lib/stores'; |
| | |
| | import { updateFileDataContentById, uploadFile } from '$lib/apis/files'; |
| | import { |
| | addFileToKnowledgeById, |
| | getKnowledgeById, |
| | getKnowledgeItems, |
| | removeFileFromKnowledgeById, |
| | resetKnowledgeById, |
| | updateFileFromKnowledgeById, |
| | updateKnowledgeById |
| | } from '$lib/apis/knowledge'; |
| | |
| | import Spinner from '$lib/components/common/Spinner.svelte'; |
| | import Tooltip from '$lib/components/common/Tooltip.svelte'; |
| | import Badge from '$lib/components/common/Badge.svelte'; |
| | import Files from './Collection/Files.svelte'; |
| | import AddFilesPlaceholder from '$lib/components/AddFilesPlaceholder.svelte'; |
| | import AddContentModal from './Collection/AddTextContentModal.svelte'; |
| | import { transcribeAudio } from '$lib/apis/audio'; |
| | import { blobToFile } from '$lib/utils'; |
| | import { processFile } from '$lib/apis/retrieval'; |
| | import AddContentMenu from './Collection/AddContentMenu.svelte'; |
| | import AddTextContentModal from './Collection/AddTextContentModal.svelte'; |
| | |
| | import SyncConfirmDialog from '../../common/ConfirmDialog.svelte'; |
| | |
| | let largeScreen = true; |
| | |
| | type Knowledge = { |
| | id: string; |
| | name: string; |
| | description: string; |
| | data: { |
| | file_ids: string[]; |
| | }; |
| | files: any[]; |
| | }; |
| | |
| | let id = null; |
| | let knowledge: Knowledge | null = null; |
| | let query = ''; |
| | |
| | let showAddTextContentModal = false; |
| | let showSyncConfirmModal = false; |
| | |
| | let inputFiles = null; |
| | |
| | let filteredItems = []; |
| | $: if (knowledge) { |
| | fuse = new Fuse(knowledge.files, { |
| | keys: ['meta.name', 'meta.description'] |
| | }); |
| | } |
| | |
| | $: if (fuse) { |
| | filteredItems = query |
| | ? fuse.search(query).map((e) => { |
| | return e.item; |
| | }) |
| | : (knowledge?.files ?? []); |
| | } |
| | |
| | let selectedFile = null; |
| | let selectedFileId = null; |
| | |
| | $: if (selectedFileId) { |
| | const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId); |
| | if (file) { |
| | file.data = file.data ?? { content: '' }; |
| | selectedFile = file; |
| | } else { |
| | selectedFile = null; |
| | } |
| | } else { |
| | selectedFile = null; |
| | } |
| | |
| | let fuse = null; |
| | let debounceTimeout = null; |
| | let mediaQuery; |
| | let dragged = false; |
| | |
| | const createFileFromText = (name, content) => { |
| | const blob = new Blob([content], { type: 'text/plain' }); |
| | const file = blobToFile(blob, `${name}.md`); |
| | |
| | console.log(file); |
| | return file; |
| | }; |
| | |
| | const uploadFileHandler = async (file) => { |
| | console.log(file); |
| | |
| | const tempItemId = uuidv4(); |
| | const fileItem = { |
| | type: 'file', |
| | file: '', |
| | id: null, |
| | url: '', |
| | name: file.name, |
| | size: file.size, |
| | status: 'uploading', |
| | error: '', |
| | itemId: tempItemId |
| | }; |
| | |
| | knowledge.files = [...(knowledge.files ?? []), fileItem]; |
| | |
| | |
| | if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) { |
| | const res = await transcribeAudio(localStorage.token, file).catch((error) => { |
| | toast.error(error); |
| | return null; |
| | }); |
| | |
| | if (res) { |
| | console.log(res); |
| | const blob = new Blob([res.text], { type: 'text/plain' }); |
| | file = blobToFile(blob, `${file.name}.txt`); |
| | } |
| | } |
| | |
| | try { |
| | const uploadedFile = await uploadFile(localStorage.token, file).catch((e) => { |
| | toast.error(e); |
| | return null; |
| | }); |
| | |
| | if (uploadedFile) { |
| | console.log(uploadedFile); |
| | knowledge.files = knowledge.files.map((item) => { |
| | if (item.itemId === tempItemId) { |
| | item.id = uploadedFile.id; |
| | } |
| | |
| | |
| | delete item.itemId; |
| | return item; |
| | }); |
| | await addFileHandler(uploadedFile.id); |
| | } else { |
| | toast.error($i18n.t('Failed to upload file.')); |
| | } |
| | } catch (e) { |
| | toast.error(e); |
| | } |
| | }; |
| | |
| | const uploadDirectoryHandler = async () => { |
| | // Check if File System Access API is supported |
| | const isFileSystemAccessSupported = 'showDirectoryPicker' in window; |
| | |
| | try { |
| | if (isFileSystemAccessSupported) { |
| | // Modern browsers (Chrome, Edge) implementation |
| | await handleModernBrowserUpload(); |
| | } else { |
| | // Firefox fallback |
| | await handleFirefoxUpload(); |
| | } |
| | } catch (error) { |
| | handleUploadError(error); |
| | } |
| | }; |
| | |
| | |
| | const hasHiddenFolder = (path) => { |
| | return path.split('/').some((part) => part.startsWith('.')); |
| | }; |
| | |
| | |
| | const handleModernBrowserUpload = async () => { |
| | const dirHandle = await window.showDirectoryPicker(); |
| | let totalFiles = 0; |
| | let uploadedFiles = 0; |
| | |
| | // Function to update the UI with the progress |
| | const updateProgress = () => { |
| | const percentage = (uploadedFiles / totalFiles) * 100; |
| | toast.info(`Upload Progress: ${uploadedFiles}/${totalFiles} (${percentage.toFixed(2)}%)`); |
| | }; |
| | |
| | // Recursive function to count all files excluding hidden ones |
| | async function countFiles(dirHandle) { |
| | for await (const entry of dirHandle.values()) { |
| | // Skip hidden files and directories |
| | if (entry.name.startsWith('.')) continue; |
| | |
| | if (entry.kind === 'file') { |
| | totalFiles++; |
| | } else if (entry.kind === 'directory') { |
| | // Only process non-hidden directories |
| | if (!entry.name.startsWith('.')) { |
| | await countFiles(entry); |
| | } |
| | } |
| | } |
| | } |
| | |
| | |
| | async function processDirectory(dirHandle, path = '') { |
| | for await (const entry of dirHandle.values()) { |
| | // Skip hidden files and directories |
| | if (entry.name.startsWith('.')) continue; |
| | |
| | const entryPath = path ? `${path}/${entry.name}` : entry.name; |
| | |
| | // Skip if the path contains any hidden folders |
| | if (hasHiddenFolder(entryPath)) continue; |
| | |
| | if (entry.kind === 'file') { |
| | const file = await entry.getFile(); |
| | const fileWithPath = new File([file], entryPath, { type: file.type }); |
| | |
| | await uploadFileHandler(fileWithPath); |
| | uploadedFiles++; |
| | updateProgress(); |
| | } else if (entry.kind === 'directory') { |
| | // Only process non-hidden directories |
| | if (!entry.name.startsWith('.')) { |
| | await processDirectory(entry, entryPath); |
| | } |
| | } |
| | } |
| | } |
| | |
| | await countFiles(dirHandle); |
| | updateProgress(); |
| | |
| | if (totalFiles > 0) { |
| | await processDirectory(dirHandle); |
| | } else { |
| | console.log('No files to upload.'); |
| | } |
| | }; |
| | |
| | |
| | const handleFirefoxUpload = async () => { |
| | return new Promise((resolve, reject) => { |
| | // Create hidden file input |
| | const input = document.createElement('input'); |
| | input.type = 'file'; |
| | input.webkitdirectory = true; |
| | input.directory = true; |
| | input.multiple = true; |
| | input.style.display = 'none'; |
| | |
| | // Add input to DOM temporarily |
| | document.body.appendChild(input); |
| | |
| | input.onchange = async () => { |
| | try { |
| | const files = Array.from(input.files) |
| | // Filter out files from hidden folders |
| | .filter((file) => !hasHiddenFolder(file.webkitRelativePath)); |
| | |
| | let totalFiles = files.length; |
| | let uploadedFiles = 0; |
| | |
| | // Function to update the UI with the progress |
| | const updateProgress = () => { |
| | const percentage = (uploadedFiles / totalFiles) * 100; |
| | toast.info( |
| | `Upload Progress: ${uploadedFiles}/${totalFiles} (${percentage.toFixed(2)}%)` |
| | ); |
| | }; |
| | |
| | updateProgress(); |
| | |
| | // Process all files |
| | for (const file of files) { |
| | // Skip hidden files (additional check) |
| | if (!file.name.startsWith('.')) { |
| | const relativePath = file.webkitRelativePath || file.name; |
| | const fileWithPath = new File([file], relativePath, { type: file.type }); |
| | |
| | await uploadFileHandler(fileWithPath); |
| | uploadedFiles++; |
| | updateProgress(); |
| | } |
| | } |
| | |
| | |
| | document.body.removeChild(input); |
| | resolve(); |
| | } catch (error) { |
| | reject(error); |
| | } |
| | }; |
| | |
| | input.onerror = (error) => { |
| | document.body.removeChild(input); |
| | reject(error); |
| | }; |
| | |
| | |
| | input.click(); |
| | }); |
| | }; |
| | |
| | |
| | const handleUploadError = (error) => { |
| | if (error.name === 'AbortError') { |
| | toast.info('Directory selection was cancelled'); |
| | } else { |
| | toast.error('Error accessing directory'); |
| | console.error('Directory access error:', error); |
| | } |
| | }; |
| | |
| | |
| | const syncDirectoryHandler = async () => { |
| | if ((knowledge?.files ?? []).length > 0) { |
| | const res = await resetKnowledgeById(localStorage.token, id).catch((e) => { |
| | toast.error(e); |
| | }); |
| | |
| | if (res) { |
| | knowledge = res; |
| | toast.success($i18n.t('Knowledge reset successfully.')); |
| | |
| | // Upload directory |
| | uploadDirectoryHandler(); |
| | } |
| | } else { |
| | uploadDirectoryHandler(); |
| | } |
| | }; |
| | |
| | const addFileHandler = async (fileId) => { |
| | const updatedKnowledge = await addFileToKnowledgeById(localStorage.token, id, fileId).catch( |
| | (e) => { |
| | toast.error(e); |
| | return null; |
| | } |
| | ); |
| | |
| | if (updatedKnowledge) { |
| | knowledge = updatedKnowledge; |
| | toast.success($i18n.t('File added successfully.')); |
| | } else { |
| | toast.error($i18n.t('Failed to add file.')); |
| | knowledge.files = knowledge.files.filter((file) => file.id !== fileId); |
| | } |
| | }; |
| | |
| | const deleteFileHandler = async (fileId) => { |
| | const updatedKnowledge = await removeFileFromKnowledgeById( |
| | localStorage.token, |
| | id, |
| | fileId |
| | ).catch((e) => { |
| | toast.error(e); |
| | }); |
| | |
| | if (updatedKnowledge) { |
| | knowledge = updatedKnowledge; |
| | toast.success($i18n.t('File removed successfully.')); |
| | } |
| | }; |
| | |
| | const updateFileContentHandler = async () => { |
| | const fileId = selectedFile.id; |
| | const content = selectedFile.data.content; |
| | |
| | const res = updateFileDataContentById(localStorage.token, fileId, content).catch((e) => { |
| | toast.error(e); |
| | }); |
| | |
| | const updatedKnowledge = await updateFileFromKnowledgeById( |
| | localStorage.token, |
| | id, |
| | fileId |
| | ).catch((e) => { |
| | toast.error(e); |
| | }); |
| | |
| | if (res && updatedKnowledge) { |
| | knowledge = updatedKnowledge; |
| | toast.success($i18n.t('File content updated successfully.')); |
| | } |
| | }; |
| | |
| | const changeDebounceHandler = () => { |
| | console.log('debounce'); |
| | if (debounceTimeout) { |
| | clearTimeout(debounceTimeout); |
| | } |
| | |
| | debounceTimeout = setTimeout(async () => { |
| | if (knowledge.name.trim() === '' || knowledge.description.trim() === '') { |
| | toast.error($i18n.t('Please fill in all fields.')); |
| | return; |
| | } |
| | |
| | const res = await updateKnowledgeById(localStorage.token, id, { |
| | name: knowledge.name, |
| | description: knowledge.description |
| | }).catch((e) => { |
| | toast.error(e); |
| | }); |
| | |
| | if (res) { |
| | toast.success($i18n.t('Knowledge updated successfully')); |
| | _knowledge.set(await getKnowledgeItems(localStorage.token)); |
| | } |
| | }, 1000); |
| | }; |
| | |
| | const handleMediaQuery = async (e) => { |
| | if (e.matches) { |
| | largeScreen = true; |
| | } else { |
| | largeScreen = false; |
| | } |
| | }; |
| | |
| | const onDragOver = (e) => { |
| | e.preventDefault(); |
| | dragged = true; |
| | }; |
| | |
| | const onDragLeave = () => { |
| | dragged = false; |
| | }; |
| | |
| | const onDrop = async (e) => { |
| | e.preventDefault(); |
| | dragged = false; |
| | |
| | if (e.dataTransfer?.files) { |
| | const inputFiles = e.dataTransfer?.files; |
| | |
| | if (inputFiles && inputFiles.length > 0) { |
| | for (const file of inputFiles) { |
| | await uploadFileHandler(file); |
| | } |
| | } else { |
| | toast.error($i18n.t(`File not found.`)); |
| | } |
| | } |
| | }; |
| | |
| | onMount(async () => { |
| | // listen to resize 1024px |
| | mediaQuery = window.matchMedia('(min-width: 1024px)'); |
| | |
| | mediaQuery.addEventListener('change', handleMediaQuery); |
| | handleMediaQuery(mediaQuery); |
| | |
| | id = $page.params.id; |
| | |
| | const res = await getKnowledgeById(localStorage.token, id).catch((e) => { |
| | toast.error(e); |
| | return null; |
| | }); |
| | |
| | if (res) { |
| | knowledge = res; |
| | } else { |
| | goto('/workspace/knowledge'); |
| | } |
| | |
| | const dropZone = document.querySelector('body'); |
| | dropZone?.addEventListener('dragover', onDragOver); |
| | dropZone?.addEventListener('drop', onDrop); |
| | dropZone?.addEventListener('dragleave', onDragLeave); |
| | }); |
| | |
| | onDestroy(() => { |
| | mediaQuery?.removeEventListener('change', handleMediaQuery); |
| | const dropZone = document.querySelector('body'); |
| | dropZone?.removeEventListener('dragover', onDragOver); |
| | dropZone?.removeEventListener('drop', onDrop); |
| | dropZone?.removeEventListener('dragleave', onDragLeave); |
| | }); |
| | </script> |
| |
|
| | {#if dragged} |
| | <div |
| | class="fixed {$showSidebar |
| | ? 'left-0 md:left-[260px] md:w-[calc(100%-260px)]' |
| | : 'left-0'} w-full h-full flex z-50 touch-none pointer-events-none" |
| | id="dropzone" |
| | role="region" |
| | aria-label="Drag and Drop Container" |
| | > |
| | <div class="absolute w-full h-full backdrop-blur bg-gray-800/40 flex justify-center"> |
| | <div class="m-auto pt-64 flex flex-col justify-center"> |
| | <div class="max-w-md"> |
| | <AddFilesPlaceholder> |
| | <div class=" mt-2 text-center text-sm dark:text-gray-200 w-full"> |
| | Drop any files here to add to my documents |
| | </div> |
| | </AddFilesPlaceholder> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | {/if} |
| |
|
| | <SyncConfirmDialog |
| | bind:show={showSyncConfirmModal} |
| | message={$i18n.t( |
| | 'This will reset the knowledge base and sync all files. Do you wish to continue?' |
| | )} |
| | on:confirm={() => { |
| | syncDirectoryHandler(); |
| | }} |
| | /> |
| |
|
| | <AddTextContentModal |
| | bind:show={showAddTextContentModal} |
| | on:submit={(e) => { |
| | const file = createFileFromText(e.detail.name, e.detail.content); |
| | uploadFileHandler(file); |
| | }} |
| | /> |
| |
|
| | <input |
| | id="files-input" |
| | bind:files={inputFiles} |
| | type="file" |
| | multiple |
| | hidden |
| | on:change={async () => { |
| | if (inputFiles && inputFiles.length > 0) { |
| | for (const file of inputFiles) { |
| | await uploadFileHandler(file); |
| | } |
| |
|
| | inputFiles = null; |
| | const fileInputElement = document.getElementById('files-input'); |
| |
|
| | if (fileInputElement) { |
| | fileInputElement.value = ''; |
| | } |
| | } else { |
| | toast.error($i18n.t(`File not found.`)); |
| | } |
| | }} |
| | /> |
| |
|
| | <div class="flex flex-col w-full max-h-[100dvh] h-full"> |
| | <div class="flex items-center justify-between"> |
| | <button |
| | class="flex space-x-1 w-fit" |
| | on:click={() => { |
| | goto('/workspace/knowledge'); |
| | }} |
| | > |
| | <div class=" self-center"> |
| | <svg |
| | xmlns="http://www.w3.org/2000/svg" |
| | viewBox="0 0 20 20" |
| | fill="currentColor" |
| | class="w-4 h-4" |
| | > |
| | <path |
| | fill-rule="evenodd" |
| | d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" |
| | clip-rule="evenodd" |
| | /> |
| | </svg> |
| | </div> |
| | <div class=" self-center font-medium text-sm">{$i18n.t('Back')}</div> |
| | </button> |
| |
|
| | <div class=" flex-shrink-0"> |
| | <div> |
| | <Badge type="success" content="Collection" /> |
| | </div> |
| | </div> |
| | </div> |
| | <div class="flex flex-col my-2 flex-1 overflow-auto h-0"> |
| | {#if id && knowledge} |
| | <div class="flex flex-row h-0 flex-1 overflow-auto"> |
| | <div |
| | class=" {largeScreen |
| | ? 'flex-shrink-0' |
| | : 'flex-1'} flex py-2.5 w-80 rounded-2xl border border-gray-50 dark:border-gray-850" |
| | > |
| | <div class=" flex flex-col w-full space-x-2 rounded-lg h-full"> |
| | <div class="w-full h-full flex flex-col"> |
| | <div class=" px-3"> |
| | <div class="flex"> |
| | <div class=" self-center ml-1 mr-3"> |
| | <svg |
| | xmlns="http://www.w3.org/2000/svg" |
| | viewBox="0 0 20 20" |
| | fill="currentColor" |
| | class="w-4 h-4" |
| | > |
| | <path |
| | fill-rule="evenodd" |
| | d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" |
| | clip-rule="evenodd" |
| | /> |
| | </svg> |
| | </div> |
| | <input |
| | class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent" |
| | bind:value={query} |
| | placeholder={$i18n.t('Search Collection')} |
| | on:focus={() => { |
| | selectedFileId = null; |
| | }} |
| | /> |
| | |
| | <div> |
| | <AddContentMenu |
| | on:upload={(e) => { |
| | if (e.detail.type === 'directory') { |
| | uploadDirectoryHandler(); |
| | } else if (e.detail.type === 'text') { |
| | showAddTextContentModal = true; |
| | } else { |
| | document.getElementById('files-input').click(); |
| | } |
| | }} |
| | on:sync={(e) => { |
| | showSyncConfirmModal = true; |
| | }} |
| | /> |
| | </div> |
| | </div> |
| | |
| | <hr class=" mt-2 mb-1 border-gray-50 dark:border-gray-850" /> |
| | </div> |
| | |
| | {#if filteredItems.length > 0} |
| | <div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs"> |
| | <Files |
| | files={filteredItems} |
| | {selectedFileId} |
| | on:click={(e) => { |
| | selectedFileId = selectedFileId === e.detail ? null : e.detail; |
| | }} |
| | on:delete={(e) => { |
| | console.log(e.detail); |
| | |
| | selectedFileId = null; |
| | deleteFileHandler(e.detail); |
| | }} |
| | /> |
| | </div> |
| | {:else} |
| | <div class="m-auto text-gray-500 text-xs">{$i18n.t('No content found')}</div> |
| | {/if} |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | {#if largeScreen} |
| | <div class="flex-1 flex justify-start max-h-full overflow-hidden pl-3"> |
| | {#if selectedFile} |
| | <div class=" flex flex-col w-full h-full"> |
| | <div class=" flex-shrink-0 mb-2 flex items-center"> |
| | <div class=" flex-1 text-xl line-clamp-1"> |
| | {selectedFile?.meta?.name} |
| | </div> |
| | |
| | <div> |
| | <button |
| | class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg" |
| | on:click={() => { |
| | updateFileContentHandler(); |
| | }} |
| | > |
| | {$i18n.t('Save')} |
| | </button> |
| | </div> |
| | </div> |
| | |
| | <div class=" flex-grow"> |
| | <textarea |
| | class=" w-full h-full resize-none rounded-xl py-4 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none" |
| | bind:value={selectedFile.data.content} |
| | placeholder={$i18n.t('Add content here')} |
| | /> |
| | </div> |
| | </div> |
| | {:else} |
| | <div class="m-auto pb-32"> |
| | <div> |
| | <div class=" flex w-full mt-1 mb-3.5"> |
| | <div class="flex-1"> |
| | <div class="flex items-center justify-between w-full px-0.5 mb-1"> |
| | <div class="w-full"> |
| | <input |
| | type="text" |
| | class="text-center w-full font-medium text-3xl font-primary bg-transparent outline-none" |
| | bind:value={knowledge.name} |
| | on:input={() => { |
| | changeDebounceHandler(); |
| | }} |
| | /> |
| | </div> |
| | </div> |
| | |
| | <div class="flex w-full px-1"> |
| | <input |
| | type="text" |
| | class="text-center w-full text-gray-500 bg-transparent outline-none" |
| | bind:value={knowledge.description} |
| | on:input={() => { |
| | changeDebounceHandler(); |
| | }} |
| | /> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | |
| | <div class=" mt-2 text-center text-sm text-gray-200 dark:text-gray-700 w-full"> |
| | {$i18n.t('Select a file to view or drag and drop a file to upload')} |
| | </div> |
| | </div> |
| | {/if} |
| | </div> |
| | {/if} |
| | </div> |
| | {:else} |
| | <Spinner /> |
| | {/if} |
| | </div> |
| | </div> |
| |
|