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"; "use client";
import { CheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react";
import { useCallback, useMemo, useState, type MouseEvent } from "react"; import { useCallback, useMemo, useState, type MouseEvent } from "react";
import type { import type {
AnchorHTMLAttributes, AnchorHTMLAttributes,
@ -56,27 +56,57 @@ function toMarkdownTable(data: TableData): string {
return [headerLine, dividerLine, ...rowLines].join("\n"); 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({ function MarkdownTable({
className, className,
children, children,
isLoading,
copyLabel, copyLabel,
downloadLabel,
...props ...props
}: ComponentPropsWithoutRef<"table"> & { }: ComponentPropsWithoutRef<"table"> & {
isLoading: boolean;
copyLabel: string; copyLabel: string;
downloadLabel: string;
}) { }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = useCallback( const getTableData = useCallback((event: MouseEvent<HTMLButtonElement>) => {
async (event: MouseEvent<HTMLButtonElement>) => {
const wrapper = event.currentTarget.closest( const wrapper = event.currentTarget.closest(
'[data-streamdown="table-wrapper"]', '[data-streamdown="table-wrapper"]',
); );
const table = wrapper?.querySelector("table"); const table = wrapper?.querySelector("table");
if (!(table instanceof HTMLTableElement)) return; if (!(table instanceof HTMLTableElement)) return null;
return parseTableData(table);
}, []);
const markdown = toMarkdownTable(parseTableData(table)); const handleCopy = useCallback(
async (event: MouseEvent<HTMLButtonElement>) => {
const data = getTableData(event);
if (!data) return;
const markdown = toMarkdownTable(data);
if (!markdown) return; if (!markdown) return;
try { try {
@ -87,7 +117,20 @@ function MarkdownTable({
// no-op // 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 ( return (
@ -97,14 +140,21 @@ function MarkdownTable({
> >
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<button <button
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all disabled:cursor-not-allowed disabled:opacity-50" className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
disabled={isLoading}
onClick={handleCopy} onClick={handleCopy}
title={copyLabel} title={copyLabel}
type="button" type="button"
> >
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />} {copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
</button> </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>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table <table
@ -165,7 +215,7 @@ export function MarkdownContent({
<MarkdownTable <MarkdownTable
className={className} className={className}
copyLabel={t.clipboard.copyToClipboard} copyLabel={t.clipboard.copyToClipboard}
isLoading={isLoading} downloadLabel={t.common.download}
{...props} {...props}
> >
{children} {children}
@ -173,7 +223,12 @@ export function MarkdownContent({
), ),
...componentsFromProps, ...componentsFromProps,
}; };
}, [componentsFromProps, isLoading, t.clipboard.copyToClipboard]); }, [
componentsFromProps,
isLoading,
t.clipboard.copyToClipboard,
t.common.download,
]);
if (!content) return null; if (!content) return null;

View File

@ -27,9 +27,11 @@ import {
import { resolveArtifactURL } from "@/core/artifacts/utils"; import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
extractSummaryTemplateBody,
extractContentFromMessage, extractContentFromMessage,
normalizeHumanMessageDisplayText, normalizeHumanMessageDisplayText,
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
isSummaryTemplateMessage,
parseUploadedFiles, parseUploadedFiles,
stripPriorityHintSuffix, stripPriorityHintSuffix,
stripUploadedFilesTag, stripUploadedFilesTag,
@ -140,6 +142,7 @@ function MessageContent_({
isLoading?: boolean; isLoading?: boolean;
threadId: string; threadId: string;
}) { }) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const isHuman = message.type === "human"; const isHuman = message.type === "human";
const components = useMemo( const components = useMemo(
@ -176,6 +179,15 @@ function MessageContent_({
} }
return rawContent ?? ""; return rawContent ?? "";
}, [rawContent, isHuman]); }, [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 = const filesList =
files && files.length > 0 && threadId ? ( files && files.length > 0 && threadId ? (
@ -211,6 +223,7 @@ function MessageContent_({
} }
if (isHuman) { if (isHuman) {
const shouldRenderSummaryCollapse = isSummaryMessage && summaryBody;
const messageResponse = contentToDisplay ? ( const messageResponse = contentToDisplay ? (
<AIElementMessageResponse <AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins} remarkPlugins={humanMessagePlugins.remarkPlugins}
@ -223,8 +236,37 @@ function MessageContent_({
return ( return (
<div className={cn("ml-auto flex flex-col gap-2", className)}> <div className={cn("ml-auto flex flex-col gap-2", className)}>
{filesList} {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 && ( {messageResponse && (
<AIElementMessageContent className="w-fit"> <AIElementMessageContent
className={cn(
"w-fit",
shouldRenderSummaryCollapse ? "hidden" : undefined,
)}
>
{messageResponse} {messageResponse}
</AIElementMessageContent> </AIElementMessageContent>
)} )}

View File

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