"use client"; import { DownloadIcon } from "lucide-react"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { AnchorHTMLAttributes, ComponentPropsWithoutRef, ReactNode, } from "react"; import { MessageResponse, type MessageResponseProps, } from "@/components/ai-elements/message"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useI18n } from "@/core/i18n/hooks"; import { streamdownPlugins } from "@/core/streamdown"; import { CopyButton } from "@/components/workspace/copy-button"; import { Tooltip } from "@/components/workspace/tooltip"; import { cn } from "@/lib/utils"; import { CitationLink } from "../citations/citation-link"; function isExternalUrl(href: string | undefined): boolean { return !!href && /^https?:\/\//.test(href); } export type MarkdownContentProps = { content: string; isLoading: boolean; rehypePlugins: MessageResponseProps["rehypePlugins"]; className?: string; remarkPlugins?: MessageResponseProps["remarkPlugins"]; components?: MessageResponseProps["components"]; }; type TableData = { headers: string[]; rows: string[][]; }; function parseTableData(table: HTMLTableElement): TableData { const headers = Array.from(table.querySelectorAll("thead th")).map((cell) => (cell.textContent ?? "").trim(), ); const rows = Array.from(table.querySelectorAll("tbody tr")).map((row) => Array.from(row.querySelectorAll("td")).map((cell) => (cell.textContent ?? "").trim(), ), ); return { headers, rows }; } function toMarkdownTable(data: TableData): string { if (data.headers.length === 0) return ""; const headerLine = `| ${data.headers.join(" | ")} |`; const dividerLine = `| ${data.headers.map(() => "---").join(" | ")} |`; const rowLines = data.rows.map((row) => `| ${row.join(" | ")} |`); return [headerLine, dividerLine, ...rowLines].join("\n"); } function escapeCsvCell(cell: string): string { const normalized = cell.replace(/\r\n/g, "\n"); if (!/["\n,]/.test(normalized)) return normalized; return `"${normalized.replace(/"/g, '""')}"`; } function toCsvTable(data: TableData): string { if (data.headers.length === 0) return ""; const headerLine = data.headers.map(escapeCsvCell).join(","); const rowLines = data.rows.map((row) => row.map(escapeCsvCell).join(",")); return [headerLine, ...rowLines].join("\r\n"); } function downloadTextFile(content: string, filename: string, mimeType: string) { const blob = new Blob(["\uFEFF", content], { type: mimeType, }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click(); URL.revokeObjectURL(url); } export function MarkdownTable({ className, children, copyLabel: _copyLabel, downloadLabel, ...props }: ComponentPropsWithoutRef<"table"> & { copyLabel: string; downloadLabel: string; }) { const tableRef = useRef(null); const [, forceUpdate] = useState(0); // 首次 mount 后 tableRef 才被赋值,用 useLayoutEffect 在 paint 前强制刷新 useLayoutEffect(() => { forceUpdate((n) => n + 1); }, []); // 在 render 阶段直接从 DOM ref 计算,不依赖 effect 异步更新 // tableRef 在上一次渲染的 commit 阶段已设置,本次渲染可用 const clipboardData = (() => { const table = tableRef.current; if (!table) return ""; const data = parseTableData(table); if (!data) return ""; return toMarkdownTable(data); })(); const handleDownload = useCallback(() => { const table = tableRef.current; if (!table) return; const data = parseTableData(table); if (!data) return; const csv = toCsvTable(data); if (!csv) return; downloadTextFile(csv, "table.csv", "text/csv;charset=utf-8"); }, []); const handleDownloadMarkdown = useCallback(() => { const table = tableRef.current; if (!table) return; const data = parseTableData(table); if (!data) return; const markdown = toMarkdownTable(data); if (!markdown) return; downloadTextFile(markdown, "table.md", "text/markdown;charset=utf-8"); }, []); return (
CSV Markdown
{children}
); } /** Renders markdown content. */ export function MarkdownContent({ content, isLoading, rehypePlugins, className, remarkPlugins = streamdownPlugins.remarkPlugins, components: componentsFromProps, }: MarkdownContentProps) { const { t } = useI18n(); const components = useMemo(() => { return { a: (props: AnchorHTMLAttributes) => { if (typeof props.children === "string") { const match = /^citation:(.+)$/.exec(props.children); if (match) { const [, text] = match; return {text}; } } const { className, target, rel, ...rest } = props; const external = isExternalUrl(props.href); return ( ); }, table: ({ children, className, ...props }: ComponentPropsWithoutRef<"table"> & { children?: ReactNode }) => ( {children} ), ...componentsFromProps, }; }, [ componentsFromProps, isLoading, t.clipboard.copyToClipboard, t.common.download, ]); if (!content) return null; return ( {content} ); }