Collection.svelte 15 KB

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