diff --git a/app/globals.css b/app/globals.css index 3b15af669..ad79ed694 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index b6e4411c3..d8623820f 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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 (
- {displayText} + + {displayText} + {showCursor ? : null}
); @@ -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' ? (
@@ -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} /> diff --git a/components/elements/openclaw-chart-card.tsx b/components/elements/openclaw-chart-card.tsx index 4081c9831..e758b20f2 100644 --- a/components/elements/openclaw-chart-card.tsx +++ b/components/elements/openclaw-chart-card.tsx @@ -306,6 +306,7 @@ export function OpenClawChartCard({ chart }: { chart: OpenClawChartSpec }) {
-
+
diff --git a/components/elements/response-pdf-download.tsx b/components/elements/response-pdf-download.tsx new file mode 100644 index 000000000..27f8daccf --- /dev/null +++ b/components/elements/response-pdf-download.tsx @@ -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; + fileName?: string; + siteLogo?: string | null; + siteName?: string | null; + title?: string; +}; + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +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( + '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( + `${escapeHtml( + safeDocumentTitle + )}` + ); + 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 = `
${escapeHtml( + resolvedSiteName + )}
${escapeHtml(resolvedTitle)}
`; + 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 ( + + ); +} diff --git a/components/elements/response.tsx b/components/elements/response.tsx index fe863d6fa..fe9768a40 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -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 & { 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 (
-
+
-
+
{ + const pdfContentRef = useRef(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 ( +
+
+ +
+
{content}
+
+ ); + }; + if (rawText) { const analysisSection = collapsibleImageAnalysis ? extractImageAnalysisSection(rawText) : null; if (analysisSection) { - return ( + return wrapPdfDownload(
{renderTextWithImages(analysisSection.before, "analysis-before")} {renderTextWithImages(rawText, "raw")}
; + return wrapPdfDownload( +
{renderTextWithImages(rawText, "raw")}
+ ); } return (