Collection.svelte 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  1. <script lang="ts">
  2. import Fuse from 'fuse.js';
  3. import { toast } from 'svelte-sonner';
  4. import { v4 as uuidv4 } from 'uuid';
  5. import { onMount, getContext, onDestroy, tick } from 'svelte';
  6. const i18n = getContext('i18n');
  7. import { goto } from '$app/navigation';
  8. import { page } from '$app/stores';
  9. import { mobile, showSidebar, knowledge as _knowledge } from '$lib/stores';
  10. import { updateFileDataContentById, uploadFile } from '$lib/apis/files';
  11. import {
  12. addFileToKnowledgeById,
  13. getKnowledgeById,
  14. getKnowledgeItems,
  15. removeFileFromKnowledgeById,
  16. resetKnowledgeById,
  17. updateFileFromKnowledgeById,
  18. updateKnowledgeById
  19. } from '$lib/apis/knowledge';
  20. import Spinner from '$lib/components/common/Spinner.svelte';
  21. import Tooltip from '$lib/components/common/Tooltip.svelte';
  22. import Badge from '$lib/components/common/Badge.svelte';
  23. import Files from './Collection/Files.svelte';
  24. import AddFilesPlaceholder from '$lib/components/AddFilesPlaceholder.svelte';
  25. import AddContentModal from './Collection/AddTextContentModal.svelte';
  26. import { transcribeAudio } from '$lib/apis/audio';
  27. import { blobToFile } from '$lib/utils';
  28. import { processFile } from '$lib/apis/retrieval';
  29. import AddContentMenu from './Collection/AddContentMenu.svelte';
  30. import AddTextContentModal from './Collection/AddTextContentModal.svelte';
  31. import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
  32. let largeScreen = true;
  33. type Knowledge = {
  34. id: string;
  35. name: string;
  36. description: string;
  37. data: {
  38. file_ids: string[];
  39. };
  40. files: any[];
  41. };
  42. let id = null;
  43. let knowledge: Knowledge | null = null;
  44. let query = '';
  45. let showAddTextContentModal = false;
  46. let showSyncConfirmModal = false;
  47. let inputFiles = null;
  48. let filteredItems = [];
  49. $: if (knowledge) {
  50. fuse = new Fuse(knowledge.files, {
  51. keys: ['meta.name', 'meta.description']
  52. });
  53. }
  54. $: if (fuse) {
  55. filteredItems = query
  56. ? fuse.search(query).map((e) => {
  57. return e.item;
  58. })
  59. : (knowledge?.files ?? []);
  60. }
  61. let selectedFile = null;
  62. let selectedFileId = null;
  63. $: if (selectedFileId) {
  64. const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId);
  65. if (file) {
  66. file.data = file.data ?? { content: '' };
  67. selectedFile = file;
  68. } else {
  69. selectedFile = null;
  70. }
  71. } else {
  72. selectedFile = null;
  73. }
  74. let fuse = null;
  75. let debounceTimeout = null;
  76. let mediaQuery;
  77. let dragged = false;
  78. const createFileFromText = (name, content) => {
  79. const blob = new Blob([content], { type: 'text/plain' });
  80. const file = blobToFile(blob, `${name}.md`);
  81. console.log(file);
  82. return file;
  83. };
  84. const uploadFileHandler = async (file) => {
  85. console.log(file);
  86. const tempItemId = uuidv4();
  87. const fileItem = {
  88. type: 'file',
  89. file: '',
  90. id: null,
  91. url: '',
  92. name: file.name,
  93. size: file.size,
  94. status: 'uploading',
  95. error: '',
  96. itemId: tempItemId
  97. };
  98. knowledge.files = [...(knowledge.files ?? []), fileItem];
  99. // Check if the file is an audio file and transcribe/convert it to text file
  100. if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
  101. const res = await transcribeAudio(localStorage.token, file).catch((error) => {
  102. toast.error(error);
  103. return null;
  104. });
  105. if (res) {
  106. console.log(res);
  107. const blob = new Blob([res.text], { type: 'text/plain' });
  108. file = blobToFile(blob, `${file.name}.txt`);
  109. }
  110. }
  111. try {
  112. const uploadedFile = await uploadFile(localStorage.token, file).catch((e) => {
  113. toast.error(e);
  114. return null;
  115. });
  116. if (uploadedFile) {
  117. console.log(uploadedFile);
  118. knowledge.files = knowledge.files.map((item) => {
  119. if (item.itemId === tempItemId) {
  120. item.id = uploadedFile.id;
  121. }
  122. // Remove temporary item id
  123. delete item.itemId;
  124. return item;
  125. });
  126. await addFileHandler(uploadedFile.id);
  127. } else {
  128. toast.error($i18n.t('Failed to upload file.'));
  129. }
  130. } catch (e) {
  131. toast.error(e);
  132. }
  133. };
  134. const uploadDirectoryHandler = async () => {
  135. // Check if File System Access API is supported
  136. const isFileSystemAccessSupported = 'showDirectoryPicker' in window;
  137. try {
  138. if (isFileSystemAccessSupported) {
  139. // Modern browsers (Chrome, Edge) implementation
  140. await handleModernBrowserUpload();
  141. } else {
  142. // Firefox fallback
  143. await handleFirefoxUpload();
  144. }
  145. } catch (error) {
  146. handleUploadError(error);
  147. }
  148. };
  149. // Helper function to check if a path contains hidden folders
  150. const hasHiddenFolder = (path) => {
  151. return path.split('/').some((part) => part.startsWith('.'));
  152. };
  153. // Modern browsers implementation using File System Access API
  154. const handleModernBrowserUpload = async () => {
  155. const dirHandle = await window.showDirectoryPicker();
  156. let totalFiles = 0;
  157. let uploadedFiles = 0;
  158. // Function to update the UI with the progress
  159. const updateProgress = () => {
  160. const percentage = (uploadedFiles / totalFiles) * 100;
  161. toast.info(`Upload Progress: ${uploadedFiles}/${totalFiles} (${percentage.toFixed(2)}%)`);
  162. };
  163. // Recursive function to count all files excluding hidden ones
  164. async function countFiles(dirHandle) {
  165. for await (const entry of dirHandle.values()) {
  166. // Skip hidden files and directories
  167. if (entry.name.startsWith('.')) continue;
  168. if (entry.kind === 'file') {
  169. totalFiles++;
  170. } else if (entry.kind === 'directory') {
  171. // Only process non-hidden directories
  172. if (!entry.name.startsWith('.')) {
  173. await countFiles(entry);
  174. }
  175. }
  176. }
  177. }
  178. // Recursive function to process directories excluding hidden files and folders
  179. async function processDirectory(dirHandle, path = '') {
  180. for await (const entry of dirHandle.values()) {
  181. // Skip hidden files and directories
  182. if (entry.name.startsWith('.')) continue;
  183. const entryPath = path ? `${path}/${entry.name}` : entry.name;
  184. // Skip if the path contains any hidden folders
  185. if (hasHiddenFolder(entryPath)) continue;
  186. if (entry.kind === 'file') {
  187. const file = await entry.getFile();
  188. const fileWithPath = new File([file], entryPath, { type: file.type });
  189. await uploadFileHandler(fileWithPath);
  190. uploadedFiles++;
  191. updateProgress();
  192. } else if (entry.kind === 'directory') {
  193. // Only process non-hidden directories
  194. if (!entry.name.startsWith('.')) {
  195. await processDirectory(entry, entryPath);
  196. }
  197. }
  198. }
  199. }
  200. await countFiles(dirHandle);
  201. updateProgress();
  202. if (totalFiles > 0) {
  203. await processDirectory(dirHandle);
  204. } else {
  205. console.log('No files to upload.');
  206. }
  207. };
  208. // Firefox fallback implementation using traditional file input
  209. const handleFirefoxUpload = async () => {
  210. return new Promise((resolve, reject) => {
  211. // Create hidden file input
  212. const input = document.createElement('input');
  213. input.type = 'file';
  214. input.webkitdirectory = true;
  215. input.directory = true;
  216. input.multiple = true;
  217. input.style.display = 'none';
  218. // Add input to DOM temporarily
  219. document.body.appendChild(input);
  220. input.onchange = async () => {
  221. try {
  222. const files = Array.from(input.files)
  223. // Filter out files from hidden folders
  224. .filter((file) => !hasHiddenFolder(file.webkitRelativePath));
  225. let totalFiles = files.length;
  226. let uploadedFiles = 0;
  227. // Function to update the UI with the progress
  228. const updateProgress = () => {
  229. const percentage = (uploadedFiles / totalFiles) * 100;
  230. toast.info(
  231. `Upload Progress: ${uploadedFiles}/${totalFiles} (${percentage.toFixed(2)}%)`
  232. );
  233. };
  234. updateProgress();
  235. // Process all files
  236. for (const file of files) {
  237. // Skip hidden files (additional check)
  238. if (!file.name.startsWith('.')) {
  239. const relativePath = file.webkitRelativePath || file.name;
  240. const fileWithPath = new File([file], relativePath, { type: file.type });
  241. await uploadFileHandler(fileWithPath);
  242. uploadedFiles++;
  243. updateProgress();
  244. }
  245. }
  246. // Clean up
  247. document.body.removeChild(input);
  248. resolve();
  249. } catch (error) {
  250. reject(error);
  251. }
  252. };
  253. input.onerror = (error) => {
  254. document.body.removeChild(input);
  255. reject(error);
  256. };
  257. // Trigger file picker
  258. input.click();
  259. });
  260. };
  261. // Error handler
  262. const handleUploadError = (error) => {
  263. if (error.name === 'AbortError') {
  264. toast.info('Directory selection was cancelled');
  265. } else {
  266. toast.error('Error accessing directory');
  267. console.error('Directory access error:', error);
  268. }
  269. };
  270. // Helper function to maintain file paths within zip
  271. const syncDirectoryHandler = async () => {
  272. if ((knowledge?.files ?? []).length > 0) {
  273. const res = await resetKnowledgeById(localStorage.token, id).catch((e) => {
  274. toast.error(e);
  275. });
  276. if (res) {
  277. knowledge = res;
  278. toast.success($i18n.t('Knowledge reset successfully.'));
  279. // Upload directory
  280. uploadDirectoryHandler();
  281. }
  282. } else {
  283. uploadDirectoryHandler();
  284. }
  285. };
  286. const addFileHandler = async (fileId) => {
  287. const updatedKnowledge = await addFileToKnowledgeById(localStorage.token, id, fileId).catch(
  288. (e) => {
  289. toast.error(e);
  290. return null;
  291. }
  292. );
  293. if (updatedKnowledge) {
  294. knowledge = updatedKnowledge;
  295. toast.success($i18n.t('File added successfully.'));
  296. } else {
  297. toast.error($i18n.t('Failed to add file.'));
  298. knowledge.files = knowledge.files.filter((file) => file.id !== fileId);
  299. }
  300. };
  301. const deleteFileHandler = async (fileId) => {
  302. const updatedKnowledge = await removeFileFromKnowledgeById(
  303. localStorage.token,
  304. id,
  305. fileId
  306. ).catch((e) => {
  307. toast.error(e);
  308. });
  309. if (updatedKnowledge) {
  310. knowledge = updatedKnowledge;
  311. toast.success($i18n.t('File removed successfully.'));
  312. }
  313. };
  314. const updateFileContentHandler = async () => {
  315. const fileId = selectedFile.id;
  316. const content = selectedFile.data.content;
  317. const res = updateFileDataContentById(localStorage.token, fileId, content).catch((e) => {
  318. toast.error(e);
  319. });
  320. const updatedKnowledge = await updateFileFromKnowledgeById(
  321. localStorage.token,
  322. id,
  323. fileId
  324. ).catch((e) => {
  325. toast.error(e);
  326. });
  327. if (res && updatedKnowledge) {
  328. knowledge = updatedKnowledge;
  329. toast.success($i18n.t('File content updated successfully.'));
  330. }
  331. };
  332. const changeDebounceHandler = () => {
  333. console.log('debounce');
  334. if (debounceTimeout) {
  335. clearTimeout(debounceTimeout);
  336. }
  337. debounceTimeout = setTimeout(async () => {
  338. if (knowledge.name.trim() === '' || knowledge.description.trim() === '') {
  339. toast.error($i18n.t('Please fill in all fields.'));
  340. return;
  341. }
  342. const res = await updateKnowledgeById(localStorage.token, id, {
  343. name: knowledge.name,
  344. description: knowledge.description
  345. }).catch((e) => {
  346. toast.error(e);
  347. });
  348. if (res) {
  349. toast.success($i18n.t('Knowledge updated successfully'));
  350. _knowledge.set(await getKnowledgeItems(localStorage.token));
  351. }
  352. }, 1000);
  353. };
  354. const handleMediaQuery = async (e) => {
  355. if (e.matches) {
  356. largeScreen = true;
  357. } else {
  358. largeScreen = false;
  359. }
  360. };
  361. const onDragOver = (e) => {
  362. e.preventDefault();
  363. dragged = true;
  364. };
  365. const onDragLeave = () => {
  366. dragged = false;
  367. };
  368. const onDrop = async (e) => {
  369. e.preventDefault();
  370. dragged = false;
  371. if (e.dataTransfer?.files) {
  372. const inputFiles = e.dataTransfer?.files;
  373. if (inputFiles && inputFiles.length > 0) {
  374. for (const file of inputFiles) {
  375. await uploadFileHandler(file);
  376. }
  377. } else {
  378. toast.error($i18n.t(`File not found.`));
  379. }
  380. }
  381. };
  382. onMount(async () => {
  383. // listen to resize 1024px
  384. mediaQuery = window.matchMedia('(min-width: 1024px)');
  385. mediaQuery.addEventListener('change', handleMediaQuery);
  386. handleMediaQuery(mediaQuery);
  387. id = $page.params.id;
  388. const res = await getKnowledgeById(localStorage.token, id).catch((e) => {
  389. toast.error(e);
  390. return null;
  391. });
  392. if (res) {
  393. knowledge = res;
  394. } else {
  395. goto('/workspace/knowledge');
  396. }
  397. const dropZone = document.querySelector('body');
  398. dropZone?.addEventListener('dragover', onDragOver);
  399. dropZone?.addEventListener('drop', onDrop);
  400. dropZone?.addEventListener('dragleave', onDragLeave);
  401. });
  402. onDestroy(() => {
  403. mediaQuery?.removeEventListener('change', handleMediaQuery);
  404. const dropZone = document.querySelector('body');
  405. dropZone?.removeEventListener('dragover', onDragOver);
  406. dropZone?.removeEventListener('drop', onDrop);
  407. dropZone?.removeEventListener('dragleave', onDragLeave);
  408. });
  409. </script>
  410. {#if dragged}
  411. <div
  412. class="fixed {$showSidebar
  413. ? 'left-0 md:left-[260px] md:w-[calc(100%-260px)]'
  414. : 'left-0'} w-full h-full flex z-50 touch-none pointer-events-none"
  415. id="dropzone"
  416. role="region"
  417. aria-label="Drag and Drop Container"
  418. >
  419. <div class="absolute w-full h-full backdrop-blur bg-gray-800/40 flex justify-center">
  420. <div class="m-auto pt-64 flex flex-col justify-center">
  421. <div class="max-w-md">
  422. <AddFilesPlaceholder>
  423. <div class=" mt-2 text-center text-sm dark:text-gray-200 w-full">
  424. Drop any files here to add to my documents
  425. </div>
  426. </AddFilesPlaceholder>
  427. </div>
  428. </div>
  429. </div>
  430. </div>
  431. {/if}
  432. <SyncConfirmDialog
  433. bind:show={showSyncConfirmModal}
  434. message={$i18n.t(
  435. 'This will reset the knowledge base and sync all files. Do you wish to continue?'
  436. )}
  437. on:confirm={() => {
  438. syncDirectoryHandler();
  439. }}
  440. />
  441. <AddTextContentModal
  442. bind:show={showAddTextContentModal}
  443. on:submit={(e) => {
  444. const file = createFileFromText(e.detail.name, e.detail.content);
  445. uploadFileHandler(file);
  446. }}
  447. />
  448. <input
  449. id="files-input"
  450. bind:files={inputFiles}
  451. type="file"
  452. multiple
  453. hidden
  454. on:change={async () => {
  455. if (inputFiles && inputFiles.length > 0) {
  456. for (const file of inputFiles) {
  457. await uploadFileHandler(file);
  458. }
  459. inputFiles = null;
  460. const fileInputElement = document.getElementById('files-input');
  461. if (fileInputElement) {
  462. fileInputElement.value = '';
  463. }
  464. } else {
  465. toast.error($i18n.t(`File not found.`));
  466. }
  467. }}
  468. />
  469. <div class="flex flex-col w-full max-h-[100dvh] h-full">
  470. <div class="flex flex-col mb-2 flex-1 overflow-auto h-0">
  471. {#if id && knowledge}
  472. <div class="flex flex-row h-0 flex-1 overflow-auto">
  473. <div
  474. class=" {largeScreen
  475. ? 'flex-shrink-0'
  476. : 'flex-1'} flex py-2.5 w-80 rounded-2xl border border-gray-50 dark:border-gray-850"
  477. >
  478. <div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
  479. <div class="w-full h-full flex flex-col">
  480. <div class=" px-3">
  481. <div class="flex">
  482. <div class=" self-center ml-1 mr-3">
  483. <svg
  484. xmlns="http://www.w3.org/2000/svg"
  485. viewBox="0 0 20 20"
  486. fill="currentColor"
  487. class="w-4 h-4"
  488. >
  489. <path
  490. fill-rule="evenodd"
  491. 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"
  492. clip-rule="evenodd"
  493. />
  494. </svg>
  495. </div>
  496. <input
  497. class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
  498. bind:value={query}
  499. placeholder={$i18n.t('Search Collection')}
  500. on:focus={() => {
  501. selectedFileId = null;
  502. }}
  503. />
  504. <div>
  505. <AddContentMenu
  506. on:upload={(e) => {
  507. if (e.detail.type === 'directory') {
  508. uploadDirectoryHandler();
  509. } else if (e.detail.type === 'text') {
  510. showAddTextContentModal = true;
  511. } else {
  512. document.getElementById('files-input').click();
  513. }
  514. }}
  515. on:sync={(e) => {
  516. showSyncConfirmModal = true;
  517. }}
  518. />
  519. </div>
  520. </div>
  521. <hr class=" mt-2 mb-1 border-gray-50 dark:border-gray-850" />
  522. </div>
  523. {#if filteredItems.length > 0}
  524. <div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
  525. <Files
  526. files={filteredItems}
  527. {selectedFileId}
  528. on:click={(e) => {
  529. selectedFileId = selectedFileId === e.detail ? null : e.detail;
  530. }}
  531. on:delete={(e) => {
  532. console.log(e.detail);
  533. selectedFileId = null;
  534. deleteFileHandler(e.detail);
  535. }}
  536. />
  537. </div>
  538. {:else}
  539. <div class="m-auto text-gray-500 text-xs">{$i18n.t('No content found')}</div>
  540. {/if}
  541. </div>
  542. </div>
  543. </div>
  544. {#if largeScreen}
  545. <div class="flex-1 flex justify-start max-h-full overflow-hidden pl-3">
  546. {#if selectedFile}
  547. <div class=" flex flex-col w-full h-full">
  548. <div class=" flex-shrink-0 mb-2 flex items-center">
  549. <div class=" flex-1 text-xl line-clamp-1">
  550. {selectedFile?.meta?.name}
  551. </div>
  552. <div>
  553. <button
  554. 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"
  555. on:click={() => {
  556. updateFileContentHandler();
  557. }}
  558. >
  559. {$i18n.t('Save')}
  560. </button>
  561. </div>
  562. </div>
  563. <div class=" flex-grow">
  564. <textarea
  565. 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"
  566. bind:value={selectedFile.data.content}
  567. placeholder={$i18n.t('Add content here')}
  568. />
  569. </div>
  570. </div>
  571. {:else}
  572. <div class="m-auto pb-32">
  573. <div>
  574. <div class=" flex w-full mt-1 mb-3.5">
  575. <div class="flex-1">
  576. <div class="flex items-center justify-between w-full px-0.5 mb-1">
  577. <div class="w-full">
  578. <input
  579. type="text"
  580. class="text-center w-full font-medium text-3xl font-primary bg-transparent outline-none"
  581. bind:value={knowledge.name}
  582. on:input={() => {
  583. changeDebounceHandler();
  584. }}
  585. />
  586. </div>
  587. </div>
  588. <div class="flex w-full px-1">
  589. <input
  590. type="text"
  591. class="text-center w-full text-gray-500 bg-transparent outline-none"
  592. bind:value={knowledge.description}
  593. on:input={() => {
  594. changeDebounceHandler();
  595. }}
  596. />
  597. </div>
  598. </div>
  599. </div>
  600. </div>
  601. <div class=" mt-2 text-center text-sm text-gray-200 dark:text-gray-700 w-full">
  602. {$i18n.t('Select a file to view or drag and drop a file to upload')}
  603. </div>
  604. </div>
  605. {/if}
  606. </div>
  607. {/if}
  608. </div>
  609. {:else}
  610. <Spinner />
  611. {/if}
  612. </div>
  613. </div>