1
0

Knowledge.svelte 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import Fuse from 'fuse.js';
  4. import dayjs from 'dayjs';
  5. import relativeTime from 'dayjs/plugin/relativeTime';
  6. dayjs.extend(relativeTime);
  7. import { tick, getContext, onMount, onDestroy } from 'svelte';
  8. import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils';
  9. import Tooltip from '$lib/components/common/Tooltip.svelte';
  10. import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
  11. import Database from '$lib/components/icons/Database.svelte';
  12. import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
  13. import Youtube from '$lib/components/icons/Youtube.svelte';
  14. const i18n = getContext('i18n');
  15. export let query = '';
  16. export let onSelect = (e) => {};
  17. export let knowledge = [];
  18. let selectedIdx = 0;
  19. let items = [];
  20. let fuse = null;
  21. export let filteredItems = [];
  22. $: if (fuse) {
  23. filteredItems = [
  24. ...(query
  25. ? fuse.search(query).map((e) => {
  26. return e.item;
  27. })
  28. : items),
  29. ...(query.startsWith('http')
  30. ? query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')
  31. ? [{ type: 'youtube', name: query, description: query }]
  32. : [
  33. {
  34. type: 'web',
  35. name: query,
  36. description: query
  37. }
  38. ]
  39. : [])
  40. ];
  41. }
  42. $: if (query) {
  43. selectedIdx = 0;
  44. }
  45. export const selectUp = () => {
  46. selectedIdx = Math.max(0, selectedIdx - 1);
  47. };
  48. export const selectDown = () => {
  49. selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
  50. };
  51. export const select = async () => {
  52. // find item with data-selected=true
  53. const item = document.querySelector(`[data-selected="true"]`);
  54. if (item) {
  55. // click the item
  56. item.click();
  57. }
  58. };
  59. const decodeString = (str: string) => {
  60. try {
  61. return decodeURIComponent(str);
  62. } catch (e) {
  63. return str;
  64. }
  65. };
  66. onMount(async () => {
  67. let legacy_documents = knowledge
  68. .filter((item) => item?.meta?.document)
  69. .map((item) => ({
  70. ...item,
  71. type: 'file'
  72. }));
  73. let legacy_collections =
  74. legacy_documents.length > 0
  75. ? [
  76. {
  77. name: 'All Documents',
  78. legacy: true,
  79. type: 'collection',
  80. description: 'Deprecated (legacy collection), please create a new knowledge base.',
  81. title: $i18n.t('All Documents'),
  82. collection_names: legacy_documents.map((item) => item.id)
  83. },
  84. ...legacy_documents
  85. .reduce((a, item) => {
  86. return [...new Set([...a, ...(item?.meta?.tags ?? []).map((tag) => tag.name)])];
  87. }, [])
  88. .map((tag) => ({
  89. name: tag,
  90. legacy: true,
  91. type: 'collection',
  92. description: 'Deprecated (legacy collection), please create a new knowledge base.',
  93. collection_names: legacy_documents
  94. .filter((item) => (item?.meta?.tags ?? []).map((tag) => tag.name).includes(tag))
  95. .map((item) => item.id)
  96. }))
  97. ]
  98. : [];
  99. let collections = knowledge
  100. .filter((item) => !item?.meta?.document)
  101. .map((item) => ({
  102. ...item,
  103. type: 'collection'
  104. }));
  105. let collection_files =
  106. knowledge.length > 0
  107. ? [
  108. ...knowledge
  109. .reduce((a, item) => {
  110. return [
  111. ...new Set([
  112. ...a,
  113. ...(item?.files ?? []).map((file) => ({
  114. ...file,
  115. collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
  116. }))
  117. ])
  118. ];
  119. }, [])
  120. .map((file) => ({
  121. ...file,
  122. name: file?.meta?.name,
  123. description: `${file?.collection?.name} - ${file?.collection?.description}`,
  124. knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE
  125. type: 'file'
  126. }))
  127. ]
  128. : [];
  129. items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map(
  130. (item) => {
  131. return {
  132. ...item,
  133. ...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
  134. };
  135. }
  136. );
  137. fuse = new Fuse(items, {
  138. keys: ['name', 'description']
  139. });
  140. await tick();
  141. });
  142. const onKeyDown = (e) => {
  143. if (e.key === 'Enter') {
  144. e.preventDefault();
  145. select();
  146. }
  147. };
  148. onMount(() => {
  149. window.addEventListener('keydown', onKeyDown);
  150. });
  151. onDestroy(() => {
  152. window.removeEventListener('keydown', onKeyDown);
  153. });
  154. </script>
  155. <div class="px-2 text-xs text-gray-500 py-1">
  156. {$i18n.t('Knowledge')}
  157. </div>
  158. {#if filteredItems.length > 0 || query.startsWith('http')}
  159. {#each filteredItems as item, idx}
  160. {#if !['youtube', 'web'].includes(item.type)}
  161. <button
  162. class=" px-2 py-1 rounded-xl w-full text-left flex justify-between items-center {idx ===
  163. selectedIdx
  164. ? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
  165. : ''}"
  166. type="button"
  167. on:click={() => {
  168. console.log(item);
  169. onSelect({
  170. type: 'knowledge',
  171. data: item
  172. });
  173. }}
  174. on:mousemove={() => {
  175. selectedIdx = idx;
  176. }}
  177. data-selected={idx === selectedIdx}
  178. >
  179. <div class=" text-black dark:text-gray-100 flex items-center gap-1">
  180. <Tooltip
  181. content={item?.legacy
  182. ? $i18n.t('Legacy')
  183. : item?.type === 'file'
  184. ? $i18n.t('File')
  185. : item?.type === 'collection'
  186. ? $i18n.t('Collection')
  187. : ''}
  188. placement="top"
  189. >
  190. {#if item?.type === 'collection'}
  191. <Database className="size-4" />
  192. {:else}
  193. <DocumentPage className="size-4" />
  194. {/if}
  195. </Tooltip>
  196. <Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
  197. <div class="line-clamp-1 flex-1">
  198. {decodeString(item?.name)}
  199. </div>
  200. </Tooltip>
  201. </div>
  202. </button>
  203. {/if}
  204. <!-- <div slot="content" class=" pl-2 pt-1 flex flex-col gap-0.5">
  205. {#if !item.legacy && (item?.files ?? []).length > 0}
  206. {#each item?.files ?? [] as file, fileIdx}
  207. <button
  208. class=" px-3 py-1.5 rounded-xl w-full text-left flex justify-between items-center hover:bg-gray-50 dark:hover:bg-gray-850 dark:hover:text-gray-100 selected-command-option-button"
  209. type="button"
  210. on:click={() => {
  211. console.log(file);
  212. }}
  213. on:mousemove={() => {
  214. selectedIdx = idx;
  215. }}
  216. >
  217. <div>
  218. <div
  219. class=" font-medium text-black dark:text-gray-100 flex items-center gap-1"
  220. >
  221. <div
  222. class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-bold px-1 shrink-0"
  223. >
  224. File
  225. </div>
  226. <div class="line-clamp-1">
  227. {file?.meta?.name}
  228. </div>
  229. </div>
  230. <div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
  231. {$i18n.t('Updated')}
  232. {dayjs(file.updated_at * 1000).fromNow()}
  233. </div>
  234. </div>
  235. </button>
  236. {/each}
  237. {:else}
  238. <div class=" text-gray-500 text-xs mt-1 mb-2">
  239. {$i18n.t('File not found.')}
  240. </div>
  241. {/if}
  242. </div> -->
  243. {/each}
  244. {#if query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')}
  245. <button
  246. class="px-2 py-1 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button"
  247. type="button"
  248. data-selected={true}
  249. on:click={() => {
  250. if (isValidHttpUrl(query)) {
  251. onSelect({
  252. type: 'youtube',
  253. data: query
  254. });
  255. } else {
  256. toast.error(
  257. $i18n.t('Oops! Looks like the URL is invalid. Please double-check and try again.')
  258. );
  259. }
  260. }}
  261. >
  262. <div class=" text-black dark:text-gray-100 line-clamp-1 flex items-center gap-1">
  263. <Tooltip content={$i18n.t('YouTube')} placement="top">
  264. <Youtube className="size-4" />
  265. </Tooltip>
  266. <div class="truncate flex-1">
  267. {query}
  268. </div>
  269. </div>
  270. </button>
  271. {:else if query.startsWith('http')}
  272. <button
  273. class="px-2 py-1 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button"
  274. type="button"
  275. data-selected={true}
  276. on:click={() => {
  277. if (isValidHttpUrl(query)) {
  278. onSelect({
  279. type: 'web',
  280. data: query
  281. });
  282. } else {
  283. toast.error(
  284. $i18n.t('Oops! Looks like the URL is invalid. Please double-check and try again.')
  285. );
  286. }
  287. }}
  288. >
  289. <div class=" text-black dark:text-gray-100 line-clamp-1 flex items-center gap-1">
  290. <Tooltip content={$i18n.t('Web')} placement="top">
  291. <GlobeAlt className="size-4" />
  292. </Tooltip>
  293. <div class="truncate flex-1">
  294. {query}
  295. </div>
  296. </div>
  297. </button>
  298. {/if}
  299. {/if}