feat(artifacts): 新增 Markdown 导出 DOCX/PDF 功能

- 新增 document-converter.ts 工具模块,支持 Markdown 转换为 DOCX(docx 库)和 PDF(html2pdf.js)
- 在 artifact-file-detail 添加下载菜单选项(downloadAsDocx、downloadAsPdf)
- .gitignore 添加 docs 目录忽略
This commit is contained in:
肖应宇 2026-03-23 17:09:39 +08:00
parent 04df7e25b1
commit da2023b42b
11 changed files with 1072 additions and 104 deletions

2
frontend/.gitignore vendored
View File

@ -21,6 +21,8 @@ next-env.d.ts
# production
/build
docs
# misc
.DS_Store
*.pem

View File

@ -59,12 +59,15 @@
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"docx": "^9.6.1",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
"gsap": "^3.13.0",
"hast": "^1.0.0",
"html2pdf.js": "^0.14.0",
"katex": "^0.16.28",
"lucide-react": "^0.562.0",
"marked": "^17.0.5",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next": "^16.1.4",

File diff suppressed because it is too large Load Diff

View File

@ -90,6 +90,7 @@ export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
icon?: LucideIcon;
asChild?: boolean;
};
export const ArtifactAction = ({
@ -100,6 +101,7 @@ export const ArtifactAction = ({
className,
size = "sm",
variant = "ghost",
asChild = false,
...props
}: ArtifactActionProps) => {
const button = (
@ -111,6 +113,7 @@ export const ArtifactAction = ({
size={size}
type="button"
variant={variant}
asChild={asChild}
{...props}
>
{Icon ? <Icon className="size-4" /> : children}

View File

@ -1,21 +1,8 @@
import {
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
SquareArrowOutUpRightIcon,
XIcon,
ZoomIn,
ZoomOut,
type LucideIcon,
} from "lucide-react";
import { DownloadIcon, FileTextIcon, LoaderIcon, FileTypeIcon } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type HTMLAttributes,
} from "react";
@ -30,14 +17,13 @@ import {
ArtifactHeader,
ArtifactTitle,
} from "@/components/ai-elements/artifact";
import { DropdownSelector } from "@/components/ui/dropdown-selector";
import { Select, SelectItem } from "@/components/ui/select";
import {
SelectContent,
SelectGroup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DropdownSelector } from "@/components/ui/dropdown-selector";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks";
@ -47,11 +33,10 @@ import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { env } from "@/env";
import { useMarkdownDownload } from "@/core/utils/markdown-download";
import { cn, copyToClipboard } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context";
@ -114,6 +99,34 @@ export function ArtifactFileDetail({
const [isInstalling, setIsInstalling] = useState(false);
const [zoom, setZoom] = useState(80);
// 获取文件名(不含路径)
const fileName = useMemo(() => getFileName(filepath), [filepath]);
// 是否可以转换为docx/pdf仅markdown文件支持
const canConvertToDocxPdf = language === "markdown";
// 使用 Markdown 下载 hook
const { isDownloading, downloadAsDocx, downloadAsPdf } = useMarkdownDownload({
onError: (error, format) => {
console.error(`Failed to download as ${format}:`, error);
toast.error(`Failed to download as ${format.toUpperCase()}`);
},
});
// 下载为 DOCX
const handleDownloadDocx = useCallback(() => {
if (content) {
void downloadAsDocx(content, fileName);
}
}, [content, fileName, downloadAsDocx]);
// 下载为 PDF
const handleDownloadPdf = useCallback(() => {
if (content) {
void downloadAsPdf(content, fileName);
}
}, [content, fileName, downloadAsPdf]);
// 全屏切换处理
const handleFullscreenToggle = useCallback(() => {
const newFullscreen = !fullscreen;
@ -153,6 +166,7 @@ export function ArtifactFileDetail({
setIsInstalling(false);
}
}, [threadId, filepath, isInstalling]);
return (
// 给滚动遮挡头部定位relative
<Artifact className={cn("relative",className)}>
@ -274,35 +288,75 @@ export function ArtifactFileDetail({
</ArtifactAction>
)}
{!isWriteFile && (
<a
href={urlOfArtifact({ filepath, threadId, download: true })}
target="_blank"
>
<ArtifactAction
label={t.common.download}
tooltip={t.common.download}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ArtifactAction
label={t.common.download}
tooltip={t.common.download}
>
<path
d="M16 9V14C16 15.1046 15.1046 16 14 16H4C2.89543 16 2 15.1046 2 14V9"
stroke="#666666"
strokeLinecap="round"
/>
<path
d="M9 2V13M9 13L5 9M9 13L13 9"
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</ArtifactAction>
</a>
{isDownloading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 9V14C16 15.1046 15.1046 16 14 16H4C2.89543 16 2 15.1046 2 14V9"
stroke="#666666"
strokeLinecap="round"
/>
<path
d="M9 2V13M9 13L5 9M9 13L13 9"
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</ArtifactAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
download: true,
})}
target="_blank"
className="w-full cursor-pointer"
>
<DownloadIcon className="size-4" />
{t.common.downloadOriginal}
</a>
</DropdownMenuItem>
{/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */}
{canConvertToDocxPdf && (
<>
<DropdownMenuItem
onClick={handleDownloadDocx}
disabled={isDownloading !== null || !content}
className="cursor-pointer"
>
<FileTextIcon className="size-4" />
{isDownloading === "docx" ? t.common.loading : t.common.downloadAsDocx}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDownloadPdf}
disabled={isDownloading !== null || !content}
className="cursor-pointer"
>
<FileTypeIcon className="size-4" />
{isDownloading === "pdf" ? t.common.loading : t.common.downloadAsPdf}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 全屏按钮 */}
<ArtifactAction

View File

@ -31,6 +31,9 @@ export const enUS: Translations = {
more: "More",
search: "Search",
download: "Download",
downloadOriginal: "Original File",
downloadAsDocx: "Download as DOCX",
downloadAsPdf: "Download as PDF",
thinking: "Thinking",
artifacts: "Artifacts",
public: "Public",

View File

@ -20,6 +20,9 @@ export interface Translations {
more: string;
search: string;
download: string;
downloadOriginal: string;
downloadAsDocx: string;
downloadAsPdf: string;
thinking: string;
artifacts: string;
public: string;

View File

@ -31,6 +31,9 @@ export const zhCN: Translations = {
more: "更多",
search: "搜索",
download: "下载",
downloadOriginal: "原文件",
downloadAsDocx: "下载为 DOCX",
downloadAsPdf: "下载为 PDF",
thinking: "思考",
artifacts: "查看结果",
public: "公共",

View File

@ -0,0 +1,464 @@
import { Document as DocxDocument, Packer, Paragraph, TextRun, HeadingLevel } from "docx";
import { marked } from "marked";
// ============================================================================
// Types
// ============================================================================
/**
* Markdown Token
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type MarkdownToken = any;
/**
* PDF
*/
export interface PdfOptions {
/**
* [, , , ] mm
* @default [15, 15, 15, 15]
*/
margin?: [number, number, number, number];
/**
*
* @default "a4"
*/
format?: "a3" | "a4" | "a5" | "letter" | "legal";
/**
*
* @default "portrait"
*/
orientation?: "portrait" | "landscape";
/**
*
* @default 2
*/
scale?: number;
}
/**
* DOCX
*/
export interface DocxOptions {
/**
*
* @default "Courier New"
*/
codeFont?: string;
/**
*
* @default 22 (11pt)
*/
codeFontSize?: number;
}
// ============================================================================
// DOCX Converter
// ============================================================================
/**
* Markdown DOCX
*
* @param markdown - Markdown
* @param filename - .md
* @param options -
*
* @example
* ```ts
* await downloadMarkdownAsDocx("# Hello World", "document");
* ```
*/
export async function downloadMarkdownAsDocx(markdown: string, filename: string, options: DocxOptions = {}): Promise<void> {
const { codeFont = "Courier New", codeFontSize = 22 } = options;
const tokens = marked.lexer(markdown);
const children = parseTokensToDocx(tokens, { codeFont, codeFontSize });
const doc = new DocxDocument({
sections: [{ children }],
});
const blob = await Packer.toBlob(doc);
downloadBlob(blob, normalizeFilename(filename, ".docx"));
}
// ============================================================================
// PDF Converter
// ============================================================================
/**
* Markdown PDF
*
* @param markdown - Markdown
* @param filename - .md
* @param options -
*
* @example
* ```ts
* await downloadMarkdownAsPdf("# Hello World", "document");
* ```
*/
export async function downloadMarkdownAsPdf(markdown: string, filename: string, options: PdfOptions = {}): Promise<void> {
const html2pdf = await loadHtml2Pdf();
const { margin = [15, 15, 15, 15], format = "a4", orientation = "portrait", scale = 2 } = options;
// 解析 Markdown 为 HTML
const htmlContent = await marked.parse(markdown);
// 创建容器并应用样式
const container = createStyledContainer(htmlContent);
// 配置 html2pdf
const opt = {
margin,
filename: normalizeFilename(filename, ".pdf"),
image: { type: "jpeg" as const, quality: 0.98 },
html2canvas: {
scale,
useCORS: true,
logging: false,
onclone: fixColorsForHtml2Canvas,
},
jsPDF: { unit: "mm" as const, format, orientation },
};
await html2pdf().set(opt).from(container).save();
}
// ============================================================================
// Internal Utilities
// ============================================================================
/**
* html2pdf.js
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
async function loadHtml2Pdf(): Promise<Function> {
const html2pdf = await import("html2pdf.js");
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return html2pdf.default;
}
/**
* HTML
*/
function createStyledContainer(htmlContent: string): HTMLDivElement {
const container = document.createElement("div");
container.innerHTML = htmlContent;
// 容器基础样式
container.style.cssText = `
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
padding: 20px;
max-width: 800px;
color: #333333;
background-color: #ffffff;
`;
// 应用元素样式
applyElementStyles(container);
return container;
}
/**
*
*/
function applyElementStyles(container: HTMLElement): void {
// 标题
container.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((h) => {
const el = h as HTMLElement;
el.style.marginTop = "1.5em";
el.style.marginBottom = "0.5em";
el.style.fontWeight = "600";
el.style.color = "#1a1a1a";
});
// 段落
container.querySelectorAll("p").forEach((p) => {
(p as HTMLElement).style.marginBottom = "1em";
});
// 代码块
container.querySelectorAll("pre, code").forEach((code) => {
const el = code as HTMLElement;
el.style.fontFamily = "'SF Mono', 'Fira Code', Consolas, monospace";
el.style.backgroundColor = "#f5f5f5";
el.style.color = "#333333";
el.style.fontSize = "13px";
if (code.tagName === "PRE") {
el.style.padding = "12px";
el.style.borderRadius = "6px";
el.style.overflow = "auto";
} else {
el.style.padding = "2px 4px";
el.style.borderRadius = "3px";
}
});
// 列表
container.querySelectorAll("ul, ol").forEach((list) => {
const el = list as HTMLElement;
el.style.marginBottom = "1em";
el.style.paddingLeft = "2em";
});
// 引用块
container.querySelectorAll("blockquote").forEach((bq) => {
const el = bq as HTMLElement;
el.style.borderLeft = "4px solid #dddddd";
el.style.marginLeft = "0";
el.style.paddingLeft = "16px";
el.style.color = "#666666";
});
// 表格
container.querySelectorAll("table").forEach((table) => {
const el = table as HTMLElement;
el.style.borderCollapse = "collapse";
el.style.width = "100%";
el.style.marginBottom = "1em";
});
container.querySelectorAll("th, td").forEach((cell) => {
const el = cell as HTMLElement;
el.style.border = "1px solid #dddddd";
el.style.padding = "8px";
});
// 链接
container.querySelectorAll("a").forEach((link) => {
const el = link as HTMLElement;
el.style.color = "#0066cc";
el.style.textDecoration = "underline";
});
// 分割线
container.querySelectorAll("hr").forEach((hr) => {
const el = hr as HTMLElement;
el.style.border = "none";
el.style.borderTop = "1px solid #dddddd";
el.style.margin = "2em 0";
});
}
/**
* html2canvas
*/
function fixColorsForHtml2Canvas(clonedDoc: Document): void {
// 移除外部样式表(可能包含 lab、oklab 等不支持的颜色)
clonedDoc.querySelectorAll<HTMLStyleElement | HTMLLinkElement>('link[rel="stylesheet"], style').forEach((sheet) => sheet.remove());
// 重置所有元素的颜色属性为安全值
clonedDoc.querySelectorAll<HTMLElement>("*").forEach((el) => {
const props = [
"color",
"background-color",
"border-color",
"border-top-color",
"border-bottom-color",
"border-left-color",
"border-right-color",
"outline-color",
"text-decoration-color",
"caret-color",
"column-rule-color",
"accent-color",
"fill",
"stroke",
];
props.forEach((prop) => el.style.removeProperty(prop));
el.style.color = "#333333";
el.style.backgroundColor = "transparent";
});
// 设置 body 背景
const body = clonedDoc.body;
body.style.color = "#333333";
body.style.backgroundColor = "#ffffff";
}
/**
* Markdown Token DOCX Paragraph
*/
function parseTokensToDocx(tokens: MarkdownToken[], options: Required<DocxOptions>): Paragraph[] {
const paragraphs: Paragraph[] = [];
for (const token of tokens) {
switch (token.type) {
case "heading": {
const runs = parseInlineTokens(token.tokens ?? [], options);
paragraphs.push(
new Paragraph({
children: runs,
heading: getHeadingLevel(token.depth),
spacing: { before: 240, after: 120 },
})
);
break;
}
case "paragraph": {
const runs = parseInlineTokens(token.tokens ?? [], options);
paragraphs.push(
new Paragraph({
children: runs.length > 0 ? runs : [new TextRun("")],
spacing: { after: 200 },
})
);
break;
}
case "code": {
const lines = token.text.split("\n");
lines.forEach((line: string) => {
paragraphs.push(
new Paragraph({
children: [
new TextRun({
text: line.length > 0 ? line : " ",
font: options.codeFont,
size: options.codeFontSize,
}),
],
shading: { fill: "F5F5F5" },
})
);
});
paragraphs.push(new Paragraph({ children: [] }));
break;
}
case "list": {
token.items?.forEach((item: MarkdownToken) => {
const runs = parseInlineTokens(item.tokens?.[0]?.tokens ?? [], options);
paragraphs.push(
new Paragraph({
children: runs.length > 0 ? runs : [new TextRun("")],
bullet: { level: 0 },
spacing: { after: 80 },
})
);
});
break;
}
case "blockquote": {
const runs = parseInlineTokens(token.tokens?.[0]?.tokens ?? [], options);
paragraphs.push(
new Paragraph({
children: runs.length > 0 ? runs : [new TextRun("")],
indent: { left: 720 },
border: { left: { style: "single", size: 12, color: "CCCCCC" } },
spacing: { after: 200 },
})
);
break;
}
case "hr": {
paragraphs.push(
new Paragraph({
children: [new TextRun({ text: "─".repeat(50), color: "CCCCCC" })],
spacing: { before: 200, after: 200 },
})
);
break;
}
case "space": {
paragraphs.push(new Paragraph({ children: [] }));
break;
}
}
}
return paragraphs;
}
/**
* Token TextRun
*/
function parseInlineTokens(tokens: MarkdownToken[], options: Required<DocxOptions>): TextRun[] {
const runs: TextRun[] = [];
for (const token of tokens) {
switch (token.type) {
case "text":
runs.push(new TextRun(token.raw ?? token.text ?? ""));
break;
case "strong":
runs.push(new TextRun({ text: token.text, bold: true }));
break;
case "em":
runs.push(new TextRun({ text: token.text, italics: true }));
break;
case "codespan":
runs.push(
new TextRun({
text: token.text,
font: options.codeFont,
shading: { fill: "F0F0F0" },
})
);
break;
case "link":
runs.push(
new TextRun({
text: token.text,
color: "0066CC",
underline: {},
})
);
break;
case "br":
runs.push(new TextRun({ text: "", break: 1 }));
break;
default:
runs.push(new TextRun(token.raw ?? ""));
}
}
return runs;
}
/**
*
*/
function getHeadingLevel(depth: number): typeof HeadingLevel[keyof typeof HeadingLevel] | undefined {
const levels = [HeadingLevel.HEADING_1, HeadingLevel.HEADING_2, HeadingLevel.HEADING_3, HeadingLevel.HEADING_4, HeadingLevel.HEADING_5, HeadingLevel.HEADING_6];
return levels[depth - 1];
}
/**
*
*/
function normalizeFilename(filename: string, extension: string): string {
return filename.replace(/\.md$/i, "") + extension;
}
/**
* Blob
*/
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}

View File

@ -0,0 +1,47 @@
/**
* Markdown
*
* @description
* Markdown DOCX PDF
* React + TypeScript 使
*
* @example
* ```tsx
* // React Hook 使用方式
* import { useMarkdownDownload } from "./markdown-download";
*
* function MyComponent() {
* const { downloadAsDocx, downloadAsPdf, isDownloading } = useMarkdownDownload();
*
* return (
* <div>
* <button onClick={() => downloadAsDocx("# Hello", "doc")}>
* Download DOCX
* </button>
* <button onClick={() => downloadAsPdf("# Hello", "doc")}>
* Download PDF
* </button>
* </div>
* );
* }
* ```
*
* @example
* ```ts
* // 非 React 环境直接使用转换函数
* import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./markdown-download";
*
* await downloadMarkdownAsDocx("# Hello World", "document");
* await downloadMarkdownAsPdf("# Hello World", "document", { format: "a4" });
* ```
*/
// React Hook
export { useMarkdownDownload } from "./use-markdown-download";
// 类型
export type { UseMarkdownDownloadOptions, UseMarkdownDownloadReturn } from "./use-markdown-download";
export type { PdfOptions, DocxOptions } from "./converter";
// 转换函数(供非 React 环境使用)
export { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";

View File

@ -0,0 +1,127 @@
import { useCallback, useState } from "react";
import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";
/**
* Markdown Hook
*/
export interface UseMarkdownDownloadOptions {
/**
*
*/
onDownloadStart?: (format: "docx" | "pdf") => void;
/**
*
*/
onDownloadEnd?: (format: "docx" | "pdf") => void;
/**
*
*/
onError?: (error: Error, format: "docx" | "pdf") => void;
}
/**
* Markdown Hook
*/
export interface UseMarkdownDownloadReturn {
/**
*
*/
isDownloading: "docx" | "pdf" | null;
/**
* DOCX
*/
downloadAsDocx: (markdown: string, filename: string) => Promise<void>;
/**
* PDF
*/
downloadAsPdf: (markdown: string, filename: string) => Promise<void>;
/**
*
*/
canDownload: boolean;
}
/**
* Markdown Hook
*
* @description
* Markdown DOCX PDF
* React + TypeScript 使
*
* @example
* ```tsx
* import { useMarkdownDownload } from "./hooks/use-markdown-download";
*
* function MyComponent() {
* const { downloadAsDocx, downloadAsPdf, isDownloading, canDownload } = useMarkdownDownload({
* onError: (error, format) => {
* console.error(`Failed to download as ${format}:`, error);
* },
* });
*
* const handleDownload = () => {
* downloadAsDocx("# Hello World", "document");
* };
*
* return (
* <button onClick={handleDownload} disabled={!canDownload}>
* {isDownloading === "docx" ? "Converting..." : "Download DOCX"}
* </button>
* );
* }
* ```
*/
export function useMarkdownDownload(options: UseMarkdownDownloadOptions = {}): UseMarkdownDownloadReturn {
const { onDownloadStart, onDownloadEnd, onError } = options;
const [isDownloading, setIsDownloading] = useState<"docx" | "pdf" | null>(null);
const downloadAsDocx = useCallback(
async (markdown: string, filename: string) => {
if (isDownloading) return;
setIsDownloading("docx");
onDownloadStart?.("docx");
try {
await downloadMarkdownAsDocx(markdown, filename);
} catch (error) {
onError?.(error instanceof Error ? error : new Error(String(error)), "docx");
} finally {
setIsDownloading(null);
onDownloadEnd?.("docx");
}
},
[isDownloading, onDownloadStart, onDownloadEnd, onError]
);
const downloadAsPdf = useCallback(
async (markdown: string, filename: string) => {
if (isDownloading) return;
setIsDownloading("pdf");
onDownloadStart?.("pdf");
try {
await downloadMarkdownAsPdf(markdown, filename);
} catch (error) {
onError?.(error instanceof Error ? error : new Error(String(error)), "pdf");
} finally {
setIsDownloading(null);
onDownloadEnd?.("pdf");
}
},
[isDownloading, onDownloadStart, onDownloadEnd, onError]
);
return {
isDownloading,
downloadAsDocx,
downloadAsPdf,
canDownload: isDownloading === null,
};
}
// 导出转换函数,供非 React 环境使用
export { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";