diff --git a/.gitignore b/.gitignore index 25335d55..1c2eca88 100644 --- a/.gitignore +++ b/.gitignore @@ -95,12 +95,15 @@ offline-backend-20260512/backend/tests/* !offline-backend-20260512/backend/tests/test_model_management.py # Keep fixed-question exact-match and streaming regression coverage versioned. !offline-backend-20260512/backend/tests/test_fixed_questions.py +# Keep configurable es_query system-prompt routing regression coverage versioned. +!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py # Keep the local LLMWiki vector-index and external API regression coverage versioned. !offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py !offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py !offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py !offline-backend-20260512/backend/tests/test_external_llmwiki_search.py !offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py +!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py # Deep-research reliability regression coverage. !offline-backend-20260512/backend/tests/test_deep_research_persistence.py !offline-backend-20260512/backend/tests/test_deep_research_event_sink.py diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 0e684bf7..7cdb7fa7 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -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() { } /> } /> + } + /> } diff --git a/frontend-web/src/core/llmwiki/source-links.ts b/frontend-web/src/core/llmwiki/source-links.ts index b3088013..60f7f677 100644 --- a/frontend-web/src/core/llmwiki/source-links.ts +++ b/frontend-web/src/core/llmwiki/source-links.ts @@ -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 ""; diff --git a/frontend-web/src/core/llmwiki/wiki-page-markdown.ts b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts index 4b8e437b..d4a24078 100644 --- a/frontend-web/src/core/llmwiki/wiki-page-markdown.ts +++ b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts @@ -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, ); } diff --git a/frontend-web/src/strategy-components/api/llmwiki.ts b/frontend-web/src/strategy-components/api/llmwiki.ts index ef2cdb56..481b2967 100644 --- a/frontend-web/src/strategy-components/api/llmwiki.ts +++ b/frontend-web/src/strategy-components/api/llmwiki.ts @@ -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 { + return readJson( + await apiFetch(`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(id)}`), + "读取公开知识库详情", + ); +} + +export async function listPublicLlmWikiWikiPages( + knowledgeBaseId: string, + page: number, + pageSize: number, + query = "", +): Promise { + 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 { + 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, diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx index cfd38771..489ff7d6 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx @@ -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>(() => 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 ? ( { + 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 ( + + + 正在加载公开 Wiki + + ); + } return ( - 当前链接缺少 Wiki 页面标识 - navigate(LIST_PATH)}>返回知识库 + {publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"} + {!publicEmbed && ( + navigate(LIST_PATH)}>返回知识库 + )} ); } @@ -245,11 +338,17 @@ export function WeKnoraWikiPageView() { 无法打开 Wiki 页面 - {page.error instanceof Error ? page.error.message : "页面不存在或当前账号无权访问"} + {page.error instanceof Error + ? page.error.message + : publicEmbed + ? "知识库未发布、页面不存在或已停止公开访问" + : "页面不存在或当前账号无权访问"} - navigate(LIST_PATH)}>返回知识库 + {!publicEmbed && ( + navigate(LIST_PATH)}>返回知识库 + )} void page.refetch()}> 重试 @@ -259,7 +358,27 @@ export function WeKnoraWikiPageView() { } return ( - + + {publicEmbed ? ( + + + + + + + {knowledgeBase.data?.name || "知识库 Wiki"} + + {title} + + + 公开 Wiki + + + ) : ( navigate(LIST_PATH)} aria-label="返回知识库"> @@ -290,6 +409,7 @@ export function WeKnoraWikiPageView() { + )}
{title}