{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
diff --git a/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx b/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx
index 7eaaca0c..1c9202d9 100644
--- a/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx
+++ b/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx
@@ -12,7 +12,11 @@ import {
Quote,
Code,
Pilcrow,
+ Download,
+ Loader2,
} from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import {
@@ -21,6 +25,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
+import { exportMarkdownToDocx } from "../../utils/export-docx";
interface ToolbarButtonProps {
onClick: () => void;
@@ -60,11 +65,32 @@ function ToolbarButton({ onClick, active, disabled, tooltip, children }: Toolbar
interface WritingToolbarProps {
editor: Editor | null;
+ /** 文档源(markdown),用于导出 Word;空字符串时下载按钮禁用 */
+ markdown?: string;
+ /** 文档标题,用作 .docx 文件名与封面 */
+ title?: string;
}
-export function WritingToolbar({ editor }: WritingToolbarProps) {
+export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps) {
+ const [exporting, setExporting] = useState(false);
+
if (!editor) return null;
+ const canExport = !exporting && !!markdown?.trim();
+
+ const handleExport = async () => {
+ if (!canExport) return;
+ setExporting(true);
+ try {
+ await exportMarkdownToDocx(markdown ?? "", title);
+ } catch (err) {
+ console.error("[export-docx] failed", err);
+ toast.error("导出 Word 失败,请稍后重试");
+ } finally {
+ setExporting(false);
+ }
+ };
+
return (
{/* Block type */}
@@ -173,8 +199,37 @@ export function WritingToolbar({ editor }: WritingToolbarProps) {
- {/* Model selector — pushed to the right */}
-
+ {/* Right side: export + model selector */}
+
+
+
+ {
+ e.preventDefault();
+ handleExport();
+ }}
+ disabled={!canExport}
+ className={cn(
+ "inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
+ "hover:bg-accent hover:text-accent-foreground",
+ "disabled:pointer-events-none disabled:opacity-40",
+ )}
+ >
+ {exporting ? (
+
+ ) : (
+
+ )}
+
+
+
+ 导出 Word
+
+
+
+
+
diff --git a/frontend-web/src/open-canvas/utils/export-docx.ts b/frontend-web/src/open-canvas/utils/export-docx.ts
new file mode 100644
index 00000000..71fe8d87
--- /dev/null
+++ b/frontend-web/src/open-canvas/utils/export-docx.ts
@@ -0,0 +1,345 @@
+/**
+ * Markdown → Word(.docx) 导出工具。
+ *
+ * 设计要点:
+ * - 纯前端,避免新增后端接口;输入是 AI 写作页右侧 artifact 的 fullMarkdown。
+ * - 文档结构:封面标题(无 H1 时补) → 正文。
+ */
+import {
+ AlignmentType,
+ Document,
+ ExternalHyperlink,
+ HeadingLevel,
+ LevelFormat,
+ Packer,
+ Paragraph,
+ TextRun,
+} from "docx";
+import { saveAs } from "file-saver";
+
+/** 标题层级(H1~H3) */
+type HeadingLevelNum = 1 | 2 | 3;
+
+interface ParsedHeading {
+ level: HeadingLevelNum;
+ text: string;
+}
+
+type Block =
+ | { kind: "heading"; level: HeadingLevelNum; text: string }
+ | { kind: "paragraph"; text: string }
+ | { kind: "blockquote"; text: string }
+ | { kind: "code"; code: string }
+ | { kind: "list"; ordered: boolean; items: string[] };
+
+// ────────────────────────────────────────────────────────────────────────────
+// Markdown 解析(行级)—— 只覆盖 AI 写作场景常见语法
+// ────────────────────────────────────────────────────────────────────────────
+
+const RE_HEADING = /^(#{1,3})\s+(.*)$/;
+const RE_FENCE = /^```/;
+const RE_QUOTE = /^>\s?/;
+const RE_UL = /^[-*]\s+/;
+const RE_OL = /^\d+\.\s+/;
+const RE_BLOCK_START = /^(#{1,3}\s|[-*]\s|\d+\.\s|>\s|```)/;
+
+function parseMarkdown(markdown: string): { blocks: Block[]; headings: ParsedHeading[] } {
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
+ const blocks: Block[] = [];
+ const headings: ParsedHeading[] = [];
+
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+
+ if (!line.trim()) {
+ i++;
+ continue;
+ }
+
+ if (RE_FENCE.test(line)) {
+ const buf: string[] = [];
+ i++;
+ while (i < lines.length && !RE_FENCE.test(lines[i])) {
+ buf.push(lines[i]);
+ i++;
+ }
+ if (i < lines.length) i++; // 跳过闭合 ```
+ blocks.push({ kind: "code", code: buf.join("\n") });
+ continue;
+ }
+
+ const hm = RE_HEADING.exec(line);
+ if (hm) {
+ const level = hm[1].length as HeadingLevelNum;
+ const text = hm[2].trim();
+ headings.push({ level, text });
+ blocks.push({ kind: "heading", level, text });
+ i++;
+ continue;
+ }
+
+ if (RE_QUOTE.test(line)) {
+ const buf: string[] = [];
+ while (i < lines.length && RE_QUOTE.test(lines[i])) {
+ buf.push(lines[i].replace(RE_QUOTE, ""));
+ i++;
+ }
+ blocks.push({ kind: "blockquote", text: buf.join("\n") });
+ continue;
+ }
+
+ if (RE_UL.test(line)) {
+ const items: string[] = [];
+ while (i < lines.length && RE_UL.test(lines[i])) {
+ items.push(lines[i].replace(RE_UL, ""));
+ i++;
+ }
+ blocks.push({ kind: "list", ordered: false, items });
+ continue;
+ }
+
+ if (RE_OL.test(line)) {
+ const items: string[] = [];
+ while (i < lines.length && RE_OL.test(lines[i])) {
+ items.push(lines[i].replace(RE_OL, ""));
+ i++;
+ }
+ blocks.push({ kind: "list", ordered: true, items });
+ continue;
+ }
+
+ // 段落:合并相邻非空、非块起始行
+ const buf: string[] = [line];
+ i++;
+ while (i < lines.length && lines[i].trim() && !RE_BLOCK_START.test(lines[i])) {
+ buf.push(lines[i]);
+ i++;
+ }
+ blocks.push({ kind: "paragraph", text: buf.join("\n") });
+ }
+
+ return { blocks, headings };
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// 行内格式 → TextRun / Hyperlink
+// ────────────────────────────────────────────────────────────────────────────
+
+/** 行内 token 匹配顺序很重要:**bold** 必须先于 *italic*;`code` 与 [link]() 独立。 */
+const RE_INLINE = /(\*\*[^*\n]+?\*\*)|(\*[^*\n]+?\*)|(_[^_\n]+?_)|(`[^`\n]+?`)|(\[[^\]\n]+\]\([^)\n]+\))/g;
+
+type InlineChild = TextRun | ExternalHyperlink;
+
+function parseInline(text: string): InlineChild[] {
+ if (!text) return [new TextRun({ text: "" })];
+
+ const out: InlineChild[] = [];
+ let last = 0;
+ let m: RegExpExecArray | null;
+ RE_INLINE.lastIndex = 0;
+
+ while ((m = RE_INLINE.exec(text)) !== null) {
+ if (m.index > last) {
+ out.push(new TextRun({ text: text.slice(last, m.index) }));
+ }
+ const tok = m[0];
+ if (tok.startsWith("**")) {
+ out.push(new TextRun({ text: tok.slice(2, -2), bold: true }));
+ } else if (tok.startsWith("`")) {
+ out.push(new TextRun({ text: tok.slice(1, -1), font: "Consolas" }));
+ } else if (tok.startsWith("[")) {
+ const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok);
+ if (linkMatch) {
+ out.push(
+ new ExternalHyperlink({
+ link: linkMatch[2],
+ children: [
+ new TextRun({
+ text: linkMatch[1],
+ color: "0563C1",
+ underline: {},
+ }),
+ ],
+ }),
+ );
+ } else {
+ out.push(new TextRun({ text: tok }));
+ }
+ } else if (tok.startsWith("*") || tok.startsWith("_")) {
+ out.push(new TextRun({ text: tok.slice(1, -1), italics: true }));
+ }
+ last = RE_INLINE.lastIndex;
+ }
+ if (last < text.length) {
+ out.push(new TextRun({ text: text.slice(last) }));
+ }
+ return out;
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// Block → Paragraph[]
+// ────────────────────────────────────────────────────────────────────────────
+
+const DOCX_HEADING: Record
= {
+ 1: HeadingLevel.HEADING_1,
+ 2: HeadingLevel.HEADING_2,
+ 3: HeadingLevel.HEADING_3,
+};
+
+function blockToParagraphs(b: Block): Paragraph[] {
+ switch (b.kind) {
+ case "heading": {
+ return [
+ new Paragraph({
+ heading: DOCX_HEADING[b.level],
+ children: parseInline(b.text),
+ }),
+ ];
+ }
+ case "paragraph": {
+ return [
+ new Paragraph({
+ children: parseInline(b.text),
+ spacing: { line: 360, after: 120 },
+ }),
+ ];
+ }
+ case "blockquote": {
+ return [
+ new Paragraph({
+ children: parseInline(b.text),
+ indent: { left: 480 },
+ spacing: { line: 360, after: 120 },
+ }),
+ ];
+ }
+ case "code": {
+ return b.code.split("\n").map(
+ (line) =>
+ new Paragraph({
+ children: [new TextRun({ text: line || " ", font: "Consolas", size: 20 })],
+ }),
+ );
+ }
+ case "list": {
+ return b.items.map(
+ (item) =>
+ new Paragraph({
+ numbering: {
+ reference: b.ordered ? "ol" : "ul",
+ level: 0,
+ },
+ children: parseInline(item),
+ spacing: { line: 320 },
+ }),
+ );
+ }
+ }
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// 文件名工具
+// ────────────────────────────────────────────────────────────────────────────
+
+function sanitizeFilename(name: string): string {
+ // Windows 非法字符 + 控制字符 → 下划线
+ return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_").trim();
+}
+
+function fallbackFilename(): string {
+ const d = new Date();
+ const pad = (n: number) => n.toString().padStart(2, "0");
+ return `AI写作-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
+}
+
+function resolveFilename(title: string | undefined): string {
+ const base = title ? sanitizeFilename(title) : "";
+ return `${base || fallbackFilename()}.docx`;
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// 主入口
+// ────────────────────────────────────────────────────────────────────────────
+
+/**
+ * 把 markdown 文档导出为 .docx 并触发浏览器下载。
+ *
+ * @param markdown 文档源(来自 AI 写作 artifact 的 fullMarkdown)
+ * @param title 文档标题,用作文件名与封面(空则用时间戳兜底)
+ */
+export async function exportMarkdownToDocx(
+ markdown: string,
+ title?: string,
+): Promise {
+ const { blocks, headings } = parseMarkdown(markdown ?? "");
+
+ // 封面标题:优先用入参 title;否则用 markdown 里的第一个 H1
+ const coverText =
+ (title && title.trim()) ||
+ headings.find((h) => h.level === 1)?.text ||
+ "未命名文档";
+
+ const children: Paragraph[] = [];
+
+ // 封面标题(仅当 markdown 本身没有 H1 时补一个,避免与正文里的 H1 重复)
+ const hasFirstH1 = headings[0]?.level === 1;
+ if (!hasFirstH1) {
+ children.push(
+ new Paragraph({
+ alignment: AlignmentType.CENTER,
+ spacing: { before: 240, after: 240 },
+ children: [new TextRun({ text: coverText, bold: true, size: 36 })],
+ }),
+ );
+ }
+
+ // 正文
+ for (const b of blocks) {
+ children.push(...blockToParagraphs(b));
+ }
+
+ const doc = new Document({
+ creator: "DeerFlow AI 写作",
+ title: coverText,
+ styles: {
+ default: {
+ document: {
+ run: { font: "微软雅黑", size: 22 }, // 默认正文 11pt 微软雅黑
+ },
+ },
+ },
+ numbering: {
+ config: [
+ {
+ reference: "ul",
+ levels: [
+ {
+ level: 0,
+ format: LevelFormat.BULLET,
+ text: "•",
+ alignment: AlignmentType.LEFT,
+ style: { paragraph: { indent: { left: 360, hanging: 240 } } },
+ },
+ ],
+ },
+ {
+ reference: "ol",
+ levels: [
+ {
+ level: 0,
+ format: LevelFormat.DECIMAL,
+ text: "%1.",
+ alignment: AlignmentType.LEFT,
+ style: { paragraph: { indent: { left: 360, hanging: 240 } } },
+ },
+ ],
+ },
+ ],
+ },
+ sections: [{ children }],
+ });
+
+ const blob = await Packer.toBlob(doc);
+ saveAs(blob, resolveFilename(title));
+}
diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx
index 2471e375..9afd198f 100644
--- a/frontend-web/src/pages/WorkspaceRoutes.tsx
+++ b/frontend-web/src/pages/WorkspaceRoutes.tsx
@@ -31,6 +31,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminMemoryPage from "./AdminMemoryPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
+import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
export function WorkspaceLayout({
children,
@@ -177,6 +178,14 @@ export default function WorkspaceRoutes({
}
/>
+
+
+
+ }
+ />
}
diff --git a/offline-backend-20260512/backend/uv.lock b/offline-backend-20260512/backend/uv.lock
index 4b2a7b64..deb9f383 100644
--- a/offline-backend-20260512/backend/uv.lock
+++ b/offline-backend-20260512/backend/uv.lock
@@ -780,12 +780,14 @@ name = "deer-flow"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
+ { name = "asyncmy" },
{ name = "bcrypt" },
{ name = "croniter" },
{ name = "deerflow-harness" },
{ name = "dingtalk-stream" },
{ name = "email-validator" },
{ name = "fastapi" },
+ { name = "hindsight-client" },
{ name = "httpx" },
{ name = "langgraph-sdk" },
{ name = "lark-oapi" },
@@ -817,6 +819,7 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "asyncmy", specifier = ">=0.2.11" },
{ name = "bcrypt", specifier = ">=4.0.0" },
{ name = "croniter", specifier = ">=6.2.2" },
{ name = "deerflow-harness", editable = "packages/harness" },
@@ -825,6 +828,7 @@ requires-dist = [
{ name = "dingtalk-stream", specifier = ">=0.24.3" },
{ name = "email-validator", specifier = ">=2.0.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
+ { name = "hindsight-client", specifier = ">=0.4.22" },
{ name = "httpx", specifier = ">=0.28.0" },
{ name = "langgraph-sdk", specifier = ">=0.1.51" },
{ name = "lark-oapi", specifier = ">=1.4.0" },