import ExcelJS from "exceljs"; import JSZip from "jszip"; import { DownloadIcon, FileTextIcon, LoaderIcon, FileTypeIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentProps, type HTMLAttributes, } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; import { Artifact, ArtifactAction, ArtifactActions, ArtifactContent, ArtifactHeader, ArtifactTitle, } from "@/components/ai-elements/artifact"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { DropdownSelector } from "@/components/ui/dropdown-selector"; import { Slider } from "@/components/ui/slider"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { useArtifactContent } from "@/core/artifacts/hooks"; import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; import { streamdownPlugins } from "@/core/streamdown"; import { checkCodeFile, getFileName } from "@/core/utils/files"; import { useMarkdownDownload } from "@/core/utils/markdown-download"; import { cn, copyToClipboard, truncateMiddle } from "@/lib/utils"; import { CitationLink } from "../citations/citation-link"; import { useArtifacts } from "./context"; const POST_MESSAGE_TYPES = { FULLSCREEN: "fullscreen", } as const; type RevoGridColumn = { prop: string; name: string }; type RevoGridRow = Record; type RevoGridSheetData = { columns: RevoGridColumn[]; rows: RevoGridRow[]; }; type RevoGridElement = HTMLElement & { columns: RevoGridColumn[]; source: RevoGridRow[]; readonly: boolean; resize: boolean; rowHeaders: boolean; theme: string; }; let revoGridLoaderPromise: Promise | null = null; function ensureRevoGridDefined() { if (typeof window === "undefined") return Promise.resolve(); if (window.customElements.get("revo-grid")) return Promise.resolve(); revoGridLoaderPromise ??= import("@revolist/revogrid/loader").then( ({ defineCustomElements }) => { defineCustomElements(window); }, ); return revoGridLoaderPromise; } function toExcelColumnLabel(index: number): string { let n = index; let label = ""; while (n > 0) { const remainder = (n - 1) % 26; label = String.fromCharCode(65 + remainder) + label; n = Math.floor((n - 1) / 26); } return label || "A"; } function toGridCellText(cell: ExcelJS.Cell): string { if (cell.text) return cell.text; const value = cell.value; if (value == null) return ""; if (value instanceof Date) return value.toISOString(); if ( typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" ) { return String(value); } if (typeof value === "object") { if ("result" in value && value.result != null) { const result = value.result; if ( typeof result === "string" || typeof result === "number" || typeof result === "boolean" || typeof result === "bigint" ) { return String(result); } } if ("text" in value && value.text) { const text = value.text; if ( typeof text === "string" || typeof text === "number" || typeof text === "boolean" || typeof text === "bigint" ) { return String(text); } } if ("hyperlink" in value && value.hyperlink) { const hyperlink = value.hyperlink; if (typeof hyperlink === "string") { return hyperlink; } } } return ""; } function toRevoGridSheetData(worksheet: ExcelJS.Worksheet): RevoGridSheetData { const maxColumns = Math.max(worksheet.columnCount, 1); const headerRow = worksheet.getRow(1); const columns = Array.from({ length: maxColumns }, (_, idx) => { const columnIndex = idx + 1; const key = `c${columnIndex}`; const header = toGridCellText(headerRow.getCell(columnIndex)).trim(); return { prop: key, name: header || toExcelColumnLabel(columnIndex), }; }); const rows: RevoGridRow[] = []; const lastRow = Math.max(worksheet.actualRowCount, worksheet.rowCount); for (let rowIndex = 2; rowIndex <= lastRow; rowIndex += 1) { const row = worksheet.getRow(rowIndex); const rowData: RevoGridRow = {}; let hasContent = false; for (let columnIndex = 1; columnIndex <= maxColumns; columnIndex += 1) { const value = toGridCellText(row.getCell(columnIndex)); rowData[`c${columnIndex}`] = value; if (!hasContent && value !== "") hasContent = true; } if (hasContent) rows.push(rowData); } return { columns, rows }; } function sendToParent(message: unknown): void { if (window.parent !== window) { window.parent.postMessage(message, "*"); } } export function ArtifactFileDetail({ className, filepath: filepathFromProps, threadId, }: { className?: string; filepath: string; threadId: string; }) { const { t } = useI18n(); const { artifacts, setOpen, select, fullscreen, setFullscreen } = useArtifacts(); const isWriteFile = useMemo(() => { return filepathFromProps.startsWith("write-file:"); }, [filepathFromProps]); const filepath = useMemo(() => { if (isWriteFile) { const url = new URL(filepathFromProps); return decodeURIComponent(url.pathname); } return filepathFromProps; }, [filepathFromProps, isWriteFile]); // 获取文件名(不含路径) const fileName = useMemo(() => getFileName(filepath), [filepath]); const isSkillFile = useMemo(() => { return filepath.endsWith(".skill"); }, [filepath]); const { isCodeFile, language } = useMemo(() => { if (isWriteFile) { let language = checkCodeFile(filepath).language; language ??= "text"; return { isCodeFile: true, language }; } // Treat .skill files as markdown (they contain SKILL.md) if (isSkillFile) { return { isCodeFile: true, language: "markdown" }; } return checkCodeFile(filepath); }, [filepath, isWriteFile, isSkillFile]); const previewable = useMemo(() => { return language === "html" || language === "markdown"; }, [language]); const artifactUrl = useMemo(() => { if (!threadId) { return ""; } return urlOfArtifact({ filepath, threadId }); }, [filepath, threadId]); const artifactPreviewKind = useMemo(() => { return getArtifactPreviewKind(filepath); }, [filepath]); const artifactViewerSrcDoc = useMemo(() => { if (!artifactUrl) { return undefined; } return buildArtifactViewerSrcDoc({ artifactUrl, fileName, kind: artifactPreviewKind, pdfPreviewMessage: t.artifactPreview.pdfPreviewFailed, unsupportedTypeMessage: t.artifactPreview.unsupportedType, openInNewTabLabel: t.artifactPreview.openInNewTab, }); }, [ artifactUrl, fileName, artifactPreviewKind, t.artifactPreview.openInNewTab, t.artifactPreview.pdfPreviewFailed, t.artifactPreview.unsupportedType, ]); // Native PDF iframe rendering is intentionally disabled; PDFs are rendered via pdf.js. const artifactViewerSrc = useMemo(() => { return undefined; }, []); const artifactViewerSandbox = "allow-same-origin allow-scripts allow-downloads"; const { content } = useArtifactContent({ threadId, filepath: filepathFromProps, enabled: Boolean(threadId) && isCodeFile && !isWriteFile, }); const displayContent = content ?? ""; const [isPackagingMarkdownBundle, setIsPackagingMarkdownBundle] = useState(false); const artifactOptions = useMemo(() => { return (artifacts ?? []).map((artifactPath) => ({ value: artifactPath, label: getFileName(artifactPath), })); }, [artifacts]); const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [zoom, setZoom] = useState(80); // 是否可以转换为docx/pdf(仅markdown文件支持) const canConvertToDocxPdf = language === "markdown"; // 使用 Markdown 下载 hook const { isDownloading, downloadAsDocx, downloadAsPdf } = useMarkdownDownload({ onError: (error, format) => { console.error(`Failed to download as ${format}:`, error); toast.error(`Failed to download as ${format.toUpperCase()}`); }, }); const resolveMarkdownAssetUrlForDownload = useCallback( (rawPath: string): string | null => { const normalizedRef = normalizeReference(rawPath); if (!normalizedRef) return null; if (isExternalReference(normalizedRef)) return normalizedRef; if (normalizedRef.startsWith("/mnt/user-data/")) { return urlOfArtifact({ filepath: normalizedRef, threadId }); } if (normalizedRef.startsWith("mnt/user-data/")) { return urlOfArtifact({ filepath: `/${normalizedRef}`, threadId }); } const resolvedVirtualPath = resolveReferencedVirtualPath( normalizedRef, filepath, ); if (!resolvedVirtualPath) return null; return urlOfArtifact({ filepath: resolvedVirtualPath, threadId }); }, [filepath, threadId], ); // 下载为 DOCX const handleDownloadDocx = useCallback(() => { if (content) { void downloadAsDocx(content, fileName, { resolveAssetUrl: resolveMarkdownAssetUrlForDownload, }); } }, [content, fileName, downloadAsDocx, resolveMarkdownAssetUrlForDownload]); // 下载为 PDF const handleDownloadPdf = useCallback(() => { if (content) { void downloadAsPdf(content, fileName, { resolveAssetUrl: resolveMarkdownAssetUrlForDownload, }); } }, [content, fileName, downloadAsPdf, resolveMarkdownAssetUrlForDownload]); const handleDownloadMarkdownBundle = useCallback(async () => { if (!threadId || !content) return; setIsPackagingMarkdownBundle(true); try { const zip = new JSZip(); const markdownEntryPath = toWorkspaceRelativePath(filepath) ?? fileName; const referencedTargets = collectMarkdownAssetTargets(content); const refToVirtualPath = new Map(); for (const ref of referencedTargets) { const resolved = resolveReferencedVirtualPath(ref, filepath); if (resolved) { refToVirtualPath.set(ref, resolved); } } const refToRelativeZipPath = new Map(); const addedVirtualPaths = new Set(); for (const [ref, virtualPath] of refToVirtualPath) { const artifactEntryPath = toWorkspaceRelativePath(virtualPath); if (!artifactEntryPath) continue; const relativeFromMarkdown = toRelativePath( dirnamePosix(markdownEntryPath), artifactEntryPath, ); refToRelativeZipPath.set( ref, relativeFromMarkdown || getFileName(artifactEntryPath), ); if (addedVirtualPaths.has(virtualPath)) continue; addedVirtualPaths.add(virtualPath); const response = await fetch( urlOfArtifact({ filepath: virtualPath, threadId, }), ); if (!response.ok) { continue; } const data = await response.arrayBuffer(); zip.file(artifactEntryPath, data); } const rewrittenMarkdown = rewriteMarkdownLinksForBundle( content, refToRelativeZipPath, ); zip.file(markdownEntryPath, rewrittenMarkdown); const zipBlob = await zip.generateAsync({ type: "blob" }); const zipName = `${fileName.replace(/\.md$/i, "") || "document"}.zip`; const url = URL.createObjectURL(zipBlob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = zipName; anchor.click(); URL.revokeObjectURL(url); toast.success(t.common.exportSuccess); } catch (error) { console.error("Failed to package markdown bundle:", error); toast.error("Failed to package markdown with referenced files."); } finally { setIsPackagingMarkdownBundle(false); } }, [threadId, content, filepath, fileName, t.common.exportSuccess]); // 全屏切换处理 const handleFullscreenToggle = useCallback(() => { const newFullscreen = !fullscreen; setFullscreen(newFullscreen); sendToParent({ type: POST_MESSAGE_TYPES.FULLSCREEN, fullscreen: newFullscreen, }); }, [fullscreen, setFullscreen]); useEffect(() => { if (previewable) { setViewMode("preview"); } else { setViewMode("code"); } }, [previewable]); return ( // 给滚动遮挡头部定位relative
{previewable && ( { if (value) { setViewMode(value as "code" | "preview"); } }} > )} {/* 代码视图显示缩放控制;Markdown 预览也显示缩放控制 */} {(isCodeFile && viewMode === "code") || (language === "markdown" && viewMode === "preview") ? ( ) : null}
{isWriteFile ? (
{truncateMiddle(getFileName(filepath), 20)}
) : ( )}
{isCodeFile && ( { try { await copyToClipboard(displayContent ?? ""); toast.success(t.clipboard.copiedToClipboard); } catch (error) { toast.error("Failed to copy to clipboard"); console.error(error); } }} tooltip={t.clipboard.copyToClipboard} > )} {!isWriteFile && ( {isDownloading ? ( ) : ( )} {language === "markdown" ? ( {isPackagingMarkdownBundle ? ( ) : ( )} {t.common.downloadOriginal} ) : ( {t.common.downloadOriginal} )} {/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */} {canConvertToDocxPdf && ( <> {isDownloading === "docx" ? t.common.loading : t.common.downloadAsDocx} {isDownloading === "pdf" ? t.common.loading : t.common.downloadAsPdf} )} )} {/* 全屏按钮 */} {fullscreen ? ( ) : ( )} {!fullscreen && ( setOpen(false)} tooltip={t.common.close} > )}
{/* 遮挡多余的滚动顶部 */} {/*
*/} {previewable && viewMode === "preview" && (language === "markdown" || language === "html") && ( )} {isCodeFile && viewMode === "code" && (
)} {!isCodeFile && (artifactPreviewKind === "pdf" ? ( ) : isOfficePreviewKind(artifactPreviewKind) ? ( ) : ( ))}
); } const USER_DATA_PREFIX = "/mnt/user-data/"; function normalizePosixPath(path: string): string { const isAbs = path.startsWith("/"); const parts = path.split("/").filter((part) => part.length > 0); const stack: string[] = []; for (const part of parts) { if (part === ".") continue; if (part === "..") { if (stack.length > 0) stack.pop(); continue; } stack.push(part); } return `${isAbs ? "/" : ""}${stack.join("/")}`; } function dirnamePosix(path: string): string { const normalized = normalizePosixPath(path); const index = normalized.lastIndexOf("/"); if (index <= 0) return ""; return normalized.slice(0, index); } function toWorkspaceRelativePath(virtualPath: string): string | null { const normalized = normalizePosixPath(virtualPath); if (!normalized.startsWith(USER_DATA_PREFIX)) return null; return normalized.slice(USER_DATA_PREFIX.length) || "artifact"; } function toRelativePath(fromDir: string, targetPath: string): string { const from = normalizePosixPath(fromDir).split("/").filter(Boolean); const to = normalizePosixPath(targetPath).split("/").filter(Boolean); let i = 0; while (i < from.length && i < to.length && from[i] === to[i]) { i += 1; } const up = new Array(from.length - i).fill(".."); const down = to.slice(i); return [...up, ...down].join("/") || "."; } function normalizeReference(ref: string): string { const trimmed = ref.trim().replace(/^<|>$/g, ""); return trimmed.split(/[ \t]/)[0] ?? ""; } function isExternalReference(ref: string): boolean { return ( !ref || ref.startsWith("#") || ref.startsWith("//") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(ref) ); } function resolveReferencedVirtualPath( ref: string, baseFilepath: string, ): string | null { const normalizedRef = normalizeReference(ref); if (isExternalReference(normalizedRef)) return null; let withoutHash = normalizedRef.split("#")[0] ?? normalizedRef; withoutHash = withoutHash.split("?")[0] ?? withoutHash; if (!withoutHash) return null; if (withoutHash.startsWith("/mnt/user-data/")) { return normalizePosixPath(withoutHash); } if (withoutHash.startsWith("mnt/user-data/")) { return normalizePosixPath(`/${withoutHash}`); } if (withoutHash.startsWith("/")) { return null; } const baseDir = dirnamePosix(baseFilepath); const combined = normalizePosixPath(`${baseDir}/${withoutHash}`); if (!combined.startsWith(USER_DATA_PREFIX)) return null; return combined; } function collectMarkdownAssetTargets(markdown: string): Set { const targets = new Set(); const markdownRefRegex = /!?\[[^\]]*\]\(([^)]+)\)/g; const htmlAttrRegex = /<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*(["'])([^"']+)\1/gi; for (const match of markdown.matchAll(markdownRefRegex)) { const raw = match[1]?.trim(); if (raw) targets.add(raw); } for (const match of markdown.matchAll(htmlAttrRegex)) { const raw = match[2]?.trim(); if (raw) targets.add(raw); } return targets; } function rewriteMarkdownLinksForBundle( markdown: string, refToRelativeZipPath: Map, ): string { const rewriteTarget = (rawTarget: string): string => { const normalized = normalizeReference(rawTarget); return refToRelativeZipPath.get(normalized) ?? rawTarget; }; const markdownRewritten = markdown.replace( /(!?\[[^\]]*\]\()([^)]+)(\))/g, (_full, prefix, target, suffix) => { return `${prefix}${rewriteTarget(String(target))}${suffix}`; }, ); return markdownRewritten.replace( /(<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*["'])([^"']+)(["'])/gi, (_full, prefix, target, suffix) => { return `${prefix}${rewriteTarget(String(target))}${suffix}`; }, ); } export function ArtifactFilePreview({ content, language, zoom = 100, threadId, filepath, }: { content: string; language: string; zoom?: number; threadId: string; filepath?: string; }) { const zoomScale = zoom / 100; const normalizedContent = useMemo(() => { return rewriteArtifactImagePaths(content ?? "", threadId, filepath); }, [content, threadId, filepath]); if (language === "markdown") { return (
{normalizedContent}
); } if (language === "html") { return ( ); } return null; } function PreviewIframe({ className, containerClassName, onLoad, src, srcDoc, ...props }: ComponentProps<"iframe"> & { containerClassName?: string; }) { const [isLoading, setIsLoading] = useState(true); useEffect(() => { setIsLoading(true); }, [src, srcDoc]); return (
`; } return `

${safeName}

${safeUnsupportedTypeMessage}

${safeOpenInNewTabLabel}
`; })(); const bodyClass = kind === "image" ? "fullbleed" : ""; return ` ${content} `; } // 缩放比例选项 const ZOOM_LEVELS = [50, 60, 70, 80, 90, 100, 110, 120, 130, 150, 175, 200]; export type ArtifactZoomSelectorProps = Omit< HTMLAttributes, "onChange" > & { value?: number; onChange?: (value: number) => void; }; export const ArtifactZoomSelector = ({ value = 100, onChange, className, ...props }: ArtifactZoomSelectorProps) => { const { t } = useI18n(); const resolvedIndex = useMemo(() => { const exactIndex = ZOOM_LEVELS.indexOf(value); if (exactIndex >= 0) return exactIndex; let nearestIndex = 0; let nearestDistance = Number.POSITIVE_INFINITY; ZOOM_LEVELS.forEach((level, index) => { const distance = Math.abs(level - value); if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; } }); return nearestIndex; }, [value]); return (
{ZOOM_LEVELS[0]}% {value}%
{ const nextIndex = values[0]; if (nextIndex === undefined) return; const nextValue = ZOOM_LEVELS[nextIndex]; if (nextValue !== undefined) onChange?.(nextValue); }} />
); };