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