| | <script lang="ts"> |
| | import type { WorkBook } from 'xlsx'; |
| | import DOMPurify from 'dompurify'; |
| | |
| | import { getContext, onMount, tick } from 'svelte'; |
| | |
| | import { formatFileSize, getLineCount } from '$lib/utils'; |
| | import { WEBUI_API_BASE_URL } from '$lib/constants'; |
| | import { getKnowledgeById } from '$lib/apis/knowledge'; |
| | import { getFileById, getFileContentById } from '$lib/apis/files'; |
| | |
| | import CodeBlock from '$lib/components/chat/Messages/CodeBlock.svelte'; |
| | import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; |
| | |
| | const i18n = getContext('i18n'); |
| | |
| | import Modal from './Modal.svelte'; |
| | import XMark from '../icons/XMark.svelte'; |
| | import Switch from './Switch.svelte'; |
| | import Tooltip from './Tooltip.svelte'; |
| | import dayjs from 'dayjs'; |
| | import Spinner from './Spinner.svelte'; |
| | |
| | export let item; |
| | export let show = false; |
| | export let edit = false; |
| | |
| | let enableFullContent = false; |
| | let loading = false; |
| | |
| | let isPDF = false; |
| | let isAudio = false; |
| | let isExcel = false; |
| | |
| | let selectedTab = ''; |
| | let excelWorkbook: WorkBook | null = null; |
| | let excelSheetNames: string[] = []; |
| | let selectedSheet = ''; |
| | let excelHtml = ''; |
| | let excelError = ''; |
| | let rowCount = 0; |
| | |
| | $: isPDF = |
| | item?.meta?.content_type === 'application/pdf' || |
| | (item?.name && item?.name.toLowerCase().endsWith('.pdf')); |
| | |
| | $: isMarkdown = |
| | item?.meta?.content_type === 'text/markdown' || |
| | (item?.name && item?.name.toLowerCase().endsWith('.md')); |
| | |
| | $: isCode = |
| | item?.name && |
| | (item.name.toLowerCase().endsWith('.py') || |
| | item.name.toLowerCase().endsWith('.js') || |
| | item.name.toLowerCase().endsWith('.ts') || |
| | item.name.toLowerCase().endsWith('.java') || |
| | item.name.toLowerCase().endsWith('.html') || |
| | item.name.toLowerCase().endsWith('.css') || |
| | item.name.toLowerCase().endsWith('.json') || |
| | item.name.toLowerCase().endsWith('.cpp') || |
| | item.name.toLowerCase().endsWith('.c') || |
| | item.name.toLowerCase().endsWith('.h') || |
| | item.name.toLowerCase().endsWith('.sh') || |
| | item.name.toLowerCase().endsWith('.bash') || |
| | item.name.toLowerCase().endsWith('.yaml') || |
| | item.name.toLowerCase().endsWith('.yml') || |
| | item.name.toLowerCase().endsWith('.xml') || |
| | item.name.toLowerCase().endsWith('.sql') || |
| | item.name.toLowerCase().endsWith('.go') || |
| | item.name.toLowerCase().endsWith('.rs') || |
| | item.name.toLowerCase().endsWith('.php') || |
| | item.name.toLowerCase().endsWith('.rb')); |
| | |
| | $: isAudio = |
| | (item?.meta?.content_type ?? '').startsWith('audio/') || |
| | (item?.name && item?.name.toLowerCase().endsWith('.mp3')) || |
| | (item?.name && item?.name.toLowerCase().endsWith('.wav')) || |
| | (item?.name && item?.name.toLowerCase().endsWith('.ogg')) || |
| | (item?.name && item?.name.toLowerCase().endsWith('.m4a')) || |
| | (item?.name && item?.name.toLowerCase().endsWith('.webm')); |
| | |
| | $: isExcel = |
| | item?.meta?.content_type === 'application/vnd.ms-excel' || |
| | item?.meta?.content_type === |
| | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || |
| | item?.meta?.content_type === 'text/csv' || |
| | item?.meta?.content_type === 'application/csv' || |
| | (item?.name && |
| | (item.name.toLowerCase().endsWith('.xls') || |
| | item.name.toLowerCase().endsWith('.xlsx') || |
| | item.name.toLowerCase().endsWith('.csv'))); |
| | |
| | const loadExcelContent = async () => { |
| | try { |
| | excelError = ''; |
| | const [arrayBuffer, { read }] = await Promise.all([ |
| | getFileContentById(item.id), |
| | import('xlsx') |
| | ]); |
| | excelWorkbook = read(arrayBuffer, { type: 'array' }); |
| | excelSheetNames = excelWorkbook.SheetNames; |
| | |
| | if (excelSheetNames.length > 0) { |
| | selectedSheet = excelSheetNames[0]; |
| | await renderExcelSheet(); |
| | } |
| | } catch (error) { |
| | console.error('Error loading Excel/CSV file:', error); |
| | excelError = $i18n.t('Failed to load Excel/CSV file. Please try downloading it instead.'); |
| | } |
| | }; |
| | |
| | const renderExcelSheet = async () => { |
| | if (!excelWorkbook || !selectedSheet) return; |
| | |
| | const worksheet = excelWorkbook.Sheets[selectedSheet]; |
| | // Calculate row count |
| | const XLSX = await import('xlsx'); |
| | const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1'); |
| | rowCount = range.e.r - range.s.r + 1; |
| | |
| | excelHtml = DOMPurify.sanitize( |
| | XLSX.utils.sheet_to_html(worksheet, { |
| | id: 'excel-table', |
| | editable: false, |
| | header: '' |
| | }) |
| | ); |
| | }; |
| | |
| | $: if (selectedSheet && excelWorkbook) { |
| | renderExcelSheet(); |
| | } |
| | |
| | const loadContent = async () => { |
| | selectedTab = ''; |
| | if (item?.type === 'collection') { |
| | loading = true; |
| | |
| | const knowledge = await getKnowledgeById(localStorage.token, item.id).catch((e) => { |
| | console.error('Error fetching knowledge base:', e); |
| | return null; |
| | }); |
| | |
| | if (knowledge) { |
| | item.files = knowledge.files || []; |
| | } |
| | loading = false; |
| | } else if (item?.type === 'file') { |
| | loading = true; |
| | |
| | const file = await getFileById(localStorage.token, item.id).catch((e) => { |
| | console.error('Error fetching file:', e); |
| | return null; |
| | }); |
| | |
| | if (file) { |
| | item.file = file || {}; |
| | } |
| | |
| | |
| | if (isExcel) { |
| | await loadExcelContent(); |
| | } |
| | |
| | loading = false; |
| | } |
| | |
| | await tick(); |
| | }; |
| | |
| | $: if (show) { |
| | loadContent(); |
| | } |
| | |
| | onMount(() => { |
| | console.log(item); |
| | if (item?.context === 'full') { |
| | enableFullContent = true; |
| | } |
| | }); |
| | </script> |
| | |
| | <Modal bind:show size="lg"> |
| | <div class="font-primary px-4.5 py-3.5 w-full flex flex-col justify-center dark:text-gray-400"> |
| | <div class=" pb-2"> |
| | <div class="flex items-start justify-between"> |
| | <div> |
| | <div class=" font-medium text-lg dark:text-gray-100"> |
| | <a |
| | href="#" |
| | class="hover:underline line-clamp-1" |
| | on:click|preventDefault={() => { |
| | if (!isPDF && item.url) { |
| | window.open( |
| | item.type === 'file' |
| | ? item?.url?.startsWith('http') |
| | ? item.url |
| | : `${WEBUI_API_BASE_URL}/files/${item.url}/content` |
| | : item.url, |
| | '_blank' |
| | ); |
| | } |
| | }} |
| | > |
| | {item?.name ?? 'File'} |
| | </a> |
| | </div> |
| | </div> |
| | |
| | <div> |
| | <button |
| | on:click={() => { |
| | show = false; |
| | }} |
| | > |
| | <XMark /> |
| | </button> |
| | </div> |
| | </div> |
| | |
| | <div> |
| | <div class="flex flex-col items-center md:flex-row gap-1 justify-between w-full"> |
| | <div class=" flex flex-wrap text-xs gap-1 text-gray-500"> |
| | {#if item?.type === 'collection'} |
| | {#if item?.type} |
| | <div class="capitalize shrink-0">{item.type}</div> |
| | • |
| | {/if} |
| | |
| | {#if item?.description} |
| | <div class="line-clamp-1">{item.description}</div> |
| | • |
| | {/if} |
| | |
| | {#if item?.created_at} |
| | <div class="capitalize shrink-0"> |
| | {dayjs(item.created_at * 1000).format('LL')} |
| | </div> |
| | {/if} |
| | {/if} |
| | |
| | {#if item.size} |
| | <div class="capitalize shrink-0">{formatFileSize(item.size)}</div> |
| | • |
| | {/if} |
| | |
| | {#if item?.file?.data?.content} |
| | <div class="capitalize shrink-0"> |
| | {#if isExcel && rowCount > 0 && selectedTab === 'preview'} |
| | {$i18n.t('{{COUNT}} Rows', { |
| | COUNT: rowCount |
| | })} |
| | {:else} |
| | {$i18n.t('{{COUNT}} extracted lines', { |
| | COUNT: getLineCount(item?.file?.data?.content ?? '') |
| | })} |
| | {/if} |
| | </div> |
| | |
| | <div class="flex items-center gap-1 shrink-0"> |
| | • {$i18n.t('Formatting may be inconsistent from source.')} |
| | </div> |
| | {/if} |
| | |
| | {#if item?.knowledge} |
| | <div class="capitalize shrink-0"> |
| | {$i18n.t('Knowledge Base')} |
| | </div> |
| | {/if} |
| | </div> |
| | |
| | {#if edit} |
| | <div class=" self-end"> |
| | <Tooltip |
| | content={enableFullContent |
| | ? $i18n.t( |
| | 'Inject the entire content as context for comprehensive processing, this is recommended for complex queries.' |
| | ) |
| | : $i18n.t( |
| | 'Default to segmented retrieval for focused and relevant content extraction, this is recommended for most cases.' |
| | )} |
| | > |
| | <div class="flex items-center gap-1.5 text-xs"> |
| | {#if enableFullContent} |
| | {$i18n.t('Using Entire Document')} |
| | {:else} |
| | {$i18n.t('Using Focused Retrieval')} |
| | {/if} |
| | <Switch |
| | bind:state={enableFullContent} |
| | on:change={(e) => { |
| | item.context = e.detail ? 'full' : undefined; |
| | }} |
| | /> |
| | </div> |
| | </Tooltip> |
| | </div> |
| | {/if} |
| | </div> |
| | </div> |
| | </div> |
| | |
| | <div class="max-h-[75vh] overflow-auto"> |
| | {#if !loading} |
| | {#if item?.type === 'collection'} |
| | <div> |
| | {#each item?.files as file} |
| | <div class="flex items-center gap-2 mb-2"> |
| | <div class="flex-shrink-0 text-xs"> |
| | {file?.meta?.name} |
| | </div> |
| | </div> |
| | {/each} |
| | </div> |
| | {/if} |
| | |
| | {#if isAudio || isPDF || isExcel || isCode || isMarkdown} |
| | <div |
| | class="flex mb-2.5 scrollbar-none overflow-x-auto w-full border-b border-gray-50 dark:border-gray-850/30 text-center text-sm font-medium bg-transparent dark:text-gray-200" |
| | > |
| | <button |
| | class="min-w-fit py-1.5 px-4 border-b {selectedTab === '' |
| | ? ' ' |
| | : ' border-transparent text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition" |
| | type="button" |
| | on:click={() => { |
| | selectedTab = ''; |
| | }}>{$i18n.t('Content')}</button |
| | > |
| | |
| | <button |
| | class="min-w-fit py-1.5 px-4 border-b {selectedTab === 'preview' |
| | ? ' ' |
| | : ' border-transparent text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition" |
| | type="button" |
| | on:click={() => { |
| | selectedTab = 'preview'; |
| | }}>{$i18n.t('Preview')}</button |
| | > |
| | </div> |
| | {/if} |
| | |
| | {#if selectedTab === ''} |
| | {#if item?.file?.data} |
| | <div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap"> |
| | {(item?.file?.data?.content ?? '').trim() || 'No content'} |
| | </div> |
| | {:else if item?.content} |
| | <div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap"> |
| | {(item?.content ?? '').trim() || 'No content'} |
| | </div> |
| | {/if} |
| | {:else if selectedTab === 'preview'} |
| | {#if isAudio} |
| | <audio |
| | src={`${WEBUI_API_BASE_URL}/files/${item.id}/content`} |
| | class="w-full border-0 rounded-lg mb-2" |
| | controls |
| | playsinline |
| | /> |
| | {:else if isPDF} |
| | <iframe |
| | title={item?.name} |
| | src={`${WEBUI_API_BASE_URL}/files/${item.id}/content`} |
| | class="w-full h-[70vh] border-0 rounded-lg" |
| | /> |
| | {:else if isExcel} |
| | {#if excelError} |
| | <div class="text-red-500 text-sm p-4"> |
| | {excelError} |
| | </div> |
| | {:else} |
| | {#if excelSheetNames.length > 1} |
| | <div |
| | class="flex mb-2.5 scrollbar-none overflow-x-auto w-full border-b border-gray-50 dark:border-gray-850/30 text-center text-sm font-medium bg-transparent dark:text-gray-200" |
| | > |
| | {#each excelSheetNames as sheetName} |
| | <button |
| | class="min-w-fit py-1.5 px-4 border-b {selectedSheet === sheetName |
| | ? ' ' |
| | : ' border-transparent text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition" |
| | type="button" |
| | on:click={() => { |
| | selectedSheet = sheetName; |
| | }}>{sheetName}</button |
| | > |
| | {/each} |
| | </div> |
| | {/if} |
| | |
| | {#if excelHtml} |
| | <div class="excel-table-container overflow-auto max-h-[60vh]"> |
| | {@html excelHtml} |
| | </div> |
| | {:else} |
| | <div class="text-gray-500 text-sm p-4">No content available</div> |
| | {/if} |
| | {/if} |
| | {:else if isCode} |
| | <div class="max-h-[60vh] overflow-scroll scrollbar-hidden text-sm relative"> |
| | <CodeBlock |
| | code={item.file.data.content} |
| | lang={item.name.split('.').pop()} |
| | token={null} |
| | edit={false} |
| | run={false} |
| | save={false} |
| | /> |
| | </div> |
| | {:else if isMarkdown} |
| | <div |
| | class="max-h-[60vh] overflow-scroll scrollbar-hidden text-sm prose dark:prose-invert max-w-full" |
| | > |
| | <Markdown content={item.file.data.content} id="markdown-viewer" /> |
| | </div> |
| | {:else} |
| | <div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap"> |
| | {(item?.file?.data?.content ?? '').trim() || 'No content'} |
| | </div> |
| | {/if} |
| | {/if} |
| | {:else} |
| | <div class="flex items-center justify-center py-6"> |
| | <Spinner className="size-5" /> |
| | </div> |
| | {/if} |
| | </div> |
| | </div> |
| | </Modal> |
| | |
| | <style> |
| | :global(.excel-table-container table) { |
| | width: 100%; |
| | border-collapse: collapse; |
| | font-size: 0.875rem; |
| | line-height: 1.25rem; |
| | } |
| | |
| | :global(.excel-table-container table td), |
| | :global(.excel-table-container table th) { |
| | border-width: 1px; |
| | border-style: solid; |
| | border-color: var(--color-gray-300, #cdcdcd); |
| | padding: 0.5rem 0.75rem; |
| | text-align: left; |
| | } |
| | |
| | :global(.dark .excel-table-container table td), |
| | :global(.dark .excel-table-container table th) { |
| | border-color: var(--color-gray-600, #676767); |
| | } |
| | |
| | :global(.excel-table-container table th) { |
| | background-color: var(--color-gray-100, #ececec); |
| | font-weight: 600; |
| | } |
| | |
| | :global(.dark .excel-table-container table th) { |
| | background-color: var(--color-gray-800, #333); |
| | color: var(--color-gray-100, #ececec); |
| | } |
| | |
| | :global(.excel-table-container table tr:nth-child(even)) { |
| | background-color: var(--color-gray-50, #f9f9f9); |
| | } |
| | |
| | :global(.dark .excel-table-container table tr:nth-child(even)) { |
| | background-color: rgba(38, 38, 38, 0.5); |
| | } |
| | |
| | :global(.excel-table-container table tr:hover) { |
| | background-color: var(--color-gray-100, #ececec); |
| | } |
| | |
| | :global(.dark .excel-table-container table tr:hover) { |
| | background-color: rgba(51, 51, 51, 0.5); |
| | } |
| | </style> |
| | |