| <script lang="ts"> |
| import DOMPurify from 'dompurify'; |
| import { createEventDispatcher, onMount, getContext } from 'svelte'; |
| const i18n = getContext('i18n'); |
|
|
| import fileSaver from 'file-saver'; |
| const { saveAs } = fileSaver; |
|
|
| import { marked, type Token } from 'marked'; |
| import { unescapeHtml } from '$lib/utils'; |
|
|
| import { WEBUI_BASE_URL } from '$lib/constants'; |
|
|
| import CodeBlock from '$lib/components/chat/Messages/CodeBlock.svelte'; |
| import MarkdownInlineTokens from '$lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte'; |
| import KatexRenderer from './KatexRenderer.svelte'; |
| import Collapsible from '$lib/components/common/Collapsible.svelte'; |
| import Tooltip from '$lib/components/common/Tooltip.svelte'; |
| import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte'; |
|
|
| const dispatch = createEventDispatcher(); |
|
|
| export let id: string; |
| export let tokens: Token[]; |
| export let top = true; |
| export let attributes = {}; |
|
|
| export let save = false; |
| export let onSourceClick: Function = () => {}; |
|
|
| const headerComponent = (depth: number) => { |
| return 'h' + depth; |
| }; |
|
|
| const exportTableToCSVHandler = (token, tokenIdx = 0) => { |
| console.log('Exporting table to CSV'); |
|
|
| |
| const header = token.header.map((headerCell) => `"${headerCell.text.replace(/"/g, '""')}"`); |
| |
| // Create an array for rows that will hold the mapped cell text. |
| const rows = token.rows.map((row) => |
| row.map((cell) => { |
| // Map tokens into a single text |
| const cellContent = cell.tokens.map((token) => token.text).join(''); |
| // Escape double quotes and wrap the content in double quotes |
| return `"${cellContent.replace(/"/g, '""')}"`; |
| }) |
| ); |
|
|
| |
| const csvData = [header, ...rows]; |
|
|
| |
| const csvContent = csvData.map((row) => row.join(',')).join('\n'); |
|
|
| |
| console.log(csvData); |
| console.log(csvContent); |
|
|
| |
| const bom = '\uFEFF'; |
|
|
| |
| const blob = new Blob([bom + csvContent], { type: 'text/csv;charset=UTF-8' }); |
|
|
| |
| saveAs(blob, `table-${id}-${tokenIdx}.csv`); |
| }; |
| </script> |
|
|
| <!-- {JSON.stringify(tokens)} --> |
| {#each tokens as token, tokenIdx (tokenIdx)} |
| {#if token.type === 'hr'} |
| <hr class=" border-gray-50 dark:border-gray-850" /> |
| {:else if token.type === 'heading'} |
| <svelte:element this={headerComponent(token.depth)}> |
| <MarkdownInlineTokens id={`${id}-${tokenIdx}-h`} tokens={token.tokens} {onSourceClick} /> |
| </svelte:element> |
| {:else if token.type === 'code'} |
| {#if token.raw.includes('```')} |
| <CodeBlock |
| id={`${id}-${tokenIdx}`} |
| {token} |
| lang={token?.lang ?? ''} |
| code={token?.text ?? ''} |
| {attributes} |
| {save} |
| on:code={(e) => { |
| dispatch('code', e.detail); |
| }} |
| on:save={(e) => { |
| dispatch('update', { |
| raw: token.raw, |
| oldContent: token.text, |
| newContent: e.detail |
| }); |
| }} |
| /> |
| {:else} |
| {token.text} |
| {/if} |
| {:else if token.type === 'table'} |
| <div class="relative w-full group"> |
| <div class="scrollbar-hidden relative overflow-x-auto max-w-full rounded-lg"> |
| <table |
| class=" w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full rounded-xl" |
| > |
| <thead |
| class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 border-none" |
| > |
| <tr class=""> |
| {#each token.header as header, headerIdx} |
| <th |
| scope="col" |
| class="!px-3 !py-1.5 cursor-pointer border border-gray-50 dark:border-gray-850" |
| style={token.align[headerIdx] ? '' : `text-align: ${token.align[headerIdx]}`} |
| > |
| <div class="flex flex-col gap-1.5 text-left"> |
| <div class="flex-shrink-0 break-normal"> |
| <MarkdownInlineTokens |
| id={`${id}-${tokenIdx}-header-${headerIdx}`} |
| tokens={header.tokens} |
| {onSourceClick} |
| /> |
| </div> |
| </div> |
| </th> |
| {/each} |
| </tr> |
| </thead> |
| <tbody> |
| {#each token.rows as row, rowIdx} |
| <tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs"> |
| {#each row ?? [] as cell, cellIdx} |
| <td |
| class="!px-3 !py-1.5 text-gray-900 dark:text-white w-max border border-gray-50 dark:border-gray-850" |
| style={token.align[cellIdx] ? '' : `text-align: ${token.align[cellIdx]}`} |
| > |
| <div class="flex flex-col break-normal"> |
| <MarkdownInlineTokens |
| id={`${id}-${tokenIdx}-row-${rowIdx}-${cellIdx}`} |
| tokens={cell.tokens} |
| {onSourceClick} |
| /> |
| </div> |
| </td> |
| {/each} |
| </tr> |
| {/each} |
| </tbody> |
| </table> |
| </div> |
|
|
| <div class=" absolute top-1 right-1.5 z-20 invisible group-hover:visible"> |
| <Tooltip content={$i18n.t('Export to CSV')}> |
| <button |
| class="p-1 rounded-lg bg-transparent transition" |
| on:click={(e) => { |
| e.stopPropagation(); |
| exportTableToCSVHandler(token, tokenIdx); |
| }} |
| > |
| <ArrowDownTray className=" size-3.5" strokeWidth="1.5" /> |
| </button> |
| </Tooltip> |
| </div> |
| </div> |
| {:else if token.type === 'blockquote'} |
| <blockquote> |
| <svelte:self id={`${id}-${tokenIdx}`} tokens={token.tokens} /> |
| </blockquote> |
| {:else if token.type === 'list'} |
| {#if token.ordered} |
| <ol start={token.start || 1}> |
| {#each token.items as item, itemIdx} |
| <li> |
| <svelte:self |
| id={`${id}-${tokenIdx}-${itemIdx}`} |
| tokens={item.tokens} |
| top={token.loose} |
| /> |
| </li> |
| {/each} |
| </ol> |
| {:else} |
| <ul> |
| {#each token.items as item, itemIdx} |
| <li> |
| <svelte:self |
| id={`${id}-${tokenIdx}-${itemIdx}`} |
| tokens={item.tokens} |
| top={token.loose} |
| /> |
| </li> |
| {/each} |
| </ul> |
| {/if} |
| {:else if token.type === 'details'} |
| <Collapsible title={token.summary} attributes={token?.attributes} className="w-full space-y-1"> |
| <div class=" mb-1.5" slot="content"> |
| <svelte:self |
| id={`${id}-${tokenIdx}-d`} |
| tokens={marked.lexer(token.text)} |
| attributes={token?.attributes} |
| /> |
| </div> |
| </Collapsible> |
| {:else if token.type === 'html'} |
| {@const html = DOMPurify.sanitize(token.text)} |
| {#if html && html.includes('<video')} |
| {@html html} |
| {:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)} |
| {@html `${token.text}`} |
| {:else} |
| {token.text} |
| {/if} |
| {:else if token.type === 'iframe'} |
| <iframe |
| src="{WEBUI_BASE_URL}/api/v1/files/{token.fileId}/content" |
| title={token.fileId} |
| width="100%" |
| frameborder="0" |
| onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';" |
| ></iframe> |
| {:else if token.type === 'paragraph'} |
| <p> |
| <MarkdownInlineTokens |
| id={`${id}-${tokenIdx}-p`} |
| tokens={token.tokens ?? []} |
| {onSourceClick} |
| /> |
| </p> |
| {:else if token.type === 'text'} |
| {#if top} |
| <p> |
| {#if token.tokens} |
| <MarkdownInlineTokens id={`${id}-${tokenIdx}-t`} tokens={token.tokens} {onSourceClick} /> |
| {:else} |
| {unescapeHtml(token.text)} |
| {/if} |
| </p> |
| {:else if token.tokens} |
| <MarkdownInlineTokens |
| id={`${id}-${tokenIdx}-p`} |
| tokens={token.tokens ?? []} |
| {onSourceClick} |
| /> |
| {:else} |
| {unescapeHtml(token.text)} |
| {/if} |
| {:else if token.type === 'inlineKatex'} |
| {#if token.text} |
| <KatexRenderer content={token.text} displayMode={token?.displayMode ?? false} /> |
| {/if} |
| {:else if token.type === 'blockKatex'} |
| {#if token.text} |
| <KatexRenderer content={token.text} displayMode={token?.displayMode ?? false} /> |
| {/if} |
| {:else if token.type === 'space'} |
| <div class="my-2" /> |
| {:else} |
| {console.log('Unknown token', token)} |
| {/if} |
| {/each} |
|
|