From a62e65acfe2a8843ee9ed2c354b375a57e17a0d5 Mon Sep 17 00:00:00 2001 From: MT-Mint <798521692@qq.com> Date: Mon, 20 Apr 2026 10:24:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A8=E6=A0=BC=E7=9A=84=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E6=8C=89=E9=92=AE=E8=A2=AB=E7=A6=81=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/messages/markdown-content.tsx | 114 +++++++++++++++++- 1 file changed, 110 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/workspace/messages/markdown-content.tsx b/frontend/src/components/workspace/messages/markdown-content.tsx index ab5de7ea..5f970bcb 100644 --- a/frontend/src/components/workspace/messages/markdown-content.tsx +++ b/frontend/src/components/workspace/messages/markdown-content.tsx @@ -1,14 +1,20 @@ "use client"; -import { useMemo } from "react"; -import type { AnchorHTMLAttributes } from "react"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useCallback, useMemo, useState, type MouseEvent } from "react"; +import type { + AnchorHTMLAttributes, + ComponentPropsWithoutRef, + ReactNode, +} from "react"; import { MessageResponse, type MessageResponseProps, } from "@/components/ai-elements/message"; +import { useI18n } from "@/core/i18n/hooks"; import { streamdownPlugins } from "@/core/streamdown"; -import { cn } from "@/lib/utils"; +import { cn, copyToClipboard } from "@/lib/utils"; import { CitationLink } from "../citations/citation-link"; @@ -25,6 +31,89 @@ export type MarkdownContentProps = { 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 MarkdownTable({ + className, + children, + isLoading, + copyLabel, + ...props +}: ComponentPropsWithoutRef<"table"> & { + isLoading: boolean; + copyLabel: string; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback( + async (event: MouseEvent) => { + const wrapper = event.currentTarget.closest('[data-streamdown="table-wrapper"]'); + const table = wrapper?.querySelector("table"); + if (!(table instanceof HTMLTableElement)) return; + + const markdown = toMarkdownTable(parseTableData(table)); + if (!markdown) return; + + try { + await copyToClipboard(markdown); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + // no-op + } + }, + [], + ); + + return ( +
+
+ +
+
+ + {children} +
+
+
+ ); +} + /** Renders markdown content. */ export function MarkdownContent({ content, @@ -34,6 +123,8 @@ export function MarkdownContent({ remarkPlugins = streamdownPlugins.remarkPlugins, components: componentsFromProps, }: MarkdownContentProps) { + const { t } = useI18n(); + const components = useMemo(() => { return { a: (props: AnchorHTMLAttributes) => { @@ -58,9 +149,23 @@ export function MarkdownContent({ /> ); }, + table: ({ + children, + className, + ...props + }: ComponentPropsWithoutRef<"table"> & { children?: ReactNode }) => ( + + {children} + + ), ...componentsFromProps, }; - }, [componentsFromProps]); + }, [componentsFromProps, isLoading, t.clipboard.copyToClipboard]); if (!content) return null; @@ -68,6 +173,7 @@ export function MarkdownContent({