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
@@ -317,6 +317,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
|||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
path: "/page/workspace/knowledge",
|
path: "/page/workspace/knowledge",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "resource-markdown-docx-editor",
|
||||||
|
label: "Markdown 转 Word",
|
||||||
|
icon: FileText,
|
||||||
|
path: "/page/workspace/knowledge/markdown-editor",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "resource-knowledge-base",
|
id: "resource-knowledge-base",
|
||||||
label: "知识库资源管理",
|
label: "知识库资源管理",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function AdminWeKnoraSettingsButton() {
|
|||||||
<>
|
<>
|
||||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||||
<Settings2 className="mr-2 size-4" />
|
<Settings2 className="mr-2 size-4" />
|
||||||
配置 WeKnora
|
配置知识服务
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-4xl">
|
<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";
|
"use client";
|
||||||
|
|
||||||
|
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
|
||||||
|
import { languages } from "@codemirror/language-data";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Check,
|
Check,
|
||||||
@@ -39,7 +44,6 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -55,6 +59,7 @@ import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
|||||||
import { reasoningPlugins } from "@/core/streamdown";
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
|
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
type PublicTask = {
|
type PublicTask = {
|
||||||
task_id: string;
|
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>;
|
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 {
|
function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("tab", tab.kind);
|
params.set("tab", tab.kind);
|
||||||
@@ -1320,12 +1377,7 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
|||||||
正在加载文件内容...
|
正在加载文件内容...
|
||||||
</div>
|
</div>
|
||||||
) : isEditing ? (
|
) : isEditing ? (
|
||||||
<Textarea
|
<PublicMarkdownEditor value={draft} onChange={setDraft} />
|
||||||
value={draft}
|
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
|
||||||
className="min-h-0 flex-1 resize-none font-mono text-sm"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
{showEmptyHint ? (
|
{showEmptyHint ? (
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
|||||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||||
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
||||||
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
|
import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage';
|
||||||
|
import MarkdownDocxEditorPage from './MarkdownDocxEditorPage';
|
||||||
import LlmMetricsPage from './LlmMetricsPage';
|
import LlmMetricsPage from './LlmMetricsPage';
|
||||||
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||||
import MemoryPage from './MemoryPage';
|
import MemoryPage from './MemoryPage';
|
||||||
@@ -665,6 +666,14 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="knowledge/markdown-editor"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<MarkdownDocxEditorPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="knowledge/:noteId"
|
path="knowledge/:noteId"
|
||||||
element={
|
element={
|
||||||
|
|||||||
+4
-4
@@ -150,7 +150,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
})()
|
})()
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const message = error instanceof Error ? error.message : "创建 WeKnora 详情会话失败";
|
const message = error instanceof Error ? error.message : "创建知识库详情会话失败";
|
||||||
setFrameError(message);
|
setFrameError(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
<AlertCircle className="size-6" />
|
<AlertCircle className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="mt-2 text-sm text-muted-foreground">{frameError}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
|
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
|
||||||
@@ -214,7 +214,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
|
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
当前账号没有访问权限,或 WeKnora 资源已不存在。
|
当前账号没有访问权限,或知识服务资源已不存在。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
|
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
|
||||||
@@ -319,7 +319,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
<iframe
|
<iframe
|
||||||
key={`${frameSrc}:${frameVersion}`}
|
key={`${frameSrc}:${frameVersion}`}
|
||||||
ref={frameRef}
|
ref={frameRef}
|
||||||
title={`${details.data.name || "WeKnora"} 详情`}
|
title={`${details.data.name || "知识库"} 详情`}
|
||||||
src={frameSrc}
|
src={frameSrc}
|
||||||
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
|
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"
|
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups"
|
||||||
|
|||||||
+15
-15
@@ -76,7 +76,7 @@ export function WeKnoraKnowledgeWorkspace() {
|
|||||||
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
|
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
|
||||||
const openDetails = (item: LlmWikiKnowledgeBase) => {
|
const openDetails = (item: LlmWikiKnowledgeBase) => {
|
||||||
if (item.remote_status === "missing") {
|
if (item.remote_status === "missing") {
|
||||||
toast.error("该知识库已从 WeKnora 删除,只能清理当前记录");
|
toast.error("该知识库已从知识服务删除,只能清理当前记录");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}`);
|
navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}`);
|
||||||
@@ -97,8 +97,8 @@ export function WeKnoraKnowledgeWorkspace() {
|
|||||||
|
|
||||||
const removeKnowledgeBase = async (item: LlmWikiKnowledgeBase) => {
|
const removeKnowledgeBase = async (item: LlmWikiKnowledgeBase) => {
|
||||||
const warning = item.remote_status === "missing"
|
const warning = item.remote_status === "missing"
|
||||||
? `知识库“${item.name}”已不在 WeKnora 中。确定清理 cmzs 中的这条失效记录吗?`
|
? `知识库“${item.name}”已不在知识服务中。确定清理 cmzs 中的这条失效记录吗?`
|
||||||
: `确定删除知识库“${item.name}”吗?WeKnora 中的文档和索引也会删除。`;
|
: `确定删除知识库“${item.name}”吗?知识服务中的文档和索引也会删除。`;
|
||||||
if (!window.confirm(warning)) return;
|
if (!window.confirm(warning)) return;
|
||||||
try {
|
try {
|
||||||
await actions.remove.mutateAsync(item.id);
|
await actions.remove.mutateAsync(item.id);
|
||||||
@@ -126,9 +126,9 @@ export function WeKnoraKnowledgeWorkspace() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="size-5 text-primary" />
|
<Database className="size-5 text-primary" />
|
||||||
<h1 className="text-xl font-semibold">问答梳理</h1>
|
<h1 className="text-xl font-semibold">问答梳理</h1>
|
||||||
<Badge variant="secondary">WeKnora</Badge>
|
<Badge variant="secondary">知识服务</Badge>
|
||||||
</div>
|
</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>
|
||||||
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
|
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
|
||||||
{tab !== "settings" && (
|
{tab !== "settings" && (
|
||||||
@@ -168,7 +168,7 @@ export function WeKnoraKnowledgeWorkspace() {
|
|||||||
|
|
||||||
{searchResults.length > 0 && tab !== "settings" && (
|
{searchResults.length > 0 && tab !== "settings" && (
|
||||||
<Card className="mt-5">
|
<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>
|
<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>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -235,7 +235,7 @@ function KnowledgeGrid({
|
|||||||
<CardContent className="border-t bg-muted/20 px-6 py-4">
|
<CardContent className="border-t bg-muted/20 px-6 py-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
<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>}
|
{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>
|
</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>}
|
{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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<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-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-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>
|
<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 || "");
|
setWebUrl(settings.data.web_base_url || settings.data.file_web_base_url || "");
|
||||||
}, [settings.data]);
|
}, [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.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>;
|
if (settings.error) return <div className="rounded-lg border border-destructive/30 p-5 text-sm text-destructive">{messageOf(settings.error)}</div>;
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-3xl">
|
<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">
|
<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="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-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">WeKnora 管理地址(可选)</label><Input id="weknora-web-url" value={webUrl} onChange={(event) => setWebUrl(event.target.value)} placeholder="https://weknora-admin.example.com" /></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 || "未配置"}。页面只配置地址;WeKnora iframe 免登录所需的管理员账号/密码只从后端 config.yaml 或环境变量读取,不在前端保存。</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">
|
<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 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() ? "已启用 WeKnora 模式" : "已切换到原 LLMWiki 模式"); } catch (error) { toast.error(messageOf(error)); } }}>{actions.save.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>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
+1
-1
@@ -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="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium">{creating ? "新建文章" : draft.title || selectedSlug}</div>
|
<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) && (
|
{(draft.pageType || draft.status || draft.parentSlug || draft.aliasesText || draft.categoryPathText) && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{[draft.pageType, draft.status, draft.parentSlug, ...splitDirectory(draft.categoryPathText), ...splitTags(draft.aliasesText)]
|
{[draft.pageType, draft.status, draft.parentSlug, ...splitDirectory(draft.categoryPathText), ...splitTags(draft.aliasesText)]
|
||||||
|
|||||||
Reference in new issue
Block a user