Collection.svelte 18 KB

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