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

@@ -317,6 +317,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-markdown-docx-editor",
label: "Markdown 转 Word",
icon: FileText,
path: "/page/workspace/knowledge/markdown-editor",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
@@ -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={
@@ -150,7 +150,7 @@ export function WeKnoraKnowledgeDetailPage() {
})()
.catch((error: unknown) => {
if (cancelled) return;
const message = error instanceof Error ? error.message : "创建 WeKnora 详情会话失败";
const message = error instanceof Error ? error.message : "创建知识库详情会话失败";
setFrameError(message);
});
@@ -195,7 +195,7 @@ export function WeKnoraKnowledgeDetailPage() {
<AlertCircle className="size-6" />
</div>
<div>
<div className="text-base font-semibold text-foreground">无法打开 WeKnora 详情</div>
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
<p className="mt-2 text-sm text-muted-foreground">{frameError}</p>
</div>
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
@@ -214,7 +214,7 @@ export function WeKnoraKnowledgeDetailPage() {
<div>
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
<p className="mt-2 text-sm text-muted-foreground">
当前账号没有访问权限,或 WeKnora 资源已不存在。
当前账号没有访问权限,或知识服务资源已不存在。
</p>
</div>
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
@@ -319,7 +319,7 @@ export function WeKnoraKnowledgeDetailPage() {
<iframe
key={`${frameSrc}:${frameVersion}`}
ref={frameRef}
title={`${details.data.name || "WeKnora"} 详情`}
title={`${details.data.name || "知识库"} 详情`}
src={frameSrc}
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups"
@@ -76,7 +76,7 @@ export function WeKnoraKnowledgeWorkspace() {
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
const openDetails = (item: LlmWikiKnowledgeBase) => {
if (item.remote_status === "missing") {
toast.error("该知识库已从 WeKnora 删除,只能清理当前记录");
toast.error("该知识库已从知识服务删除,只能清理当前记录");
return;
}
navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}`);
@@ -97,8 +97,8 @@ export function WeKnoraKnowledgeWorkspace() {
const removeKnowledgeBase = async (item: LlmWikiKnowledgeBase) => {
const warning = item.remote_status === "missing"
? `知识库“${item.name}”已不在 WeKnora 中。确定清理 cmzs 中的这条失效记录吗?`
: `确定删除知识库“${item.name}”吗?WeKnora 中的文档和索引也会删除。`;
? `知识库“${item.name}”已不在知识服务中。确定清理 cmzs 中的这条失效记录吗?`
: `确定删除知识库“${item.name}”吗?知识服务中的文档和索引也会删除。`;
if (!window.confirm(warning)) return;
try {
await actions.remove.mutateAsync(item.id);
@@ -126,9 +126,9 @@ export function WeKnoraKnowledgeWorkspace() {
<div className="flex items-center gap-2">
<Database className="size-5 text-primary" />
<h1 className="text-xl font-semibold">问答梳理</h1>
<Badge variant="secondary">WeKnora</Badge>
<Badge variant="secondary">知识服务</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">由 WeKnora 负责解析与检索,用户、权限和发布范围由 cmzs 管理。</p>
<p className="mt-1 text-sm text-muted-foreground">由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。</p>
</div>
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
{tab !== "settings" && (
@@ -168,7 +168,7 @@ export function WeKnoraKnowledgeWorkspace() {
{searchResults.length > 0 && tab !== "settings" && (
<Card className="mt-5">
<CardHeader className="pb-3"><CardTitle className="text-base">检索结果</CardTitle><CardDescription>结果由 WeKnora 检索返回,问答中使用同一授权链路。</CardDescription></CardHeader>
<CardHeader className="pb-3"><CardTitle className="text-base">检索结果</CardTitle><CardDescription>结果由知识服务检索返回,问答中使用同一授权链路。</CardDescription></CardHeader>
<CardContent className="space-y-3">{searchResults.map((result) => <div key={result.chunk_id} className="rounded-lg border p-3"><div className="flex items-center justify-between gap-3 text-sm font-medium"><span>{result.title || "未命名来源"}</span><Badge variant="outline">{result.knowledge_base_name}</Badge></div><p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm text-muted-foreground">{result.content}</p></div>)}</CardContent>
</Card>
)}
@@ -235,7 +235,7 @@ function KnowledgeGrid({
<CardContent className="border-t bg-muted/20 px-6 py-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{item.remote_status === "missing" ? <span className="text-xs text-muted-foreground">WeKnora 资源不存在,可清理本地记录</span> : <Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>}
{item.remote_status === "missing" ? <span className="text-xs text-muted-foreground">知识服务资源不存在,可清理本地记录</span> : <Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>}
{item.remote_status !== "missing" && item.is_owner && !item.is_conversation_deposit && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
</div>
{item.can_write && !item.is_conversation_deposit && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={item.remote_status === "missing" ? `清理失效记录 ${item.name}` : `删除知识库 ${item.name}`} title={item.remote_status === "missing" ? "清理失效记录" : "删除知识库"} disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
@@ -267,7 +267,7 @@ function CreateKnowledgeDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由 WeKnora 管理。</DialogDescription></DialogHeader>
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由知识服务管理。</DialogDescription></DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div>
@@ -294,20 +294,20 @@ export function ConnectionSettings() {
setWebUrl(settings.data.web_base_url || settings.data.file_web_base_url || "");
}, [settings.data]);
const status = useMemo(() => settings.data?.effective_provider === "weknora" ? "WeKnora 模式" : "原 LLMWiki 模式", [settings.data]);
const status = useMemo(() => settings.data?.effective_provider === "weknora" ? "知识服务模式" : "原 LLMWiki 模式", [settings.data]);
if (settings.isLoading) return <div className="flex h-52 items-center justify-center"><Loader2 className="size-5 animate-spin" /></div>;
if (settings.error) return <div className="rounded-lg border border-destructive/30 p-5 text-sm text-destructive">{messageOf(settings.error)}</div>;
return (
<Card className="max-w-3xl">
<CardHeader><CardTitle className="flex items-center gap-2 text-base"><Settings2 className="size-5" />WeKnora 连接</CardTitle><CardDescription>这里只管理 cmzs 到 WeKnora 的连接地址。数据库、Redis、模型、存储和解析器仍在 WeKnora 自己的配置中维护。</CardDescription></CardHeader>
<CardHeader><CardTitle className="flex items-center gap-2 text-base"><Settings2 className="size-5" />知识服务连接</CardTitle><CardDescription>这里只管理 cmzs 到知识服务的连接地址。数据库、Redis、模型、存储和解析器仍在服务端配置中维护。</CardDescription></CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-wrap gap-2 text-sm"><Badge>{status}</Badge><Badge variant="secondary">服务端代理鉴权</Badge>{settings.data?.override_enabled && <Badge variant="outline">页面配置覆盖中</Badge>}</div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="weknora-api-url">WeKnora API 地址</label><Input id="weknora-api-url" value={apiUrl} onChange={(event) => setApiUrl(event.target.value)} placeholder="http://weknora-app:8080" /><p className="text-xs text-muted-foreground">非空启用 WeKnora;保存为空会切回原 LLMWiki。</p></div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="weknora-web-url">WeKnora 管理地址(可选)</label><Input id="weknora-web-url" value={webUrl} onChange={(event) => setWebUrl(event.target.value)} placeholder="https://weknora-admin.example.com" /></div>
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">配置文件地址:{settings.data?.file_api_base_url || "未配置"}。页面只配置地址;WeKnora iframe 免登录所需的管理员账号/密码只从后端 config.yaml 或环境变量读取,不在前端保存。</div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="weknora-api-url">知识服务 API 地址</label><Input id="weknora-api-url" value={apiUrl} onChange={(event) => setApiUrl(event.target.value)} placeholder="http://knowledge-service:8080" /><p className="text-xs text-muted-foreground">非空启用知识服务;保存为空会切回原 LLMWiki。</p></div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="weknora-web-url">知识服务管理地址(可选)</label><Input id="weknora-web-url" value={webUrl} onChange={(event) => setWebUrl(event.target.value)} placeholder="https://knowledge-admin.example.com" /></div>
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">配置文件地址:{settings.data?.file_api_base_url || "未配置"}。页面只配置地址;免登录详情页所需的管理员账号/密码只从后端 config.yaml 或环境变量读取,不在前端保存。</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" disabled={!apiUrl.trim() || actions.test.isPending} onClick={async () => { try { await actions.test.mutateAsync(apiUrl.trim()); toast.success("WeKnora 连接正常"); } catch (error) { toast.error(messageOf(error)); } }}>{actions.test.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}测试连接</Button>
<Button disabled={actions.save.isPending} onClick={async () => { try { await actions.save.mutateAsync({ override_enabled: true, api_base_url: apiUrl.trim(), web_base_url: webUrl.trim() }); toast.success(apiUrl.trim() ? "已启用 WeKnora 模式" : "已切换到原 LLMWiki 模式"); } catch (error) { toast.error(messageOf(error)); } }}>{actions.save.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存页面配置</Button>
<Button variant="outline" disabled={!apiUrl.trim() || actions.test.isPending} onClick={async () => { try { await actions.test.mutateAsync(apiUrl.trim()); toast.success("知识服务连接正常"); } catch (error) { toast.error(messageOf(error)); } }}>{actions.test.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}测试连接</Button>
<Button disabled={actions.save.isPending} onClick={async () => { try { await actions.save.mutateAsync({ override_enabled: true, api_base_url: apiUrl.trim(), web_base_url: webUrl.trim() }); toast.success(apiUrl.trim() ? "已启用知识服务模式" : "已切换到原 LLMWiki 模式"); } catch (error) { toast.error(messageOf(error)); } }}>{actions.save.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存页面配置</Button>
<Button variant="secondary" disabled={actions.reset.isPending} onClick={async () => { try { await actions.reset.mutateAsync(); toast.success("已恢复 config.yaml 配置"); } catch (error) { toast.error(messageOf(error)); } }}>恢复配置文件</Button>
</div>
</CardContent>
@@ -631,7 +631,7 @@ export function WeKnoraWikiManagementPage() {
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{creating ? "新建文章" : draft.title || selectedSlug}</div>
<div className="truncate text-xs text-muted-foreground">{creating ? "创建后写入 WeKnora Wiki" : draft.slug}</div>
<div className="truncate text-xs text-muted-foreground">{creating ? "创建后写入知识库 Wiki" : draft.slug}</div>
{(draft.pageType || draft.status || draft.parentSlug || draft.aliasesText || draft.categoryPathText) && (
<div className="mt-1 flex flex-wrap gap-1">
{[draft.pageType, draft.status, draft.parentSlug, ...splitDirectory(draft.categoryPathText), ...splitTags(draft.aliasesText)]