feat: complete public wiki and embedded previews

This commit is contained in:
吉鑫升 committed 2026-08-24 20:40:44 +08:00
1 parent bc49df4561
commit 30943159bf
18 files changed
+926 -48

No files matched your search

@@ -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,
);
+70 -3
View File
@@ -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 };
}