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
+704 -52

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
# Keep fixed-question exact-match and streaming regression coverage versioned.
!offline-backend-20260512/backend/tests/test_fixed_questions.py
# Keep configurable es_query system-prompt routing regression coverage versioned.
!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
+11 -1
View File
@@ -38,6 +38,7 @@ import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView";
import {
isPositionRoundtableStandaloneMode,
POSITION_ROUNDTABLE_ROUTE,
@@ -45,7 +46,12 @@ import {
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
const PUBLIC_EMBED_PREFIXES = [
"/share/",
"/public/",
"/roundtable-share/",
"/embed/knowledge/",
];
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
@@ -189,6 +195,10 @@ export function App() {
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/embed/knowledge/:noteId"
element={<WeKnoraWikiPageView publicEmbed />}
/>
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
@@ -114,6 +114,24 @@ export function buildWeKnoraWikiPagePath(
return `/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view?${params.toString()}`;
}
/** Public, chrome-free Wiki reader intended for third-party iframe embedding. */
export function buildPublicKnowledgeWikiPath(
knowledgeBaseId: string,
wikiSlug?: string,
): string {
const params = new URLSearchParams();
if (wikiSlug) params.set("wikiSlug", wikiSlug);
const query = params.toString();
return `/embed/knowledge/${encodeURIComponent(knowledgeBaseId)}${query ? `?${query}` : ""}`;
}
export function buildPublicKnowledgeWikiUrl(
knowledgeBaseId: string,
wikiSlug?: string,
): string {
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId, wikiSlug)}`;
}
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
if (!recUuid) return "";
@@ -1,5 +1,10 @@
import { buildWeKnoraWikiPageUrl } from "@/core/llmwiki/source-links";
export type WikiPageUrlBuilder = (
knowledgeBaseId: string,
wikiSlug: string,
) => string;
export function stripWikiFrontmatter(value: string) {
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
}
@@ -16,12 +21,16 @@ export function exportableWikiMarkdown(title: string, summary: string, content:
.join("\n\n");
}
export function wikiLinkMarkdown(markdown: string, knowledgeBaseId: string) {
export function wikiLinkMarkdown(
markdown: string,
knowledgeBaseId: string,
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
) {
const link = (slugValue: string, labelValue?: string) => {
const slug = slugValue.trim();
const fallbackLabel = slug.split("/").filter(Boolean).at(-1) || slug;
const label = (labelValue || fallbackLabel).trim().replace(/\]/g, "\\]");
return `[${label}](${buildWeKnoraWikiPageUrl(knowledgeBaseId, slug)})`;
return `[${label}](${buildPageUrl(knowledgeBaseId, slug)})`;
};
return markdown
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label))
@@ -33,6 +42,7 @@ export function renderableWikiMarkdown(
title: string,
content: string,
knowledgeBaseId: string,
buildPageUrl?: WikiPageUrlBuilder,
) {
const lines = content.trim().split("\n");
if (lines[0]?.replace(/^#+\s*/, "").trim() === title.trim()) {
@@ -41,5 +51,6 @@ export function renderableWikiMarkdown(
return wikiLinkMarkdown(
exportableWikiMarkdown(title, "", lines.join("\n")),
knowledgeBaseId,
buildPageUrl,
);
}
@@ -5,6 +5,7 @@ import { useLiveAccount } from "@/core/auth/use-live-account";
import { getBackendBaseURL } from "@/core/config";
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
const SETTINGS_API = () => `${getBackendBaseURL()}/api/system-settings/llmwiki`;
export type LlmWikiProvider = "legacy" | "weknora";
@@ -671,6 +672,44 @@ export async function searchLlmWiki(input: {
);
}
export async function getPublicLlmWikiKnowledgeBase(
id: string,
): Promise<LlmWikiKnowledgeBase> {
return readJson(
await apiFetch(`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(id)}`),
"读取公开知识库详情",
);
}
export async function listPublicLlmWikiWikiPages(
knowledgeBaseId: string,
page: number,
pageSize: number,
query = "",
): Promise<LlmWikiWikiPageList> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (query.trim()) params.set("query", query.trim());
return readJson(
await apiFetch(
`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages?${params.toString()}`,
),
"读取公开 Wiki 页面",
);
}
export async function getPublicLlmWikiWikiPage(
knowledgeBaseId: string,
slug: string,
): Promise<LlmWikiWikiPage> {
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
return readJson(
await apiFetch(
`${PUBLIC_API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`,
),
"读取公开 Wiki 页面详情",
);
}
export async function createLlmWikiConversationDeposit(input: {
thread_id: string;
question: string;
@@ -860,6 +899,46 @@ export function useLlmWikiWikiPage(
});
}
export function usePublicLlmWikiKnowledgeBase(
knowledgeBaseId: string | null,
enabled = true,
) {
return useQuery({
queryKey: ["public-llmwiki", "knowledge-base", knowledgeBaseId],
queryFn: () => getPublicLlmWikiKnowledgeBase(knowledgeBaseId!),
enabled: enabled && !!knowledgeBaseId,
staleTime: 60_000,
});
}
export function usePublicLlmWikiWikiPages(
knowledgeBaseId: string | null,
page: number,
pageSize = 50,
query = "",
enabled = true,
) {
return useQuery({
queryKey: ["public-llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query],
queryFn: () => listPublicLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
enabled: enabled && !!knowledgeBaseId,
staleTime: 60_000,
});
}
export function usePublicLlmWikiWikiPage(
knowledgeBaseId: string | null,
slug: string | null,
enabled = true,
) {
return useQuery({
queryKey: ["public-llmwiki", "wiki-page", knowledgeBaseId, slug],
queryFn: () => getPublicLlmWikiWikiPage(knowledgeBaseId!, slug!),
enabled: enabled && !!knowledgeBaseId && !!slug,
staleTime: 60_000,
});
}
export function useLlmWikiWikiIndexStatus(
knowledgeBaseId: string | null,
enabled = true,
@@ -18,12 +18,16 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
import {
buildPublicKnowledgeWikiPath,
buildWeKnoraWikiPagePath,
} from "@/core/llmwiki/source-links";
import {
buildWikiTree,
filterWikiPagesWithAncestors,
type WikiTreeNode,
} from "@/core/llmwiki/wiki-tree";
import { usePublicBackground } from "@/core/embed/background";
import { reasoningPlugins } from "@/core/streamdown";
import { markdownToDocxBlob } from "@/core/scheduled-tasks/markdown-docx";
import {
@@ -38,6 +42,9 @@ import {
useLlmWikiKnowledgeBase,
useLlmWikiWikiPage,
useLlmWikiWikiPages,
usePublicLlmWikiKnowledgeBase,
usePublicLlmWikiWikiPage,
usePublicLlmWikiWikiPages,
} from "@/strategy-components/api/llmwiki";
const LIST_PATH = "/page/workspace/knowledge";
@@ -85,27 +92,84 @@ function downloadBlob(blob: Blob, fileName: string) {
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
export function WeKnoraWikiPageView() {
function selectLandingPage(pages: LlmWikiWikiPage[]): LlmWikiWikiPage | undefined {
return (
pages.find((page) => page.page_type?.toLowerCase() === "overview") ||
pages.find((page) => /^(?:index|home|readme)$/i.test(page.slug)) ||
pages.find((page) => !page.parent_slug && (page.depth ?? 0) === 0) ||
pages[0]
);
}
export function WeKnoraWikiPageView({
publicEmbed = false,
}: {
publicEmbed?: boolean;
}) {
const { noteId = "" } = useParams();
const [searchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
const page = useLlmWikiWikiPage(noteId || null, wikiSlug || null, !!noteId && !!wikiSlug);
const knowledgeBase = useLlmWikiKnowledgeBase(noteId || null, !!noteId);
const pages = useLlmWikiWikiPages(noteId || null, 1, 500, "", !!noteId);
const publicBackground = usePublicBackground();
const requestedWikiSlug = searchParams.get("wikiSlug")?.trim() || "";
const privateKnowledgeBase = useLlmWikiKnowledgeBase(
noteId || null,
!!noteId && !publicEmbed,
);
const publicKnowledgeBase = usePublicLlmWikiKnowledgeBase(
noteId || null,
!!noteId && publicEmbed,
);
const knowledgeBase = publicEmbed ? publicKnowledgeBase : privateKnowledgeBase;
const privatePages = useLlmWikiWikiPages(
noteId || null,
1,
500,
"",
!!noteId && !publicEmbed,
);
const publicPages = usePublicLlmWikiWikiPages(
noteId || null,
1,
500,
"",
!!noteId && publicEmbed,
);
const pages = publicEmbed ? publicPages : privatePages;
const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
const landingPage = useMemo(() => selectLandingPage(pagesList), [pagesList]);
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : "");
const privatePage = useLlmWikiWikiPage(
noteId || null,
wikiSlug || null,
!!noteId && !!wikiSlug && !publicEmbed,
);
const publicPage = usePublicLlmWikiWikiPage(
noteId || null,
wikiSlug || null,
!!noteId && !!wikiSlug && publicEmbed,
);
const page = publicEmbed ? publicPage : privatePage;
const [downloading, setDownloading] = useState<"md" | "word" | null>(null);
const [directoryQuery, setDirectoryQuery] = useState("");
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(() => new Set());
const routedPage = (location.state as { wikiPage?: LlmWikiWikiPage } | null)?.wikiPage;
const pageData = page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined);
const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
const filteredPages = useMemo(
() => filterWikiPagesWithAncestors(pagesList, directoryQuery),
[directoryQuery, pagesList],
);
const wikiTree = useMemo(() => buildWikiTree(filteredPages), [filteredPages]);
useEffect(() => {
if (!publicEmbed || requestedWikiSlug || !wikiSlug) return;
const next = new URLSearchParams(searchParams);
next.set("wikiSlug", wikiSlug);
navigate(`${buildPublicKnowledgeWikiPath(noteId)}?${next.toString()}`, {
replace: true,
});
}, [navigate, noteId, publicEmbed, requestedWikiSlug, searchParams, wikiSlug]);
useEffect(() => {
const activeParents = wikiTree.parentKeysBySlug.get(wikiSlug) || [];
if (activeParents.length === 0) return;
@@ -121,7 +185,18 @@ export function WeKnoraWikiPageView() {
const summary = cleanText(pageData?.summary);
const content = stripWikiFrontmatter(cleanText(pageData?.content));
const displayMarkdown = pageData
? renderableWikiMarkdown(title, content, noteId)
? renderableWikiMarkdown(
title,
content,
noteId,
publicEmbed
? (knowledgeBaseId, slug) => {
const next = new URLSearchParams(searchParams);
next.set("wikiSlug", slug);
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId)}?${next.toString()}`;
}
: undefined,
)
: "";
const exportMarkdown = pageData
? wikiLinkMarkdown(exportableWikiMarkdown(title, summary, content), noteId)
@@ -189,7 +264,15 @@ export function WeKnoraWikiPageView() {
)}
{node.page ? (
<Link
to={buildWeKnoraWikiPagePath(noteId, node.page.slug)}
to={
publicEmbed
? (() => {
const next = new URLSearchParams(searchParams);
next.set("wikiSlug", node.page.slug);
return `${buildPublicKnowledgeWikiPath(noteId)}?${next.toString()}`;
})()
: buildWeKnoraWikiPagePath(noteId, node.page.slug)
}
state={{ wikiPage: node.page }}
className="flex min-w-0 flex-1 items-center gap-2 py-2"
aria-current={active ? "page" : undefined}
@@ -219,10 +302,20 @@ export function WeKnoraWikiPageView() {
};
if (!wikiSlug) {
if (publicEmbed && pages.isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在加载公开 Wiki
</div>
);
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
当前链接缺少 Wiki 页面标识
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
{!publicEmbed && (
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
)}
</div>
);
}
@@ -245,11 +338,17 @@ export function WeKnoraWikiPageView() {
<div>
<div className="font-medium text-foreground">无法打开 Wiki 页面</div>
<div className="mt-1 text-sm text-muted-foreground">
{page.error instanceof Error ? page.error.message : "页面不存在或当前账号无权访问"}
{page.error instanceof Error
? page.error.message
: publicEmbed
? "知识库未发布、页面不存在或已停止公开访问"
: "页面不存在或当前账号无权访问"}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
{!publicEmbed && (
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
)}
<Button onClick={() => void page.refetch()}>
<RefreshCw className="mr-2 size-4" />重试
</Button>
@@ -259,7 +358,27 @@ export function WeKnoraWikiPageView() {
}
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/70 dark:bg-slate-950/40">
<div
className="flex h-full min-h-0 flex-col bg-slate-50/70 dark:bg-slate-950/40"
style={publicEmbed ? publicBackground : undefined}
data-public-wiki-embed={publicEmbed ? "true" : undefined}
>
{publicEmbed ? (
<header className="flex min-h-14 shrink-0 items-center gap-3 border-b bg-background/95 px-4 py-2.5 shadow-sm sm:px-6">
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
<BookOpenText className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold text-foreground">
{knowledgeBase.data?.name || "知识库 Wiki"}
</h1>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{title}</p>
</div>
<Badge variant="outline" className="shrink-0 border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-300">
公开 Wiki
</Badge>
</header>
) : (
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b bg-background px-4 py-3 shadow-sm sm:px-6">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Button size="icon-sm" variant="ghost" onClick={() => navigate(LIST_PATH)} aria-label="返回知识库">
@@ -290,6 +409,7 @@ export function WeKnoraWikiPageView() {
</Button>
</div>
</header>
)}
<div className="flex min-h-0 flex-1 overflow-hidden">
<aside className="flex w-64 shrink-0 flex-col border-r bg-background/90 lg:w-72">
@@ -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.
- **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
- **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
- **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.
@@ -1471,6 +1478,17 @@ Wiki directory, reconstruct hierarchy from `parent_slug`, `wiki_path`,
directory navigation on the same lightweight route rather than mounting the
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
/api/llmwiki/knowledge-bases/{id}/vectorize`; status is available through `GET
/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
- `tool_groups` - Logical tool groupings
- `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
- `summarization` - Context summarization settings
- `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
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
/api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx` exports every processed Wiki
page, including its full directory path, current directory, parent-directory
@@ -1778,6 +1778,24 @@ async def _authorized_mapping(
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]:
document = await client.get_document(document_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}
@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")
async def list_knowledge_bases(
request: Request,
@@ -372,6 +372,15 @@ skills:
path: skills
security_scan:
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:
enabled: true
@@ -270,9 +270,21 @@ skills:
# Host path (optional, default: ../skills)
path: /custom/path/to/skills
# Container mount path (default: /mnt/skills)
container_path: /mnt/skills
```
# Container mount path (default: /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**:
- Skills are stored in `deer-flow/skills/{public,custom}/`
@@ -271,7 +271,42 @@ answers — and never create a skill silently without user confirmation.
"""
def _build_available_subagents_description(available_names: list[str], bash_available: bool, *, app_config: AppConfig | None = None) -> str:
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:
"""Dynamically build subagent type descriptions from registry.
Mirrors Codex's pattern where agent_type_description is dynamically generated
@@ -548,9 +583,11 @@ User: "staging"
You: "Deploying to staging..." [proceed]
</clarification_system>
{skills_section}
{bootstrap_section}
{skills_section}
{es_query_routing_section}
{bootstrap_section}
{deferred_tools_section}
@@ -663,10 +700,12 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
# style, clarification system, citation rules, response-style / critical-reminder
# defaults) and no personal memory injection. Only the mechanical sections an
# agent still needs to actually use its configured skills/tools are kept.
AGENT_ONLY_PROMPT_TEMPLATE = """{soul}
{network_policy_section}{skills_section}
{deferred_tools_section}
AGENT_ONLY_PROMPT_TEMPLATE = """{soul}
{network_policy_section}{skills_section}
{es_query_routing_section}
{deferred_tools_section}
{writing_mode_section}
@@ -719,12 +758,16 @@ def apply_agent_only_prompt_template(
soul = get_agent_soul(storage_agent_id)
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
display_enabled=rag_mode_enabled,
)
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
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)
n = max_concurrent_subagents
subagent_section = _build_subagent_section(n, app_config=app_config) if subagent_enabled else ""
@@ -766,9 +809,10 @@ RAG/reference display mode is enabled.
)
prompt = AGENT_ONLY_PROMPT_TEMPLATE.format(
soul=soul,
skills_section=skills_section,
deferred_tools_section=deferred_tools_section,
soul=soul,
skills_section=skills_section,
es_query_routing_section=es_query_routing_section,
deferred_tools_section=deferred_tools_section,
writing_mode_section=writing_mode_section,
subagent_section=subagent_section,
network_policy_section=network_policy_section,
@@ -1377,11 +1421,15 @@ def apply_prompt_template(
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
# Get skills section
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
display_enabled=rag_mode_enabled,
)
skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
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)
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
@@ -1438,9 +1486,10 @@ When a search, knowledge, skill, or MCP tool returns a result list:
# Format the prompt with dynamic skills and memory
prompt = SYSTEM_PROMPT_TEMPLATE.format(
agent_name=agent_name or storage_agent_id or "CM 助手",
soul=get_agent_soul(storage_agent_id),
skills_section=skills_section,
bootstrap_section=bootstrap_section,
soul=get_agent_soul(storage_agent_id),
skills_section=skills_section,
es_query_routing_section=es_query_routing_section,
bootstrap_section=bootstrap_section,
deferred_tools_section=deferred_tools_section,
writing_mode_section=writing_mode_section,
memory_context=memory_context,
@@ -5,8 +5,26 @@ from pydantic import BaseModel, Field
from deerflow.config.runtime_paths import project_root, resolve_path
class SecurityScanConfig(BaseModel):
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):
"""Toggle for the LLM-based skill security scanner."""
enabled: bool = Field(
@@ -33,10 +51,14 @@ class SkillsConfig(BaseModel):
default="/mnt/skills",
description="Path where skills are mounted in the sandbox container",
)
security_scan: SecurityScanConfig = Field(
default_factory=SecurityScanConfig,
description="LLM security-scan toggle for skill content.",
)
security_scan: SecurityScanConfig = Field(
default_factory=SecurityScanConfig,
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:
"""
@@ -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
security_scan:
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:
enabled: true
# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。