Add Claw report PDF export

This commit is contained in:
134 2026-06-24 18:33:47 +08:00
parent 05387d08f3
commit 57f4d08c31
5 changed files with 435 additions and 10 deletions

View File

@ -261,6 +261,34 @@
background-clip: padding-box;
}
.chat-card-x-scroll {
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.42) rgba(148, 163, 184, 0.14);
}
.chat-card-x-scroll::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.chat-card-x-scroll::-webkit-scrollbar-track {
background: rgba(148, 163, 184, 0.14);
border-radius: 999px;
}
.chat-card-x-scroll::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.42);
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 999px;
}
.chat-card-x-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.58);
background-clip: padding-box;
}
.step {
counter-increment: step;
}

View File

@ -959,6 +959,11 @@ type CanvasTypewriterTextProps = {
className?: string;
renderMarkdown?: boolean;
preserveMarkdownTables?: boolean;
enablePdfDownload?: boolean;
pdfFileName?: string;
pdfSiteLogo?: string | null;
pdfSiteName?: string | null;
pdfTitle?: string;
onActiveChange?: (typingKey: string, active: boolean) => void;
onComplete?: (typingKey: string) => void;
};
@ -1077,6 +1082,11 @@ function CanvasTypewriterText({
className,
renderMarkdown = false,
preserveMarkdownTables = false,
enablePdfDownload = false,
pdfFileName,
pdfSiteLogo,
pdfSiteName,
pdfTitle,
onActiveChange,
onComplete,
}: CanvasTypewriterTextProps) {
@ -1131,7 +1141,16 @@ function CanvasTypewriterText({
if (renderMarkdown) {
return (
<div className="w-full min-w-0">
<Response className={className}>{displayText}</Response>
<Response
className={className}
enablePdfDownload={enablePdfDownload}
pdfFileName={pdfFileName}
pdfSiteLogo={pdfSiteLogo}
pdfSiteName={pdfSiteName}
pdfTitle={pdfTitle}
>
{displayText}
</Response>
{showCursor ? <CanvasTypewriterCursor /> : null}
</div>
);
@ -2995,6 +3014,9 @@ export function CanvasChatPanel({
// 获取网站设置(包含广告横幅配置和画布设置)
const { data: siteSettings } = useSWR<{
siteName?: string;
siteLogo?: string | null;
siteLogoDark?: string | null;
bannerEnabled?: boolean;
bannerText?: string;
bannerLink?: string | null;
@ -11178,7 +11200,7 @@ export function CanvasChatPanel({
"flex min-w-0 flex-col gap-2",
message.role === 'user'
? "max-w-[88%] items-end"
: "w-full max-w-[88%] items-start"
: "w-full max-w-full items-start"
)}>
{message.role === 'user' ? (
<div
@ -11398,6 +11420,11 @@ export function CanvasChatPanel({
className="break-words [overflow-wrap:anywhere]"
renderMarkdown
preserveMarkdownTables={shouldTypeClawAssistantMessage}
enablePdfDownload={message.role === "assistant" && !shouldAnimateAnalysis}
pdfFileName={`Claw分析报告-${message.id}`}
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
pdfSiteName={siteSettings?.siteName || "AI Chatbot"}
pdfTitle="Claw 分析报告"
onActiveChange={handleTypewriterActiveChange}
onComplete={handleTypewriterComplete}
/>
@ -11415,6 +11442,11 @@ export function CanvasChatPanel({
className="break-words [overflow-wrap:anywhere]"
renderMarkdown
preserveMarkdownTables={shouldTypeClawAssistantMessage}
enablePdfDownload={message.role === "assistant" && !shouldAnimatePlainText}
pdfFileName={`Claw分析报告-${message.id}`}
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
pdfSiteName={siteSettings?.siteName || "AI Chatbot"}
pdfTitle="Claw 分析报告"
onActiveChange={handleTypewriterActiveChange}
onComplete={handleTypewriterComplete}
/>

View File

@ -306,6 +306,7 @@ export function OpenClawChartCard({ chart }: { chart: OpenClawChartSpec }) {
<button
type="button"
onClick={handleDownload}
data-pdf-exclude="true"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
title="下载图表数据 CSV"
>
@ -313,9 +314,9 @@ export function OpenClawChartCard({ chart }: { chart: OpenClawChartSpec }) {
<span>下载数据</span>
</button>
</div>
<div className="overflow-x-auto px-3 py-3">
<div className="chat-card-x-scroll overflow-x-auto px-3 pb-4 pt-3">
<svg
className="min-w-[620px] max-w-full"
className="min-w-[720px] max-w-none"
role="img"
viewBox={`0 0 ${width} ${height}`}
>

View File

@ -0,0 +1,308 @@
"use client";
import { FileDownIcon } from "lucide-react";
import { useState, type RefObject } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
type ResponsePdfDownloadButtonProps = {
contentRef: RefObject<HTMLElement | null>;
fileName?: string;
siteLogo?: string | null;
siteName?: string | null;
title?: string;
};
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function getSafeTitle(value?: string | null) {
return String(value || "").replace(/\s+/g, " ").trim();
}
function getSafeFileName(value?: string | null) {
const normalized = getSafeTitle(value || "Claw 分析报告")
.replace(/[\\/:*?"<>|]/g, "_")
.slice(0, 80);
return normalized || "Claw 分析报告";
}
function clonePrintableStyles(targetWindow: Window) {
document
.querySelectorAll<HTMLLinkElement | HTMLStyleElement>(
'link[rel="stylesheet"], style'
)
.forEach((node) => {
targetWindow.document.head.appendChild(node.cloneNode(true));
});
}
function createPrintOverrides(targetWindow: Window) {
const style = targetWindow.document.createElement("style");
style.textContent = `
:root {
--background: #ffffff;
--foreground: #111827;
--border: #e5e7eb;
--card: #ffffff;
--muted: #f3f4f6;
--muted-foreground: #6b7280;
--primary: #111827;
}
html,
body {
margin: 0;
background: #ffffff;
color: #111827;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.claw-pdf-shell {
box-sizing: border-box;
width: 1120px;
max-width: 100%;
margin: 0 auto;
padding: 32px;
background: #ffffff;
}
.claw-pdf-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding-bottom: 18px;
margin-bottom: 24px;
border-bottom: 1px solid #e5e7eb;
}
.claw-pdf-brand {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.claw-pdf-logo,
.claw-pdf-logo-fallback {
width: 38px;
height: 38px;
flex: 0 0 auto;
border-radius: 10px;
}
.claw-pdf-logo {
object-fit: contain;
background: #ffffff;
}
.claw-pdf-logo-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
background: #111827;
color: #ffffff;
font-size: 15px;
font-weight: 700;
}
.claw-pdf-site {
font-size: 18px;
line-height: 1.2;
font-weight: 700;
color: #111827;
}
.claw-pdf-title {
margin-top: 4px;
font-size: 12px;
line-height: 1.4;
color: #6b7280;
}
.claw-pdf-time {
flex: 0 0 auto;
text-align: right;
font-size: 11px;
color: #6b7280;
}
.claw-pdf-content {
font-size: 14px;
line-height: 1.7;
}
[data-pdf-exclude="true"] {
display: none !important;
}
.chat-card-x-scroll {
overflow: visible !important;
padding-bottom: 0 !important;
scrollbar-width: none !important;
}
.chat-card-x-scroll::-webkit-scrollbar {
display: none !important;
}
table {
page-break-inside: auto;
}
tr,
svg,
img {
page-break-inside: avoid;
}
svg {
max-width: 100% !important;
height: auto;
}
@page {
size: A4 landscape;
margin: 12mm;
}
@media print {
.claw-pdf-shell {
width: auto;
max-width: none;
padding: 0;
}
}
`;
targetWindow.document.head.appendChild(style);
}
export function ResponsePdfDownloadButton({
contentRef,
fileName,
siteLogo,
siteName,
title = "Claw 分析报告",
}: ResponsePdfDownloadButtonProps) {
const [isPreparing, setIsPreparing] = useState(false);
const resolvedSiteName = getSafeTitle(siteName) || "AI Chatbot";
const resolvedTitle = getSafeTitle(title) || "Claw 分析报告";
const handleDownloadPdf = () => {
const contentElement = contentRef.current;
if (!contentElement) {
toast.error("未找到可导出的内容");
return;
}
const printWindow = window.open(
"",
"_blank",
"width=1200,height=820"
);
if (!printWindow) {
toast.error("浏览器阻止了 PDF 导出窗口,请允许弹窗后重试");
return;
}
setIsPreparing(true);
try {
const safeDocumentTitle = getSafeFileName(fileName || resolvedTitle);
printWindow.document.open();
printWindow.document.write(
`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(
safeDocumentTitle
)}</title><base href="${escapeHtml(window.location.origin)}"></head><body></body></html>`
);
printWindow.document.close();
clonePrintableStyles(printWindow);
createPrintOverrides(printWindow);
const shell = printWindow.document.createElement("main");
shell.className = "claw-pdf-shell";
const header = printWindow.document.createElement("header");
header.className = "claw-pdf-header";
const brand = printWindow.document.createElement("div");
brand.className = "claw-pdf-brand";
if (siteLogo) {
const logo = printWindow.document.createElement("img");
logo.className = "claw-pdf-logo";
logo.alt = resolvedSiteName;
logo.src = siteLogo;
brand.appendChild(logo);
} else {
const fallback = printWindow.document.createElement("span");
fallback.className = "claw-pdf-logo-fallback";
fallback.textContent = resolvedSiteName.slice(0, 1).toUpperCase();
brand.appendChild(fallback);
}
const brandText = printWindow.document.createElement("div");
brandText.innerHTML = `<div class="claw-pdf-site">${escapeHtml(
resolvedSiteName
)}</div><div class="claw-pdf-title">${escapeHtml(resolvedTitle)}</div>`;
brand.appendChild(brandText);
const time = printWindow.document.createElement("div");
time.className = "claw-pdf-time";
time.textContent = new Date().toLocaleString("zh-CN", {
hour12: false,
});
header.appendChild(brand);
header.appendChild(time);
shell.appendChild(header);
const content = printWindow.document.createElement("section");
content.className = "claw-pdf-content";
const clonedContent = contentElement.cloneNode(true) as HTMLElement;
clonedContent
.querySelectorAll('[data-pdf-exclude="true"]')
.forEach((node) => node.remove());
content.appendChild(clonedContent);
shell.appendChild(content);
printWindow.document.body.appendChild(shell);
window.setTimeout(() => {
setIsPreparing(false);
printWindow.focus();
printWindow.print();
}, 600);
} catch (error) {
setIsPreparing(false);
printWindow.close();
console.error("[Response PDF] export failed:", error);
toast.error("PDF 导出失败,请稍后重试");
}
};
return (
<button
type="button"
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-2.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground",
isPreparing && "pointer-events-none opacity-70"
)}
onClick={handleDownloadPdf}
disabled={isPreparing}
title="导出整段回复为 PDF"
>
<FileDownIcon className="h-3.5 w-3.5" />
<span>{isPreparing ? "准备中" : "下载 PDF"}</span>
</button>
);
}

View File

@ -9,6 +9,7 @@ import {
OpenClawChartCard,
splitOpenClawChartBlocks,
} from "@/components/elements/openclaw-chart-card";
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
import {
Collapsible,
CollapsibleContent,
@ -19,6 +20,11 @@ import { cn } from "@/lib/utils";
type ResponseProps = ComponentProps<typeof Streamdown> & {
collapsibleImageAnalysis?: boolean;
enablePdfDownload?: boolean;
pdfFileName?: string;
pdfSiteLogo?: string | null;
pdfSiteName?: string | null;
pdfTitle?: string;
onImageEdit?: (imageUrl: string) => void;
onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void;
@ -59,6 +65,19 @@ type DownloadableMarkdownTableProps = ComponentProps<"table"> & {
node?: unknown;
};
const OPENCLAW_CHART_BLOCK_REGEX =
/```(?:openclaw-chart|openclaw_chart|claw-chart|claw_chart|chart)\s*\n[\s\S]*?```/i;
const MARKDOWN_TABLE_REGEX =
/(^|\n)\s*\|[^\n|]+(?:\|[^\n|]+)+\|\s*\n\s*\|(?:\s*:?-{3,}:?\s*\|)+/;
export function hasDownloadableReportContent(content: string) {
const normalized = String(content || "");
return (
OPENCLAW_CHART_BLOCK_REGEX.test(normalized) ||
MARKDOWN_TABLE_REGEX.test(normalized)
);
}
function normalizeTableCellText(value: string) {
return value.replace(/\s+/g, " ").trim();
}
@ -295,10 +314,14 @@ function DownloadableMarkdownTable({
return (
<div className="my-3 max-w-full overflow-hidden rounded-xl border border-border bg-background/70 whitespace-normal">
<div className="flex items-center justify-end border-b border-border/70 bg-muted/40 px-2 py-1.5">
<div
className="flex items-center justify-end border-b border-border/70 bg-muted/40 px-2 py-1.5"
data-pdf-exclude="true"
>
<button
type="button"
onClick={handleDownload}
data-pdf-exclude="true"
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
title="下载表格 CSV"
>
@ -306,13 +329,14 @@ function DownloadableMarkdownTable({
<span>下载表格</span>
</button>
</div>
<div className="max-w-full overflow-x-auto overscroll-x-contain">
<div className="chat-card-x-scroll max-w-full overflow-x-auto overscroll-x-contain pb-2">
<table
ref={tableRef}
className={cn(
"w-max min-w-full border-collapse text-sm",
"[&_td]:min-w-32 [&_td]:align-top [&_td]:break-words [&_td]:whitespace-normal",
"[&_th]:min-w-32 [&_th]:whitespace-nowrap",
"[&_td]:min-w-[12rem] [&_td]:align-top [&_td]:break-words [&_td]:whitespace-normal",
"[&_td:first-child]:min-w-[22rem] [&_th:first-child]:min-w-[22rem]",
"[&_th]:min-w-[12rem] [&_th]:whitespace-nowrap",
className
)}
{...props}
@ -329,6 +353,11 @@ function DownloadableMarkdownTable({
export const Response = ({
className,
collapsibleImageAnalysis = false,
enablePdfDownload = false,
pdfFileName,
pdfSiteLogo,
pdfSiteName,
pdfTitle,
onImageEdit,
onDrawingComplete,
onImageEnhance,
@ -343,6 +372,7 @@ export const Response = ({
isProcessingWatermarkRemove,
...props
}: ResponseProps) => {
const pdfContentRef = useRef<HTMLDivElement>(null);
const streamdownClassName = cn(
"w-full min-w-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto",
className,
@ -478,13 +508,37 @@ export const Response = ({
typeof props.children === "string"
? normalizeMarkdownContent(props.children)
: null;
const shouldShowPdfDownload =
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
const wrapPdfDownload = (content: ReactNode) => {
if (!shouldShowPdfDownload) return content;
return (
<div className="w-full min-w-0">
<div
className="not-prose mb-2 flex justify-end"
data-pdf-exclude="true"
>
<ResponsePdfDownloadButton
contentRef={pdfContentRef}
fileName={pdfFileName}
siteLogo={pdfSiteLogo}
siteName={pdfSiteName}
title={pdfTitle}
/>
</div>
<div ref={pdfContentRef}>{content}</div>
</div>
);
};
if (rawText) {
const analysisSection = collapsibleImageAnalysis
? extractImageAnalysisSection(rawText)
: null;
if (analysisSection) {
return (
return wrapPdfDownload(
<div className={streamdownClassName}>
{renderTextWithImages(analysisSection.before, "analysis-before")}
<ImageAnalysisCollapsible
@ -497,7 +551,9 @@ export const Response = ({
);
}
return <div className={streamdownClassName}>{renderTextWithImages(rawText, "raw")}</div>;
return wrapPdfDownload(
<div className={streamdownClassName}>{renderTextWithImages(rawText, "raw")}</div>
);
}
return (