feat: add markdown word editor for knowledge workspace
This commit is contained in:
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={
|
||||
|
||||
Reference in new issue
Block a user