ai / frontend /src /lib /components /common /FileItemModal.svelte
fdrah
initial-commit
9e93b10
Raw
History Blame Contribute Delete
22.4 kB
<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 { REXPRO_API_BASE_URL } from "$lib/constants";
import { settings } from "$lib/stores";
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");
const CONTENT_PREVIEW_LIMIT = 10000;
let expandedContent = false;
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";
import PDFViewer from "./PDFViewer.svelte";
import PanzoomContainer from "./PanzoomContainer.svelte";
import Reset from "../icons/Reset.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 isImage = false;
let isExcel = false;
let isDocx = false;
let isPptx = false;
let selectedTab = "";
let excelWorkbook: WorkBook | null = null;
let excelSheetNames: string[] = [];
let selectedSheet = "";
let excelHtml = "";
let excelError = "";
let rowCount = 0;
// DOCX state
let docxHtml = "";
let docxError = "";
// PPTX state
let pptxSlides: string[] = [];
let pptxCurrentSlide = 0;
let pptxError = "";
let panzoomRef: PanzoomContainer;
const resetImageView = () => {
panzoomRef?.reset();
};
$: 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"));
$: isImage =
(item?.meta?.content_type ?? "").startsWith("image/") ||
(item?.name &&
(item.name.toLowerCase().endsWith(".png") ||
item.name.toLowerCase().endsWith(".jpg") ||
item.name.toLowerCase().endsWith(".jpeg") ||
item.name.toLowerCase().endsWith(".gif") ||
item.name.toLowerCase().endsWith(".webp") ||
item.name.toLowerCase().endsWith(".svg") ||
item.name.toLowerCase().endsWith(".bmp") ||
item.name.toLowerCase().endsWith(".ico")));
$: 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")));
$: isDocx =
item?.meta?.content_type ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
(item?.name && item.name.toLowerCase().endsWith(".docx"));
$: isPptx =
item?.meta?.content_type ===
"application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
(item?.name && item.name.toLowerCase().endsWith(".pptx"));
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 { excelToTable } = await import("$lib/utils/excelToTable");
const worksheet = excelWorkbook.Sheets[selectedSheet];
const result = await excelToTable(worksheet);
excelHtml = DOMPurify.sanitize(result.html);
rowCount = result.rowCount;
};
$: if (selectedSheet && excelWorkbook) {
renderExcelSheet();
}
const loadDocxContent = async () => {
try {
docxError = "";
const [arrayBuffer, mammoth] = await Promise.all([
getFileContentById(item.id),
import("mammoth"),
]);
const result = await mammoth.convertToHtml({ arrayBuffer });
docxHtml = DOMPurify.sanitize(result.value);
} catch (error) {
console.error("Error loading DOCX file:", error);
docxError = $i18n.t(
"Failed to load DOCX file. Please try downloading it instead.",
);
}
};
const loadPptxContent = async () => {
try {
pptxError = "";
const [arrayBuffer, { pptxToImages }] = await Promise.all([
getFileContentById(item.id),
import("$lib/utils/pptxToHtml"),
]);
const result = await pptxToImages(arrayBuffer);
pptxSlides = result.images;
pptxCurrentSlide = 0;
} catch (error) {
console.error("Error loading PPTX file:", error);
pptxError = $i18n.t(
"Failed to load PPTX file. Please try downloading it instead.",
);
}
};
const loadContent = async () => {
selectedTab = "";
expandedContent = false;
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 || {};
}
// Load Excel content if it's an Excel file
if (isExcel) {
await loadExcelContent();
}
if (isDocx) {
await loadDocxContent();
}
if (isPptx) {
await loadPptxContent();
}
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 (item.type === "file" || item.url) {
let fileId = item?.id ?? item?.tempId;
window.open(
item.type === "file"
? item?.url?.startsWith("http")
? item.url
: `${REXPRO_API_BASE_URL}/files/${fileId}/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 || isDocx || isPptx}
<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 isImage}
<div class="relative w-full max-h-[70vh] overflow-hidden">
<div class="absolute top-2 right-2 z-10">
<Tooltip content={$i18n.t("Reset view")}>
<button
class="p-1.5 rounded-lg bg-white/80 dark:bg-gray-850/80 backdrop-blur-sm shadow-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={resetImageView}
>
<Reset className="size-4" />
</button>
</Tooltip>
</div>
<PanzoomContainer bind:this={panzoomRef}>
<img
src={`${REXPRO_API_BASE_URL}/files/${item.id}/content`}
alt={item?.name ?? "Image"}
class="w-full object-contain rounded-lg"
loading="lazy"
draggable="false"
/>
</PanzoomContainer>
</div>
{:else if selectedTab === ""}
{#if item?.file?.data}
{@const rawContent =
(item?.file?.data?.content ?? "").trim() ||
"No content"}
{@const isTruncated =
($settings?.renderMarkdownInPreviews ?? true) &&
rawContent.length > CONTENT_PREVIEW_LIMIT &&
!expandedContent}
{#if $settings?.renderMarkdownInPreviews ?? true}
<div
class="max-h-96 overflow-scroll scrollbar-hidden text-sm prose dark:prose-invert max-w-full"
>
<Markdown
content={isTruncated
? rawContent.slice(
0,
CONTENT_PREVIEW_LIMIT,
)
: rawContent}
id="file-preview"
/>
</div>
{#if isTruncated}
<button
class="mt-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={() => {
expandedContent = true;
}}
>
{$i18n.t(
"Show all ({{COUNT}} characters)",
{
COUNT: rawContent.length.toLocaleString(),
},
)}
</button>
{/if}
{:else}
<div
class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap"
>
{rawContent}
</div>
{/if}
{:else if item?.content}
{@const rawContent =
(item?.content ?? "").trim() || "No content"}
{@const isTruncated =
($settings?.renderMarkdownInPreviews ?? true) &&
rawContent.length > CONTENT_PREVIEW_LIMIT &&
!expandedContent}
{#if $settings?.renderMarkdownInPreviews ?? true}
<div
class="max-h-96 overflow-scroll scrollbar-hidden text-sm prose dark:prose-invert max-w-full"
>
<Markdown
content={isTruncated
? rawContent.slice(
0,
CONTENT_PREVIEW_LIMIT,
)
: rawContent}
id="file-preview-content"
/>
</div>
{#if isTruncated}
<button
class="mt-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={() => {
expandedContent = true;
}}
>
{$i18n.t(
"Show all ({{COUNT}} characters)",
{
COUNT: rawContent.length.toLocaleString(),
},
)}
</button>
{/if}
{:else}
<div
class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap"
>
{rawContent}
</div>
{/if}
{/if}
{:else if selectedTab === "preview"}
{#if isAudio}
<audio
src={`${REXPRO_API_BASE_URL}/files/${item.id}/content`}
class="w-full border-0 rounded-lg mb-2"
controls
playsinline
/>
{:else if isPDF}
<PDFViewer
url={`${REXPRO_API_BASE_URL}/files/${item.id}/content`}
className="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="office-preview 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 if isDocx}
{#if docxError}
<div class="text-red-500 text-sm p-4">
{docxError}
</div>
{:else if docxHtml}
<div
class="office-preview max-h-[60vh] overflow-auto p-4 prose dark:prose-invert max-w-full text-sm"
>
{@html docxHtml}
</div>
{:else}
<div class="text-gray-500 text-sm p-4">
No content available
</div>
{/if}
{:else if isPptx}
{#if pptxError}
<div class="text-red-500 text-sm p-4">
{pptxError}
</div>
{:else if pptxSlides.length > 0}
<div class="max-h-[60vh] overflow-auto">
<div class="flex justify-center p-4">
<img
src={pptxSlides[pptxCurrentSlide]}
alt="Slide {pptxCurrentSlide + 1}"
class="max-w-full max-h-[50vh] object-contain rounded-md shadow-lg"
draggable="false"
/>
</div>
{#if pptxSlides.length > 1}
<div
class="flex items-center justify-center gap-3 pb-3 text-sm text-gray-500"
>
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={pptxCurrentSlide === 0}
on:click={() =>
(pptxCurrentSlide = Math.max(
0,
pptxCurrentSlide - 1,
))}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
<span
>{pptxCurrentSlide + 1} / {pptxSlides.length}</span
>
<button
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={pptxCurrentSlide ===
pptxSlides.length - 1}
on:click={() =>
(pptxCurrentSlide = Math.min(
pptxSlides.length - 1,
pptxCurrentSlide + 1,
))}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
</div>
{:else}
<div class="text-gray-500 text-sm p-4">
No content available
</div>
{/if}
{: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>