feat: add markdown word editor for knowledge workspace

This commit is contained in:
Administrator committed 2026-08-18 00:44:29 +08:00
1 parent 68c4896088
commit 64a5305bbf
8 files changed
+297 -28

No files matched your search

@@ -37,7 +37,7 @@ function AdminWeKnoraSettingsButton() {
<>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<Settings2 className="mr-2 size-4" />
配置 WeKnora
配置知识服务
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl">
@@ -0,0 +1,202 @@
"use client";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import { Download, FileUp, Loader2, RefreshCw } from "lucide-react";
import { useTheme } from "next-themes";
import { useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ReportMarkdownView } from "@/components/wiki/ReportMarkdownView";
import { downloadMarkdownDocxFromBackend } from "@/core/api/markdown-docx-export";
const SAMPLE_MARKDOWN = `# Markdown 文档
在左侧编辑 Markdown,右侧会实时预览。
## 支持内容
- 标题、列表、引用
- 表格和代码块
- 下载为 Word 文档
| 字段 | 说明 |
| --- | --- |
| 上传 | 支持 .md / .markdown |
| 导出 | 生成 .docx |
`;
const editorDarkTheme = monokaiInit({
settings: {
background: "transparent",
gutterBackground: "transparent",
fontSize: "14px",
},
});
const editorLightTheme = basicLightInit({
settings: {
background: "transparent",
fontSize: "14px",
},
});
function stripMarkdownExtension(name: string): string {
return name.replace(/\.(markdown|md)$/i, "");
}
function isMarkdownFilename(name: string): boolean {
return /\.(markdown|md)$/i.test(name);
}
export default function MarkdownDocxEditorPage() {
const inputRef = useRef<HTMLInputElement>(null);
const { resolvedTheme } = useTheme();
const [title, setTitle] = useState("Markdown 文档");
const [markdownText, setMarkdownText] = useState(SAMPLE_MARKDOWN);
const [fileName, setFileName] = useState("markdown-document");
const [exporting, setExporting] = useState(false);
const extensions = useMemo(
() => [
markdown({
base: markdownLanguage,
codeLanguages: languages,
}),
],
[],
);
const handleFile = async (file: File | undefined) => {
if (!file) return;
if (!isMarkdownFilename(file.name)) {
toast.error("请上传 .md 或 .markdown 文件");
return;
}
try {
const text = await file.text();
setMarkdownText(text);
const baseName = stripMarkdownExtension(file.name);
setFileName(baseName || "markdown-document");
setTitle(baseName || "Markdown 文档");
toast.success("Markdown 文件已载入");
} catch (error) {
toast.error(error instanceof Error ? error.message : "读取文件失败");
} finally {
if (inputRef.current) inputRef.current.value = "";
}
};
const handleExport = async () => {
if (!markdownText.trim()) {
toast.error("当前 Markdown 内容为空");
return;
}
setExporting(true);
try {
await downloadMarkdownDocxFromBackend({
title: title.trim() || fileName || "Markdown 文档",
markdown: markdownText,
});
toast.success("Word 文档已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "导出 Word 失败");
} finally {
setExporting(false);
}
};
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<header className="shrink-0 border-b px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h1 className="text-xl font-semibold">Markdown 转 Word</h1>
<p className="mt-1 text-sm text-muted-foreground">
上传 md 文件后在线编辑,确认内容后下载为 Word。
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
ref={inputRef}
type="file"
accept=".md,.markdown,text/markdown,text/plain"
className="hidden"
onChange={(event) => void handleFile(event.target.files?.[0])}
/>
<Button variant="outline" onClick={() => inputRef.current?.click()}>
<FileUp className="size-4" />
上传 md
</Button>
<Button
variant="outline"
onClick={() => {
setMarkdownText(SAMPLE_MARKDOWN);
setTitle("Markdown 文档");
setFileName("markdown-document");
}}
>
<RefreshCw className="size-4" />
重置
</Button>
<Button onClick={handleExport} disabled={exporting || !markdownText.trim()}>
{exporting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
下载 Word
</Button>
</div>
</div>
<div className="mt-4 max-w-xl">
<label htmlFor="markdown-docx-title" className="mb-1 block text-sm font-medium">
文档标题
</label>
<Input
id="markdown-docx-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="请输入导出的 Word 标题"
/>
</div>
</header>
<main className="grid min-h-0 flex-1 gap-0 lg:grid-cols-2">
<section className="flex min-h-0 flex-col border-r">
<div className="flex h-10 shrink-0 items-center border-b px-4 text-sm font-medium">
Markdown 编辑
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<CodeMirror
value={markdownText}
onChange={setMarkdownText}
theme={resolvedTheme === "dark" ? editorDarkTheme : editorLightTheme}
extensions={extensions}
basicSetup={{
foldGutter: true,
highlightActiveLine: true,
highlightActiveLineGutter: false,
lineNumbers: true,
}}
className="h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!"
/>
</div>
</section>
<section className="min-h-0 overflow-auto bg-muted/20 p-4">
<ReportMarkdownView
content={markdownText || "暂无内容"}
title={title || fileName}
badgeLabel="Markdown 预览"
className="max-w-none"
/>
</section>
</main>
</div>
);
}
@@ -1,6 +1,11 @@
"use client";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import {
BookOpen,
Check,
@@ -39,7 +44,6 @@ import {
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
@@ -55,6 +59,7 @@ import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { reasoningPlugins } from "@/core/streamdown";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import { useTheme } from "next-themes";
type PublicTask = {
task_id: string;
@@ -903,6 +908,58 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen
return <pre className="whitespace-pre-wrap break-words text-sm">{preview.content || "无正文内容"}</pre>;
}
const markdownEditorDarkTheme = monokaiInit({
settings: {
background: "transparent",
gutterBackground: "transparent",
fontSize: "14px",
},
});
const markdownEditorLightTheme = basicLightInit({
settings: {
background: "transparent",
fontSize: "14px",
},
});
function PublicMarkdownEditor({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const { resolvedTheme } = useTheme();
const extensions = useMemo(
() => [
markdown({
base: markdownLanguage,
codeLanguages: languages,
}),
],
[],
);
return (
<div className="min-h-0 flex-1 overflow-hidden rounded-md border bg-background">
<CodeMirror
value={value}
onChange={onChange}
theme={resolvedTheme === "dark" ? markdownEditorDarkTheme : markdownEditorLightTheme}
extensions={extensions}
basicSetup={{
foldGutter: true,
highlightActiveLine: true,
highlightActiveLineGutter: false,
lineNumbers: true,
}}
className="h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!"
/>
</div>
);
}
function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): string {
const params = new URLSearchParams();
params.set("tab", tab.kind);
@@ -1320,12 +1377,7 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
正在加载文件内容...
</div>
) : isEditing ? (
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="min-h-0 flex-1 resize-none font-mono text-sm"
spellCheck={false}
/>
<PublicMarkdownEditor value={draft} onChange={setDraft} />
) : (
<ScrollArea className="min-h-0 flex-1">
{showEmptyHint ? (
@@ -40,6 +40,7 @@ import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
@@ -665,6 +666,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="knowledge/markdown-editor"
element={
<WorkspaceLayout {...layoutProps}>
<MarkdownDocxEditorPage />
</WorkspaceLayout>
}
/>
<Route
path="knowledge/:noteId"
element={