feat(frontend-messages): 支持摘要折叠与表格导出

This commit is contained in:
肖应宇 2026-04-24 17:04:39 +08:00
parent 612f1cdb9f
commit 138b4a1f7d
3 changed files with 156 additions and 15 deletions

View File

@ -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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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({
>
<div className="flex items-center justify-end gap-1">
<button
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLoading}
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
onClick={handleCopy}
title={copyLabel}
type="button"
>
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
</button>
<button
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
onClick={handleDownload}
title={downloadLabel}
type="button"
>
<DownloadIcon size={14} />
</button>
</div>
<div className="overflow-x-auto">
<table
@ -165,7 +215,7 @@ export function MarkdownContent({
<MarkdownTable
className={className}
copyLabel={t.clipboard.copyToClipboard}
isLoading={isLoading}
downloadLabel={t.common.download}
{...props}
>
{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;

View File

@ -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 ? (
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
@ -223,8 +236,37 @@ function MessageContent_({
return (
<div className={cn("ml-auto flex flex-col gap-2", className)}>
{filesList}
{shouldRenderSummaryCollapse && (
<details
className="w-fit max-w-full rounded-lg border"
open={isSummaryExpanded}
onToggle={(event) => {
setIsSummaryExpanded(event.currentTarget.open);
}}
>
<summary className="text-muted-foreground cursor-pointer px-3 py-2 text-xs select-none">
{isSummaryExpanded
? t.toolCalls.collapseContent
: t.toolCalls.expandContent}
</summary>
<AIElementMessageContent className="w-fit border-t">
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
>
{summaryBody}
</AIElementMessageResponse>
</AIElementMessageContent>
</details>
)}
{messageResponse && (
<AIElementMessageContent className="w-fit">
<AIElementMessageContent
className={cn(
"w-fit",
shouldRenderSummaryCollapse ? "hidden" : undefined,
)}
>
{messageResponse}
</AIElementMessageContent>
)}

View File

@ -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<T>(
messages: Message[],
mapper: (group: MessageGroup) => T,
@ -57,6 +98,9 @@ export function groupMessages<T>(
}
if (message.type === "human") {
// if (isSummaryTemplateMessage(message)) {
// continue;
// }
groups.push({ id: message.id, type: "human", messages: [message] });
continue;
}