diff --git a/frontend/src/components/workspace/messages/markdown-content.tsx b/frontend/src/components/workspace/messages/markdown-content.tsx index 2a184df1..5720b7de 100644 --- a/frontend/src/components/workspace/messages/markdown-content.tsx +++ b/frontend/src/components/workspace/messages/markdown-content.tsx @@ -1,6 +1,6 @@ "use client"; -import { CheckIcon, CopyIcon } from "lucide-react"; +import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react"; import { useCallback, useMemo, useState, type MouseEvent } from "react"; import type { AnchorHTMLAttributes, @@ -56,27 +56,57 @@ function toMarkdownTable(data: TableData): string { return [headerLine, dividerLine, ...rowLines].join("\n"); } +function escapeCsvCell(value: string): string { + if (!/[",\n\r]/.test(value)) return value; + return `"${value.replaceAll('"', '""')}"`; +} + +function toCsvTable(data: TableData): string { + if (data.headers.length === 0) return ""; + return [data.headers, ...data.rows] + .map((row) => row.map(escapeCsvCell).join(",")) + .join("\n"); +} + +function downloadCsvFile(content: string, filename: string) { + const blob = new Blob(["\uFEFF", content], { + type: "text/csv;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + function MarkdownTable({ className, children, - isLoading, copyLabel, + downloadLabel, ...props }: ComponentPropsWithoutRef<"table"> & { - isLoading: boolean; copyLabel: string; + downloadLabel: string; }) { const [copied, setCopied] = useState(false); + const getTableData = useCallback((event: MouseEvent) => { + const wrapper = event.currentTarget.closest( + '[data-streamdown="table-wrapper"]', + ); + const table = wrapper?.querySelector("table"); + if (!(table instanceof HTMLTableElement)) return null; + return parseTableData(table); + }, []); + 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 data = getTableData(event); + if (!data) return; - const markdown = toMarkdownTable(parseTableData(table)); + const markdown = toMarkdownTable(data); if (!markdown) return; try { @@ -87,7 +117,20 @@ function MarkdownTable({ // no-op } }, - [], + [getTableData], + ); + + const handleDownload = useCallback( + (event: MouseEvent) => { + const data = getTableData(event); + if (!data) return; + + const csv = toCsvTable(data); + if (!csv) return; + + downloadCsvFile(csv, "table.csv"); + }, + [getTableData], ); return ( @@ -97,14 +140,21 @@ function MarkdownTable({ >
+
{children} @@ -173,7 +223,12 @@ export function MarkdownContent({ ), ...componentsFromProps, }; - }, [componentsFromProps, isLoading, t.clipboard.copyToClipboard]); + }, [ + componentsFromProps, + isLoading, + t.clipboard.copyToClipboard, + t.common.download, + ]); if (!content) return null; diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 7cc04ea2..9e76aae8 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -27,9 +27,11 @@ import { import { resolveArtifactURL } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; import { + extractSummaryTemplateBody, extractContentFromMessage, normalizeHumanMessageDisplayText, extractReasoningContentFromMessage, + isSummaryTemplateMessage, parseUploadedFiles, stripPriorityHintSuffix, stripUploadedFilesTag, @@ -140,6 +142,7 @@ function MessageContent_({ isLoading?: boolean; threadId: string; }) { + const { t } = useI18n(); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const isHuman = message.type === "human"; const components = useMemo( @@ -176,6 +179,15 @@ function MessageContent_({ } return rawContent ?? ""; }, [rawContent, isHuman]); + const isSummaryMessage = useMemo( + () => isHuman && isSummaryTemplateMessage(message), + [isHuman, message], + ); + const summaryBody = useMemo( + () => (isSummaryMessage ? extractSummaryTemplateBody(message) : ""), + [isSummaryMessage, message], + ); + const [isSummaryExpanded, setIsSummaryExpanded] = useState(false); const filesList = files && files.length > 0 && threadId ? ( @@ -211,6 +223,7 @@ function MessageContent_({ } if (isHuman) { + const shouldRenderSummaryCollapse = isSummaryMessage && summaryBody; const messageResponse = contentToDisplay ? ( {filesList} + {shouldRenderSummaryCollapse && ( +
{ + setIsSummaryExpanded(event.currentTarget.open); + }} + > + + {isSummaryExpanded + ? t.toolCalls.collapseContent + : t.toolCalls.expandContent} + + + + {summaryBody} + + +
+ )} {messageResponse && ( - + {messageResponse} )} diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index 37c89480..c682fc39 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -26,6 +26,47 @@ type MessageGroup = | AssistantClarificationGroup | AssistantSubagentGroup; +const SUMMARY_MESSAGE_TITLES = [ + "Here is a summary of the conversation to date", + "以下是目前对话的摘要", +]; + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function getSummaryTemplateTitle(content: string) { + return ( + SUMMARY_MESSAGE_TITLES.find((title) => { + const titlePattern = new RegExp( + `^\\s*${escapeRegExp(title)}\\s*[::]?(?:\\n|$)`, + "i", + ); + return titlePattern.test(content); + }) ?? null + ); +} + +export function isSummaryTemplateMessage(message: Message) { + if (message.type !== "human") { + return false; + } + return getSummaryTemplateTitle(extractTextFromMessage(message)) !== null; +} + +export function extractSummaryTemplateBody(message: Message) { + const content = extractTextFromMessage(message); + const title = getSummaryTemplateTitle(content); + if (!title) { + return content; + } + const titlePrefixPattern = new RegExp( + `^\\s*${escapeRegExp(title)}\\s*[::]?\\s*\\n*`, + "i", + ); + return content.replace(titlePrefixPattern, "").trim(); +} + export function groupMessages( messages: Message[], mapper: (group: MessageGroup) => T, @@ -57,6 +98,9 @@ export function groupMessages( } if (message.type === "human") { + // if (isSummaryTemplateMessage(message)) { + // continue; + // } groups.push({ id: message.id, type: "human", messages: [message] }); continue; }