feat: complete public wiki and embedded previews
This commit is contained in:
1 parent
c4d61288b4
commit
644cabd23d
18 files changed
+926
-48
No files matched your search
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
HTMLAttributes,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
MessageResponse,
|
||||
@@ -34,6 +39,12 @@ export type MarkdownContentProps = {
|
||||
className?: string;
|
||||
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
||||
components?: MessageResponseProps["components"];
|
||||
linkClassName?: (href: string, label: string) => string | undefined;
|
||||
onLinkClick?: (
|
||||
event: ReactMouseEvent<HTMLAnchorElement>,
|
||||
href: string,
|
||||
label: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
/** Renders markdown content. */
|
||||
@@ -43,6 +54,8 @@ export function MarkdownContent({
|
||||
className,
|
||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||
components: componentsFromProps,
|
||||
linkClassName,
|
||||
onLinkClick,
|
||||
}: MarkdownContentProps) {
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
const components = useMemo(() => {
|
||||
@@ -93,6 +106,7 @@ export function MarkdownContent({
|
||||
);
|
||||
}
|
||||
const finalHref = resolvedHref ?? props.href;
|
||||
const label = extractTextFromChildren(props.children);
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
@@ -100,6 +114,7 @@ export function MarkdownContent({
|
||||
className={cn(
|
||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||
className,
|
||||
linkClassName?.(finalHref || "", label),
|
||||
)}
|
||||
target={target ?? (external ? "_blank" : undefined)}
|
||||
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
|
||||
@@ -110,6 +125,7 @@ export function MarkdownContent({
|
||||
event.preventDefault();
|
||||
scrollToInPageAnchor(event.currentTarget, finalHref);
|
||||
}
|
||||
onLinkClick?.(event, finalHref || "", label);
|
||||
rest.onClick?.(event);
|
||||
}}
|
||||
/>
|
||||
@@ -117,7 +133,7 @@ export function MarkdownContent({
|
||||
},
|
||||
...componentsFromProps,
|
||||
};
|
||||
}, [anchorResolver, componentsFromProps]);
|
||||
}, [anchorResolver, componentsFromProps, linkClassName, onLinkClick]);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
|
||||
@@ -5,6 +5,38 @@ export type WikiPageUrlBuilder = (
|
||||
wikiSlug: string,
|
||||
) => string;
|
||||
|
||||
export type WikiPageLinkTarget = {
|
||||
slug: string;
|
||||
title: string;
|
||||
aliases?: string[];
|
||||
page_type?: string;
|
||||
summary?: string;
|
||||
category_path?: string[];
|
||||
};
|
||||
|
||||
export type WikiIndexGroup = {
|
||||
type: string;
|
||||
total: number;
|
||||
items: WikiPageLinkTarget[];
|
||||
};
|
||||
|
||||
type WikiIndexCategoryNode = {
|
||||
label: string;
|
||||
pages: WikiPageLinkTarget[];
|
||||
children: Map<string, WikiIndexCategoryNode>;
|
||||
};
|
||||
|
||||
const WIKI_INDEX_TYPE_LABELS: Record<string, string> = {
|
||||
summary: "摘要",
|
||||
entity: "实体",
|
||||
concept: "概念",
|
||||
overview: "概览",
|
||||
directory: "目录",
|
||||
article: "文章",
|
||||
};
|
||||
|
||||
const WIKI_INDEX_TYPE_ORDER = ["summary", "entity", "concept"];
|
||||
|
||||
export function stripWikiFrontmatter(value: string) {
|
||||
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
|
||||
}
|
||||
@@ -38,18 +70,192 @@ export function wikiLinkMarkdown(
|
||||
.replace(/\[\[([^\]]+)\]\]/g, (_match, slug) => link(slug));
|
||||
}
|
||||
|
||||
function normalizeWikiLinkLabel(value: string): string {
|
||||
return value
|
||||
.replace(/\\([\\`*_[\]{}()#+\-.!])/g, "$1")
|
||||
.replace(/[*_`~]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLocaleLowerCase("zh-CN");
|
||||
}
|
||||
|
||||
function isLegacyWikiPlaceholderUrl(value: string): boolean {
|
||||
return /\/platform\/knowledge-bases\/[^/?#)]+/i.test(value);
|
||||
}
|
||||
|
||||
function wikiIndexTypeRank(pageType: string): number {
|
||||
const index = WIKI_INDEX_TYPE_ORDER.indexOf(pageType);
|
||||
return index >= 0 ? index : WIKI_INDEX_TYPE_ORDER.length;
|
||||
}
|
||||
|
||||
function wikiIndexPageLine(
|
||||
page: WikiPageLinkTarget,
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl: WikiPageUrlBuilder,
|
||||
): string {
|
||||
const label = (page.title || page.slug).replace(/\]/g, "\\]");
|
||||
const summary = (page.summary || "").replace(/\s+/g, " ").trim();
|
||||
return `[${label}](${buildPageUrl(knowledgeBaseId, page.slug)})${summary ? ` — ${summary}` : ""}`;
|
||||
}
|
||||
|
||||
function renderWikiIndexCategory(
|
||||
node: WikiIndexCategoryNode,
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl: WikiPageUrlBuilder,
|
||||
): string[] {
|
||||
const lines = node.label ? [`**${node.label}**`] : [];
|
||||
lines.push(...node.pages.map((page) => wikiIndexPageLine(page, knowledgeBaseId, buildPageUrl)));
|
||||
const children = Array.from(node.children.values()).sort((left, right) =>
|
||||
left.label.localeCompare(right.label, "zh-Hans-CN"),
|
||||
);
|
||||
for (const child of children) {
|
||||
lines.push(...renderWikiIndexCategory(child, knowledgeBaseId, buildPageUrl));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Build the category catalogue that WeKnora renders dynamically for its index page. */
|
||||
export function buildWikiIndexCatalogMarkdown(
|
||||
pages: WikiPageLinkTarget[],
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
|
||||
indexGroups: WikiIndexGroup[] = [],
|
||||
): string {
|
||||
if (indexGroups.length > 0) {
|
||||
const sections: string[] = [];
|
||||
for (const group of indexGroups) {
|
||||
if (group.items.length === 0) continue;
|
||||
const seenCategories = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
for (const page of group.items) {
|
||||
const categories = (page.category_path || []).map((item) => item.trim()).filter(Boolean);
|
||||
for (let depth = 0; depth < categories.length; depth += 1) {
|
||||
const path = categories.slice(0, depth + 1).join("/");
|
||||
if (seenCategories.has(path)) continue;
|
||||
seenCategories.add(path);
|
||||
lines.push(`${" ".repeat(depth)}**${categories[depth]}**`);
|
||||
}
|
||||
lines.push(
|
||||
`${" ".repeat(categories.length)}${wikiIndexPageLine(page, knowledgeBaseId, buildPageUrl)}`,
|
||||
);
|
||||
}
|
||||
const label = WIKI_INDEX_TYPE_LABELS[group.type] || group.type;
|
||||
sections.push(`## ${label} (${group.total})`, lines.join("\n"));
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
const groups = new Map<string, WikiPageLinkTarget[]>();
|
||||
for (const page of pages) {
|
||||
const pageType = (page.page_type || "article").trim().toLowerCase();
|
||||
const slugName = page.slug.split("/").filter(Boolean).at(-1)?.toLowerCase();
|
||||
if (pageType === "index" || ["index", "home", "readme"].includes(slugName || "")) continue;
|
||||
const group = groups.get(pageType) || [];
|
||||
group.push(page);
|
||||
groups.set(pageType, group);
|
||||
}
|
||||
|
||||
const pageTypes = Array.from(groups.keys()).sort((left, right) => {
|
||||
const rankDifference = wikiIndexTypeRank(left) - wikiIndexTypeRank(right);
|
||||
if (rankDifference !== 0) return rankDifference;
|
||||
return (WIKI_INDEX_TYPE_LABELS[left] || left).localeCompare(
|
||||
WIKI_INDEX_TYPE_LABELS[right] || right,
|
||||
"zh-Hans-CN",
|
||||
);
|
||||
});
|
||||
|
||||
const sections: string[] = [];
|
||||
for (const pageType of pageTypes) {
|
||||
const groupPages = groups.get(pageType) || [];
|
||||
const root: WikiIndexCategoryNode = { label: "", pages: [], children: new Map() };
|
||||
for (const page of groupPages) {
|
||||
let node = root;
|
||||
for (const category of page.category_path || []) {
|
||||
const label = category.trim();
|
||||
if (!label) continue;
|
||||
let child = node.children.get(label);
|
||||
if (!child) {
|
||||
child = { label, pages: [], children: new Map() };
|
||||
node.children.set(label, child);
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
node.pages.push(page);
|
||||
}
|
||||
const label = WIKI_INDEX_TYPE_LABELS[pageType] || pageType;
|
||||
sections.push(
|
||||
`## ${label} (${groupPages.length})`,
|
||||
...renderWikiIndexCategory(root, knowledgeBaseId, buildPageUrl),
|
||||
);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* WeKnora-generated index pages may contain Markdown links whose href only
|
||||
* points at the knowledge-base Wiki shell. Resolve those placeholders by the
|
||||
* visible link label so the native/public reader can open the exact page.
|
||||
*/
|
||||
export function resolveWikiIndexPageLinks(
|
||||
markdown: string,
|
||||
knowledgeBaseId: string,
|
||||
pages: WikiPageLinkTarget[],
|
||||
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
|
||||
): string {
|
||||
const pagesByLabel = new Map<string, WikiPageLinkTarget>();
|
||||
for (const page of pages) {
|
||||
const labels = [page.title, page.slug, page.slug.split("/").filter(Boolean).at(-1), ...(page.aliases || [])];
|
||||
for (const label of labels) {
|
||||
const normalized = normalizeWikiLinkLabel(label || "");
|
||||
if (normalized && !pagesByLabel.has(normalized)) pagesByLabel.set(normalized, page);
|
||||
}
|
||||
}
|
||||
|
||||
return markdown.replace(
|
||||
/(?<!!)\[([^\]\n]+)\]\(([^)\n]+)\)/g,
|
||||
(original, rawLabel: string, rawUrl: string) => {
|
||||
if (!isLegacyWikiPlaceholderUrl(rawUrl)) return original;
|
||||
const normalizedLabel = normalizeWikiLinkLabel(rawLabel);
|
||||
const withoutSummarySuffix = normalizedLabel.replace(/\s+-\s+summary$/i, "");
|
||||
const target = pagesByLabel.get(normalizedLabel) || pagesByLabel.get(withoutSummarySuffix);
|
||||
if (!target) return original;
|
||||
return `[${rawLabel}](${buildPageUrl(knowledgeBaseId, target.slug)})`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function renderableWikiMarkdown(
|
||||
title: string,
|
||||
content: string,
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl?: WikiPageUrlBuilder,
|
||||
pages: WikiPageLinkTarget[] = [],
|
||||
indexGroups: WikiIndexGroup[] = [],
|
||||
) {
|
||||
const lines = content.trim().split("\n");
|
||||
if (lines[0]?.replace(/^#+\s*/, "").trim() === title.trim()) {
|
||||
lines[0] = `# ${title}`;
|
||||
}
|
||||
const baseMarkdown = exportableWikiMarkdown(title, "", lines.join("\n"));
|
||||
const isIndex = pages.some((page) => {
|
||||
const slugName = page.slug.split("/").filter(Boolean).at(-1)?.toLowerCase();
|
||||
return page.title === title && (
|
||||
page.page_type?.toLowerCase() === "index" ||
|
||||
["index", "home", "readme"].includes(slugName || "")
|
||||
);
|
||||
});
|
||||
const existingMarkdownLinkCount = (baseMarkdown.match(/(?<!!)\[[^\]\n]+\]\([^)\n]+\)/g) || []).length;
|
||||
const catalogue = isIndex && existingMarkdownLinkCount === 0
|
||||
? buildWikiIndexCatalogMarkdown(pages, knowledgeBaseId, buildPageUrl, indexGroups)
|
||||
: "";
|
||||
const markdown = resolveWikiIndexPageLinks(
|
||||
[baseMarkdown, catalogue].filter(Boolean).join("\n\n"),
|
||||
knowledgeBaseId,
|
||||
pages,
|
||||
buildPageUrl,
|
||||
);
|
||||
return wikiLinkMarkdown(
|
||||
exportableWikiMarkdown(title, "", lines.join("\n")),
|
||||
markdown,
|
||||
knowledgeBaseId,
|
||||
buildPageUrl,
|
||||
);
|
||||
|
||||
@@ -13,6 +13,34 @@ export type WikiTree = {
|
||||
parentKeysBySlug: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export type BuildWikiTreeOptions = {
|
||||
preferredRootPageSlug?: string;
|
||||
};
|
||||
|
||||
export function isWikiIndexPage(page: LlmWikiWikiPage): boolean {
|
||||
const pageType = (page.page_type || "").trim().toLowerCase();
|
||||
const title = (page.title || "").trim().toLowerCase();
|
||||
const slug = (page.slug || "").trim().replace(/^\/+|\/+$/g, "");
|
||||
const slugName = slug.split("/").pop()?.toLowerCase() || "";
|
||||
return (
|
||||
pageType === "index" ||
|
||||
title === "索引" ||
|
||||
title === "index" ||
|
||||
["index", "home", "readme"].includes(slugName)
|
||||
);
|
||||
}
|
||||
|
||||
export function selectDefaultWikiPage(
|
||||
pages: LlmWikiWikiPage[],
|
||||
): LlmWikiWikiPage | undefined {
|
||||
return (
|
||||
pages.find(isWikiIndexPage) ||
|
||||
pages.find((page) => page.page_type?.toLowerCase() === "overview") ||
|
||||
pages.find((page) => !page.parent_slug && (page.depth ?? 0) === 0) ||
|
||||
pages[0]
|
||||
);
|
||||
}
|
||||
|
||||
function pageDirectoryParts(page: LlmWikiWikiPage): string[] {
|
||||
const wikiPath = (page.wiki_path || "").split("/").map((item) => item.trim()).filter(Boolean);
|
||||
if (wikiPath.length > 2) return wikiPath.slice(1, -1);
|
||||
@@ -62,20 +90,49 @@ function attachNode(parent: WikiTreeNode | null, roots: WikiTreeNode[], node: Wi
|
||||
if (!siblings.some((item) => item.key === node.key)) siblings.push(node);
|
||||
}
|
||||
|
||||
function sortWikiNodes(nodes: WikiTreeNode[]) {
|
||||
function sortWikiNodes(
|
||||
nodes: WikiTreeNode[],
|
||||
preferredRootPageSlug?: string,
|
||||
isRoot = false,
|
||||
) {
|
||||
nodes.sort((left, right) => {
|
||||
if (isRoot && preferredRootPageSlug) {
|
||||
const leftPreferred = left.page?.slug === preferredRootPageSlug;
|
||||
const rightPreferred = right.page?.slug === preferredRootPageSlug;
|
||||
if (leftPreferred !== rightPreferred) return leftPreferred ? -1 : 1;
|
||||
}
|
||||
if (!!left.children.length !== !!right.children.length) return left.children.length ? -1 : 1;
|
||||
return left.label.localeCompare(right.label, "zh-Hans-CN");
|
||||
});
|
||||
nodes.forEach((node) => sortWikiNodes(node.children));
|
||||
}
|
||||
|
||||
function detachPageNode(nodes: WikiTreeNode[], slug: string): WikiTreeNode | null {
|
||||
const index = nodes.findIndex((node) => node.page?.slug === slug);
|
||||
if (index >= 0) return nodes.splice(index, 1)[0] || null;
|
||||
for (const node of nodes) {
|
||||
const detached = detachPageNode(node.children, slug);
|
||||
if (detached) return detached;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pruneEmptyDirectories(nodes: WikiTreeNode[]): WikiTreeNode[] {
|
||||
return nodes.filter((node) => {
|
||||
node.children = pruneEmptyDirectories(node.children);
|
||||
return node.page !== null || node.children.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function countPages(node: WikiTreeNode): number {
|
||||
node.count = (node.page ? 1 : 0) + node.children.reduce((sum, child) => sum + countPages(child), 0);
|
||||
return node.count;
|
||||
}
|
||||
|
||||
export function buildWikiTree(pages: LlmWikiWikiPage[]): WikiTree {
|
||||
export function buildWikiTree(
|
||||
pages: LlmWikiWikiPage[],
|
||||
options: BuildWikiTreeOptions = {},
|
||||
): WikiTree {
|
||||
const roots: WikiTreeNode[] = [];
|
||||
const pageNodes = new Map<string, WikiTreeNode>();
|
||||
const directoryNodes = new Map<string, WikiTreeNode>();
|
||||
@@ -140,7 +197,17 @@ export function buildWikiTree(pages: LlmWikiWikiPage[]): WikiTree {
|
||||
parentKeysBySlug.set(page.slug, ancestorKeys);
|
||||
}
|
||||
|
||||
const preferredSlug = options.preferredRootPageSlug?.trim();
|
||||
if (preferredSlug) {
|
||||
const preferredNode = detachPageNode(roots, preferredSlug);
|
||||
if (preferredNode) {
|
||||
roots.splice(0, roots.length, ...pruneEmptyDirectories(roots));
|
||||
roots.unshift(preferredNode);
|
||||
parentKeysBySlug.set(preferredSlug, []);
|
||||
}
|
||||
}
|
||||
|
||||
roots.forEach(countPages);
|
||||
sortWikiNodes(roots);
|
||||
sortWikiNodes(roots, preferredSlug, true);
|
||||
return { roots, parentKeysBySlug };
|
||||
}
|
||||
@@ -144,6 +144,27 @@ export interface LlmWikiWikiPageList {
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface LlmWikiWikiIndexItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
category_path: string[];
|
||||
wiki_path: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface LlmWikiWikiIndexGroup {
|
||||
type: string;
|
||||
total: number;
|
||||
items: LlmWikiWikiIndexItem[];
|
||||
}
|
||||
|
||||
export interface LlmWikiWikiIndex {
|
||||
intro: string;
|
||||
version: number;
|
||||
groups: LlmWikiWikiIndexGroup[];
|
||||
}
|
||||
|
||||
export interface LlmWikiWikiIndexStatus {
|
||||
knowledge_base_id: string;
|
||||
state: "not_synced" | "idle" | "running" | "partial" | "failed" | "rebuilding" | "rebuild_required";
|
||||
@@ -710,6 +731,17 @@ export async function getPublicLlmWikiWikiPage(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPublicLlmWikiWikiIndex(
|
||||
knowledgeBaseId: string,
|
||||
): Promise<LlmWikiWikiIndex> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/index`,
|
||||
),
|
||||
"读取公开 Wiki 索引",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLlmWikiConversationDeposit(input: {
|
||||
thread_id: string;
|
||||
question: string;
|
||||
@@ -939,6 +971,18 @@ export function usePublicLlmWikiWikiPage(
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublicLlmWikiWikiIndex(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["public-llmwiki", "wiki-index", knowledgeBaseId],
|
||||
queryFn: () => getPublicLlmWikiWikiIndex(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiWikiIndexStatus(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
|
||||
-1
@@ -273,7 +273,6 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
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 allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => postThemeToFrame(frameRef.current)}
|
||||
/>
|
||||
|
||||
+57
-3
@@ -21,12 +21,15 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
buildWeKnoraWikiPagePath,
|
||||
parseWeKnoraPreviewUrl,
|
||||
type WeKnoraSourceTarget,
|
||||
} from "@/core/llmwiki/source-links";
|
||||
import { SpreadsheetPreview } from "@/components/workspace/artifacts/spreadsheet-preview";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
exportableWikiMarkdown,
|
||||
renderableWikiMarkdown,
|
||||
@@ -81,6 +84,14 @@ type CachedSourceDetail = {
|
||||
|
||||
const sourceDetailCache = new Map<string, CachedSourceDetail>();
|
||||
|
||||
function buildDrawerWikiPageUrl(knowledgeBaseId: string, wikiSlug: string) {
|
||||
const params = new URLSearchParams({
|
||||
knowledge_base_id: knowledgeBaseId,
|
||||
wiki_slug: wikiSlug,
|
||||
});
|
||||
return `/weknora-source-preview?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function getAvailableWikiPage(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug: string,
|
||||
@@ -539,6 +550,7 @@ export function WeKnoraSourceDrawer({
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [wikiDownload, setWikiDownload] = useState<"md" | "word" | null>(null);
|
||||
const [resolvedDocumentId, setResolvedDocumentId] = useState("");
|
||||
const wikiNavigationRequestRef = useRef(0);
|
||||
|
||||
const targetKey = useMemo(() => {
|
||||
if (!target) return "";
|
||||
@@ -690,7 +702,12 @@ export function WeKnoraSourceDrawer({
|
||||
const summary = cleanText(wikiPage?.summary || document?.description);
|
||||
const wikiContent = stripWikiFrontmatter(cleanText(wikiPage?.content));
|
||||
const wikiDisplayContent = wikiPage && target
|
||||
? renderableWikiMarkdown(resolvedTitle, wikiContent, target.knowledgeBaseId)
|
||||
? renderableWikiMarkdown(
|
||||
resolvedTitle,
|
||||
wikiContent,
|
||||
target.knowledgeBaseId,
|
||||
buildDrawerWikiPageUrl,
|
||||
)
|
||||
: "";
|
||||
const wikiMarkdown = wikiPage
|
||||
? wikiLinkMarkdown(
|
||||
@@ -703,6 +720,32 @@ export function WeKnoraSourceDrawer({
|
||||
? buildWeKnoraWikiPagePath(target.knowledgeBaseId, resolvedWikiSlug)
|
||||
: "";
|
||||
|
||||
const openWikiPageInDrawer = async (linkedTarget: WeKnoraSourceTarget) => {
|
||||
if (!linkedTarget.wikiSlug) return;
|
||||
const requestId = ++wikiNavigationRequestRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const page = await getAvailableWikiPage(
|
||||
linkedTarget.knowledgeBaseId,
|
||||
linkedTarget.wikiSlug,
|
||||
);
|
||||
if (requestId === wikiNavigationRequestRef.current) {
|
||||
setWikiPage(page);
|
||||
setDocument(null);
|
||||
setResolvedDocumentId("");
|
||||
setPreview({ kind: "none" });
|
||||
setPreviewError("");
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === wikiNavigationRequestRef.current) {
|
||||
setError(error instanceof Error ? error.message : "无法打开 Wiki 页面");
|
||||
}
|
||||
} finally {
|
||||
if (requestId === wikiNavigationRequestRef.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadOriginal = async () => {
|
||||
if (!target || !resolvedDocumentId) return;
|
||||
setDownloading(true);
|
||||
@@ -925,6 +968,17 @@ export function WeKnoraSourceDrawer({
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
linkClassName={(href) =>
|
||||
parseWeKnoraPreviewUrl(href)?.wikiSlug
|
||||
? "wiki-content-link font-medium text-[#be2323] decoration-[#be2323]/55 decoration-dashed hover:decoration-solid"
|
||||
: undefined
|
||||
}
|
||||
onLinkClick={(event, href) => {
|
||||
const linkedTarget = parseWeKnoraPreviewUrl(href);
|
||||
if (!linkedTarget?.wikiSlug) return;
|
||||
event.preventDefault();
|
||||
void openWikiPageInDrawer(linkedTarget);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+59
-13
@@ -25,6 +25,8 @@ import {
|
||||
import {
|
||||
buildWikiTree,
|
||||
filterWikiPagesWithAncestors,
|
||||
isWikiIndexPage,
|
||||
selectDefaultWikiPage,
|
||||
type WikiTreeNode,
|
||||
} from "@/core/llmwiki/wiki-tree";
|
||||
import { usePublicBackground } from "@/core/embed/background";
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
useLlmWikiWikiPage,
|
||||
useLlmWikiWikiPages,
|
||||
usePublicLlmWikiKnowledgeBase,
|
||||
usePublicLlmWikiWikiIndex,
|
||||
usePublicLlmWikiWikiPage,
|
||||
usePublicLlmWikiWikiPages,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
@@ -92,13 +95,13 @@ function downloadBlob(blob: Blob, fileName: string) {
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
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({
|
||||
@@ -137,7 +140,11 @@ export function WeKnoraWikiPageView({
|
||||
);
|
||||
const pages = publicEmbed ? publicPages : privatePages;
|
||||
const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||
const landingPage = useMemo(() => selectLandingPage(pagesList), [pagesList]);
|
||||
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,
|
||||
@@ -150,6 +157,10 @@ export function WeKnoraWikiPageView({
|
||||
!!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());
|
||||
@@ -159,7 +170,12 @@ export function WeKnoraWikiPageView({
|
||||
() => filterWikiPagesWithAncestors(pagesList, directoryQuery),
|
||||
[directoryQuery, pagesList],
|
||||
);
|
||||
const wikiTree = useMemo(() => buildWikiTree(filteredPages), [filteredPages]);
|
||||
const wikiTree = useMemo(
|
||||
() => buildWikiTree(filteredPages, {
|
||||
preferredRootPageSlug: publicEmbed ? indexPage?.slug : undefined,
|
||||
}),
|
||||
[filteredPages, indexPage?.slug, publicEmbed],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!publicEmbed || requestedWikiSlug || !wikiSlug) return;
|
||||
@@ -182,8 +198,16 @@ export function WeKnoraWikiPageView({
|
||||
}, [wikiSlug, wikiTree.parentKeysBySlug]);
|
||||
|
||||
const title = pageData?.title || wikiSlug || "Wiki 页面";
|
||||
const summary = cleanText(pageData?.summary);
|
||||
const content = stripWikiFrontmatter(cleanText(pageData?.content));
|
||||
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,
|
||||
@@ -196,6 +220,8 @@ export function WeKnoraWikiPageView({
|
||||
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId)}?${next.toString()}`;
|
||||
}
|
||||
: undefined,
|
||||
publicEmbed ? pagesList : [],
|
||||
publicEmbed ? publicWikiIndex.data?.groups || [] : [],
|
||||
)
|
||||
: "";
|
||||
const exportMarkdown = pageData
|
||||
@@ -239,7 +265,11 @@ export function WeKnoraWikiPageView({
|
||||
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 ? node.label : directoryLabel(node.label);
|
||||
const label = node.page
|
||||
? publicEmbed && isWikiIndexPage(node.page)
|
||||
? "索引"
|
||||
: node.label
|
||||
: directoryLabel(node.label);
|
||||
return (
|
||||
<div key={node.key}>
|
||||
<div
|
||||
@@ -372,7 +402,7 @@ export function WeKnoraWikiPageView({
|
||||
<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>
|
||||
<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
|
||||
@@ -467,6 +497,22 @@ export function WeKnoraWikiPageView({
|
||||
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>
|
||||
|
||||
@@ -21,11 +21,20 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "@/env",
|
||||
replacement: path.resolve(rootDir, "src/env.ts"),
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
// react-syntax-highlighter@16.1.1 exposes an async `sqlMore`
|
||||
// loader, but its own highlight.js@10.x dependency has no
|
||||
// `sql_more` module. Reuse the bundled SQL grammar so Rollup can
|
||||
// resolve every generated language loader without leaving a broken
|
||||
// browser-time external import.
|
||||
find: /^highlight\.js\/lib\/languages\/sql_more$/,
|
||||
replacement: "highlight.js/lib/languages/sql",
|
||||
},
|
||||
{
|
||||
find: "@/env",
|
||||
replacement: path.resolve(rootDir, "src/env.ts"),
|
||||
},
|
||||
{
|
||||
find: "next/navigation",
|
||||
|
||||
@@ -1015,7 +1015,7 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
||||
|
||||
`llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and `system` owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use `cmzs` branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only `system_role === "admin"` sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call `/api/llmwiki/knowledge-bases/{id}/weknora/session` through the normal DeerFlow API auth path, then load `/platform/knowledge-bases/{weknora_id}` with a signed `deerflow_weknora_embed` cookie for `/platform`, `/assets`, `/locales`, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe `/api/v1/*`, including colliding `/api/v1/auth/*`, through `/api/llmwiki/weknora-embed/api/v1/*`; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors `X-Forwarded-Proto` for HTTPS reverse proxies. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`.
|
||||
|
||||
The embedded detail proxy hides WeKnora's outer `.main > .aside_box` application sidebar and expands the route outlet, but deliberately keeps the Wiki page's internal directory/index sidebar.
|
||||
The embedded detail proxy hides WeKnora's outer `.main > .aside_box` application sidebar and expands the route outlet, but deliberately keeps the Wiki page's internal directory/index sidebar. Do not add an HTML `sandbox` attribute to this trusted, permission-filtered detail iframe: a sandboxed ancestor causes Chromium's built-in Blob PDF viewer to render the browser-blocked page. The Gateway follows upstream redirects only for `/knowledge/{id}/preview` and `/knowledge-bases/{id}/files`, keeping PDF bytes and protected Wiki images inside the signed proxy instead of sending the browser to WeKnora object storage.
|
||||
|
||||
**Embedded native knowledge-base switching:** The original WeKnora breadcrumb picker remains available inside the detail iframe. A signed 15-minute embed cookie carries only the current DeerFlow user's visible mappings (their own plus published bases); the proxied `/api/v1/knowledge-bases` response is filtered to that allowlist. Choosing another entry performs a full iframe navigation, rechecks the DeerFlow mapping and read/write permission, then reissues the cookie for the selected base. Never expose the upstream service-admin list directly or permit an arbitrary remote knowledge-base ID.
|
||||
|
||||
@@ -1451,6 +1451,11 @@ embedding succeeds. Never delete the old generation before a remote embedding
|
||||
call and never fall back to WeKnora raw chunks while the local feature flag is
|
||||
enabled. NumPy matrices are per-mapping LRU snapshots invalidated by
|
||||
`index_revision`; large decode/matrix work runs through `asyncio.to_thread`.
|
||||
`embedding.dimensions` is a local response-size invariant and fingerprint
|
||||
input, not an outbound API field: fixed-dimension BGE/OpenAI-compatible
|
||||
endpoints can reject a `dimensions` request parameter with HTTP 400. When the
|
||||
value is omitted, the embedding client probes and freezes the first response's
|
||||
dimension before sync lease/rebuild decisions.
|
||||
During a fingerprint rebuild the mapping uses the Wiki-page API until every
|
||||
page is successfully rebuilt. `WikiRetrievalService` partitions mixed requests
|
||||
per mapping, so ready libraries stay local while incomplete libraries use that
|
||||
@@ -1480,14 +1485,33 @@ WeKnora iframe.
|
||||
|
||||
Third-party pages embed published Wiki content through the chrome-free route
|
||||
`/#/embed/knowledge/{mapping_id}` (optional exact-page `wikiSlug`). It defaults
|
||||
to an overview/root Wiki page, retains directory navigation, and supports the
|
||||
shared public-embed `theme` / `bg` / `text` appearance inputs. All anonymous
|
||||
reader calls use `/api/public/llmwiki/knowledge-bases/{id}` and its Wiki-page
|
||||
children. Those endpoints must resolve the mapping with a synthetic anonymous
|
||||
user and `is_admin=False`, explicitly require `publication_status=published`,
|
||||
and hard-deny the system conversation-deposit mapping. Never point this page at
|
||||
the authenticated `/api/llmwiki` reads or weaken the published-only gate merely
|
||||
because the mapping id is hard to guess.
|
||||
to a recognized Wiki index page (title `索引`/`index`, page type `index`, or an
|
||||
`index`/`home`/`readme` slug), promotes that page to the first public-directory
|
||||
entry, then falls back to an overview/root page when no index exists. It retains
|
||||
directory navigation and supports the shared public-embed `theme` / `bg` /
|
||||
`text` appearance inputs. WeKnora dynamically renders index page-type counts,
|
||||
nested category headings, page summaries, and `data-slug` links even though the
|
||||
stored index Markdown only contains its introduction; the native public reader
|
||||
reads the same ordered groups through the published-only
|
||||
`/api/public/llmwiki/knowledge-bases/{id}/wiki/index` proxy and renders them with
|
||||
`buildWikiIndexCatalogMarkdown`. The Gateway paginates every WeKnora index type
|
||||
(`summary`, `entity`, `concept`, `synthesis`, `comparison`) to completion while
|
||||
preserving upstream group/item order. Index Markdown
|
||||
may also contain WeKnora shell-only placeholder links;
|
||||
`resolveWikiIndexPageLinks` matches their visible labels to
|
||||
the loaded page title, slug, or alias and rewrites them to exact embedded
|
||||
`wikiSlug` routes. Preserve WeKnora's `wiki-content-link` interaction: primary
|
||||
brand color, medium weight, dashed underline becoming solid on hover, focus
|
||||
outline, and in-reader SPA navigation rather than a page reload. `GET
|
||||
/api/public/llmwiki/knowledge-bases` is the
|
||||
anonymous discovery endpoint and returns only each published ordinary mapping's
|
||||
`id` and `name` (plus the collection total). All anonymous reader calls use that
|
||||
public namespace and the `/knowledge-bases/{id}` Wiki-page children. Those
|
||||
endpoints must resolve with a synthetic anonymous user and `is_admin=False`,
|
||||
explicitly require `publication_status=published`, and hard-deny the system
|
||||
conversation-deposit mapping. Never point this page at the authenticated
|
||||
`/api/llmwiki` reads or weaken the published-only gate merely because the
|
||||
mapping id is hard to guess.
|
||||
|
||||
Owners manually force a full sync/vectorization through `POST
|
||||
/api/llmwiki/knowledge-bases/{id}/vectorize`; status is available through `GET
|
||||
|
||||
@@ -82,7 +82,7 @@ Per-thread isolated execution with virtual path translation:
|
||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
||||
- **Enterprise Research workbench (temporarily disabled)**: the independent `/api/enterprise-research` router, its report executor, and its ORM table registration are intentionally inactive. The implementation remains in the repository, but startup does not create or alter `enterprise_research_tasks` or `enterprise_research_report_jobs`; this keeps the active Deep Research workflow independent of the paused workbench and avoids MySQL schema errors.
|
||||
- **Embedded WeKnora detail chrome**: the proxy hides WeKnora's application sidebar (`.main > .aside_box`) and expands the detail outlet to the full iframe width, while preserving the Wiki page's own index/navigation panel.
|
||||
- **Embedded WeKnora detail chrome**: the proxy hides WeKnora's application sidebar (`.main > .aside_box`) and expands the detail outlet to the full iframe width, while preserving the Wiki page's own index/navigation panel. The host does not sandbox this trusted, permission-filtered detail frame because browser PDF viewers are disabled inside sandboxed ancestor frames. Preview and knowledge-base file redirects are followed by the Gateway so PDF bytes and protected Wiki images remain inside the authorized proxy instead of leaking to an unreachable object-storage URL.
|
||||
- **Embedded WeKnora knowledge-base switching**: the original breadcrumb picker works inside the iframe, but lists only the signed-in user's own and published knowledge bases. Every switch is reauthorized through DeerFlow and receives a fresh short-lived iframe session, so it never exposes the upstream service-admin knowledge-base list.
|
||||
- **Prefixed WeKnora deployment**: the frontend may be published under a separate prefix such as `/magentweb`, while embedded WeKnora detail traffic is served by the Gateway under `/deerflow`. The Gateway rewrites HTML root assets, WeKnora API URLs, redirects, and Vite dynamic dependency maps so page assets stay under `/deerflow`; the prefixed proxy routes must be registered before the unprefixed fallback. The signed embed cookie covers `/deerflow`, and auth/CSRF recognition includes all supported static directories.
|
||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
||||
@@ -780,6 +780,11 @@ matched Wiki pages. It never falls back to raw documents or chunks. The local
|
||||
vector feature is disabled by default. Configure
|
||||
`llmwiki.local_wiki_index.embedding` first, then enable `enabled`,
|
||||
`sync_enabled`, and `internal_search_enabled` in `config.yaml`.
|
||||
The optional `embedding.dimensions` value is used only to validate the returned
|
||||
vector size and build the index fingerprint; it is deliberately not sent in
|
||||
the embedding request because fixed-dimension BGE/OpenAI-compatible endpoints
|
||||
may reject that extra field with HTTP 400. Omit it to discover the dimension
|
||||
from the first successful response.
|
||||
|
||||
The migration creates `llmwiki_wiki_pages`, `llmwiki_wiki_vectors`, and
|
||||
`llmwiki_wiki_sync_states`. Wiki Markdown and normalized float32 vectors are
|
||||
@@ -814,13 +819,26 @@ without mounting the WeKnora iframe.
|
||||
|
||||
Published knowledge bases also have a login-free, chrome-free reader at
|
||||
`/#/embed/knowledge/{mapping_id}` for direct third-party iframe use. With no
|
||||
`wikiSlug`, it selects an overview/root page and keeps directory navigation
|
||||
inside the embed route; `?wikiSlug=...` deep-links one page. The reader supports
|
||||
`theme=light|dark`, `bg`, and `text` embed appearance parameters. Its data comes
|
||||
`wikiSlug`, it selects the Wiki **索引** page first (recognized by title, page
|
||||
type, or `index`/`home`/`readme` slug), pins that page to the top of the public
|
||||
directory, and otherwise falls back to an overview/root page;
|
||||
`?wikiSlug=...` deep-links one page. The reader supports
|
||||
`theme=light|dark`, `bg`, and `text` embed appearance parameters. Index-article
|
||||
catalogue sections (page-type counts, nested categories, page summaries) are
|
||||
read from the published-only `/wiki/index` proxy because WeKnora renders that
|
||||
part dynamically rather than storing it in the index page body; group and item
|
||||
ordering therefore stays identical to WeKnora. Links generated as
|
||||
knowledge-base-shell placeholders are matched
|
||||
against the public page title, slug, or alias and rewritten to exact embedded
|
||||
`wikiSlug` links. They retain WeKnora's brand-color medium text and dashed
|
||||
underline (solid on hover), and switch articles inside the current reader
|
||||
without a full-page reload. Data comes
|
||||
only from the read-only `/api/public/llmwiki/knowledge-bases/{id}/...` namespace,
|
||||
which rechecks `publication_status=published` on every request and denies the
|
||||
system conversation-deposit knowledge base. Private or unpublished bases are
|
||||
never exposed by this route.
|
||||
never exposed by this route. Anonymous consumers can discover the available
|
||||
public Wiki libraries through `GET /api/public/llmwiki/knowledge-bases`; each
|
||||
entry deliberately contains only the DeerFlow mapping `id` and display `name`.
|
||||
|
||||
Wiki management also provides **导出 Excel**. `GET
|
||||
/api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx` exports every processed Wiki
|
||||
|
||||
@@ -483,6 +483,21 @@ def _safe_response_headers(headers: httpx.Headers) -> dict[str, str]:
|
||||
return result
|
||||
|
||||
|
||||
def _should_follow_weknora_binary_redirect(path: str) -> bool:
|
||||
"""Keep protected preview/image bytes inside the DeerFlow proxy."""
|
||||
|
||||
segments = _path_segments(path)
|
||||
return (
|
||||
len(segments) == 5
|
||||
and segments[:3] == ["api", "v1", "knowledge"]
|
||||
and segments[4] == "preview"
|
||||
) or (
|
||||
len(segments) == 5
|
||||
and segments[:3] == ["api", "v1", "knowledge-bases"]
|
||||
and segments[4] == "files"
|
||||
)
|
||||
|
||||
|
||||
def _forward_request_headers(request: Request) -> dict[str, str]:
|
||||
blocked = {
|
||||
"host",
|
||||
@@ -1571,8 +1586,13 @@ async def _proxy_weknora_request(
|
||||
headers["X-Tenant-ID"] = tenant_id
|
||||
|
||||
url = f"{target_base_url.rstrip('/')}{_encode_upstream_path(upstream_path)}"
|
||||
follow_binary_redirects = is_api and _should_follow_weknora_binary_redirect(upstream_path)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False, trust_env=False) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(60.0),
|
||||
follow_redirects=follow_binary_redirects,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
response = await client.request(
|
||||
request.method,
|
||||
url,
|
||||
@@ -1586,7 +1606,11 @@ async def _proxy_weknora_request(
|
||||
tenant_id = _tenant_id_from_session(session)
|
||||
if tenant_id:
|
||||
headers["X-Tenant-ID"] = tenant_id
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False, trust_env=False) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(60.0),
|
||||
follow_redirects=follow_binary_redirects,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
response = await client.request(
|
||||
request.method,
|
||||
url,
|
||||
@@ -2417,6 +2441,27 @@ async def test_llmwiki_connection(request: Request, body: LlmWikiConnectionTest)
|
||||
return {"success": True, "provider": "weknora", "health": health}
|
||||
|
||||
|
||||
@router.get("/api/public/llmwiki/knowledge-bases")
|
||||
async def list_public_knowledge_bases(request: Request) -> dict[str, Any]:
|
||||
"""List the minimal identity of every knowledge base published as a public Wiki."""
|
||||
|
||||
rows = await _store(request).list_visible(
|
||||
"__public_wiki__",
|
||||
scope="public",
|
||||
is_admin=False,
|
||||
)
|
||||
knowledge_bases = [
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"name": str(row.get("name") or ""),
|
||||
}
|
||||
for row in rows
|
||||
if row.get("publication_status") == "published"
|
||||
and not _is_conversation_deposit_mapping(row)
|
||||
]
|
||||
return {"knowledge_bases": knowledge_bases, "total": len(knowledge_bases)}
|
||||
|
||||
|
||||
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}")
|
||||
async def get_public_knowledge_base(request: Request, mapping_id: str) -> dict[str, Any]:
|
||||
"""Return the safe public metadata used by the chrome-free Wiki reader."""
|
||||
@@ -2465,6 +2510,89 @@ async def list_public_wiki_pages(
|
||||
return {**result, "pages": pages}
|
||||
|
||||
|
||||
_PUBLIC_WIKI_INDEX_TYPES = ("summary", "entity", "concept", "synthesis", "comparison")
|
||||
|
||||
|
||||
def _public_wiki_index_item(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
slug = str(value.get("slug") or "").strip()
|
||||
if not slug:
|
||||
return None
|
||||
return {
|
||||
"slug": slug,
|
||||
"title": str(value.get("title") or slug),
|
||||
"summary": str(value.get("summary") or ""),
|
||||
"category_path": (
|
||||
[str(item) for item in value.get("category_path", []) if str(item).strip()]
|
||||
if isinstance(value.get("category_path"), list)
|
||||
else []
|
||||
),
|
||||
"wiki_path": str(value.get("wiki_path") or ""),
|
||||
"depth": int(value.get("depth") or 0),
|
||||
}
|
||||
|
||||
|
||||
async def _all_public_wiki_index(client: WeKnoraClient, remote_id: str) -> dict[str, Any]:
|
||||
intro = ""
|
||||
version = 0
|
||||
groups: list[dict[str, Any]] = []
|
||||
for page_type in _PUBLIC_WIKI_INDEX_TYPES:
|
||||
items: list[dict[str, Any]] = []
|
||||
cursor = ""
|
||||
seen_cursors: set[str] = set()
|
||||
total = 0
|
||||
for _page_number in range(100):
|
||||
payload = await client.get_wiki_index(
|
||||
remote_id,
|
||||
types=[page_type],
|
||||
limit=500,
|
||||
cursor=cursor,
|
||||
)
|
||||
intro = intro or str(payload.get("intro") or "")
|
||||
version = max(version, int(payload.get("version") or 0))
|
||||
raw_groups = payload.get("groups") if isinstance(payload.get("groups"), list) else []
|
||||
group = next(
|
||||
(
|
||||
value
|
||||
for value in raw_groups
|
||||
if isinstance(value, dict) and value.get("type") == page_type
|
||||
),
|
||||
{},
|
||||
)
|
||||
total = max(total, int(group.get("total") or 0))
|
||||
raw_items = group.get("items") if isinstance(group.get("items"), list) else []
|
||||
items.extend(
|
||||
item
|
||||
for value in raw_items
|
||||
if (item := _public_wiki_index_item(value)) is not None
|
||||
)
|
||||
next_cursor = str(group.get("next_cursor") or "")
|
||||
if not next_cursor or next_cursor in seen_cursors:
|
||||
break
|
||||
seen_cursors.add(next_cursor)
|
||||
cursor = next_cursor
|
||||
else:
|
||||
logger.warning(
|
||||
"Stopped public Wiki index pagination at safety limit: mapping_remote_id=%s page_type=%s",
|
||||
remote_id,
|
||||
page_type,
|
||||
)
|
||||
groups.append({"type": page_type, "total": total or len(items), "items": items})
|
||||
return {"intro": intro, "version": version, "groups": groups}
|
||||
|
||||
|
||||
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}/wiki/index")
|
||||
async def get_public_wiki_index(request: Request, mapping_id: str) -> dict[str, Any]:
|
||||
"""Return WeKnora's ordered dynamic Wiki index for the public reader."""
|
||||
|
||||
row = await _public_mapping(request, mapping_id)
|
||||
try:
|
||||
return await _all_public_wiki_index(_client_or_503(), str(row["weknora_id"]))
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
|
||||
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}")
|
||||
async def get_public_wiki_page(
|
||||
request: Request,
|
||||
|
||||
@@ -573,6 +573,8 @@ llmwiki:
|
||||
model: bge-m3
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
# Expected BGE-M3 response size for local validation/indexing only.
|
||||
# This value is not sent as an embedding API request parameter.
|
||||
dimensions: 1024
|
||||
batch_size: 32
|
||||
timeout_seconds: 30
|
||||
|
||||
@@ -58,7 +58,12 @@ class LocalWikiEmbeddingConfig(BaseModel):
|
||||
model: str = "bge-m3"
|
||||
base_url: str = ""
|
||||
api_key: str = Field(default="", repr=False)
|
||||
dimensions: int | None = Field(default=1024, ge=1, le=65536)
|
||||
dimensions: int | None = Field(
|
||||
default=1024,
|
||||
ge=1,
|
||||
le=65536,
|
||||
description="Expected response vector dimensions; not sent to the embedding endpoint.",
|
||||
)
|
||||
batch_size: int = Field(default=32, ge=1, le=512)
|
||||
timeout_seconds: float = Field(default=30, gt=0, le=300)
|
||||
max_retries: int = Field(default=3, ge=0, le=10)
|
||||
|
||||
+25
@@ -492,6 +492,31 @@ class WeKnoraClient:
|
||||
raise WeKnoraError("WeKnora returned an invalid Wiki page")
|
||||
return dict(value)
|
||||
|
||||
async def get_wiki_index(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
*,
|
||||
types: Sequence[str],
|
||||
limit: int = 50,
|
||||
cursor: str = "",
|
||||
) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {
|
||||
"types": ",".join(value for value in types if value),
|
||||
"limit": max(1, min(limit, 500)),
|
||||
}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
value = _data(
|
||||
await self._request(
|
||||
"GET",
|
||||
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/index",
|
||||
params=params,
|
||||
)
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
raise WeKnoraError("WeKnora returned an invalid Wiki index")
|
||||
return dict(value)
|
||||
|
||||
async def create_wiki_page(self, knowledge_base_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
value = _data(
|
||||
await self._request(
|
||||
|
||||
+4
-2
@@ -66,9 +66,11 @@ class StrictWikiEmbeddingClient:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.config.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.config.api_key}"
|
||||
# ``dimensions`` is a local invariant, not a portable request option.
|
||||
# OpenAI-compatible BGE endpoints commonly reject that extra field
|
||||
# with HTTP 400 even though they always return a fixed-size vector.
|
||||
# Keep validating the response against the configured value below.
|
||||
payload: dict = {"model": self.config.model, "input": texts}
|
||||
if self.config.dimensions:
|
||||
payload["dimensions"] = self.config.dimensions
|
||||
last_error: WikiEmbeddingError | None = None
|
||||
for attempt in range(self.config.max_retries + 1):
|
||||
started = monotonic()
|
||||
|
||||
@@ -117,6 +117,23 @@ def test_weknora_url_rejects_credentials_and_non_http_schemes() -> None:
|
||||
LlmWikiConfig.model_validate({"weknora": {"api_base_url": "http://user:pass@weknora.local"}})
|
||||
|
||||
|
||||
def test_weknora_embed_follows_only_protected_binary_redirects() -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
assert llmwiki_router._should_follow_weknora_binary_redirect(
|
||||
"/api/v1/knowledge/doc-one/preview"
|
||||
)
|
||||
assert llmwiki_router._should_follow_weknora_binary_redirect(
|
||||
"/api/v1/knowledge-bases/kb-one/files"
|
||||
)
|
||||
assert not llmwiki_router._should_follow_weknora_binary_redirect(
|
||||
"/api/v1/knowledge/doc-one"
|
||||
)
|
||||
assert not llmwiki_router._should_follow_weknora_binary_redirect(
|
||||
"/api/v1/knowledge-bases/kb-one"
|
||||
)
|
||||
|
||||
|
||||
def test_agent_config_tracks_allowed_llmwiki_knowledge_bases() -> None:
|
||||
config = AgentConfig(
|
||||
name="Research agent",
|
||||
@@ -547,6 +564,42 @@ async def test_weknora_client_imports_url_manual_content_and_lists_wiki_pages()
|
||||
assert pages["pages"][0]["slug"] == "guide"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_client_reads_ordered_wiki_index_groups() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/index"
|
||||
assert request.url.params["types"] == "entity,concept"
|
||||
assert request.url.params["limit"] == "50"
|
||||
assert request.url.params["cursor"] == "next-page"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": {
|
||||
"intro": "# Wiki Index",
|
||||
"version": 2,
|
||||
"groups": [
|
||||
{
|
||||
"type": "entity",
|
||||
"total": 1,
|
||||
"items": [{"slug": "entity/one", "title": "实体一"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
||||
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
||||
index = await client.get_wiki_index(
|
||||
"kb-one",
|
||||
types=["entity", "concept"],
|
||||
limit=50,
|
||||
cursor="next-page",
|
||||
)
|
||||
|
||||
assert index["groups"][0]["items"][0]["slug"] == "entity/one"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_client_creates_updates_and_deletes_wiki_pages() -> None:
|
||||
seen: list[tuple[str, str]] = []
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from deerflow.config.llmwiki_config import LocalWikiIndexConfig
|
||||
from deerflow.config.llmwiki_config import LocalWikiEmbeddingConfig, LocalWikiIndexConfig
|
||||
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
|
||||
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
|
||||
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
|
||||
from deerflow.persistence.llmwiki_index.memory import MemoryLlmWikiIndexStore
|
||||
@@ -53,6 +57,55 @@ class FakeWeKnora:
|
||||
return dict(self.pages[slug])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configured_dimensions_are_validated_but_not_sent_to_bge() -> None:
|
||||
requests: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
payload = json.loads(request.content)
|
||||
requests.append(payload)
|
||||
if "dimensions" in payload:
|
||||
return httpx.Response(400, json={"detail": "dimensions is not supported"})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": [{"index": 0, "embedding": [1.0, 0.0, 0.0]}]},
|
||||
)
|
||||
|
||||
config = LocalWikiEmbeddingConfig(
|
||||
model="bge-embedding-m3",
|
||||
base_url="http://embedding.test/v1",
|
||||
dimensions=3,
|
||||
max_retries=0,
|
||||
)
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
||||
client = StrictWikiEmbeddingClient(config, http_client=http_client)
|
||||
vector = await client.embed_query("Wiki page")
|
||||
|
||||
assert vector == [1.0, 0.0, 0.0]
|
||||
assert requests == [{"model": "bge-embedding-m3", "input": ["Wiki page"]}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_omitted_dimensions_are_discovered_from_first_response() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": [{"index": 0, "embedding": [0.0, 1.0]}]},
|
||||
)
|
||||
|
||||
config = LocalWikiEmbeddingConfig(
|
||||
model="bge-embedding-m3",
|
||||
base_url="http://embedding.test/v1",
|
||||
dimensions=None,
|
||||
max_retries=0,
|
||||
)
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
||||
client = StrictWikiEmbeddingClient(config, http_client=http_client)
|
||||
await client.embed_query("dimension probe")
|
||||
|
||||
assert client.dimensions == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_is_incremental_and_full_scan_deletes_missing_pages() -> None:
|
||||
config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0)
|
||||
|
||||
@@ -38,6 +38,22 @@ class PublicStore:
|
||||
return self.row
|
||||
|
||||
|
||||
class PublicListStore:
|
||||
def __init__(self, rows: list[dict[str, object]]) -> None:
|
||||
self.rows = rows
|
||||
self.calls: list[tuple[str, str, bool]] = []
|
||||
|
||||
async def list_visible(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
scope: str,
|
||||
is_admin: bool,
|
||||
) -> list[dict[str, object]]:
|
||||
self.calls.append((user_id, scope, is_admin))
|
||||
return self.rows
|
||||
|
||||
|
||||
def _published_row() -> dict[str, object]:
|
||||
return {
|
||||
"id": "kb-one",
|
||||
@@ -50,6 +66,39 @@ def _published_row() -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_knowledge_base_list_exposes_only_published_wiki_identity() -> None:
|
||||
store = PublicListStore(
|
||||
[
|
||||
{
|
||||
**_published_row(),
|
||||
"id": "1063edee-332a-43b4-bbd9-73bf0a708b94",
|
||||
"owner_user_id": "secret-owner",
|
||||
},
|
||||
{**_published_row(), "id": "private-kb", "publication_status": "private"},
|
||||
{
|
||||
**_published_row(),
|
||||
"id": "conversation-deposit",
|
||||
"owner_user_id": "system",
|
||||
"name": "对话沉淀",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
payload = await llmwiki.list_public_knowledge_bases(_request(store))
|
||||
|
||||
assert store.calls == [("__public_wiki__", "public", False)]
|
||||
assert payload == {
|
||||
"knowledge_bases": [
|
||||
{
|
||||
"id": "1063edee-332a-43b4-bbd9-73bf0a708b94",
|
||||
"name": "公开知识库",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_wiki_reader_uses_anonymous_non_admin_authorization(monkeypatch) -> None:
|
||||
store = PublicStore(_published_row())
|
||||
@@ -126,3 +175,81 @@ async def test_public_wiki_page_list_is_normalized(monkeypatch) -> None:
|
||||
|
||||
assert payload["pages"][0]["slug"] == "guide/start"
|
||||
assert payload["pages"][0]["aliases"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_wiki_index_preserves_weknora_group_and_item_order(monkeypatch) -> None:
|
||||
store = PublicStore(_published_row())
|
||||
calls: list[tuple[tuple[str, ...], str]] = []
|
||||
|
||||
class Client:
|
||||
async def get_wiki_index(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
*,
|
||||
types: list[str],
|
||||
limit: int,
|
||||
cursor: str,
|
||||
):
|
||||
assert knowledge_base_id == "remote-one"
|
||||
assert limit == 500
|
||||
page_type = types[0]
|
||||
calls.append((tuple(types), cursor))
|
||||
if page_type == "concept" and not cursor:
|
||||
return {
|
||||
"intro": "# Wiki Index\n\n公开目录",
|
||||
"version": 3,
|
||||
"groups": [
|
||||
{
|
||||
"type": "concept",
|
||||
"total": 2,
|
||||
"next_cursor": "concept-next",
|
||||
"items": [
|
||||
{
|
||||
"slug": "concept/first",
|
||||
"title": "第一项",
|
||||
"summary": "第一项摘要",
|
||||
"category_path": ["流程图", "规范"],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
if page_type == "concept":
|
||||
return {
|
||||
"intro": "# Wiki Index\n\n公开目录",
|
||||
"version": 3,
|
||||
"groups": [
|
||||
{
|
||||
"type": "concept",
|
||||
"total": 2,
|
||||
"items": [
|
||||
{
|
||||
"slug": "concept/second",
|
||||
"title": "第二项",
|
||||
"summary": "第二项摘要",
|
||||
"category_path": ["流程图", "规范"],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
return {
|
||||
"intro": "# Wiki Index\n\n公开目录",
|
||||
"version": 3,
|
||||
"groups": [{"type": page_type, "total": 0, "items": []}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(llmwiki, "_client_or_503", lambda: Client())
|
||||
|
||||
payload = await llmwiki.get_public_wiki_index(_request(store), "kb-one")
|
||||
|
||||
concept = next(group for group in payload["groups"] if group["type"] == "concept")
|
||||
assert payload["intro"] == "# Wiki Index\n\n公开目录"
|
||||
assert payload["version"] == 3
|
||||
assert [item["slug"] for item in concept["items"]] == [
|
||||
"concept/first",
|
||||
"concept/second",
|
||||
]
|
||||
assert concept["total"] == 2
|
||||
assert (("concept",), "concept-next") in calls
|
||||
Reference in new issue
Block a user