Collection.svelte 12 KB

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