feat: add public wiki embeds and ES query routing

This commit is contained in:
吉鑫升 committed 2026-08-24 15:21:28 +08:00
1 parent e818c24769
commit bc49df4561
16 files changed
+670 -18

No files matched your search

+3
View File
@@ -95,12 +95,15 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_model_management.py !offline-backend-20260512/backend/tests/test_model_management.py
# Keep fixed-question exact-match and streaming regression coverage versioned. # Keep fixed-question exact-match and streaming regression coverage versioned.
!offline-backend-20260512/backend/tests/test_fixed_questions.py !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. # 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_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.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_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.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_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
# Deep-research reliability regression coverage. # Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py !offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py !offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
+11 -1
View File
@@ -38,6 +38,7 @@ import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage"; import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage"; import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage"; import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
import { import {
isPositionRoundtableStandaloneMode, isPositionRoundtableStandaloneMode,
POSITION_ROUNDTABLE_ROUTE, POSITION_ROUNDTABLE_ROUTE,
@@ -45,7 +46,12 @@ import {
import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage"; 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 }) { function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []); const locale = useMemo(() => detectLocale(), []);
@@ -189,6 +195,10 @@ export function App() {
} }
/> />
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} /> <Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/embed/knowledge/:noteId"
element={<WeKnoraWikiPageView publicEmbed />}
/>
<Route <Route
path="/roundtable-dashboard" path="/roundtable-dashboard"
element={<DashboardStandalonePage />} element={<DashboardStandalonePage />}
@@ -114,6 +114,24 @@ export function buildWeKnoraWikiPagePath(
return `/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view?${params.toString()}`; 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 { export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
const recUuid = source.recUuid || source.rec_uuid || source.url || ""; const recUuid = source.recUuid || source.rec_uuid || source.url || "";
if (!recUuid) return ""; if (!recUuid) return "";
@@ -1,5 +1,10 @@
import { buildWeKnoraWikiPageUrl } from "@/core/llmwiki/source-links"; import { buildWeKnoraWikiPageUrl } from "@/core/llmwiki/source-links";
export type WikiPageUrlBuilder = (
knowledgeBaseId: string,
wikiSlug: string,
) => string;
export function stripWikiFrontmatter(value: string) { export function stripWikiFrontmatter(value: string) {
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim(); 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"); .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 link = (slugValue: string, labelValue?: string) => {
const slug = slugValue.trim(); const slug = slugValue.trim();
const fallbackLabel = slug.split("/").filter(Boolean).at(-1) || slug; const fallbackLabel = slug.split("/").filter(Boolean).at(-1) || slug;
const label = (labelValue || fallbackLabel).trim().replace(/\]/g, "\\]"); const label = (labelValue || fallbackLabel).trim().replace(/\]/g, "\\]");
return `[${label}](${buildWeKnoraWikiPageUrl(knowledgeBaseId, slug)})`; return `[${label}](${buildPageUrl(knowledgeBaseId, slug)})`;
}; };
return markdown return markdown
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label)) .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label))
@@ -33,6 +42,7 @@ export function renderableWikiMarkdown(
title: string, title: string,
content: string, content: string,
knowledgeBaseId: string, knowledgeBaseId: string,
buildPageUrl?: WikiPageUrlBuilder,
) { ) {
const lines = content.trim().split("\n"); const lines = content.trim().split("\n");
if (lines[0]?.replace(/^#+\s*/, "").trim() === title.trim()) { if (lines[0]?.replace(/^#+\s*/, "").trim() === title.trim()) {
@@ -41,5 +51,6 @@ export function renderableWikiMarkdown(
return wikiLinkMarkdown( return wikiLinkMarkdown(
exportableWikiMarkdown(title, "", lines.join("\n")), exportableWikiMarkdown(title, "", lines.join("\n")),
knowledgeBaseId, knowledgeBaseId,
buildPageUrl,
); );
} }
@@ -5,6 +5,7 @@ import { useLiveAccount } from "@/core/auth/use-live-account";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
const API = () => `${getBackendBaseURL()}/api/llmwiki`; const API = () => `${getBackendBaseURL()}/api/llmwiki`;
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
const SETTINGS_API = () => `${getBackendBaseURL()}/api/system-settings/llmwiki`; const SETTINGS_API = () => `${getBackendBaseURL()}/api/system-settings/llmwiki`;
export type LlmWikiProvider = "legacy" | "weknora"; export type LlmWikiProvider = "legacy" | "weknora";
@@ -671,6 +672,44 @@ export async function searchLlmWiki(input: {
); );
} }
export async function getPublicLlmWikiKnowledgeBase(
id: string,
): Promise<LlmWikiKnowledgeBase> {
return readJson(
await apiFetch(`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(id)}`),
"读取公开知识库详情",
);
}
export async function listPublicLlmWikiWikiPages(
knowledgeBaseId: string,
page: number,
pageSize: number,
query = "",
): Promise<LlmWikiWikiPageList> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (query.trim()) params.set("query", query.trim());
return readJson(
await apiFetch(
`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages?${params.toString()}`,
),
"读取公开 Wiki 页面",
);
}
export async function getPublicLlmWikiWikiPage(
knowledgeBaseId: string,
slug: string,
): Promise<LlmWikiWikiPage> {
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
return readJson(
await apiFetch(
`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`,
),
"读取公开 Wiki 页面详情",
);
}
export async function createLlmWikiConversationDeposit(input: { export async function createLlmWikiConversationDeposit(input: {
thread_id: string; thread_id: string;
question: 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( export function useLlmWikiWikiIndexStatus(
knowledgeBaseId: string | null, knowledgeBaseId: string | null,
enabled = true, enabled = true,
@@ -18,12 +18,16 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links"; import {
buildPublicKnowledgeWikiPath,
buildWeKnoraWikiPagePath,
} from "@/core/llmwiki/source-links";
import { import {
buildWikiTree, buildWikiTree,
filterWikiPagesWithAncestors, filterWikiPagesWithAncestors,
type WikiTreeNode, type WikiTreeNode,
} from "@/core/llmwiki/wiki-tree"; } from "@/core/llmwiki/wiki-tree";
import { usePublicBackground } from "@/core/embed/background";
import { reasoningPlugins } from "@/core/streamdown"; import { reasoningPlugins } from "@/core/streamdown";
import { markdownToDocxBlob } from "@/core/scheduled-tasks/markdown-docx"; import { markdownToDocxBlob } from "@/core/scheduled-tasks/markdown-docx";
import { import {
@@ -38,6 +42,9 @@ import {
useLlmWikiKnowledgeBase, useLlmWikiKnowledgeBase,
useLlmWikiWikiPage, useLlmWikiWikiPage,
useLlmWikiWikiPages, useLlmWikiWikiPages,
usePublicLlmWikiKnowledgeBase,
usePublicLlmWikiWikiPage,
usePublicLlmWikiWikiPages,
} from "@/strategy-components/api/llmwiki"; } from "@/strategy-components/api/llmwiki";
const LIST_PATH = "/page/workspace/knowledge"; const LIST_PATH = "/page/workspace/knowledge";
@@ -85,27 +92,84 @@ function downloadBlob(blob: Blob, fileName: string) {
window.setTimeout(() => URL.revokeObjectURL(url), 30_000); 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 { noteId = "" } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const wikiSlug = searchParams.get("wikiSlug")?.trim() || ""; const publicBackground = usePublicBackground();
const page = useLlmWikiWikiPage(noteId || null, wikiSlug || null, !!noteId && !!wikiSlug); const requestedWikiSlug = searchParams.get("wikiSlug")?.trim() || "";
const knowledgeBase = useLlmWikiKnowledgeBase(noteId || null, !!noteId); const privateKnowledgeBase = useLlmWikiKnowledgeBase(
const pages = useLlmWikiWikiPages(noteId || null, 1, 500, "", !!noteId); 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 [downloading, setDownloading] = useState<"md" | "word" | null>(null);
const [directoryQuery, setDirectoryQuery] = useState(""); const [directoryQuery, setDirectoryQuery] = useState("");
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(() => new Set()); const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(() => new Set());
const routedPage = (location.state as { wikiPage?: LlmWikiWikiPage } | null)?.wikiPage; const routedPage = (location.state as { wikiPage?: LlmWikiWikiPage } | null)?.wikiPage;
const pageData = page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined); const pageData = page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined);
const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
const filteredPages = useMemo( const filteredPages = useMemo(
() => filterWikiPagesWithAncestors(pagesList, directoryQuery), () => filterWikiPagesWithAncestors(pagesList, directoryQuery),
[directoryQuery, pagesList], [directoryQuery, pagesList],
); );
const wikiTree = useMemo(() => buildWikiTree(filteredPages), [filteredPages]); 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(() => { useEffect(() => {
const activeParents = wikiTree.parentKeysBySlug.get(wikiSlug) || []; const activeParents = wikiTree.parentKeysBySlug.get(wikiSlug) || [];
if (activeParents.length === 0) return; if (activeParents.length === 0) return;
@@ -121,7 +185,18 @@ export function WeKnoraWikiPageView() {
const summary = cleanText(pageData?.summary); const summary = cleanText(pageData?.summary);
const content = stripWikiFrontmatter(cleanText(pageData?.content)); const content = stripWikiFrontmatter(cleanText(pageData?.content));
const displayMarkdown = pageData 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 const exportMarkdown = pageData
? wikiLinkMarkdown(exportableWikiMarkdown(title, summary, content), noteId) ? wikiLinkMarkdown(exportableWikiMarkdown(title, summary, content), noteId)
@@ -189,7 +264,15 @@ export function WeKnoraWikiPageView() {
)} )}
{node.page ? ( {node.page ? (
<Link <Link
to={buildWeKnoraWikiPagePath(noteId, node.page.slug)} to={
publicEmbed
? (() => {
const next = new URLSearchParams(searchParams);
next.set("wikiSlug", node.page.slug);
return `${buildPublicKnowledgeWikiPath(noteId)}?${next.toString()}`;
})()
: buildWeKnoraWikiPagePath(noteId, node.page.slug)
}
state={{ wikiPage: node.page }} state={{ wikiPage: node.page }}
className="flex min-w-0 flex-1 items-center gap-2 py-2" className="flex min-w-0 flex-1 items-center gap-2 py-2"
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
@@ -219,10 +302,20 @@ export function WeKnoraWikiPageView() {
}; };
if (!wikiSlug) { if (!wikiSlug) {
if (publicEmbed && pages.isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在加载公开 Wiki
</div>
);
}
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground"> <div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
当前链接缺少 Wiki 页面标识 {publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button> {!publicEmbed && (
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
)}
</div> </div>
); );
} }
@@ -245,11 +338,17 @@ export function WeKnoraWikiPageView() {
<div> <div>
<div className="font-medium text-foreground">无法打开 Wiki 页面</div> <div className="font-medium text-foreground">无法打开 Wiki 页面</div>
<div className="mt-1 text-sm text-muted-foreground"> <div className="mt-1 text-sm text-muted-foreground">
{page.error instanceof Error ? page.error.message : "页面不存在或当前账号无权访问"} {page.error instanceof Error
? page.error.message
: publicEmbed
? "知识库未发布、页面不存在或已停止公开访问"
: "页面不存在或当前账号无权访问"}
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button> {!publicEmbed && (
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
)}
<Button onClick={() => void page.refetch()}> <Button onClick={() => void page.refetch()}>
<RefreshCw className="mr-2 size-4" />重试 <RefreshCw className="mr-2 size-4" />重试
</Button> </Button>
@@ -259,7 +358,27 @@ export function WeKnoraWikiPageView() {
} }
return ( return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/70 dark:bg-slate-950/40"> <div
className="flex h-full min-h-0 flex-col bg-slate-50/70 dark:bg-slate-950/40"
style={publicEmbed ? publicBackground : undefined}
data-public-wiki-embed={publicEmbed ? "true" : undefined}
>
{publicEmbed ? (
<header className="flex min-h-14 shrink-0 items-center gap-3 border-b bg-background/95 px-4 py-2.5 shadow-sm sm:px-6">
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
<BookOpenText className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold text-foreground">
{knowledgeBase.data?.name || "知识库 Wiki"}
</h1>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{title}</p>
</div>
<Badge variant="outline" className="shrink-0 border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-300">
公开 Wiki
</Badge>
</header>
) : (
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b bg-background px-4 py-3 shadow-sm sm:px-6"> <header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b bg-background px-4 py-3 shadow-sm sm:px-6">
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
<Button size="icon-sm" variant="ghost" onClick={() => navigate(LIST_PATH)} aria-label="返回知识库"> <Button size="icon-sm" variant="ghost" onClick={() => navigate(LIST_PATH)} aria-label="返回知识库">
@@ -290,6 +409,7 @@ export function WeKnoraWikiPageView() {
</Button> </Button>
</div> </div>
</header> </header>
)}
<div className="flex min-h-0 flex-1 overflow-hidden"> <div className="flex min-h-0 flex-1 overflow-hidden">
<aside className="flex w-64 shrink-0 flex-col border-r bg-background/90 lg:w-72"> <aside className="flex w-64 shrink-0 flex-col border-r bg-background/90 lg:w-72">
@@ -719,6 +719,13 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json. **A directory containing `SKILL.md` is a self-contained leaf** — discovery does not descend into its subdirectories, so a skill *package* that bundles its own `examples/`/`references/` sub-skills (each with a SKILL.md) surfaces as **one** skill, not a swarm of phantom entries. - **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json. **A directory containing `SKILL.md` is a self-contained leaf** — discovery does not descend into its subdirectories, so a skill *package* that bundles its own `examples/`/`references/` sub-skills (each with a SKILL.md) surfaces as **one** skill, not a swarm of phantom entries.
- **Discovery↔delete consistency**: a skill is keyed for discovery by its frontmatter `name`, but its on-disk directory name may differ (hand-dropped / mis-extracted package). `LocalSkillStorage._resolve_skill_dir(name, category)` resolves the *discovered* directory (canonical `<category>/<name>` fast-path, else scan-by-frontmatter-name); `custom_skill_exists`/`read_custom_skill`/`delete_custom_skill` all go through it, so **anything that gets listed can also be read and deleted** regardless of directory name (the old code assumed `custom/<name>/` and 404'd "Custom skill 'x' not found." on any non-canonical layout). Delete is path-guarded to never escape the `custom/` root. Tests: `tests/test_skill_storage_delete.py`. - **Discovery↔delete consistency**: a skill is keyed for discovery by its frontmatter `name`, but its on-disk directory name may differ (hand-dropped / mis-extracted package). `LocalSkillStorage._resolve_skill_dir(name, category)` resolves the *discovered* directory (canonical `<category>/<name>` fast-path, else scan-by-frontmatter-name); `custom_skill_exists`/`read_custom_skill`/`delete_custom_skill` all go through it, so **anything that gets listed can also be read and deleted** regardless of directory name (the old code assumed `custom/<name>/` and 404'd "Custom skill 'x' not found." on any non-canonical layout). Delete is path-guarded to never escape the `custom/` root. Tests: `tests/test_skill_storage_delete.py`.
- **Injection**: Enabled skills listed in agent system prompt with container paths - **Injection**: Enabled skills listed in agent system prompt with container paths
- **Configurable `es_query` routing prompt**: `config.yaml → skills.es_query_routing`
contains an `enabled` switch and editable `prompt`. When enabled,
`lead_agent.prompt._build_es_query_routing_section` injects the rule into the
default lead-agent system prompt. Custom-agent-only prompts receive it only
when their explicit skill allowlist contains `es_query`; disabling the switch
or leaving the prompt blank emits no block. Tests:
`tests/test_es_query_routing_prompt.py`.
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory - **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
- **Installable sentiment analysis package**: `skill-packages/sentiment-analysis.skill` is deliberately separate from the existing frontend virtual-agent path. Its self-contained `config/sentiment-agent.json` owns the external AG-UI address; Basic Auth is resolved from `SENTIMENT_AGENT_AUTH_USERNAME` / `SENTIMENT_AGENT_AUTH_PASSWORD` at runtime (not committed into the package). `scripts/sentiment_stream.py` uses only the Python standard library and converts upstream SSE to stable NDJSON events (`thinking_delta`, `answer_delta`, `done`), with a Markdown convenience mode for normal agent calls. Installation/calling guide: `skill-packages/sentiment-analysis/docs/调用与安装说明.md`. - **Installable sentiment analysis package**: `skill-packages/sentiment-analysis.skill` is deliberately separate from the existing frontend virtual-agent path. Its self-contained `config/sentiment-agent.json` owns the external AG-UI address; Basic Auth is resolved from `SENTIMENT_AGENT_AUTH_USERNAME` / `SENTIMENT_AGENT_AUTH_PASSWORD` at runtime (not committed into the package). `scripts/sentiment_stream.py` uses only the Python standard library and converts upstream SSE to stable NDJSON events (`thinking_delta`, `answer_delta`, `done`), with a Markdown convenience mode for normal agent calls. Installation/calling guide: `skill-packages/sentiment-analysis/docs/调用与安装说明.md`.
- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) ? never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill ? owner or admin; built-in/public ? admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (????: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` ? an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) ? distinct from the model returning an unusable name (? `None`). The single endpoint maps that to `502 ?????,?????????:?`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N ?????????". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI ??" plus an **admin-only** header "???????" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action. - **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) ? never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill ? owner or admin; built-in/public ? admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (????: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` ? an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) ? distinct from the model returning an unusable name (? `None`). The single endpoint maps that to `502 ?????,?????????:?`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N ?????????". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI ??" plus an **admin-only** header "???????" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action.
@@ -1471,6 +1478,17 @@ Wiki directory, reconstruct hierarchy from `parent_slug`, `wiki_path`,
directory navigation on the same lightweight route rather than mounting the directory navigation on the same lightweight route rather than mounting the
WeKnora iframe. 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.
Owners manually force a full sync/vectorization through `POST Owners manually force a full sync/vectorization through `POST
/api/llmwiki/knowledge-bases/{id}/vectorize`; status is available through `GET /api/llmwiki/knowledge-bases/{id}/vectorize`; status is available through `GET
/api/llmwiki/knowledge-bases/{id}/wiki-index-status`. The Wiki management page /api/llmwiki/knowledge-bases/{id}/wiki-index-status`. The Wiki management page
+13 -1
View File
@@ -504,7 +504,9 @@ Key sections:
- `tools` - Tool definitions with module paths and groups - `tools` - Tool definitions with module paths and groups
- `tool_groups` - Logical tool groupings - `tool_groups` - Logical tool groupings
- `sandbox` - Execution environment provider - `sandbox` - Execution environment provider
- `skills` - Skills directory paths - `skills` - Skills directory paths plus optional prompt routing; `skills.es_query_routing`
can inject an editable system-prompt rule that sends flexible Elasticsearch Query DSL
requests to the `es_query` skill, and can be disabled without changing code
- `title` - Auto-title generation settings - `title` - Auto-title generation settings
- `summarization` - Context summarization settings - `summarization` - Context summarization settings
- `subagents` - Subagent system (enabled/disabled) - `subagents` - Subagent system (enabled/disabled)
@@ -810,6 +812,16 @@ left. It reconstructs hierarchy from `parent_slug`, `wiki_path`,
page, and switches directory entries through the same lightweight reader route page, and switches directory entries through the same lightweight reader route
without mounting the WeKnora iframe. 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
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.
Wiki management also provides **导出 Excel**. `GET Wiki management also provides **导出 Excel**. `GET
/api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx` exports every processed Wiki /api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx` exports every processed Wiki
page, including its full directory path, current directory, parent-directory page, including its full directory path, current directory, parent-directory
@@ -1778,6 +1778,24 @@ async def _authorized_mapping(
return row, user_id, is_admin return row, user_id, is_admin
async def _public_mapping(request: Request, mapping_id: str) -> dict[str, Any]:
"""Resolve a world-readable knowledge base without inheriting actor/admin state."""
row = await _store(request).get_authorized(
mapping_id,
"__public_wiki__",
write=False,
is_admin=False,
)
if (
row is None
or row.get("publication_status") != "published"
or _is_conversation_deposit_mapping(row)
):
raise HTTPException(status_code=404, detail="Published knowledge base not found")
return row
async def _verify_document(client: WeKnoraClient, row: dict[str, Any], document_id: str) -> dict[str, Any]: async def _verify_document(client: WeKnoraClient, row: dict[str, Any], document_id: str) -> dict[str, Any]:
document = await client.get_document(document_id) document = await client.get_document(document_id)
if str(document.get("knowledge_base_id") or "") != str(row["weknora_id"]): if str(document.get("knowledge_base_id") or "") != str(row["weknora_id"]):
@@ -2399,6 +2417,70 @@ async def test_llmwiki_connection(request: Request, body: LlmWikiConnectionTest)
return {"success": True, "provider": "weknora", "health": health} return {"success": True, "provider": "weknora", "health": health}
@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."""
row = await _public_mapping(request, mapping_id)
try:
remote = await _client_or_503().get_knowledge_base(str(row["weknora_id"]))
except WeKnoraError as exc:
_raise_upstream(exc)
return _mapping_view(
row,
remote,
actor_user_id="__public_wiki__",
is_admin=False,
)
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
async def list_public_wiki_pages(
request: Request,
mapping_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
query: str = Query(default="", max_length=255),
) -> dict[str, Any]:
"""List Wiki pages for a published knowledge base without requiring login."""
row = await _public_mapping(request, mapping_id)
try:
result = await _client_or_503().list_wiki_pages(
str(row["weknora_id"]),
page=page,
page_size=page_size,
query=query.strip(),
)
except WeKnoraError as exc:
_raise_upstream(exc)
raw_pages = result.get("pages") if isinstance(result, dict) else []
pages = [
view
for page_item in (raw_pages if isinstance(raw_pages, list) else [])
if isinstance(page_item, dict)
for view in [_wiki_page_view(page_item)]
if view is not None
]
return {**result, "pages": pages}
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}")
async def get_public_wiki_page(
request: Request,
mapping_id: str,
slug: str,
) -> dict[str, Any]:
"""Read one Wiki article from a published knowledge base."""
row = await _public_mapping(request, mapping_id)
try:
page = await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
except WeKnoraError as exc:
_raise_upstream(exc)
return _wiki_page_view(page) or page
@router.get("/api/llmwiki/knowledge-bases") @router.get("/api/llmwiki/knowledge-bases")
async def list_knowledge_bases( async def list_knowledge_bases(
request: Request, request: Request,
@@ -372,6 +372,15 @@ skills:
path: skills path: skills
security_scan: security_scan:
enabled: false enabled: false
# ES 灵活查询技能路由提示:开启后注入整体 system prompt;修改 prompt 可调整
# 触发边界和调用要求。关闭后该提示完全不注入。
es_query_routing:
enabled: true
prompt: |
当用户的问题需要基于 Elasticsearch 查询语句(Query DSL)灵活构造、调整或执行查询时,必须优先使用 `es_query` 技能。
典型场景包括:根据用户条件动态组合 bool/must/filter/should、范围、聚合、排序或分页等 ES 查询;或者用户直接提供、要求修改 ES 语句并据此查询数据。
执行前先读取 `es_query` 技能的 SKILL.md,严格按照技能中的流程和参数调用;不要用通用搜索替代,也不要凭记忆臆测查询结果。
仅当问题确实需要 ES 数据查询时使用;纯概念解释、普通代码说明或与 ES 无关的问题不要调用。
skill_evolution: skill_evolution:
enabled: true enabled: true
@@ -272,8 +272,20 @@ skills:
# Container mount path (default: /mnt/skills) # Container mount path (default: /mnt/skills)
container_path: /mnt/skills container_path: /mnt/skills
# Optional system-prompt routing rule for the es_query skill
es_query_routing:
enabled: true
prompt: |
当用户的问题需要基于 Elasticsearch Query DSL 灵活构造、调整或执行查询时,优先使用 `es_query` 技能。
执行前先读取该技能的 SKILL.md,并严格遵循其中的流程和参数。
``` ```
`skills.es_query_routing.enabled` controls whether the routing rule is injected
into the overall agent system prompt. Edit `prompt` to tune the matching boundary
or invocation requirements. When disabled (the schema default), the block is not
injected. Custom agents receive it only when their explicit skill allowlist
contains `es_query`.
**How Skills Work**: **How Skills Work**:
- Skills are stored in `deer-flow/skills/{public,custom}/` - Skills are stored in `deer-flow/skills/{public,custom}/`
- Each skill has a `SKILL.md` file with metadata - Each skill has a `SKILL.md` file with metadata
@@ -271,6 +271,41 @@ answers — and never create a skill silently without user confirmation.
""" """
def _build_es_query_routing_section(
*,
app_config: AppConfig | None = None,
available_skills: set[str] | None = None,
) -> str:
"""Return the configurable ``es_query`` routing rule for the system prompt.
A custom agent with an explicit skill allowlist only receives the rule when
``es_query`` is in that allowlist. The default lead agent has no allowlist,
so the operator-controlled config switch is authoritative there.
"""
if available_skills is not None and "es_query" not in available_skills:
return ""
config = app_config
if config is None:
try:
from deerflow.config import get_app_config
config = get_app_config()
except Exception:
logger.debug("Failed to load es_query routing config", exc_info=True)
return ""
routing = getattr(getattr(config, "skills", None), "es_query_routing", None)
if routing is None or not routing.enabled:
return ""
prompt = (routing.prompt or "").strip()
if not prompt:
return ""
return f'<skill_routing skill="es_query">\n{prompt}\n</skill_routing>'
def _build_available_subagents_description(available_names: list[str], bash_available: bool, *, app_config: AppConfig | None = None) -> str: def _build_available_subagents_description(available_names: list[str], bash_available: bool, *, app_config: AppConfig | None = None) -> str:
"""Dynamically build subagent type descriptions from registry. """Dynamically build subagent type descriptions from registry.
@@ -550,6 +585,8 @@ You: "Deploying to staging..." [proceed]
{skills_section} {skills_section}
{es_query_routing_section}
{bootstrap_section} {bootstrap_section}
{deferred_tools_section} {deferred_tools_section}
@@ -666,6 +703,8 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
AGENT_ONLY_PROMPT_TEMPLATE = """{soul} AGENT_ONLY_PROMPT_TEMPLATE = """{soul}
{network_policy_section}{skills_section} {network_policy_section}{skills_section}
{es_query_routing_section}
{deferred_tools_section} {deferred_tools_section}
{writing_mode_section} {writing_mode_section}
@@ -724,6 +763,10 @@ def apply_agent_only_prompt_template(
app_config=app_config, app_config=app_config,
display_enabled=rag_mode_enabled, display_enabled=rag_mode_enabled,
) )
es_query_routing_section = _build_es_query_routing_section(
app_config=app_config,
available_skills=available_skills,
)
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config) deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
n = max_concurrent_subagents n = max_concurrent_subagents
@@ -768,6 +811,7 @@ RAG/reference display mode is enabled.
prompt = AGENT_ONLY_PROMPT_TEMPLATE.format( prompt = AGENT_ONLY_PROMPT_TEMPLATE.format(
soul=soul, soul=soul,
skills_section=skills_section, skills_section=skills_section,
es_query_routing_section=es_query_routing_section,
deferred_tools_section=deferred_tools_section, deferred_tools_section=deferred_tools_section,
writing_mode_section=writing_mode_section, writing_mode_section=writing_mode_section,
subagent_section=subagent_section, subagent_section=subagent_section,
@@ -1382,6 +1426,10 @@ def apply_prompt_template(
app_config=app_config, app_config=app_config,
display_enabled=rag_mode_enabled, display_enabled=rag_mode_enabled,
) )
es_query_routing_section = _build_es_query_routing_section(
app_config=app_config,
available_skills=available_skills,
)
# Get deferred tools section (tool_search) # Get deferred tools section (tool_search)
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config) deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
@@ -1440,6 +1488,7 @@ When a search, knowledge, skill, or MCP tool returns a result list:
agent_name=agent_name or storage_agent_id or "CM 助手", agent_name=agent_name or storage_agent_id or "CM 助手",
soul=get_agent_soul(storage_agent_id), soul=get_agent_soul(storage_agent_id),
skills_section=skills_section, skills_section=skills_section,
es_query_routing_section=es_query_routing_section,
bootstrap_section=bootstrap_section, bootstrap_section=bootstrap_section,
deferred_tools_section=deferred_tools_section, deferred_tools_section=deferred_tools_section,
writing_mode_section=writing_mode_section, writing_mode_section=writing_mode_section,
@@ -5,6 +5,24 @@ from pydantic import BaseModel, Field
from deerflow.config.runtime_paths import project_root, resolve_path from deerflow.config.runtime_paths import project_root, resolve_path
DEFAULT_ES_QUERY_ROUTING_PROMPT = """当用户的问题需要基于 Elasticsearch 查询语句(Query DSL)灵活构造、调整或执行查询时,必须优先使用 `es_query` 技能。
典型场景包括:根据用户条件动态组合 bool/must/filter/should、范围、聚合、排序或分页等 ES 查询;或者用户直接提供、要求修改 ES 语句并据此查询数据。
执行前先读取 `es_query` 技能的 SKILL.md,严格按照技能中的流程和参数调用;不要用通用搜索替代,也不要凭记忆臆测查询结果。
仅当问题确实需要 ES 数据查询时使用;纯概念解释、普通代码说明或与 ES 无关的问题不要调用。"""
class EsQueryRoutingConfig(BaseModel):
"""Prompt steering that routes flexible Elasticsearch queries to ``es_query``."""
enabled: bool = Field(
default=False,
description="Inject the es_query routing prompt into the agent system prompt.",
)
prompt: str = Field(
default=DEFAULT_ES_QUERY_ROUTING_PROMPT,
description="Editable system-prompt instructions for deciding when and how to use es_query.",
)
class SecurityScanConfig(BaseModel): class SecurityScanConfig(BaseModel):
"""Toggle for the LLM-based skill security scanner.""" """Toggle for the LLM-based skill security scanner."""
@@ -37,6 +55,10 @@ class SkillsConfig(BaseModel):
default_factory=SecurityScanConfig, default_factory=SecurityScanConfig,
description="LLM security-scan toggle for skill content.", description="LLM security-scan toggle for skill content.",
) )
es_query_routing: EsQueryRoutingConfig = Field(
default_factory=EsQueryRoutingConfig,
description="Optional configurable routing prompt for the es_query skill.",
)
def get_skills_path(self) -> Path: def get_skills_path(self) -> Path:
""" """
@@ -0,0 +1,70 @@
from types import SimpleNamespace
from deerflow.agents.lead_agent import prompt as prompt_module
from deerflow.config.skills_config import EsQueryRoutingConfig, SkillsConfig
def _app_config(*, enabled: bool, prompt: str = "自定义 ES 路由规则") -> SimpleNamespace:
return SimpleNamespace(
skills=SkillsConfig(
es_query_routing=EsQueryRoutingConfig(enabled=enabled, prompt=prompt),
)
)
def test_es_query_routing_config_is_disabled_by_default() -> None:
config = SkillsConfig()
assert config.es_query_routing.enabled is False
assert "es_query" in config.es_query_routing.prompt
def test_es_query_routing_section_honors_switch_prompt_and_allowlist() -> None:
enabled = _app_config(enabled=True, prompt=" 自定义 ES 路由规则 ")
assert prompt_module._build_es_query_routing_section(app_config=enabled) == (
'<skill_routing skill="es_query">\n自定义 ES 路由规则\n</skill_routing>'
)
assert (
prompt_module._build_es_query_routing_section(
app_config=enabled,
available_skills={"other_skill"},
)
== ""
)
assert "自定义 ES 路由规则" in prompt_module._build_es_query_routing_section(
app_config=enabled,
available_skills={"es_query"},
)
assert prompt_module._build_es_query_routing_section(app_config=_app_config(enabled=False)) == ""
assert prompt_module._build_es_query_routing_section(app_config=_app_config(enabled=True, prompt=" ")) == ""
def test_es_query_routing_is_injected_into_lead_and_allowed_custom_agent_prompts(monkeypatch) -> None:
config = _app_config(enabled=True)
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "<skill_system />")
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_build_custom_mounts_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_current_time_context", lambda: "")
lead_prompt = prompt_module.apply_prompt_template(app_config=config)
allowed_custom_prompt = prompt_module.apply_agent_only_prompt_template(
agent_id="agent-with-es",
agent_name="Agent with ES",
available_skills={"es_query"},
app_config=config,
)
denied_custom_prompt = prompt_module.apply_agent_only_prompt_template(
agent_id="agent-without-es",
agent_name="Agent without ES",
available_skills={"other_skill"},
app_config=config,
)
assert '<skill_routing skill="es_query">' in lead_prompt
assert "自定义 ES 路由规则" in lead_prompt
assert '<skill_routing skill="es_query">' in allowed_custom_prompt
assert '<skill_routing skill="es_query">' not in denied_custom_prompt
@@ -0,0 +1,128 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from app.gateway.routers import llmwiki
def _request(store: object) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"path": "/api/public/llmwiki/knowledge-bases/kb-one",
"headers": [],
"app": SimpleNamespace(state=SimpleNamespace(llmwiki_store=store)),
}
)
class PublicStore:
def __init__(self, row: dict[str, object] | None) -> None:
self.row = row
self.calls: list[tuple[str, str, bool, bool]] = []
async def get_authorized(
self,
mapping_id: str,
user_id: str,
*,
write: bool,
is_admin: bool,
) -> dict[str, object] | None:
self.calls.append((mapping_id, user_id, write, is_admin))
return self.row
def _published_row() -> dict[str, object]:
return {
"id": "kb-one",
"weknora_id": "remote-one",
"owner_user_id": "owner-one",
"name": "公开知识库",
"description": "公开说明",
"kb_type": "document",
"publication_status": "published",
}
@pytest.mark.asyncio
async def test_public_wiki_reader_uses_anonymous_non_admin_authorization(monkeypatch) -> None:
store = PublicStore(_published_row())
class Client:
async def get_knowledge_base(self, knowledge_base_id: str):
assert knowledge_base_id == "remote-one"
return {"id": knowledge_base_id, "name": "公开知识库", "knowledge_count": 2}
monkeypatch.setattr(llmwiki, "_client_or_503", lambda: Client())
payload = await llmwiki.get_public_knowledge_base(_request(store), "kb-one")
assert store.calls == [("kb-one", "__public_wiki__", False, False)]
assert payload["publication_status"] == "published"
assert payload["owner_user_id"] is None
assert payload["can_write"] is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
"row",
[
{**_published_row(), "publication_status": "private"},
{
**_published_row(),
"owner_user_id": "system",
"name": "对话沉淀",
},
None,
],
)
async def test_public_wiki_reader_rejects_non_public_resources(row) -> None:
with pytest.raises(HTTPException) as exc_info:
await llmwiki._public_mapping(_request(PublicStore(row)), "kb-one")
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_public_wiki_page_list_is_normalized(monkeypatch) -> None:
store = PublicStore(_published_row())
class Client:
async def list_wiki_pages(self, knowledge_base_id: str, **kwargs):
assert knowledge_base_id == "remote-one"
assert kwargs == {"page": 1, "page_size": 50, "query": "目录"}
return {
"pages": [
{
"id": "page-one",
"slug": "guide/start",
"title": "开始使用",
"page_type": "article",
"status": "completed",
"content": "# 开始使用",
}
],
"total": 1,
"page": 1,
"page_size": 50,
"total_pages": 1,
}
monkeypatch.setattr(llmwiki, "_client_or_503", lambda: Client())
payload = await llmwiki.list_public_wiki_pages(
_request(store),
"kb-one",
page=1,
page_size=50,
query=" 目录 ",
)
assert payload["pages"][0]["slug"] == "guide/start"
assert payload["pages"][0]["aliases"] == []
+9
View File
@@ -190,6 +190,15 @@ skills:
path: /app/skills path: /app/skills
security_scan: security_scan:
enabled: false enabled: false
# ES 灵活查询技能路由提示:开启后注入整体 system prompt;修改 prompt 可调整
# 触发边界和调用要求。关闭后该提示完全不注入。
es_query_routing:
enabled: true
prompt: |
当用户的问题需要基于 Elasticsearch 查询语句(Query DSL)灵活构造、调整或执行查询时,必须优先使用 `es_query` 技能。
典型场景包括:根据用户条件动态组合 bool/must/filter/should、范围、聚合、排序或分页等 ES 查询;或者用户直接提供、要求修改 ES 语句并据此查询数据。
执行前先读取 `es_query` 技能的 SKILL.md,严格按照技能中的流程和参数调用;不要用通用搜索替代,也不要凭记忆臆测查询结果。
仅当问题确实需要 ES 数据查询时使用;纯概念解释、普通代码说明或与 ES 无关的问题不要调用。
agents_api: agents_api:
enabled: true enabled: true
# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。 # 登录鉴权扩展:在基础登录之上叠加两种自定义模式。