mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-03 06:12:14 +08:00
feat(web): add multi-format report export (Markdown, HTML, PDF, Word,… (#756)
* feat(web): add multi-format report export (Markdown, HTML, PDF, Word, Image) * fix: correct import order for docx (lint error) * fix(web): address Copilot review comments for multi-format export - Add i18n support for dropdown menu items (en/zh) - Add DOMPurify for HTML sanitization (XSS protection) - Fix async handling for canvas.toBlob with Promise wrapper - Add toast notifications for export errors - Fix Tooltip + DropdownMenuTrigger nesting (accessibility) - Ensure container cleanup in finally block * fix(web): enhance markdown parsing for PDF and Word export - Add list support (bullet and numbered) for PDF export - Add parseInlineMarkdown helper for Word export to handle bold, italic, code, links - Add list support for Word export (bullet and numbered) - Address Copilot review comments from PR #756 * fix(web): address PR review feedback for multi-format export - Extract PDF formatting magic numbers into PDF_CONSTANTS - Add Tooltip wrapper for download dropdown button - Reduce triggerDownload cleanup timeout from 1000ms to 100ms - Use marked.Lexer.lexInline for robust markdown parsing - Add console.warn for image export cleanup errors - Add numbering config for Word document ordered lists - Fix CSS class typo: px-5pb-20 -> px-5 pb-20 - Remove unreachable dead code in parseInlineMarkdown --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
@@ -130,7 +130,13 @@
|
||||
"generatePodcast": "Generate podcast",
|
||||
"edit": "Edit",
|
||||
"copy": "Copy",
|
||||
"downloadReport": "Download report as markdown",
|
||||
"downloadReport": "Download report",
|
||||
"downloadMarkdown": "Markdown (.md)",
|
||||
"downloadHTML": "HTML (.html)",
|
||||
"downloadPDF": "PDF (.pdf)",
|
||||
"downloadWord": "Word (.docx)",
|
||||
"downloadImage": "Image (.png)",
|
||||
"exportFailed": "Export failed, please try again",
|
||||
"searchingFor": "Searching for",
|
||||
"reading": "Reading",
|
||||
"runningPythonCode": "Running Python code",
|
||||
|
||||
@@ -130,7 +130,13 @@
|
||||
"generatePodcast": "生成播客",
|
||||
"edit": "编辑",
|
||||
"copy": "复制",
|
||||
"downloadReport": "下载报告为 Markdown",
|
||||
"downloadReport": "下载报告",
|
||||
"downloadMarkdown": "Markdown (.md)",
|
||||
"downloadHTML": "HTML (.html)",
|
||||
"downloadPDF": "PDF (.pdf)",
|
||||
"downloadWord": "Word (.docx)",
|
||||
"downloadImage": "图片 (.png)",
|
||||
"exportFailed": "导出失败,请重试",
|
||||
"searchingFor": "搜索",
|
||||
"reading": "阅读中",
|
||||
"runningPythonCode": "运行 Python 代码",
|
||||
|
||||
@@ -49,21 +49,28 @@
|
||||
"@tiptap/extension-table-row": "^2.11.7",
|
||||
"@tiptap/extension-text": "^2.12.0",
|
||||
"@tiptap/react": "^2.11.7",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@xyflow/react": "^12.6.0",
|
||||
"best-effort-json-parser": "^1.1.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"docx": "^9.5.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^12.6.5",
|
||||
"hast": "^1.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"immer": "^10.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jspdf": "^3.0.4",
|
||||
"katex": "^0.16.21",
|
||||
"lowlight": "^3.3.0",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-react": "^0.487.0",
|
||||
"marked": "^17.0.1",
|
||||
"motion": "^12.7.4",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "^15.4.10",
|
||||
@@ -94,6 +101,7 @@
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.14.10",
|
||||
|
||||
525
web/pnpm-lock.yaml
generated
525
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,46 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { Check, Copy, Headphones, Pencil, Undo2, X, Download } from "lucide-react";
|
||||
import {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
HeadingLevel,
|
||||
ExternalHyperlink,
|
||||
} from "docx";
|
||||
import DOMPurify from "dompurify";
|
||||
import { saveAs } from "file-saver";
|
||||
import html2canvas from "html2canvas";
|
||||
import { jsPDF } from "jspdf";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Headphones,
|
||||
Pencil,
|
||||
Undo2,
|
||||
X,
|
||||
Download,
|
||||
FileText,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileType,
|
||||
} from "lucide-react";
|
||||
import { marked } from "marked";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ScrollContainer } from "~/components/deer-flow/scroll-container";
|
||||
import { Tooltip } from "~/components/deer-flow/tooltip";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { useReplay } from "~/core/replay";
|
||||
import { closeResearch, listenToPodcast, useStore } from "~/core/store";
|
||||
@@ -50,6 +82,7 @@ export function ResearchBlock({
|
||||
}, [researchId]);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!reportId) {
|
||||
@@ -66,38 +99,534 @@ export function ResearchBlock({
|
||||
}, 1000);
|
||||
}, [reportId]);
|
||||
|
||||
// Download report as markdown
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!reportId) {
|
||||
return;
|
||||
}
|
||||
const report = useStore.getState().messages.get(reportId);
|
||||
if (!report) {
|
||||
return;
|
||||
}
|
||||
// Helper function to generate timestamp for filenames
|
||||
const getTimestamp = useCallback(() => {
|
||||
const now = new Date();
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
|
||||
const filename = `research-report-${timestamp}.md`;
|
||||
const blob = new Blob([report.content], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (a.parentNode) {
|
||||
a.parentNode.removeChild(a);
|
||||
}
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, 0);
|
||||
}, [reportId]);
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
|
||||
}, []);
|
||||
|
||||
// Helper function to trigger file download
|
||||
const triggerDownload = useCallback(
|
||||
(content: string, filename: string, mimeType: string) => {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (a.parentNode) {
|
||||
a.parentNode.removeChild(a);
|
||||
}
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Download report as Markdown
|
||||
const handleDownloadMarkdown = useCallback(() => {
|
||||
if (!reportId) return;
|
||||
const report = useStore.getState().messages.get(reportId);
|
||||
if (!report) return;
|
||||
triggerDownload(
|
||||
report.content,
|
||||
`research-report-${getTimestamp()}.md`,
|
||||
"text/markdown",
|
||||
);
|
||||
}, [reportId, getTimestamp, triggerDownload]);
|
||||
|
||||
// Download report as HTML
|
||||
const handleDownloadHTML = useCallback(() => {
|
||||
if (!reportId) return;
|
||||
const report = useStore.getState().messages.get(reportId);
|
||||
if (!report) return;
|
||||
const rawHtml = marked(report.content) as string;
|
||||
const htmlContent = DOMPurify.sanitize(rawHtml);
|
||||
const fullHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Research Report</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }
|
||||
h1, h2, h3 { color: #333; }
|
||||
code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; }
|
||||
pre { background: #f4f4f4; padding: 16px; border-radius: 8px; overflow-x: auto; }
|
||||
blockquote { border-left: 4px solid #ddd; margin: 0; padding-left: 16px; color: #666; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background: #f4f4f4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${htmlContent}
|
||||
</body>
|
||||
</html>`;
|
||||
triggerDownload(
|
||||
fullHTML,
|
||||
`research-report-${getTimestamp()}.html`,
|
||||
"text/html",
|
||||
);
|
||||
}, [reportId, getTimestamp, triggerDownload]);
|
||||
|
||||
// Download report as PDF (text-based, no html2canvas)
|
||||
const handleDownloadPDF = useCallback(async () => {
|
||||
if (!reportId || isDownloading) return;
|
||||
const report = useStore.getState().messages.get(reportId);
|
||||
if (!report) return;
|
||||
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const pdf = new jsPDF("p", "mm", "a4");
|
||||
const pageWidth = 210;
|
||||
const pageHeight = 297;
|
||||
const margin = 20;
|
||||
const maxWidth = pageWidth - 2 * margin;
|
||||
let y = margin;
|
||||
|
||||
// PDF formatting constants for maintainability
|
||||
const PDF_CONSTANTS = {
|
||||
headings: {
|
||||
h1: { fontSize: 20, lineHeight: 9, spacing: 6 },
|
||||
h2: { fontSize: 16, lineHeight: 7, spacing: 5 },
|
||||
h3: { fontSize: 14, lineHeight: 6, spacing: 4 },
|
||||
},
|
||||
text: { fontSize: 11, normalHeight: 5, paragraphSpacing: 2 },
|
||||
list: { bullet: "• ", indentLevel: 2 },
|
||||
emptyLine: { height: 4 },
|
||||
};
|
||||
|
||||
const lines = report.content.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Handle headings
|
||||
if (line.startsWith("### ")) {
|
||||
const h3 = PDF_CONSTANTS.headings.h3;
|
||||
pdf.setFontSize(h3.fontSize);
|
||||
pdf.setFont("helvetica", "bold");
|
||||
const text = line.substring(4);
|
||||
const splitText = pdf.splitTextToSize(text, maxWidth);
|
||||
if (y + 10 > pageHeight - margin) {
|
||||
pdf.addPage();
|
||||
y = margin;
|
||||
}
|
||||
pdf.text(splitText, margin, y);
|
||||
y += splitText.length * h3.lineHeight + h3.spacing;
|
||||
} else if (line.startsWith("## ")) {
|
||||
const h2 = PDF_CONSTANTS.headings.h2;
|
||||
pdf.setFontSize(h2.fontSize);
|
||||
pdf.setFont("helvetica", "bold");
|
||||
const text = line.substring(3);
|
||||
const splitText = pdf.splitTextToSize(text, maxWidth);
|
||||
if (y + 12 > pageHeight - margin) {
|
||||
pdf.addPage();
|
||||
y = margin;
|
||||
}
|
||||
pdf.text(splitText, margin, y);
|
||||
y += splitText.length * h2.lineHeight + h2.spacing;
|
||||
} else if (line.startsWith("# ")) {
|
||||
const h1 = PDF_CONSTANTS.headings.h1;
|
||||
pdf.setFontSize(h1.fontSize);
|
||||
pdf.setFont("helvetica", "bold");
|
||||
const text = line.substring(2);
|
||||
const splitText = pdf.splitTextToSize(text, maxWidth);
|
||||
if (y + 14 > pageHeight - margin) {
|
||||
pdf.addPage();
|
||||
y = margin;
|
||||
}
|
||||
pdf.text(splitText, margin, y);
|
||||
y += splitText.length * h1.lineHeight + h1.spacing;
|
||||
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||
// Unordered list item
|
||||
const textConfig = PDF_CONSTANTS.text;
|
||||
pdf.setFontSize(textConfig.fontSize);
|
||||
pdf.setFont("helvetica", "normal");
|
||||
const cleanText = line
|
||||
.substring(2)
|
||||
.replace(/\*\*(.*?)\*\*/g, "$1")
|
||||
.replace(/\*(.*?)\*/g, "$1")
|
||||
.replace(/`(.*?)`/g, "$1")
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, "$1");
|
||||
const bulletText = `• ${cleanText}`;
|
||||
const splitText = pdf.splitTextToSize(bulletText, maxWidth - 5);
|
||||
|
||||
if (
|
||||
y + splitText.length * textConfig.normalHeight >
|
||||
pageHeight - margin
|
||||
) {
|
||||
pdf.addPage();
|
||||
y = margin;
|
||||
}
|
||||
pdf.text(splitText, margin + PDF_CONSTANTS.list.indentLevel, y);
|
||||
y +=
|
||||
splitText.length * textConfig.normalHeight +
|
||||
PDF_CONSTANTS.text.paragraphSpacing;
|
||||
} else if (/^\d+\.\s/.test(line)) {
|
||||
// Ordered list item
|
||||
const textConfig = PDF_CONSTANTS.text;
|
||||
pdf.setFontSize(textConfig.fontSize);
|
||||
pdf.setFont("helvetica", "normal");
|
||||
const match = /^(\d+)\.\s(.*)$/.exec(line);
|
||||
if (match?.[1] && match[2]) {
|
||||
const cleanText = match[2]
|
||||
.replace(/\*\*(.*?)\*\*/g, "$1")
|
||||
.replace(/\*(.*?)\*/g, "$1")
|
||||
.replace(/`(.*?)`/g, "$1")
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, "$1");
|
||||
const numberedText = `${match[1]}. ${cleanText}`;
|
||||
const splitText = pdf.splitTextToSize(numberedText, maxWidth - 5);
|
||||
|
||||
if (
|
||||
y + splitText.length * textConfig.normalHeight >
|
||||
pageHeight - margin
|
||||
) {
|
||||
pdf.addPage();
|
||||
y = margin;
|
||||
}
|
||||
pdf.text(splitText, margin + PDF_CONSTANTS.list.indentLevel, y);
|
||||
y +=
|
||||
splitText.length * textConfig.normalHeight +
|
||||
PDF_CONSTANTS.text.paragraphSpacing;
|
||||
}
|
||||
} else if (line.trim()) {
|
||||
// Normal text
|
||||
const textConfig = PDF_CONSTANTS.text;
|
||||
pdf.setFontSize(textConfig.fontSize);
|
||||
pdf.setFont("helvetica", "normal");
|
||||
// Remove markdown formatting
|
||||
const cleanText = line
|
||||
.replace(/\*\*(.*?)\*\*/g, "$1")
|
||||
.replace(/\*(.*?)\*/g, "$1")
|
||||
.replace(/`(.*?)`/g, "$1")
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, "$1");
|
||||
const splitText = pdf.splitTextToSize(cleanText, maxWidth);
|
||||
|
||||
if (
|
||||
y + splitText.length * textConfig.normalHeight >
|
||||
pageHeight - margin
|
||||
) {
|
||||
pdf.addPage();
|
||||
y = margin;
|
||||
}
|
||||
pdf.text(splitText, margin, y);
|
||||
y +=
|
||||
splitText.length * textConfig.normalHeight +
|
||||
PDF_CONSTANTS.text.paragraphSpacing;
|
||||
} else {
|
||||
// Empty line
|
||||
y += PDF_CONSTANTS.emptyLine.height;
|
||||
}
|
||||
|
||||
// Check page overflow
|
||||
if (y > pageHeight - margin) {
|
||||
pdf.addPage();
|
||||
y = margin;
|
||||
}
|
||||
}
|
||||
|
||||
pdf.save(`research-report-${getTimestamp()}.pdf`);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate PDF:", error);
|
||||
toast.error(t("exportFailed"));
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [reportId, getTimestamp, isDownloading, t]);
|
||||
|
||||
// Helper function to parse inline markdown formatting for Word export
|
||||
const parseInlineMarkdown = useCallback(
|
||||
(text: string): (TextRun | ExternalHyperlink)[] => {
|
||||
// Process text recursively using marked's inline lexer
|
||||
const runs: (TextRun | ExternalHyperlink)[] = [];
|
||||
|
||||
try {
|
||||
// Use marked's Lexer to safely parse inline markdown
|
||||
const tokens = marked.Lexer.lexInline(text);
|
||||
|
||||
interface MarkedToken {
|
||||
type: string;
|
||||
text?: string;
|
||||
tokens?: MarkedToken[];
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const processTokens = (tokens: MarkedToken[]): void => {
|
||||
for (const token of tokens) {
|
||||
if (token.type === "text") {
|
||||
// Regular text
|
||||
if (token.text) {
|
||||
runs.push(new TextRun(token.text));
|
||||
}
|
||||
} else if (token.type === "strong") {
|
||||
// Bold text - may contain nested tokens
|
||||
if (token.tokens && token.tokens.length > 0) {
|
||||
// Process nested tokens and mark them as bold
|
||||
const nestedRuns: TextRun[] = [];
|
||||
for (const nestedToken of token.tokens) {
|
||||
if (nestedToken.type === "text") {
|
||||
nestedRuns.push(
|
||||
new TextRun({ text: nestedToken.text, bold: true }),
|
||||
);
|
||||
} else if (nestedToken.type === "em") {
|
||||
// Bold + italic nested tokens
|
||||
nestedRuns.push(
|
||||
new TextRun({
|
||||
text: nestedToken.text,
|
||||
bold: true,
|
||||
italics: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
runs.push(...nestedRuns);
|
||||
} else {
|
||||
runs.push(new TextRun({ text: token.text, bold: true }));
|
||||
}
|
||||
} else if (token.type === "em") {
|
||||
// Italic text
|
||||
runs.push(
|
||||
new TextRun({
|
||||
text: token.text ?? token.tokens?.[0]?.text,
|
||||
italics: true,
|
||||
}),
|
||||
);
|
||||
} else if (token.type === "codespan") {
|
||||
// Inline code
|
||||
if (token.text) {
|
||||
runs.push(
|
||||
new TextRun({ text: token.text, font: "Courier New" }),
|
||||
);
|
||||
}
|
||||
} else if (token.type === "link") {
|
||||
// Link - use the link text or fallback to URL
|
||||
const linkText = token.text ?? token.href ?? "";
|
||||
const linkUrl = token.href ?? "";
|
||||
if (linkUrl) {
|
||||
runs.push(
|
||||
new ExternalHyperlink({
|
||||
children: [
|
||||
new TextRun({ text: linkText, style: "Hyperlink" }),
|
||||
],
|
||||
link: linkUrl,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// Fallback to plain text if no URL
|
||||
runs.push(new TextRun(linkText));
|
||||
}
|
||||
} else if (token.type === "space") {
|
||||
// Preserve spaces
|
||||
runs.push(new TextRun(" "));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processTokens(tokens);
|
||||
} catch (error) {
|
||||
// Fallback to simple text parsing if marked fails
|
||||
console.warn("Marked parsing failed, using fallback:", error);
|
||||
// Pattern to match: bold (**text**), italic (*text*), inline code (`text`), links [text](url)
|
||||
const pattern =
|
||||
/(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`(.+?)`)|\[(.+?)\]\((.+?)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
// Add plain text before the match
|
||||
if (match.index > lastIndex) {
|
||||
runs.push(new TextRun(text.slice(lastIndex, match.index)));
|
||||
}
|
||||
|
||||
if (match[1]) {
|
||||
// Bold: **text**
|
||||
const boldText = match[2] ?? "";
|
||||
runs.push(new TextRun({ text: boldText, bold: true }));
|
||||
} else if (match[3]) {
|
||||
// Italic: *text*
|
||||
const italicText = match[4] ?? "";
|
||||
runs.push(new TextRun({ text: italicText, italics: true }));
|
||||
} else if (match[5]) {
|
||||
// Inline code: `text`
|
||||
const codeText = match[6] ?? "";
|
||||
runs.push(new TextRun({ text: codeText, font: "Courier New" }));
|
||||
} else if (match[7] && match[8]) {
|
||||
// Link: [text](url)
|
||||
runs.push(
|
||||
new ExternalHyperlink({
|
||||
children: [
|
||||
new TextRun({ text: match[7] ?? "", style: "Hyperlink" }),
|
||||
],
|
||||
link: match[8],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining plain text
|
||||
if (lastIndex < text.length) {
|
||||
runs.push(new TextRun(text.slice(lastIndex)));
|
||||
}
|
||||
}
|
||||
|
||||
return runs.length > 0 ? runs : [new TextRun(text)];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Download report as Word document
|
||||
const handleDownloadWord = useCallback(async () => {
|
||||
if (!reportId || isDownloading) return;
|
||||
const report = useStore.getState().messages.get(reportId);
|
||||
if (!report) return;
|
||||
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
// Parse markdown content into paragraphs
|
||||
const lines = report.content.split("\n");
|
||||
const children: Paragraph[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("# ")) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: parseInlineMarkdown(line.substring(2)),
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
);
|
||||
} else if (line.startsWith("## ")) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: parseInlineMarkdown(line.substring(3)),
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
}),
|
||||
);
|
||||
} else if (line.startsWith("### ")) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: parseInlineMarkdown(line.substring(4)),
|
||||
heading: HeadingLevel.HEADING_3,
|
||||
}),
|
||||
);
|
||||
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||
// Unordered list item
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: parseInlineMarkdown(line.substring(2)),
|
||||
bullet: { level: 0 },
|
||||
}),
|
||||
);
|
||||
} else if (/^\d+\.\s/.test(line)) {
|
||||
// Ordered list item
|
||||
const text = line.replace(/^\d+\.\s/, "");
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: parseInlineMarkdown(text),
|
||||
numbering: { reference: "default-numbering", level: 0 },
|
||||
}),
|
||||
);
|
||||
} else if (line.trim()) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: parseInlineMarkdown(line),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
children.push(new Paragraph({ text: "" }));
|
||||
}
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
numbering: {
|
||||
config: [
|
||||
{
|
||||
reference: "default-numbering",
|
||||
levels: [
|
||||
{
|
||||
level: 0,
|
||||
format: "decimal",
|
||||
text: "%1.",
|
||||
alignment: "start",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: [{ children }],
|
||||
});
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
saveAs(blob, `research-report-${getTimestamp()}.docx`);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate Word document:", error);
|
||||
toast.error(t("exportFailed"));
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [reportId, getTimestamp, isDownloading, t, parseInlineMarkdown]);
|
||||
|
||||
// Download report as Image
|
||||
const handleDownloadImage = useCallback(async () => {
|
||||
if (!reportId || isDownloading) return;
|
||||
const report = useStore.getState().messages.get(reportId);
|
||||
if (!report) return;
|
||||
|
||||
setIsDownloading(true);
|
||||
let container: HTMLDivElement | null = null;
|
||||
try {
|
||||
// Create a temporary container with simple styles to avoid color parsing issues
|
||||
container = document.createElement("div");
|
||||
container.style.cssText =
|
||||
"position: absolute; left: -9999px; top: 0; width: 800px; padding: 40px; font-family: Arial, sans-serif; line-height: 1.6; background-color: #ffffff; color: #000000;";
|
||||
const styleTag =
|
||||
"<style>* { color: #000000; } h1,h2,h3,h4,h5,h6 { color: #333333; } a { color: #0066cc; } code { background-color: #f5f5f5; padding: 2px 4px; } pre { background-color: #f5f5f5; padding: 12px; }</style>";
|
||||
const rawHtml = marked(report.content) as string;
|
||||
const sanitizedHtml = DOMPurify.sanitize(rawHtml);
|
||||
container.innerHTML = styleTag + sanitizedHtml;
|
||||
document.body.appendChild(container);
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
|
||||
// Promisify toBlob for proper async handling
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob((b) => resolve(b), "image/png");
|
||||
});
|
||||
|
||||
if (blob) {
|
||||
saveAs(blob, `research-report-${getTimestamp()}.png`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to generate image:", error);
|
||||
toast.error(t("exportFailed"));
|
||||
} finally {
|
||||
// Ensure container is always removed
|
||||
try {
|
||||
container?.parentNode?.removeChild(container);
|
||||
} catch (error) {
|
||||
// Log cleanup errors for better debugging (not just in development)
|
||||
console.warn(
|
||||
"Failed to remove temporary container during image export cleanup:",
|
||||
error,
|
||||
);
|
||||
// Don't throw - cleanup failures are expected and harmless
|
||||
}
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [reportId, getTimestamp, isDownloading, t]);
|
||||
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setEditing((editing) => !editing);
|
||||
}, []);
|
||||
@@ -147,16 +676,50 @@ export function ResearchBlock({
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("downloadReport")}>
|
||||
<Button
|
||||
className="text-gray-400"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<Tooltip title={t("downloadReport")}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="text-gray-400"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleDownloadMarkdown}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
{t("downloadMarkdown")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDownloadHTML}>
|
||||
<FileCode className="mr-2 h-4 w-4" />
|
||||
{t("downloadHTML")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<FileType className="mr-2 h-4 w-4" />
|
||||
{t("downloadPDF")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownloadWord}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
{t("downloadWord")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownloadImage}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<FileImage className="mr-2 h-4 w-4" />
|
||||
{t("downloadImage")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<Tooltip title={t("close")}>
|
||||
@@ -198,7 +761,7 @@ export function ResearchBlock({
|
||||
hidden={activeTab !== "report"}
|
||||
>
|
||||
<ScrollContainer
|
||||
className="px-5pb-20 h-full"
|
||||
className="h-full px-5 pb-20"
|
||||
scrollShadowColor="var(--card)"
|
||||
autoScrollToBottom={!hasReport || reportStreaming}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user