Collection.svelte 12 KB

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