MarkdownTokens.svelte 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. <script lang="ts">
  2. import DOMPurify from 'dompurify';
  3. import { createEventDispatcher, onMount, getContext } from 'svelte';
  4. const i18n = getContext('i18n');
  5. import fileSaver from 'file-saver';
  6. const { saveAs } = fileSaver;
  7. import { marked, type Token } from 'marked';
  8. import { unescapeHtml } from '$lib/utils';
  9. import { WEBUI_BASE_URL } from '$lib/constants';
  10. import CodeBlock from '$lib/components/chat/Messages/CodeBlock.svelte';
  11. import MarkdownInlineTokens from '$lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte';
  12. import KatexRenderer from './KatexRenderer.svelte';
  13. import Collapsible from '$lib/components/common/Collapsible.svelte';
  14. import Tooltip from '$lib/components/common/Tooltip.svelte';
  15. import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
  16. const dispatch = createEventDispatcher();
  17. export let id: string;
  18. export let tokens: Token[];
  19. export let top = true;
  20. export let attributes = {};
  21. export let save = false;
  22. export let onSourceClick: Function = () => {};
  23. const headerComponent = (depth: number) => {
  24. return 'h' + depth;
  25. };
  26. const exportTableToCSVHandler = (token, tokenIdx = 0) => {
  27. console.log('Exporting table to CSV');
  28. // Extract header row text and escape for CSV.
  29. const header = token.header.map((headerCell) => `"${headerCell.text.replace(/"/g, '""')}"`);
  30. // Create an array for rows that will hold the mapped cell text.
  31. const rows = token.rows.map((row) =>
  32. row.map((cell) => {
  33. // Map tokens into a single text
  34. const cellContent = cell.tokens.map((token) => token.text).join('');
  35. // Escape double quotes and wrap the content in double quotes
  36. return `"${cellContent.replace(/"/g, '""')}"`;
  37. })
  38. );
  39. // Combine header and rows
  40. const csvData = [header, ...rows];
  41. // Join the rows using commas (,) as the separator and rows using newline (\n).
  42. const csvContent = csvData.map((row) => row.join(',')).join('\n');
  43. // Log rows and CSV content to ensure everything is correct.
  44. console.log(csvData);
  45. console.log(csvContent);
  46. // To handle Unicode characters, you need to prefix the data with a BOM:
  47. const bom = '\uFEFF'; // BOM for UTF-8
  48. // Create a new Blob prefixed with the BOM to ensure proper Unicode encoding.
  49. const blob = new Blob([bom + csvContent], { type: 'text/csv;charset=UTF-8' });
  50. // Use FileSaver.js's saveAs function to save the generated CSV file.
  51. saveAs(blob, `table-${id}-${tokenIdx}.csv`);
  52. };
  53. </script>
  54. <!-- {JSON.stringify(tokens)} -->
  55. {#each tokens as token, tokenIdx (tokenIdx)}
  56. {#if token.type === 'hr'}
  57. <hr class=" border-gray-50 dark:border-gray-850" />
  58. {:else if token.type === 'heading'}
  59. <svelte:element this={headerComponent(token.depth)}>
  60. <MarkdownInlineTokens id={`${id}-${tokenIdx}-h`} tokens={token.tokens} {onSourceClick} />
  61. </svelte:element>
  62. {:else if token.type === 'code'}
  63. {#if token.raw.includes('```')}
  64. <CodeBlock
  65. id={`${id}-${tokenIdx}`}
  66. {token}
  67. lang={token?.lang ?? ''}
  68. code={token?.text ?? ''}
  69. {attributes}
  70. {save}
  71. on:code={(e) => {
  72. dispatch('code', e.detail);
  73. }}
  74. on:save={(e) => {
  75. dispatch('update', {
  76. raw: token.raw,
  77. oldContent: token.text,
  78. newContent: e.detail
  79. });
  80. }}
  81. />
  82. {:else}
  83. {token.text}
  84. {/if}
  85. {:else if token.type === 'table'}
  86. <div class="relative w-full group">
  87. <div class="scrollbar-hidden relative overflow-x-auto max-w-full rounded-lg">
  88. <table
  89. class=" w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full rounded-xl"
  90. >
  91. <thead
  92. class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 border-none"
  93. >
  94. <tr class="">
  95. {#each token.header as header, headerIdx}
  96. <th
  97. scope="col"
  98. class="!px-3 !py-1.5 cursor-pointer border border-gray-50 dark:border-gray-850"
  99. style={token.align[headerIdx] ? '' : `text-align: ${token.align[headerIdx]}`}
  100. >
  101. <div class="flex flex-col gap-1.5 text-left">
  102. <div class="flex-shrink-0 break-normal">
  103. <MarkdownInlineTokens
  104. id={`${id}-${tokenIdx}-header-${headerIdx}`}
  105. tokens={header.tokens}
  106. {onSourceClick}
  107. />
  108. </div>
  109. </div>
  110. </th>
  111. {/each}
  112. </tr>
  113. </thead>
  114. <tbody>
  115. {#each token.rows as row, rowIdx}
  116. <tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
  117. {#each row ?? [] as cell, cellIdx}
  118. <td
  119. class="!px-3 !py-1.5 text-gray-900 dark:text-white w-max border border-gray-50 dark:border-gray-850"
  120. style={token.align[cellIdx] ? '' : `text-align: ${token.align[cellIdx]}`}
  121. >
  122. <div class="flex flex-col break-normal">
  123. <MarkdownInlineTokens
  124. id={`${id}-${tokenIdx}-row-${rowIdx}-${cellIdx}`}
  125. tokens={cell.tokens}
  126. {onSourceClick}
  127. />
  128. </div>
  129. </td>
  130. {/each}
  131. </tr>
  132. {/each}
  133. </tbody>
  134. </table>
  135. </div>
  136. <div class=" absolute top-1 right-1.5 z-20 invisible group-hover:visible">
  137. <Tooltip content={$i18n.t('Export to CSV')}>
  138. <button
  139. class="p-1 rounded-lg bg-transparent transition"
  140. on:click={(e) => {
  141. e.stopPropagation();
  142. exportTableToCSVHandler(token, tokenIdx);
  143. }}
  144. >
  145. <ArrowDownTray className=" size-3.5" strokeWidth="1.5" />
  146. </button>
  147. </Tooltip>
  148. </div>
  149. </div>
  150. {:else if token.type === 'blockquote'}
  151. <blockquote>
  152. <svelte:self id={`${id}-${tokenIdx}`} tokens={token.tokens} />
  153. </blockquote>
  154. {:else if token.type === 'list'}
  155. {#if token.ordered}
  156. <ol start={token.start || 1}>
  157. {#each token.items as item, itemIdx}
  158. <li>
  159. <svelte:self
  160. id={`${id}-${tokenIdx}-${itemIdx}`}
  161. tokens={item.tokens}
  162. top={token.loose}
  163. />
  164. </li>
  165. {/each}
  166. </ol>
  167. {:else}
  168. <ul>
  169. {#each token.items as item, itemIdx}
  170. <li>
  171. <svelte:self
  172. id={`${id}-${tokenIdx}-${itemIdx}`}
  173. tokens={item.tokens}
  174. top={token.loose}
  175. />
  176. </li>
  177. {/each}
  178. </ul>
  179. {/if}
  180. {:else if token.type === 'details'}
  181. <Collapsible title={token.summary} attributes={token?.attributes} className="w-full space-y-1">
  182. <div class=" mb-1.5" slot="content">
  183. <svelte:self
  184. id={`${id}-${tokenIdx}-d`}
  185. tokens={marked.lexer(token.text)}
  186. attributes={token?.attributes}
  187. />
  188. </div>
  189. </Collapsible>
  190. {:else if token.type === 'html'}
  191. {@const html = DOMPurify.sanitize(token.text)}
  192. {#if html && html.includes('<video')}
  193. {@html html}
  194. {:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)}
  195. {@html `${token.text}`}
  196. {:else}
  197. {token.text}
  198. {/if}
  199. {:else if token.type === 'iframe'}
  200. <iframe
  201. src="{WEBUI_BASE_URL}/api/v1/files/{token.fileId}/content"
  202. title={token.fileId}
  203. width="100%"
  204. frameborder="0"
  205. onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';"
  206. ></iframe>
  207. {:else if token.type === 'paragraph'}
  208. <p>
  209. <MarkdownInlineTokens
  210. id={`${id}-${tokenIdx}-p`}
  211. tokens={token.tokens ?? []}
  212. {onSourceClick}
  213. />
  214. </p>
  215. {:else if token.type === 'text'}
  216. {#if top}
  217. <p>
  218. {#if token.tokens}
  219. <MarkdownInlineTokens id={`${id}-${tokenIdx}-t`} tokens={token.tokens} {onSourceClick} />
  220. {:else}
  221. {unescapeHtml(token.text)}
  222. {/if}
  223. </p>
  224. {:else if token.tokens}
  225. <MarkdownInlineTokens
  226. id={`${id}-${tokenIdx}-p`}
  227. tokens={token.tokens ?? []}
  228. {onSourceClick}
  229. />
  230. {:else}
  231. {unescapeHtml(token.text)}
  232. {/if}
  233. {:else if token.type === 'inlineKatex'}
  234. {#if token.text}
  235. <KatexRenderer content={token.text} displayMode={token?.displayMode ?? false} />
  236. {/if}
  237. {:else if token.type === 'blockKatex'}
  238. {#if token.text}
  239. <KatexRenderer content={token.text} displayMode={token?.displayMode ?? false} />
  240. {/if}
  241. {:else if token.type === 'space'}
  242. <div class="my-2" />
  243. {:else}
  244. {console.log('Unknown token', token)}
  245. {/if}
  246. {/each}