From c82223f8f4185e3f129d0f286e491e3c0a7cc8ae Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 23 Jun 2026 22:41:17 +0800 Subject: [PATCH] feat: add markdown table download --- components/elements/response.tsx | 93 +++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/components/elements/response.tsx b/components/elements/response.tsx index e67c83c7d..7f2f783bb 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -1,8 +1,8 @@ "use client"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, DownloadIcon } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { Streamdown } from "streamdown"; import { ImagePreview } from "@/components/image-preview"; import { @@ -44,6 +44,42 @@ type AnalysisSection = { after: string; }; +type DownloadableMarkdownTableProps = ComponentProps<"table"> & { + node?: unknown; +}; + +function normalizeTableCellText(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +function escapeCsvCell(value: string) { + const normalized = normalizeTableCellText(value); + const escaped = normalized.replace(/"/g, '""'); + return /[",\r\n]/.test(escaped) ? `"${escaped}"` : escaped; +} + +function tableToCsv(table: HTMLTableElement) { + return Array.from(table.rows) + .map((row) => + Array.from(row.cells) + .map((cell) => escapeCsvCell(cell.innerText || "")) + .join(",") + ) + .join("\r\n"); +} + +function downloadTextFile(filename: string, content: string, type: string) { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + function splitMarkdownImages(content: string) { const segments: Array< | { type: "text"; value: string } @@ -212,6 +248,58 @@ function ImageAnalysisCollapsible({ ); } +function DownloadableMarkdownTable({ + children, + className, + node: _node, + ...props +}: DownloadableMarkdownTableProps) { + const tableRef = useRef(null); + + const handleDownload = () => { + const table = tableRef.current; + if (!table) return; + + const csv = tableToCsv(table); + if (!csv.trim()) return; + + const timestamp = new Date() + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d+Z$/, ""); + downloadTextFile( + `chat-table-${timestamp}.csv`, + `\uFEFF${csv}`, + "text/csv;charset=utf-8" + ); + }; + + return ( +
+
+ +
+
+ + {children} +
+
+
+ ); +} + // 注意:移除了 memo 的自定义比较函数,确保 isProcessing* 状态变化时组件能正确重新渲染 // 之前的 memo 比较函数可能导致 Streamdown 内部的 ImagePreview 组件没有正确更新 export const Response = ({ @@ -269,6 +357,7 @@ export const Response = ({ } return ; }, + table: DownloadableMarkdownTable, }; const renderImagePreview = (src: string, alt: string | undefined, key: string) => (