feat: add public wiki embeds and ES query routing
This commit is contained in:
1 parent
e818c24769
commit
bc49df4561
16 files changed
+704
-52
No files matched your search
@@ -38,6 +38,7 @@ import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
|
||||
import {
|
||||
isPositionRoundtableStandaloneMode,
|
||||
POSITION_ROUNDTABLE_ROUTE,
|
||||
@@ -45,7 +46,12 @@ import {
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
|
||||
const PUBLIC_EMBED_PREFIXES = [
|
||||
"/share/",
|
||||
"/public/",
|
||||
"/roundtable-share/",
|
||||
"/embed/knowledge/",
|
||||
];
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
@@ -189,6 +195,10 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
|
||||
<Route
|
||||
path="/embed/knowledge/:noteId"
|
||||
element={<WeKnoraWikiPageView publicEmbed />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-dashboard"
|
||||
element={<DashboardStandalonePage />}
|
||||
|
||||
@@ -114,6 +114,24 @@ export function buildWeKnoraWikiPagePath(
|
||||
return `/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** Public, chrome-free Wiki reader intended for third-party iframe embedding. */
|
||||
export function buildPublicKnowledgeWikiPath(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug?: string,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (wikiSlug) params.set("wikiSlug", wikiSlug);
|
||||
const query = params.toString();
|
||||
return `/embed/knowledge/${encodeURIComponent(knowledgeBaseId)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export function buildPublicKnowledgeWikiUrl(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug?: string,
|
||||
): string {
|
||||
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId, wikiSlug)}`;
|
||||
}
|
||||
|
||||
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
|
||||
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
|
||||
if (!recUuid) return "";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { buildWeKnoraWikiPageUrl } from "@/core/llmwiki/source-links";
|
||||
|
||||
export type WikiPageUrlBuilder = (
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug: string,
|
||||
) => string;
|
||||
|
||||
export function stripWikiFrontmatter(value: string) {
|
||||
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
|
||||
}
|
||||
@@ -16,12 +21,16 @@ export function exportableWikiMarkdown(title: string, summary: string, content:
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function wikiLinkMarkdown(markdown: string, knowledgeBaseId: string) {
|
||||
export function wikiLinkMarkdown(
|
||||
markdown: string,
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
|
||||
) {
|
||||
const link = (slugValue: string, labelValue?: string) => {
|
||||
const slug = slugValue.trim();
|
||||
const fallbackLabel = slug.split("/").filter(Boolean).at(-1) || slug;
|
||||
const label = (labelValue || fallbackLabel).trim().replace(/\]/g, "\\]");
|
||||
return `[${label}](${buildWeKnoraWikiPageUrl(knowledgeBaseId, slug)})`;
|
||||
return `[${label}](${buildPageUrl(knowledgeBaseId, slug)})`;
|
||||
};
|
||||
return markdown
|
||||
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label))
|
||||
@@ -33,6 +42,7 @@ export function renderableWikiMarkdown(
|
||||
title: string,
|
||||
content: string,
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl?: WikiPageUrlBuilder,
|
||||
) {
|
||||
const lines = content.trim().split("\n");
|
||||
if (lines[0]?.replace(/^#+\s*/, "").trim() === title.trim()) {
|
||||
@@ -41,5 +51,6 @@ export function renderableWikiMarkdown(
|
||||
return wikiLinkMarkdown(
|
||||
exportableWikiMarkdown(title, "", lines.join("\n")),
|
||||
knowledgeBaseId,
|
||||
buildPageUrl,
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
||||
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
|
||||
const SETTINGS_API = () => `${getBackendBaseURL()}/api/system-settings/llmwiki`;
|
||||
|
||||
export type LlmWikiProvider = "legacy" | "weknora";
|
||||
@@ -671,6 +672,44 @@ export async function searchLlmWiki(input: {
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPublicLlmWikiKnowledgeBase(
|
||||
id: string,
|
||||
): Promise<LlmWikiKnowledgeBase> {
|
||||
return readJson(
|
||||
await apiFetch(`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(id)}`),
|
||||
"读取公开知识库详情",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPublicLlmWikiWikiPages(
|
||||
knowledgeBaseId: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
query = "",
|
||||
): Promise<LlmWikiWikiPageList> {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (query.trim()) params.set("query", query.trim());
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages?${params.toString()}`,
|
||||
),
|
||||
"读取公开 Wiki 页面",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPublicLlmWikiWikiPage(
|
||||
knowledgeBaseId: string,
|
||||
slug: string,
|
||||
): Promise<LlmWikiWikiPage> {
|
||||
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`,
|
||||
),
|
||||
"读取公开 Wiki 页面详情",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLlmWikiConversationDeposit(input: {
|
||||
thread_id: string;
|
||||
question: string;
|
||||
@@ -860,6 +899,46 @@ export function useLlmWikiWikiPage(
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublicLlmWikiKnowledgeBase(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["public-llmwiki", "knowledge-base", knowledgeBaseId],
|
||||
queryFn: () => getPublicLlmWikiKnowledgeBase(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublicLlmWikiWikiPages(
|
||||
knowledgeBaseId: string | null,
|
||||
page: number,
|
||||
pageSize = 50,
|
||||
query = "",
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["public-llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query],
|
||||
queryFn: () => listPublicLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublicLlmWikiWikiPage(
|
||||
knowledgeBaseId: string | null,
|
||||
slug: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["public-llmwiki", "wiki-page", knowledgeBaseId, slug],
|
||||
queryFn: () => getPublicLlmWikiWikiPage(knowledgeBaseId!, slug!),
|
||||
enabled: enabled && !!knowledgeBaseId && !!slug,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiWikiIndexStatus(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
|
||||
+134
-14
@@ -18,12 +18,16 @@ 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 { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
buildPublicKnowledgeWikiPath,
|
||||
buildWeKnoraWikiPagePath,
|
||||
} from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
buildWikiTree,
|
||||
filterWikiPagesWithAncestors,
|
||||
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 {
|
||||
@@ -38,6 +42,9 @@ import {
|
||||
useLlmWikiKnowledgeBase,
|
||||
useLlmWikiWikiPage,
|
||||
useLlmWikiWikiPages,
|
||||
usePublicLlmWikiKnowledgeBase,
|
||||
usePublicLlmWikiWikiPage,
|
||||
usePublicLlmWikiWikiPages,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
|
||||
const LIST_PATH = "/page/workspace/knowledge";
|
||||
@@ -85,27 +92,84 @@ function downloadBlob(blob: Blob, fileName: string) {
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
export function WeKnoraWikiPageView() {
|
||||
function selectLandingPage(pages: LlmWikiWikiPage[]): LlmWikiWikiPage | undefined {
|
||||
return (
|
||||
pages.find((page) => page.page_type?.toLowerCase() === "overview") ||
|
||||
pages.find((page) => /^(?:index|home|readme)$/i.test(page.slug)) ||
|
||||
pages.find((page) => !page.parent_slug && (page.depth ?? 0) === 0) ||
|
||||
pages[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function WeKnoraWikiPageView({
|
||||
publicEmbed = false,
|
||||
}: {
|
||||
publicEmbed?: boolean;
|
||||
}) {
|
||||
const { noteId = "" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
|
||||
const page = useLlmWikiWikiPage(noteId || null, wikiSlug || null, !!noteId && !!wikiSlug);
|
||||
const knowledgeBase = useLlmWikiKnowledgeBase(noteId || null, !!noteId);
|
||||
const pages = useLlmWikiWikiPages(noteId || null, 1, 500, "", !!noteId);
|
||||
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 landingPage = useMemo(() => selectLandingPage(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 [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 pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||
const filteredPages = useMemo(
|
||||
() => filterWikiPagesWithAncestors(pagesList, directoryQuery),
|
||||
[directoryQuery, pagesList],
|
||||
);
|
||||
const wikiTree = useMemo(() => buildWikiTree(filteredPages), [filteredPages]);
|
||||
|
||||
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;
|
||||
@@ -121,7 +185,18 @@ export function WeKnoraWikiPageView() {
|
||||
const summary = cleanText(pageData?.summary);
|
||||
const content = stripWikiFrontmatter(cleanText(pageData?.content));
|
||||
const displayMarkdown = pageData
|
||||
? renderableWikiMarkdown(title, content, noteId)
|
||||
? renderableWikiMarkdown(
|
||||
title,
|
||||
content,
|
||||
noteId,
|
||||
publicEmbed
|
||||
? (knowledgeBaseId, slug) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("wikiSlug", slug);
|
||||
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId)}?${next.toString()}`;
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
: "";
|
||||
const exportMarkdown = pageData
|
||||
? wikiLinkMarkdown(exportableWikiMarkdown(title, summary, content), noteId)
|
||||
@@ -189,7 +264,15 @@ export function WeKnoraWikiPageView() {
|
||||
)}
|
||||
{node.page ? (
|
||||
<Link
|
||||
to={buildWeKnoraWikiPagePath(noteId, node.page.slug)}
|
||||
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}
|
||||
@@ -219,10 +302,20 @@ export function WeKnoraWikiPageView() {
|
||||
};
|
||||
|
||||
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">
|
||||
当前链接缺少 Wiki 页面标识
|
||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
||||
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
|
||||
{!publicEmbed && (
|
||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -245,11 +338,17 @@ export function WeKnoraWikiPageView() {
|
||||
<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 : "页面不存在或当前账号无权访问"}
|
||||
{page.error instanceof Error
|
||||
? page.error.message
|
||||
: publicEmbed
|
||||
? "知识库未发布、页面不存在或已停止公开访问"
|
||||
: "页面不存在或当前账号无权访问"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
||||
{!publicEmbed && (
|
||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
||||
)}
|
||||
<Button onClick={() => void page.refetch()}>
|
||||
<RefreshCw className="mr-2 size-4" />重试
|
||||
</Button>
|
||||
@@ -259,7 +358,27 @@ export function WeKnoraWikiPageView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-slate-50/70 dark:bg-slate-950/40">
|
||||
<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">{title}</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="返回知识库">
|
||||
@@ -290,6 +409,7 @@ export function WeKnoraWikiPageView() {
|
||||
</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">
|
||||
|
||||
Reference in new issue
Block a user