diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 4daa04ce..70e42054 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -5,6 +5,7 @@ import { useCallback, useContext, useMemo, + useState, type AnchorHTMLAttributes, type ImgHTMLAttributes, } from "react"; @@ -24,6 +25,10 @@ import { } from "@/components/ai-elements/reasoning"; import { Task, TaskTrigger } from "@/components/ai-elements/task"; import { Badge } from "@/components/ui/badge"; +import { + UploadedFilePreviewDialog, + useAuthedObjectUrl, +} from "@/components/workspace/messages/uploaded-file-preview-dialog"; import { resolveArtifactURL } from "@/core/artifacts/utils"; import { useChatAskTimes } from "@/core/chat-timestamps"; import { getBackendBaseURL } from "@/core/config"; @@ -466,8 +471,17 @@ function RichFileCard({ }) { const { t } = useI18n(); const resolveArtifact = useArtifactResolver(threadId); + const [previewOpen, setPreviewOpen] = useState(false); const isUploading = file.status === "uploading"; const isImage = isImageFile(file.filename); + const fileUrl = file.path ? resolveArtifact(file.path) : null; + // Thumbnails must be fetched with auth: the artifact route is gated and a + // plain sends no Authorization header → 401 (broken image). Fetch + // the bytes via apiFetch and display the resulting object URL instead. + const { objectUrl: thumbUrl, loading: thumbLoading } = useAuthedObjectUrl( + fileUrl, + isImage && !isUploading && !!fileUrl, + ); if (isUploading) { return ( @@ -496,50 +510,81 @@ function RichFileCard({ ); } - if (!file.path) return null; + if (!fileUrl) return null; - const fileUrl = resolveArtifact(file.path); + const preview = ( + + ); if (isImage) { return ( - - {file.filename} - + <> + + {preview} + ); } return ( -
-
- - - {file.filename} - -
-
- - {getFileTypeLabel(file.filename)} - - - {formatBytes(file.size)} - -
-
+ <> + + {preview} + ); } diff --git a/frontend-web/src/components/workspace/messages/uploaded-file-preview-dialog.tsx b/frontend-web/src/components/workspace/messages/uploaded-file-preview-dialog.tsx new file mode 100644 index 00000000..0141242e --- /dev/null +++ b/frontend-web/src/components/workspace/messages/uploaded-file-preview-dialog.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { DownloadIcon, Loader2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; +import { apiFetch } from "@/core/api/fetch-client"; +import { useI18n } from "@/core/i18n/hooks"; +import { reasoningPlugins } from "@/core/streamdown"; + +const markdownClassName = + "!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " + + "[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " + + "[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " + + "[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " + + "[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " + + "[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " + + "[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse " + + "[&_th]:border [&_th]:px-2 [&_th]:py-1 [&_td]:border [&_td]:px-2 [&_td]:py-1 " + + "[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " + + "[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " + + "[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " + + "[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0"; + +const IMAGE_EXT = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]; +const TEXT_EXT = [ + "txt", "md", "markdown", "csv", "tsv", "json", "log", "yaml", "yml", + "xml", "html", "htm", "css", "js", "ts", "tsx", "jsx", "py", "java", + "go", "rs", "c", "cpp", "h", "hpp", "sh", "sql", "ini", "toml", "env", +]; +// Office documents are converted to a sibling Markdown file server-side +// (uploads.auto_convert_documents). We preview that .md instead of the binary. +const OFFICE_EXT = ["xlsx", "xls", "docx", "doc", "pptx", "ppt"]; + +function extOf(filename: string): string { + const i = filename.lastIndexOf("."); + return i >= 0 ? filename.slice(i + 1).toLowerCase() : ""; +} + +type PreviewKind = "image" | "text" | "office" | "pdf" | "binary"; + +function kindOf(filename: string): PreviewKind { + const ext = extOf(filename); + if (IMAGE_EXT.includes(ext)) return "image"; + if (ext === "pdf") return "pdf"; + if (OFFICE_EXT.includes(ext)) return "office"; + if (TEXT_EXT.includes(ext)) return "text"; + return "binary"; +} + +function withReplacedExtension(url: string): string { + // Replace the path extension with .md, keeping any query string intact. + const [base, query] = url.split("?"); + const i = base.lastIndexOf("."); + const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md"; + return query ? `${swapped}?${query}` : swapped; +} + +/** + * Fetch an auth-gated artifact (the artifact route requires an + * `Authorization: Bearer` header, which a plain /