MarkdownTokens.svelte 7.8 KB

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