Menu.svelte 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { DropdownMenu } from 'bits-ui';
  4. import { getContext, tick } from 'svelte';
  5. import fileSaver from 'file-saver';
  6. const { saveAs } = fileSaver;
  7. import { downloadChatAsPDF } from '$lib/apis/utils';
  8. import { copyToClipboard, createMessagesList } from '$lib/utils';
  9. import {
  10. showOverview,
  11. showControls,
  12. showArtifacts,
  13. mobile,
  14. temporaryChatEnabled,
  15. theme,
  16. user,
  17. settings,
  18. folders
  19. } from '$lib/stores';
  20. import { flyAndScale } from '$lib/utils/transitions';
  21. import { getChatById } from '$lib/apis/chats';
  22. import Dropdown from '$lib/components/common/Dropdown.svelte';
  23. import Tags from '$lib/components/chat/Tags.svelte';
  24. import Map from '$lib/components/icons/Map.svelte';
  25. import Clipboard from '$lib/components/icons/Clipboard.svelte';
  26. import AdjustmentsHorizontal from '$lib/components/icons/AdjustmentsHorizontal.svelte';
  27. import Cube from '$lib/components/icons/Cube.svelte';
  28. import Folder from '$lib/components/icons/Folder.svelte';
  29. import Share from '$lib/components/icons/Share.svelte';
  30. import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
  31. import Messages from '$lib/components/chat/Messages.svelte';
  32. import Download from '$lib/components/icons/Download.svelte';
  33. const i18n = getContext('i18n');
  34. export let shareEnabled: boolean = false;
  35. export let shareHandler: Function;
  36. export let moveChatHandler: Function;
  37. export let archiveChatHandler: Function;
  38. // export let tagHandler: Function;
  39. export let chat;
  40. export let onClose: Function = () => {};
  41. let showFullMessages = false;
  42. const getChatAsText = async () => {
  43. const history = chat.chat.history;
  44. const messages = createMessagesList(history, history.currentId);
  45. const chatText = messages.reduce((a, message, i, arr) => {
  46. return `${a}### ${message.role.toUpperCase()}\n${message.content}\n\n`;
  47. }, '');
  48. return chatText.trim();
  49. };
  50. const downloadTxt = async () => {
  51. const chatText = await getChatAsText();
  52. let blob = new Blob([chatText], {
  53. type: 'text/plain'
  54. });
  55. saveAs(blob, `chat-${chat.chat.title}.txt`);
  56. };
  57. const downloadPdf = async () => {
  58. const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([
  59. import('jspdf'),
  60. import('html2canvas-pro')
  61. ]);
  62. if ($settings?.stylizedPdfExport ?? true) {
  63. showFullMessages = true;
  64. await tick();
  65. const containerElement = document.getElementById('full-messages-container');
  66. if (containerElement) {
  67. try {
  68. const isDarkMode = document.documentElement.classList.contains('dark');
  69. const virtualWidth = 800; // px, fixed width for cloned element
  70. // Clone and style
  71. const clonedElement = containerElement.cloneNode(true);
  72. clonedElement.classList.add('text-black');
  73. clonedElement.classList.add('dark:text-white');
  74. clonedElement.style.width = `${virtualWidth}px`;
  75. clonedElement.style.position = 'absolute';
  76. clonedElement.style.left = '-9999px';
  77. clonedElement.style.height = 'auto';
  78. document.body.appendChild(clonedElement);
  79. // Wait for DOM update/layout
  80. await new Promise((r) => setTimeout(r, 100));
  81. // Render entire content once
  82. const canvas = await html2canvas(clonedElement, {
  83. backgroundColor: isDarkMode ? '#000' : '#fff',
  84. useCORS: true,
  85. scale: 2, // increase resolution
  86. width: virtualWidth
  87. });
  88. document.body.removeChild(clonedElement);
  89. const pdf = new jsPDF('p', 'mm', 'a4');
  90. const pageWidthMM = 210;
  91. const pageHeightMM = 297;
  92. // Convert page height in mm to px on canvas scale for cropping
  93. // Get canvas DPI scale:
  94. const pxPerMM = canvas.width / virtualWidth; // width in px / width in px?
  95. // Since 1 page width is 210 mm, but canvas width is 800 px at scale 2
  96. // Assume 1 mm = px / (pageWidthMM scaled)
  97. // Actually better: Calculate scale factor from px/mm:
  98. // virtualWidth px corresponds directly to 210mm in PDF, so pxPerMM:
  99. const pxPerPDFMM = canvas.width / pageWidthMM; // canvas px per PDF mm
  100. // Height in px for one page slice:
  101. const pagePixelHeight = Math.floor(pxPerPDFMM * pageHeightMM);
  102. let offsetY = 0;
  103. let page = 0;
  104. while (offsetY < canvas.height) {
  105. // Height of slice
  106. const sliceHeight = Math.min(pagePixelHeight, canvas.height - offsetY);
  107. // Create temp canvas for slice
  108. const pageCanvas = document.createElement('canvas');
  109. pageCanvas.width = canvas.width;
  110. pageCanvas.height = sliceHeight;
  111. const ctx = pageCanvas.getContext('2d');
  112. // Draw the slice of original canvas onto pageCanvas
  113. ctx.drawImage(
  114. canvas,
  115. 0,
  116. offsetY,
  117. canvas.width,
  118. sliceHeight,
  119. 0,
  120. 0,
  121. canvas.width,
  122. sliceHeight
  123. );
  124. const imgData = pageCanvas.toDataURL('image/jpeg', 0.7);
  125. // Calculate image height in PDF units keeping aspect ratio
  126. const imgHeightMM = (sliceHeight * pageWidthMM) / canvas.width;
  127. if (page > 0) pdf.addPage();
  128. if (isDarkMode) {
  129. pdf.setFillColor(0, 0, 0);
  130. pdf.rect(0, 0, pageWidthMM, pageHeightMM, 'F'); // black bg
  131. }
  132. pdf.addImage(imgData, 'JPEG', 0, 0, pageWidthMM, imgHeightMM);
  133. offsetY += sliceHeight;
  134. page++;
  135. }
  136. pdf.save(`chat-${chat.chat.title}.pdf`);
  137. showFullMessages = false;
  138. } catch (error) {
  139. console.error('Error generating PDF', error);
  140. }
  141. }
  142. } else {
  143. console.log('Downloading PDF');
  144. const chatText = await getChatAsText();
  145. const doc = new jsPDF();
  146. // Margins
  147. const left = 15;
  148. const top = 20;
  149. const right = 15;
  150. const bottom = 20;
  151. const pageWidth = doc.internal.pageSize.getWidth();
  152. const pageHeight = doc.internal.pageSize.getHeight();
  153. const usableWidth = pageWidth - left - right;
  154. const usableHeight = pageHeight - top - bottom;
  155. // Font size and line height
  156. const fontSize = 8;
  157. doc.setFontSize(fontSize);
  158. const lineHeight = fontSize * 1; // adjust if needed
  159. // Split the markdown into lines (handles \n)
  160. const paragraphs = chatText.split('\n');
  161. let y = top;
  162. for (let paragraph of paragraphs) {
  163. // Wrap each paragraph to fit the width
  164. const lines = doc.splitTextToSize(paragraph, usableWidth);
  165. for (let line of lines) {
  166. // If the line would overflow the bottom, add a new page
  167. if (y + lineHeight > pageHeight - bottom) {
  168. doc.addPage();
  169. y = top;
  170. }
  171. doc.text(line, left, y);
  172. y += lineHeight * 0.5;
  173. }
  174. // Add empty line at paragraph breaks
  175. y += lineHeight * 0.1;
  176. }
  177. doc.save(`chat-${chat.chat.title}.pdf`);
  178. }
  179. };
  180. const downloadJSONExport = async () => {
  181. if (chat.id) {
  182. let chatObj = null;
  183. if (chat.id === 'local' || $temporaryChatEnabled) {
  184. chatObj = chat;
  185. } else {
  186. chatObj = await getChatById(localStorage.token, chat.id);
  187. }
  188. let blob = new Blob([JSON.stringify([chatObj])], {
  189. type: 'application/json'
  190. });
  191. saveAs(blob, `chat-export-${Date.now()}.json`);
  192. }
  193. };
  194. </script>
  195. {#if showFullMessages}
  196. <div class="hidden w-full h-full flex-col">
  197. <div id="full-messages-container">
  198. <Messages
  199. className="h-full flex pt-4 pb-8 w-full"
  200. chatId={`chat-preview-${chat?.id ?? ''}`}
  201. user={$user}
  202. readOnly={true}
  203. history={chat.chat.history}
  204. messages={chat.chat.messages}
  205. autoScroll={true}
  206. sendMessage={() => {}}
  207. continueResponse={() => {}}
  208. regenerateResponse={() => {}}
  209. messagesCount={null}
  210. editCodeBlock={false}
  211. />
  212. </div>
  213. </div>
  214. {/if}
  215. <Dropdown
  216. on:change={(e) => {
  217. if (e.detail === false) {
  218. onClose();
  219. }
  220. }}
  221. >
  222. <slot />
  223. <div slot="content">
  224. <DropdownMenu.Content
  225. class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
  226. sideOffset={8}
  227. side="bottom"
  228. align="end"
  229. transition={flyAndScale}
  230. >
  231. <!-- <DropdownMenu.Item
  232. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer dark:hover:bg-gray-800 rounded-xl"
  233. on:click={async () => {
  234. await showSettings.set(!$showSettings);
  235. }}
  236. >
  237. <svg
  238. xmlns="http://www.w3.org/2000/svg"
  239. fill="none"
  240. viewBox="0 0 24 24"
  241. stroke-width="1.5"
  242. stroke="currentColor"
  243. class="size-4"
  244. >
  245. <path
  246. stroke-linecap="round"
  247. stroke-linejoin="round"
  248. d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
  249. />
  250. <path
  251. stroke-linecap="round"
  252. stroke-linejoin="round"
  253. d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
  254. />
  255. </svg>
  256. <div class="flex items-center">{$i18n.t('Settings')}</div>
  257. </DropdownMenu.Item> -->
  258. {#if $mobile}
  259. <DropdownMenu.Item
  260. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
  261. id="chat-controls-button"
  262. on:click={async () => {
  263. await showControls.set(true);
  264. await showOverview.set(false);
  265. await showArtifacts.set(false);
  266. }}
  267. >
  268. <AdjustmentsHorizontal className=" size-4" strokeWidth="1.5" />
  269. <div class="flex items-center">{$i18n.t('Controls')}</div>
  270. </DropdownMenu.Item>
  271. {/if}
  272. <DropdownMenu.Item
  273. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
  274. id="chat-overview-button"
  275. on:click={async () => {
  276. await showControls.set(true);
  277. await showOverview.set(true);
  278. await showArtifacts.set(false);
  279. }}
  280. >
  281. <Map className=" size-4" strokeWidth="1.5" />
  282. <div class="flex items-center">{$i18n.t('Overview')}</div>
  283. </DropdownMenu.Item>
  284. <DropdownMenu.Item
  285. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
  286. id="chat-overview-button"
  287. on:click={async () => {
  288. await showControls.set(true);
  289. await showArtifacts.set(true);
  290. await showOverview.set(false);
  291. }}
  292. >
  293. <Cube className=" size-4" strokeWidth="1.5" />
  294. <div class="flex items-center">{$i18n.t('Artifacts')}</div>
  295. </DropdownMenu.Item>
  296. <hr class="border-gray-50 dark:border-gray-800 my-1" />
  297. {#if !$temporaryChatEnabled && ($user?.role === 'admin' || ($user.permissions?.chat?.share ?? true))}
  298. <DropdownMenu.Item
  299. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
  300. id="chat-share-button"
  301. on:click={() => {
  302. shareHandler();
  303. }}
  304. >
  305. <Share strokeWidth="1.5" />
  306. <div class="flex items-center">{$i18n.t('Share')}</div>
  307. </DropdownMenu.Item>
  308. {/if}
  309. <DropdownMenu.Sub>
  310. <DropdownMenu.SubTrigger
  311. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
  312. >
  313. <Download strokeWidth="1.5" />
  314. <div class="flex items-center">{$i18n.t('Download')}</div>
  315. </DropdownMenu.SubTrigger>
  316. <DropdownMenu.SubContent
  317. class="w-full rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white border border-gray-100 dark:border-gray-800 shadow-lg max-h-52 overflow-y-auto scrollbar-hidden"
  318. transition={flyAndScale}
  319. sideOffset={8}
  320. >
  321. {#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
  322. <DropdownMenu.Item
  323. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
  324. on:click={() => {
  325. downloadJSONExport();
  326. }}
  327. >
  328. <div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
  329. </DropdownMenu.Item>
  330. {/if}
  331. <DropdownMenu.Item
  332. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
  333. on:click={() => {
  334. downloadTxt();
  335. }}
  336. >
  337. <div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</div>
  338. </DropdownMenu.Item>
  339. <DropdownMenu.Item
  340. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
  341. on:click={() => {
  342. downloadPdf();
  343. }}
  344. >
  345. <div class="flex items-center line-clamp-1">{$i18n.t('PDF document (.pdf)')}</div>
  346. </DropdownMenu.Item>
  347. </DropdownMenu.SubContent>
  348. </DropdownMenu.Sub>
  349. <DropdownMenu.Item
  350. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
  351. id="chat-copy-button"
  352. on:click={async () => {
  353. const res = await copyToClipboard(await getChatAsText()).catch((e) => {
  354. console.error(e);
  355. });
  356. if (res) {
  357. toast.success($i18n.t('Copied to clipboard'));
  358. }
  359. }}
  360. >
  361. <Clipboard className=" size-4" strokeWidth="1.5" />
  362. <div class="flex items-center">{$i18n.t('Copy')}</div>
  363. </DropdownMenu.Item>
  364. <hr class="border-gray-50 dark:border-gray-800 my-1" />
  365. {#if chat?.id}
  366. <DropdownMenu.Sub>
  367. <DropdownMenu.SubTrigger
  368. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
  369. >
  370. <Folder strokeWidth="1.5" />
  371. <div class="flex items-center">{$i18n.t('Move')}</div>
  372. </DropdownMenu.SubTrigger>
  373. <DropdownMenu.SubContent
  374. class="w-full rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white border border-gray-100 dark:border-gray-800 shadow-lg max-h-52 overflow-y-auto scrollbar-hidden"
  375. transition={flyAndScale}
  376. sideOffset={8}
  377. >
  378. {#each $folders.sort((a, b) => b.updated_at - a.updated_at) as folder}
  379. <DropdownMenu.Item
  380. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
  381. on:click={() => {
  382. moveChatHandler(chat?.id, folder?.id);
  383. }}
  384. >
  385. <Folder strokeWidth="1.5" />
  386. <div class="flex items-center">{folder?.name ?? 'Folder'}</div>
  387. </DropdownMenu.Item>
  388. {/each}
  389. </DropdownMenu.SubContent>
  390. </DropdownMenu.Sub>
  391. {/if}
  392. <DropdownMenu.Item
  393. class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
  394. on:click={() => {
  395. archiveChatHandler();
  396. }}
  397. >
  398. <ArchiveBox className="size-4" strokeWidth="1.5" />
  399. <div class="flex items-center">{$i18n.t('Archive')}</div>
  400. </DropdownMenu.Item>
  401. {#if !$temporaryChatEnabled}
  402. <hr class="border-gray-50 dark:border-gray-800 my-1" />
  403. <div class="flex p-1">
  404. <Tags chatId={chat.id} />
  405. </div>
  406. {/if}
  407. </DropdownMenu.Content>
  408. </div>
  409. </Dropdown>