526 lines
20 KiB
TypeScript
526 lines
20 KiB
TypeScript
import {
|
|
ArrowLeft,
|
|
BookOpenText,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Download,
|
|
FileText,
|
|
Folder,
|
|
Loader2,
|
|
RefreshCw,
|
|
Search,
|
|
} from "lucide-react";
|
|
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
|
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
|
import {
|
|
buildPublicKnowledgeWikiPath,
|
|
buildWeKnoraWikiPagePath,
|
|
} from "@/core/llmwiki/source-links";
|
|
import {
|
|
buildWikiTree,
|
|
filterWikiPagesWithAncestors,
|
|
isWikiIndexPage,
|
|
selectDefaultWikiPage,
|
|
type WikiTreeNode,
|
|
} from "@/core/llmwiki/wiki-tree";
|
|
import { usePublicBackground } from "@/core/embed/background";
|
|
import { reasoningPlugins } from "@/core/streamdown";
|
|
import { markdownToDocxBlob } from "@/core/scheduled-tasks/markdown-docx";
|
|
import {
|
|
exportableWikiMarkdown,
|
|
renderableWikiMarkdown,
|
|
safeWikiFileName,
|
|
stripWikiFrontmatter,
|
|
wikiLinkMarkdown,
|
|
} from "@/core/llmwiki/wiki-page-markdown";
|
|
import {
|
|
type LlmWikiWikiPage,
|
|
useLlmWikiKnowledgeBase,
|
|
useLlmWikiWikiPage,
|
|
useLlmWikiWikiPages,
|
|
usePublicLlmWikiKnowledgeBase,
|
|
usePublicLlmWikiWikiIndex,
|
|
usePublicLlmWikiWikiPage,
|
|
usePublicLlmWikiWikiPages,
|
|
} from "@/strategy-components/api/llmwiki";
|
|
|
|
const LIST_PATH = "/page/workspace/knowledge";
|
|
|
|
function cleanText(value?: string | null) {
|
|
return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
}
|
|
|
|
function formatDate(value?: string | null) {
|
|
if (!value) return "";
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime())
|
|
? value
|
|
: date.toLocaleString("zh-CN", { hour12: false });
|
|
}
|
|
|
|
function pageTypeLabel(value?: string) {
|
|
const pageType = String(value || "").toLowerCase();
|
|
if (pageType === "directory") return "Wiki 目录页";
|
|
if (pageType === "overview") return "Wiki 概览页";
|
|
if (pageType === "article") return "Wiki 文章页";
|
|
return "Wiki 文档";
|
|
}
|
|
|
|
function directoryLabel(value: string) {
|
|
const labels: Record<string, string> = {
|
|
concept: "概念",
|
|
entity: "实体",
|
|
summary: "摘要",
|
|
index: "索引",
|
|
"未分类": "其他页面",
|
|
};
|
|
return labels[value.toLowerCase()] || value;
|
|
}
|
|
|
|
function downloadBlob(blob: Blob, fileName: string) {
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = window.document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = fileName;
|
|
anchor.rel = "noopener";
|
|
window.document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
|
}
|
|
|
|
function publicWikiTargetFromHref(href: string, knowledgeBaseId: string) {
|
|
const routePrefix = `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId)}`;
|
|
if (!href.startsWith(`${routePrefix}?`)) return null;
|
|
const route = href.slice(2);
|
|
const queryIndex = route.indexOf("?");
|
|
const slug = new URLSearchParams(route.slice(queryIndex + 1)).get("wikiSlug")?.trim();
|
|
return slug ? { route, slug } : null;
|
|
}
|
|
|
|
export function WeKnoraWikiPageView({
|
|
publicEmbed = false,
|
|
}: {
|
|
publicEmbed?: boolean;
|
|
}) {
|
|
const { noteId = "" } = useParams();
|
|
const [searchParams] = useSearchParams();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const publicBackground = usePublicBackground();
|
|
const requestedWikiSlug = searchParams.get("wikiSlug")?.trim() || "";
|
|
const privateKnowledgeBase = useLlmWikiKnowledgeBase(
|
|
noteId || null,
|
|
!!noteId && !publicEmbed,
|
|
);
|
|
const publicKnowledgeBase = usePublicLlmWikiKnowledgeBase(
|
|
noteId || null,
|
|
!!noteId && publicEmbed,
|
|
);
|
|
const knowledgeBase = publicEmbed ? publicKnowledgeBase : privateKnowledgeBase;
|
|
const privatePages = useLlmWikiWikiPages(
|
|
noteId || null,
|
|
1,
|
|
500,
|
|
"",
|
|
!!noteId && !publicEmbed,
|
|
);
|
|
const publicPages = usePublicLlmWikiWikiPages(
|
|
noteId || null,
|
|
1,
|
|
500,
|
|
"",
|
|
!!noteId && publicEmbed,
|
|
);
|
|
const pages = publicEmbed ? publicPages : privatePages;
|
|
const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
|
const indexPage = useMemo(
|
|
() => (publicEmbed ? pagesList.find(isWikiIndexPage) : undefined),
|
|
[pagesList, publicEmbed],
|
|
);
|
|
const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]);
|
|
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : "");
|
|
const privatePage = useLlmWikiWikiPage(
|
|
noteId || null,
|
|
wikiSlug || null,
|
|
!!noteId && !!wikiSlug && !publicEmbed,
|
|
);
|
|
const publicPage = usePublicLlmWikiWikiPage(
|
|
noteId || null,
|
|
wikiSlug || null,
|
|
!!noteId && !!wikiSlug && publicEmbed,
|
|
);
|
|
const page = publicEmbed ? publicPage : privatePage;
|
|
const publicWikiIndex = usePublicLlmWikiWikiIndex(
|
|
noteId || null,
|
|
!!noteId && publicEmbed && !!indexPage && wikiSlug === indexPage.slug,
|
|
);
|
|
const [downloading, setDownloading] = useState<"md" | "word" | null>(null);
|
|
const [directoryQuery, setDirectoryQuery] = useState("");
|
|
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(() => new Set());
|
|
const routedPage = (location.state as { wikiPage?: LlmWikiWikiPage } | null)?.wikiPage;
|
|
const pageData = page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined);
|
|
const filteredPages = useMemo(
|
|
() => filterWikiPagesWithAncestors(pagesList, directoryQuery),
|
|
[directoryQuery, pagesList],
|
|
);
|
|
const wikiTree = useMemo(
|
|
() => buildWikiTree(filteredPages, {
|
|
preferredRootPageSlug: publicEmbed ? indexPage?.slug : undefined,
|
|
}),
|
|
[filteredPages, indexPage?.slug, publicEmbed],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!publicEmbed || requestedWikiSlug || !wikiSlug) return;
|
|
const next = new URLSearchParams(searchParams);
|
|
next.set("wikiSlug", wikiSlug);
|
|
navigate(`${buildPublicKnowledgeWikiPath(noteId)}?${next.toString()}`, {
|
|
replace: true,
|
|
});
|
|
}, [navigate, noteId, publicEmbed, requestedWikiSlug, searchParams, wikiSlug]);
|
|
|
|
useEffect(() => {
|
|
const activeParents = wikiTree.parentKeysBySlug.get(wikiSlug) || [];
|
|
if (activeParents.length === 0) return;
|
|
setCollapsedDirectories((previous) => {
|
|
if (activeParents.every((key) => !previous.has(key))) return previous;
|
|
const next = new Set(previous);
|
|
activeParents.forEach((key) => next.delete(key));
|
|
return next;
|
|
});
|
|
}, [wikiSlug, wikiTree.parentKeysBySlug]);
|
|
|
|
const title = pageData?.title || wikiSlug || "Wiki 页面";
|
|
const displayTitle = publicEmbed && pageData && isWikiIndexPage(pageData)
|
|
? "索引"
|
|
: title;
|
|
const currentPageIsIndex = !!pageData && isWikiIndexPage(pageData);
|
|
const summary = publicEmbed && currentPageIsIndex ? "" : cleanText(pageData?.summary);
|
|
const content = stripWikiFrontmatter(cleanText(
|
|
publicEmbed && currentPageIsIndex && publicWikiIndex.data?.intro
|
|
? publicWikiIndex.data.intro
|
|
: pageData?.content,
|
|
));
|
|
const displayMarkdown = pageData
|
|
? renderableWikiMarkdown(
|
|
title,
|
|
content,
|
|
noteId,
|
|
publicEmbed
|
|
? (knowledgeBaseId, slug) => {
|
|
const next = new URLSearchParams(searchParams);
|
|
next.set("wikiSlug", slug);
|
|
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId)}?${next.toString()}`;
|
|
}
|
|
: undefined,
|
|
publicEmbed ? pagesList : [],
|
|
publicEmbed ? publicWikiIndex.data?.groups || [] : [],
|
|
)
|
|
: "";
|
|
const exportMarkdown = pageData
|
|
? wikiLinkMarkdown(exportableWikiMarkdown(title, summary, content), noteId)
|
|
: "";
|
|
|
|
const download = async (format: "md" | "word") => {
|
|
if (!exportMarkdown) return;
|
|
setDownloading(format);
|
|
try {
|
|
const fileName = safeWikiFileName(title);
|
|
if (format === "md") {
|
|
downloadBlob(
|
|
new Blob([exportMarkdown], { type: "text/markdown;charset=utf-8" }),
|
|
`${fileName}.md`,
|
|
);
|
|
} else {
|
|
downloadBlob(
|
|
await markdownToDocxBlob(exportMarkdown, title),
|
|
`${fileName}.docx`,
|
|
);
|
|
}
|
|
toast.success(format === "md" ? "Markdown 文件已生成" : "Word 文件已生成");
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "下载 Wiki 页面失败");
|
|
} finally {
|
|
setDownloading(null);
|
|
}
|
|
};
|
|
|
|
const toggleDirectory = (key: string) => {
|
|
setCollapsedDirectories((previous) => {
|
|
const next = new Set(previous);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const renderDirectoryNode = (node: WikiTreeNode, depth = 0): ReactNode => {
|
|
const hasChildren = node.children.length > 0;
|
|
const expanded = directoryQuery.trim() ? true : !collapsedDirectories.has(node.key);
|
|
const active = node.page?.slug === wikiSlug;
|
|
const label = node.page
|
|
? publicEmbed && isWikiIndexPage(node.page)
|
|
? "索引"
|
|
: node.label
|
|
: directoryLabel(node.label);
|
|
return (
|
|
<div key={node.key}>
|
|
<div
|
|
className={`group flex min-h-9 items-center gap-1 rounded-lg pr-2 text-sm transition-colors ${
|
|
active
|
|
? "bg-emerald-100 font-medium text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
|
|
: "text-muted-foreground hover:bg-muted/70 hover:text-foreground"
|
|
}`}
|
|
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
|
>
|
|
{hasChildren ? (
|
|
<button
|
|
type="button"
|
|
className="flex size-6 shrink-0 items-center justify-center rounded hover:bg-background/70"
|
|
onClick={() => toggleDirectory(node.key)}
|
|
aria-label={expanded ? "收起目录" : "展开目录"}
|
|
>
|
|
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
|
</button>
|
|
) : (
|
|
<span className="size-6 shrink-0" />
|
|
)}
|
|
{node.page ? (
|
|
<Link
|
|
to={
|
|
publicEmbed
|
|
? (() => {
|
|
const next = new URLSearchParams(searchParams);
|
|
next.set("wikiSlug", node.page.slug);
|
|
return `${buildPublicKnowledgeWikiPath(noteId)}?${next.toString()}`;
|
|
})()
|
|
: buildWeKnoraWikiPagePath(noteId, node.page.slug)
|
|
}
|
|
state={{ wikiPage: node.page }}
|
|
className="flex min-w-0 flex-1 items-center gap-2 py-2"
|
|
aria-current={active ? "page" : undefined}
|
|
title={node.page.title || node.page.slug}
|
|
>
|
|
<FileText className={`size-3.5 shrink-0 ${active ? "text-emerald-600" : "text-muted-foreground/70"}`} />
|
|
<span className="truncate">{label}</span>
|
|
</Link>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="flex min-w-0 flex-1 items-center gap-2 py-2 text-left"
|
|
onClick={() => toggleDirectory(node.key)}
|
|
title={label}
|
|
>
|
|
<Folder className="size-3.5 shrink-0 text-amber-500" />
|
|
<span className="truncate">{label}</span>
|
|
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/70">{node.count}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{hasChildren && expanded && (
|
|
<div>{node.children.map((child) => renderDirectoryNode(child, depth + 1))}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (!wikiSlug) {
|
|
if (publicEmbed && pages.isLoading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
|
正在加载公开 Wiki
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
|
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
|
|
{!publicEmbed && (
|
|
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (page.isLoading && !pageData) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
|
正在加载 Wiki 页面
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!pageData) {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
|
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
|
<BookOpenText className="size-6" />
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-foreground">无法打开 Wiki 页面</div>
|
|
<div className="mt-1 text-sm text-muted-foreground">
|
|
{page.error instanceof Error
|
|
? page.error.message
|
|
: publicEmbed
|
|
? "知识库未发布、页面不存在或已停止公开访问"
|
|
: "页面不存在或当前账号无权访问"}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{!publicEmbed && (
|
|
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
|
)}
|
|
<Button onClick={() => void page.refetch()}>
|
|
<RefreshCw className="mr-2 size-4" />重试
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex h-full min-h-0 flex-col bg-slate-50/70 dark:bg-slate-950/40"
|
|
style={publicEmbed ? publicBackground : undefined}
|
|
data-public-wiki-embed={publicEmbed ? "true" : undefined}
|
|
>
|
|
{publicEmbed ? (
|
|
<header className="flex min-h-14 shrink-0 items-center gap-3 border-b bg-background/95 px-4 py-2.5 shadow-sm sm:px-6">
|
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
|
<BookOpenText className="size-4.5" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<h1 className="truncate text-sm font-semibold text-foreground">
|
|
{knowledgeBase.data?.name || "知识库 Wiki"}
|
|
</h1>
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">{displayTitle}</p>
|
|
</div>
|
|
<Badge variant="outline" className="shrink-0 border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-300">
|
|
公开 Wiki
|
|
</Badge>
|
|
</header>
|
|
) : (
|
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b bg-background px-4 py-3 shadow-sm sm:px-6">
|
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
<Button size="icon-sm" variant="ghost" onClick={() => navigate(LIST_PATH)} aria-label="返回知识库">
|
|
<ArrowLeft className="size-4" />
|
|
</Button>
|
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
|
<BookOpenText className="size-5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h1 className="truncate text-base font-semibold text-foreground">{title}</h1>
|
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
<Badge variant="outline">{pageTypeLabel(pageData.page_type)}</Badge>
|
|
<span>{knowledgeBase.data?.name || "知识库 Wiki"}</span>
|
|
{formatDate(pageData.updated_at || pageData.created_at) && (
|
|
<span>更新于 {formatDate(pageData.updated_at || pageData.created_at)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
|
|
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
|
下载 MD
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => void download("word")} disabled={downloading !== null}>
|
|
{downloading === "word" ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
|
|
下载 Word
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
)}
|
|
|
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
|
<aside className="flex w-64 shrink-0 flex-col border-r bg-background/90 lg:w-72">
|
|
<div className="shrink-0 border-b px-3 py-3">
|
|
<div className="flex items-center justify-between gap-3 px-1">
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
<BookOpenText className="size-4 text-emerald-600" />
|
|
Wiki 目录
|
|
</div>
|
|
<span className="text-xs tabular-nums text-muted-foreground">
|
|
{pagesList.length} 篇
|
|
</span>
|
|
</div>
|
|
<div className="relative mt-3">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={directoryQuery}
|
|
onChange={(event) => setDirectoryQuery(event.target.value)}
|
|
placeholder="搜索目录或页面"
|
|
className="h-9 bg-muted/35 pl-9"
|
|
aria-label="搜索 Wiki 目录"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="min-h-0 flex-1 overflow-auto p-2">
|
|
{pages.isLoading && pagesList.length === 0 ? (
|
|
<div className="flex items-center justify-center gap-2 px-3 py-10 text-sm text-muted-foreground">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
正在加载目录
|
|
</div>
|
|
) : wikiTree.roots.length === 0 ? (
|
|
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
|
|
{directoryQuery.trim() ? "没有匹配的 Wiki 页面" : "暂无 Wiki 目录"}
|
|
</div>
|
|
) : (
|
|
<nav aria-label="Wiki 目录" className="space-y-0.5">
|
|
{wikiTree.roots.map((node) => renderDirectoryNode(node))}
|
|
</nav>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="min-h-0 flex-1 overflow-auto px-4 py-6 sm:px-8">
|
|
<article className="mx-auto max-w-5xl overflow-hidden rounded-2xl border bg-background shadow-sm">
|
|
{summary && (
|
|
<section className="border-b bg-muted/30 px-6 py-5 sm:px-10">
|
|
<div className="mb-2 text-xs font-medium tracking-wide text-muted-foreground">页面摘要</div>
|
|
<p className="text-sm leading-7 text-foreground">{summary}</p>
|
|
</section>
|
|
)}
|
|
<section className="px-6 py-7 sm:px-10 sm:py-9">
|
|
<MarkdownContent
|
|
className="max-w-none"
|
|
content={displayMarkdown}
|
|
isLoading={false}
|
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
|
linkClassName={(href) =>
|
|
publicEmbed && publicWikiTargetFromHref(href, noteId)
|
|
? "wiki-content-link border-b border-dashed !border-[var(--primary)] font-medium !text-primary !no-underline hover:border-solid focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
: undefined
|
|
}
|
|
onLinkClick={(event, href) => {
|
|
if (!publicEmbed) return;
|
|
const target = publicWikiTargetFromHref(href, noteId);
|
|
if (!target) return;
|
|
event.preventDefault();
|
|
navigate(target.route, {
|
|
state: {
|
|
wikiPage: pagesList.find((item) => item.slug === target.slug),
|
|
},
|
|
});
|
|
}}
|
|
/>
|
|
</section>
|
|
</article>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default WeKnoraWikiPageView;
|