mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-19 22:52:43 +00:00
Compare commits
1 Commits
v3.0.2
...
jamison/wo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8183193583 |
@@ -19,12 +19,16 @@ class OnyxMimeTypes:
|
||||
PLAIN_TEXT_MIME_TYPE,
|
||||
"text/markdown",
|
||||
"text/x-markdown",
|
||||
"text/x-log",
|
||||
"text/x-config",
|
||||
"text/tab-separated-values",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"text/xml",
|
||||
"application/x-yaml",
|
||||
"application/yaml",
|
||||
"text/yaml",
|
||||
"text/x-yaml",
|
||||
}
|
||||
DOCUMENT_MIME_TYPES = {
|
||||
PDF_MIME_TYPE,
|
||||
|
||||
@@ -8,7 +8,6 @@ interface CodeBlockProps {
|
||||
children?: ReactNode;
|
||||
codeText: string;
|
||||
showHeader?: boolean;
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
const MemoizedCodeLine = memo(({ content }: { content: ReactNode }) => (
|
||||
@@ -20,7 +19,6 @@ export const CodeBlock = memo(function CodeBlock({
|
||||
children,
|
||||
codeText,
|
||||
showHeader = true,
|
||||
noPadding = false,
|
||||
}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -117,12 +115,7 @@ export const CodeBlock = memo(function CodeBlock({
|
||||
return (
|
||||
<>
|
||||
{showHeader ? (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background-tint-00 rounded-12 max-w-full min-w-0",
|
||||
!noPadding && "px-1 pb-1"
|
||||
)}
|
||||
>
|
||||
<div className="bg-background-tint-00 px-1 pb-1 rounded-12 max-w-full min-w-0">
|
||||
{language && (
|
||||
<div className="flex items-center px-2 py-1 text-sm text-text-04 gap-x-2">
|
||||
<SvgCode
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ChatFileType, FileDescriptor } from "@/app/app/interfaces";
|
||||
import Attachment from "@/refresh-components/Attachment";
|
||||
import { InMessageImage } from "@/app/app/components/files/images/InMessageImage";
|
||||
import CsvContent from "@/components/tools/CSVContent";
|
||||
import TextViewModal from "@/sections/modals/TextViewModal";
|
||||
import PreviewModal from "@/sections/modals/PreviewModal";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import ExpandableContentWrapper from "@/components/tools/ExpandableContentWrapper";
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function FileDisplay({ files }: FileDisplayProps) {
|
||||
return (
|
||||
<>
|
||||
{previewingFile && (
|
||||
<TextViewModal
|
||||
<PreviewModal
|
||||
presentingDocument={presentingDocument}
|
||||
onClose={() => setPreviewingFile(null)}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Callout } from "@/components/ui/callout";
|
||||
import OnyxInitializingLoader from "@/components/OnyxInitializingLoader";
|
||||
import { Persona } from "@/app/admin/agents/interfaces";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import TextViewModal from "@/sections/modals/TextViewModal";
|
||||
import PreviewModal from "@/sections/modals/PreviewModal";
|
||||
import { UNNAMED_CHAT } from "@/lib/constants";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import useOnMount from "@/hooks/useOnMount";
|
||||
@@ -64,7 +64,7 @@ export default function SharedChatDisplay({
|
||||
return (
|
||||
<>
|
||||
{presentingDocument && (
|
||||
<TextViewModal
|
||||
<PreviewModal
|
||||
presentingDocument={presentingDocument}
|
||||
onClose={() => setPresentingDocument(null)}
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,7 @@ import { SvgUser, SvgMenu, SvgAlertTriangle } from "@opal/icons";
|
||||
import { useAppBackground } from "@/providers/AppBackgroundProvider";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import DocumentsSidebar from "@/sections/document-sidebar/DocumentsSidebar";
|
||||
import TextViewModal from "@/sections/modals/TextViewModal";
|
||||
import PreviewModal from "@/sections/modals/PreviewModal";
|
||||
import { personaIncludesRetrieval } from "@/app/app/services/lib";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import { eeGated } from "@/ce";
|
||||
@@ -537,7 +537,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
|
||||
{/* Text/document preview modal */}
|
||||
{presentingDocument && (
|
||||
<TextViewModal
|
||||
<PreviewModal
|
||||
presentingDocument={presentingDocument}
|
||||
onClose={() => setPresentingDocument(null)}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
MemoizedLink,
|
||||
MemoizedParagraph,
|
||||
} from "@/app/app/message/MemoizedTextComponents";
|
||||
import React, { useMemo, CSSProperties } from "react";
|
||||
import { useMemo, CSSProperties } from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
@@ -19,6 +19,7 @@ interface MinimalMarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
showHeader?: boolean;
|
||||
/**
|
||||
* Override specific markdown renderers.
|
||||
* Any renderer not provided will fall back to this component's defaults.
|
||||
@@ -30,6 +31,7 @@ export default function MinimalMarkdown({
|
||||
content,
|
||||
className = "",
|
||||
style,
|
||||
showHeader = true,
|
||||
components,
|
||||
}: MinimalMarkdownProps) {
|
||||
const markdownComponents = useMemo(() => {
|
||||
@@ -43,7 +45,11 @@ export default function MinimalMarkdown({
|
||||
code: ({ node, inline, className, children, ...props }: any) => {
|
||||
const codeText = extractCodeText(node, content, children);
|
||||
return (
|
||||
<CodeBlock className={className} codeText={codeText}>
|
||||
<CodeBlock
|
||||
className={className}
|
||||
codeText={codeText}
|
||||
showHeader={showHeader}
|
||||
>
|
||||
{children}
|
||||
</CodeBlock>
|
||||
);
|
||||
@@ -54,7 +60,7 @@ export default function MinimalMarkdown({
|
||||
...defaults,
|
||||
...(components ?? {}),
|
||||
} satisfies Components;
|
||||
}, [content, components]);
|
||||
}, [content, components, showHeader]);
|
||||
|
||||
return (
|
||||
<div style={style || {}} className={`${className}`}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { FileDescriptor } from "@/app/app/interfaces";
|
||||
import { cn } from "@/lib/utils";
|
||||
import TextViewModal from "@/sections/modals/TextViewModal";
|
||||
import PreviewModal from "@/sections/modals/PreviewModal";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
|
||||
export interface ExpandableContentWrapperProps {
|
||||
@@ -95,7 +95,7 @@ export default function ExpandableContentWrapper({
|
||||
return (
|
||||
<>
|
||||
{expanded && (
|
||||
<TextViewModal
|
||||
<PreviewModal
|
||||
presentingDocument={presentingDocument}
|
||||
onClose={() => setExpanded(false)}
|
||||
/>
|
||||
|
||||
@@ -10,18 +10,12 @@ import { Section } from "@/layouts/general-layouts";
|
||||
import { getCodeLanguage, getDataLanguage } from "@/lib/languages";
|
||||
import { fetchChatFile } from "@/lib/chat/svc";
|
||||
import { PreviewContext } from "@/sections/modals/PreviewModal/interfaces";
|
||||
import {
|
||||
getMimeLanguage,
|
||||
resolveMimeType,
|
||||
} from "@/sections/modals/PreviewModal/mimeUtils";
|
||||
import { resolveVariant } from "@/sections/modals/PreviewModal/variants";
|
||||
|
||||
function resolveMimeType(mimeType: string, fileName: string): string {
|
||||
if (mimeType !== "application/octet-stream") return mimeType;
|
||||
const lower = fileName.toLowerCase();
|
||||
if (lower.endsWith(".md") || lower.endsWith(".markdown"))
|
||||
return "text/markdown";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".csv")) return "text/csv";
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
interface PreviewModalProps {
|
||||
presentingDocument: MinimalOnyxDocument;
|
||||
onClose: () => void;
|
||||
@@ -47,9 +41,10 @@ export default function PreviewModal({
|
||||
const language = useMemo(
|
||||
() =>
|
||||
getCodeLanguage(presentingDocument.semantic_identifier || "") ||
|
||||
getMimeLanguage(mimeType) ||
|
||||
getDataLanguage(presentingDocument.semantic_identifier || "") ||
|
||||
"plaintext",
|
||||
[presentingDocument.semantic_identifier]
|
||||
[mimeType, presentingDocument.semantic_identifier]
|
||||
);
|
||||
|
||||
const lineCount = useMemo(() => {
|
||||
|
||||
50
web/src/sections/modals/PreviewModal/mimeUtils.ts
Normal file
50
web/src/sections/modals/PreviewModal/mimeUtils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
const MIME_LANGUAGE_PREFIXES: Array<[prefix: string, language: string]> = [
|
||||
["application/json", "json"],
|
||||
["application/xml", "xml"],
|
||||
["text/xml", "xml"],
|
||||
["application/x-yaml", "yaml"],
|
||||
["application/yaml", "yaml"],
|
||||
["text/yaml", "yaml"],
|
||||
["text/x-yaml", "yaml"],
|
||||
];
|
||||
|
||||
const OCTET_STREAM_EXTENSION_TO_MIME: Record<string, string> = {
|
||||
".md": "text/markdown",
|
||||
".markdown": "text/markdown",
|
||||
".txt": "text/plain",
|
||||
".log": "text/plain",
|
||||
".conf": "text/plain",
|
||||
".sql": "text/plain",
|
||||
".csv": "text/csv",
|
||||
".tsv": "text/tab-separated-values",
|
||||
".json": "application/json",
|
||||
".xml": "application/xml",
|
||||
".yml": "application/x-yaml",
|
||||
".yaml": "application/x-yaml",
|
||||
};
|
||||
|
||||
export function getMimeLanguage(mimeType: string): string | null {
|
||||
return (
|
||||
MIME_LANGUAGE_PREFIXES.find(([prefix]) =>
|
||||
mimeType.startsWith(prefix)
|
||||
)?.[1] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMimeType(mimeType: string, fileName: string): string {
|
||||
if (mimeType !== "application/octet-stream") {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
const lowerFileName = fileName.toLowerCase();
|
||||
|
||||
for (const [extension, resolvedMime] of Object.entries(
|
||||
OCTET_STREAM_EXTENSION_TO_MIME
|
||||
)) {
|
||||
if (lowerFileName.endsWith(extension)) {
|
||||
return resolvedMime;
|
||||
}
|
||||
}
|
||||
|
||||
return mimeType;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import "@/app/app/message/custom-code-styles.css";
|
||||
|
||||
interface CodePreviewProps {
|
||||
content: string;
|
||||
language?: string | null;
|
||||
}
|
||||
|
||||
export function CodePreview({ content, language }: CodePreviewProps) {
|
||||
const normalizedContent = content.replace(/~~~/g, "\\~\\~\\~");
|
||||
const fenceHeader = language ? `~~~${language}` : "~~~";
|
||||
|
||||
return (
|
||||
<MinimalMarkdown
|
||||
content={`${fenceHeader}\n${normalizedContent}\n\n~~~`}
|
||||
className="w-full h-full"
|
||||
showHeader={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { getCodeLanguage } from "@/lib/languages";
|
||||
import { CodeBlock } from "@/app/app/message/CodeBlock";
|
||||
import { extractCodeText } from "@/app/app/message/codeUtils";
|
||||
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
|
||||
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
|
||||
import {
|
||||
CopyButton,
|
||||
DownloadButton,
|
||||
@@ -24,20 +22,7 @@ export const codeVariant: PreviewVariant = {
|
||||
: "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<MinimalMarkdown
|
||||
content={`\`\`\`${ctx.language}\n${ctx.fileContent}\n\n\`\`\``}
|
||||
className="w-full break-words h-full"
|
||||
components={{
|
||||
code: ({ node, children }: any) => {
|
||||
const codeText = extractCodeText(node, ctx.fileContent, children);
|
||||
return (
|
||||
<CodeBlock className="" codeText={codeText}>
|
||||
{children}
|
||||
</CodeBlock>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<CodePreview content={ctx.fileContent} language={ctx.language} />
|
||||
),
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { getDataLanguage } from "@/lib/languages";
|
||||
import { CodeBlock } from "@/app/app/message/CodeBlock";
|
||||
import { extractCodeText } from "@/app/app/message/codeUtils";
|
||||
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
|
||||
import { getMimeLanguage } from "@/sections/modals/PreviewModal/mimeUtils";
|
||||
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
|
||||
import {
|
||||
CopyButton,
|
||||
DownloadButton,
|
||||
@@ -22,7 +21,8 @@ function formatContent(language: string, content: string): string {
|
||||
}
|
||||
|
||||
export const dataVariant: PreviewVariant = {
|
||||
matches: (name) => !!getDataLanguage(name || ""),
|
||||
matches: (name, mime) =>
|
||||
!!getDataLanguage(name || "") || !!getMimeLanguage(mime),
|
||||
width: "md",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
@@ -36,22 +36,7 @@ export const dataVariant: PreviewVariant = {
|
||||
|
||||
renderContent: (ctx) => {
|
||||
const formatted = formatContent(ctx.language, ctx.fileContent);
|
||||
return (
|
||||
<MinimalMarkdown
|
||||
content={`\`\`\`${ctx.language}\n${formatted}\n\n\`\`\``}
|
||||
className="w-full break-words h-full"
|
||||
components={{
|
||||
code: ({ node, children }: any) => {
|
||||
const codeText = extractCodeText(node, formatted, children);
|
||||
return (
|
||||
<CodeBlock className="" codeText={codeText}>
|
||||
{children}
|
||||
</CodeBlock>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <CodePreview content={formatted} language={ctx.language} />;
|
||||
},
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { pdfVariant } from "@/sections/modals/PreviewModal/variants/pdfVariant";
|
||||
import { csvVariant } from "@/sections/modals/PreviewModal/variants/csvVariant";
|
||||
import { markdownVariant } from "@/sections/modals/PreviewModal/variants/markdownVariant";
|
||||
import { dataVariant } from "@/sections/modals/PreviewModal/variants/dataVariant";
|
||||
import { textVariant } from "@/sections/modals/PreviewModal/variants/textVariant";
|
||||
import { unsupportedVariant } from "@/sections/modals/PreviewModal/variants/unsupportedVariant";
|
||||
import { docxVariant } from "@/sections/modals/PreviewModal/variants/docxVariant";
|
||||
|
||||
@@ -15,6 +16,7 @@ const PREVIEW_VARIANTS: PreviewVariant[] = [
|
||||
pdfVariant,
|
||||
csvVariant,
|
||||
dataVariant,
|
||||
textVariant,
|
||||
markdownVariant,
|
||||
docxVariant,
|
||||
];
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
const MARKDOWN_MIMES = [
|
||||
"text/markdown",
|
||||
"text/x-markdown",
|
||||
"text/plain",
|
||||
"text/x-rst",
|
||||
"text/x-org",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
|
||||
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
|
||||
import {
|
||||
CopyButton,
|
||||
DownloadButton,
|
||||
} from "@/sections/modals/PreviewModal/variants/shared";
|
||||
|
||||
const TEXT_MIMES = [
|
||||
"text/plain",
|
||||
"text/x-log",
|
||||
"text/x-config",
|
||||
"text/tab-separated-values",
|
||||
];
|
||||
|
||||
const TEXT_EXTENSIONS = [".txt", ".log", ".conf", ".tsv"];
|
||||
|
||||
export const textVariant: PreviewVariant = {
|
||||
matches: (name, mime) => {
|
||||
if (TEXT_MIMES.some((supportedMime) => mime.startsWith(supportedMime))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lowerName = (name || "").toLowerCase();
|
||||
return TEXT_EXTENSIONS.some((extension) => lowerName.endsWith(extension));
|
||||
},
|
||||
width: "md",
|
||||
height: "lg",
|
||||
needsTextContent: true,
|
||||
headerDescription: (ctx) =>
|
||||
ctx.fileContent
|
||||
? `${ctx.lineCount} ${ctx.lineCount === 1 ? "line" : "lines"} · ${
|
||||
ctx.fileSize
|
||||
}`
|
||||
: "",
|
||||
|
||||
renderContent: (ctx) => (
|
||||
<CodePreview content={ctx.fileContent} language={ctx.language} />
|
||||
),
|
||||
|
||||
renderFooterLeft: (ctx) => (
|
||||
<Text text03 mainUiBody className="select-none">
|
||||
{ctx.lineCount} {ctx.lineCount === 1 ? "line" : "lines"}
|
||||
</Text>
|
||||
),
|
||||
|
||||
renderFooterRight: (ctx) => (
|
||||
<Section flexDirection="row" width="fit">
|
||||
<CopyButton getText={() => ctx.fileContent} />
|
||||
<DownloadButton fileUrl={ctx.fileUrl} fileName={ctx.fileName} />
|
||||
</Section>
|
||||
),
|
||||
};
|
||||
@@ -1,333 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import { Button } from "@opal/components";
|
||||
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import {
|
||||
SvgDownloadCloud,
|
||||
SvgFileText,
|
||||
SvgZoomIn,
|
||||
SvgZoomOut,
|
||||
} from "@opal/icons";
|
||||
import PreviewImage from "@/refresh-components/PreviewImage";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
|
||||
export interface TextViewProps {
|
||||
presentingDocument: MinimalOnyxDocument;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TextViewModal({
|
||||
presentingDocument,
|
||||
onClose,
|
||||
}: TextViewProps) {
|
||||
const [zoom, setZoom] = useState(100);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [fileUrl, setFileUrl] = useState("");
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [fileType, setFileType] = useState("application/octet-stream");
|
||||
const csvData = useMemo(() => {
|
||||
if (!fileType.startsWith("text/csv")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = fileContent.split(/\r?\n/).filter((l) => l.length > 0);
|
||||
const headers = lines.length > 0 ? lines[0]?.split(",") ?? [] : [];
|
||||
const rows = lines.slice(1).map((line) => line.split(","));
|
||||
|
||||
return { headers, rows } as { headers: string[]; rows: string[][] };
|
||||
}, [fileContent, fileType]);
|
||||
|
||||
// Detect if a given MIME type is one of the recognized markdown formats
|
||||
const isMarkdownFormat = (mimeType: string): boolean => {
|
||||
const markdownFormats = [
|
||||
"text/markdown",
|
||||
"text/x-markdown",
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"text/x-rst",
|
||||
"text/x-org",
|
||||
"txt",
|
||||
];
|
||||
return markdownFormats.some((format) => mimeType.startsWith(format));
|
||||
};
|
||||
|
||||
const isImageFormat = (mimeType: string) => {
|
||||
const imageFormats = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/svg+xml",
|
||||
];
|
||||
return imageFormats.some((format) => mimeType.startsWith(format));
|
||||
};
|
||||
// Detect if a given MIME type can be rendered in an <iframe>
|
||||
const isSupportedIframeFormat = (mimeType: string): boolean => {
|
||||
const supportedFormats = [
|
||||
"application/pdf",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/svg+xml",
|
||||
];
|
||||
return supportedFormats.some((format) => mimeType.startsWith(format));
|
||||
};
|
||||
|
||||
const fetchFile = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
setFileContent("");
|
||||
const fileIdLocal =
|
||||
presentingDocument.document_id.split("__")[1] ||
|
||||
presentingDocument.document_id;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/chat/file/${encodeURIComponent(fileIdLocal)}`,
|
||||
{
|
||||
method: "GET",
|
||||
signal,
|
||||
cache: "force-cache",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
setLoadError("Failed to load document.");
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
setFileUrl((prev) => {
|
||||
if (prev) {
|
||||
window.URL.revokeObjectURL(prev);
|
||||
}
|
||||
return url;
|
||||
});
|
||||
|
||||
const originalFileName =
|
||||
presentingDocument.semantic_identifier || "document";
|
||||
setFileName(originalFileName);
|
||||
|
||||
let contentType =
|
||||
response.headers.get("Content-Type") || "application/octet-stream";
|
||||
|
||||
// If it's octet-stream but file name suggests a text-based extension, override accordingly
|
||||
if (contentType === "application/octet-stream") {
|
||||
const lowerName = originalFileName.toLowerCase();
|
||||
if (lowerName.endsWith(".md") || lowerName.endsWith(".markdown")) {
|
||||
contentType = "text/markdown";
|
||||
} else if (lowerName.endsWith(".txt")) {
|
||||
contentType = "text/plain";
|
||||
} else if (lowerName.endsWith(".csv")) {
|
||||
contentType = "text/csv";
|
||||
}
|
||||
}
|
||||
setFileType(contentType);
|
||||
|
||||
// If the final content type looks like markdown, read its text
|
||||
if (isMarkdownFormat(contentType)) {
|
||||
const text = await blob.text();
|
||||
setFileContent(text);
|
||||
}
|
||||
} catch (error) {
|
||||
// Abort is expected on unmount / doc change
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
setLoadError("Failed to load document.");
|
||||
} finally {
|
||||
// Prevent stale/aborted requests from clobbering the loading state.
|
||||
// This is especially important in React StrictMode where effects can run twice.
|
||||
if (!signal?.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[presentingDocument]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchFile(controller.signal);
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [fetchFile]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fileUrl) {
|
||||
window.URL.revokeObjectURL(fileUrl);
|
||||
}
|
||||
};
|
||||
}, [fileUrl]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement("a");
|
||||
link.href = fileUrl;
|
||||
link.download = fileName || presentingDocument.document_id;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleZoomIn = () => setZoom((prev) => Math.min(prev + 25, 200));
|
||||
const handleZoomOut = () => setZoom((prev) => Math.max(prev - 25, 100));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Modal.Content
|
||||
width="lg"
|
||||
height="full"
|
||||
preventAccidentalClose={false}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Modal.Header
|
||||
icon={SvgFileText}
|
||||
title={fileName || "Document"}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Section flexDirection="row" justifyContent="start" gap={0.25}>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
onClick={handleZoomOut}
|
||||
icon={SvgZoomOut}
|
||||
tooltip="Zoom Out"
|
||||
/>
|
||||
<Text mainUiBody>{zoom}%</Text>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
onClick={handleZoomIn}
|
||||
icon={SvgZoomIn}
|
||||
tooltip="Zoom In"
|
||||
/>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
onClick={handleDownload}
|
||||
icon={SvgDownloadCloud}
|
||||
tooltip="Download"
|
||||
/>
|
||||
</Section>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body>
|
||||
<Section>
|
||||
{isLoading ? (
|
||||
<SimpleLoader className="h-8 w-8" />
|
||||
) : loadError ? (
|
||||
<Text text03 mainUiBody>
|
||||
{loadError}
|
||||
</Text>
|
||||
) : (
|
||||
<div
|
||||
className="flex flex-col flex-1 min-h-0 min-w-0 w-full transform origin-center transition-transform duration-300 ease-in-out"
|
||||
style={{ transform: `scale(${zoom / 100})` }}
|
||||
>
|
||||
{isImageFormat(fileType) ? (
|
||||
<PreviewImage
|
||||
src={fileUrl}
|
||||
alt={fileName}
|
||||
className="w-full flex-1 min-h-0"
|
||||
/>
|
||||
) : isSupportedIframeFormat(fileType) ? (
|
||||
<iframe
|
||||
src={`${fileUrl}#toolbar=0`}
|
||||
className="w-full h-full flex-1 min-h-0 border-none"
|
||||
title="File Viewer"
|
||||
/>
|
||||
) : isMarkdownFormat(fileType) ? (
|
||||
<ScrollIndicatorDiv
|
||||
className="flex-1 min-h-0 p-4"
|
||||
variant="shadow"
|
||||
>
|
||||
{csvData ? (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-sticky">
|
||||
<TableRow className="bg-background-tint-02">
|
||||
{csvData.headers.map((h, i) => (
|
||||
<TableHead key={i}>
|
||||
<Text
|
||||
as="p"
|
||||
className="line-clamp-2 font-medium"
|
||||
text03
|
||||
mainUiBody
|
||||
>
|
||||
{h}
|
||||
</Text>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{csvData.rows.map((row, rIdx) => (
|
||||
<TableRow key={rIdx}>
|
||||
{csvData.headers.map((_, cIdx) => (
|
||||
<TableCell
|
||||
key={cIdx}
|
||||
className={cn(
|
||||
cIdx === 0 &&
|
||||
"sticky left-0 bg-background-tint-01",
|
||||
"py-0 px-4 whitespace-normal break-words"
|
||||
)}
|
||||
>
|
||||
{row?.[cIdx] ?? ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<MinimalMarkdown
|
||||
content={fileContent}
|
||||
className="w-full pb-4 h-full text-lg break-words"
|
||||
/>
|
||||
)}
|
||||
</ScrollIndicatorDiv>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center flex-1 min-h-0 p-6 gap-4">
|
||||
<Text as="p" text03 mainUiBody>
|
||||
This file format is not supported for preview.
|
||||
</Text>
|
||||
<Button onClick={handleDownload}>Download File</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<BasicModalFooter
|
||||
submit={<Button onClick={handleDownload}>Download File</Button>}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user