feat(frontend-messages): 支持摘要折叠与表格导出
This commit is contained in:
parent
612f1cdb9f
commit
138b4a1f7d
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue