fix: harden WeKnora knowledge permissions and details
This commit is contained in:
1 parent
e0c09f6704
commit
b4d076a2d4
20 files changed
+1713
-298
No files matched your search
@@ -240,7 +240,18 @@ type WeKnoraSourceContext = {
|
||||
knowledge_base_name?: string;
|
||||
document_title?: string;
|
||||
document_file_name?: string;
|
||||
display_mode?: "wiki" | "chunks";
|
||||
focus_chunk_id: string;
|
||||
wiki_page?: {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
page_type?: string;
|
||||
status?: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
source_refs?: string[];
|
||||
} | null;
|
||||
chunks: Array<{
|
||||
id: string;
|
||||
chunk_index?: number;
|
||||
@@ -312,6 +323,33 @@ function WeKnoraSourceContextPanel({
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
const wikiPage = context.wiki_page;
|
||||
const wikiContent = cleanReferenceText(
|
||||
[wikiPage?.summary, wikiPage?.content].filter(Boolean).join("\n\n"),
|
||||
[],
|
||||
);
|
||||
if (wikiPage && wikiContent) {
|
||||
return (
|
||||
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
|
||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
|
||||
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
|
||||
<span> · LLMWiki</span>
|
||||
{wikiPage.page_type && <span> · {wikiPage.page_type}</span>}
|
||||
</div>
|
||||
<section className="border-primary/40 bg-primary/5 rounded-lg border p-3 text-sm leading-relaxed shadow-sm">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{wikiPage.title || wikiPage.slug || "LLMWiki 页面"}
|
||||
</span>
|
||||
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 text-xs font-medium">
|
||||
清洗页
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3 whitespace-pre-wrap">{wikiContent}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
|
||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
|
||||
|
||||
@@ -13,19 +13,33 @@ export function buildIntranetSourceUrl(recUuid?: string | null): string {
|
||||
return `${INTRANET_SOURCE_URL_BASE}${encodeURIComponent(recUuid)}`;
|
||||
}
|
||||
|
||||
function getBaseOrigin() {
|
||||
function getBaseOrigin() {
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
}
|
||||
// Fallback for SSR
|
||||
return "http://localhost:2026";
|
||||
}
|
||||
|
||||
export function getBackendBaseURL() {
|
||||
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
|
||||
return new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin())
|
||||
.toString()
|
||||
.replace(/\/+$/, "");
|
||||
return "http://localhost:2026";
|
||||
}
|
||||
|
||||
function alignLoopbackHostname(url: URL): URL {
|
||||
if (typeof window === "undefined") return url;
|
||||
const loopbackHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
||||
if (
|
||||
loopbackHosts.has(url.hostname) &&
|
||||
loopbackHosts.has(window.location.hostname)
|
||||
) {
|
||||
url.hostname = window.location.hostname;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function getBackendBaseURL() {
|
||||
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
|
||||
return alignLoopbackHostname(
|
||||
new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin()),
|
||||
)
|
||||
.toString()
|
||||
.replace(/\/+$/, "");
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
||||
@@ -52,6 +53,7 @@ export interface LlmWikiKnowledgeBase {
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
published_at?: string | null;
|
||||
is_conversation_deposit?: boolean;
|
||||
}
|
||||
|
||||
export interface LlmWikiDocument {
|
||||
@@ -152,6 +154,11 @@ export interface LlmWikiSearchResult {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface LlmWikiWeKnoraEmbedSession {
|
||||
frame_path: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null as unknown);
|
||||
@@ -206,6 +213,19 @@ export async function getLlmWikiKnowledgeBase(
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLlmWikiWeKnoraEmbedSession(
|
||||
id: string,
|
||||
tab: "documents" | "wiki" | "graph",
|
||||
): Promise<LlmWikiWeKnoraEmbedSession> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(id)}/weknora/session?tab=${encodeURIComponent(tab)}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
"创建 WeKnora 详情会话",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLlmWikiKnowledgeBase(input: {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -497,8 +517,8 @@ export async function testLlmWikiConnection(
|
||||
|
||||
const runtimeKey = ["llmwiki", "runtime"] as const;
|
||||
const settingsKey = ["llmwiki", "settings"] as const;
|
||||
const knowledgeBasesKey = (scope: LlmWikiScope) =>
|
||||
["llmwiki", "knowledge-bases", scope] as const;
|
||||
const knowledgeBasesKey = (userId: string | null, scope: LlmWikiScope) =>
|
||||
["llmwiki", "knowledge-bases", userId ?? "anonymous", scope] as const;
|
||||
|
||||
export function useLlmWikiRuntime() {
|
||||
return useQuery({
|
||||
@@ -513,8 +533,9 @@ export function useLlmWikiKnowledgeBases(
|
||||
scope: LlmWikiScope,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: knowledgeBasesKey(scope),
|
||||
queryKey: knowledgeBasesKey(userId, scope),
|
||||
queryFn: () => listLlmWikiKnowledgeBases(scope),
|
||||
enabled,
|
||||
refetchInterval: 15_000,
|
||||
@@ -525,8 +546,9 @@ export function useAgentLlmWikiKnowledgeBases(
|
||||
agentId: string | null | undefined,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "agents", agentId, "knowledge-bases"],
|
||||
queryKey: ["llmwiki", "agents", agentId, "knowledge-bases", userId ?? "anonymous"],
|
||||
queryFn: () => listAgentLlmWikiKnowledgeBases(agentId!),
|
||||
enabled: enabled && !!agentId,
|
||||
});
|
||||
@@ -536,8 +558,9 @@ export function useLlmWikiKnowledgeBase(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "knowledge-base", knowledgeBaseId],
|
||||
queryKey: ["llmwiki", "knowledge-base", knowledgeBaseId, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiKnowledgeBase(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
refetchInterval: 8_000,
|
||||
@@ -548,8 +571,9 @@ export function useLlmWikiDocuments(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "documents", knowledgeBaseId],
|
||||
queryKey: ["llmwiki", "documents", knowledgeBaseId, userId ?? "anonymous"],
|
||||
queryFn: () => listLlmWikiDocuments(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
refetchInterval: 8_000,
|
||||
@@ -561,8 +585,9 @@ export function useLlmWikiDocument(
|
||||
documentId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "document", knowledgeBaseId, documentId],
|
||||
queryKey: ["llmwiki", "document", knowledgeBaseId, documentId, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiDocument(knowledgeBaseId!, documentId!),
|
||||
enabled: enabled && !!knowledgeBaseId && !!documentId,
|
||||
});
|
||||
@@ -575,16 +600,18 @@ export function useLlmWikiDocumentChunks(
|
||||
pageSize = 20,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "document-chunks", knowledgeBaseId, documentId, page, pageSize],
|
||||
queryKey: ["llmwiki", "document-chunks", knowledgeBaseId, documentId, page, pageSize, userId ?? "anonymous"],
|
||||
queryFn: () => listLlmWikiDocumentChunks(knowledgeBaseId!, documentId!, page, pageSize),
|
||||
enabled: enabled && !!knowledgeBaseId && !!documentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiGraph(knowledgeBaseId: string | null, enabled = true) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "graph", knowledgeBaseId],
|
||||
queryKey: ["llmwiki", "graph", knowledgeBaseId, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiGraph(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
});
|
||||
@@ -597,8 +624,9 @@ export function useLlmWikiWikiPages(
|
||||
query = "",
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query],
|
||||
queryKey: ["llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query, userId ?? "anonymous"],
|
||||
queryFn: () => listLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
refetchInterval: 10_000,
|
||||
@@ -610,16 +638,18 @@ export function useLlmWikiWikiPage(
|
||||
slug: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "wiki-page", knowledgeBaseId, slug],
|
||||
queryKey: ["llmwiki", "wiki-page", knowledgeBaseId, slug, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiWikiPage(knowledgeBaseId!, slug!),
|
||||
enabled: enabled && !!knowledgeBaseId && !!slug,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiSettings(enabled = true) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: settingsKey,
|
||||
queryKey: [...settingsKey, userId ?? "anonymous"],
|
||||
queryFn: getLlmWikiSettings,
|
||||
enabled,
|
||||
});
|
||||
|
||||
+129
-23
@@ -1,10 +1,13 @@
|
||||
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { AlertCircle, Loader2, RefreshCw, Upload } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
createLlmWikiWeKnoraEmbedSession,
|
||||
useLlmWikiActions,
|
||||
useLlmWikiKnowledgeBase,
|
||||
useLlmWikiRuntime,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
@@ -60,18 +63,46 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
|
||||
const navigate = useNavigate();
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [frameSrc, setFrameSrc] = useState("");
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
|
||||
const actions = useLlmWikiActions();
|
||||
|
||||
const detailsReady = !!details.data;
|
||||
const preferredTab = details.data?.indexing_strategy?.wiki_enabled ? "wiki" : "documents";
|
||||
const frameSrc = useMemo(() => {
|
||||
if (!knowledgeBaseId || runtime.data?.provider !== "weknora") return "";
|
||||
const base = getBackendBaseURL().replace(/\/+$/, "");
|
||||
const params = new URLSearchParams({ tab: preferredTab });
|
||||
return `${base}/api/llmwiki/knowledge-bases/${encodeURIComponent(
|
||||
knowledgeBaseId,
|
||||
)}/weknora/frame?${params.toString()}`;
|
||||
}, [knowledgeBaseId, preferredTab, runtime.data?.provider]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFrameSrc("");
|
||||
setFrameError(null);
|
||||
if (!knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab)
|
||||
.then((session) => {
|
||||
if (cancelled) return;
|
||||
const base = getBackendBaseURL().replace(/\/+$/, "");
|
||||
const path = session.frame_path.startsWith("/")
|
||||
? session.frame_path
|
||||
: `/${session.frame_path}`;
|
||||
setFrameSrc(`${base}${path}`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = error instanceof Error ? error.message : "创建 WeKnora 详情会话失败";
|
||||
setFrameError(message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detailsReady, knowledgeBaseId, preferredTab, runtime.data?.provider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameSrc) return;
|
||||
@@ -95,13 +126,30 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
正在打开 WeKnora 详情页
|
||||
加载中
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtime.data?.provider !== "weknora") return <Navigate to={LIST_PATH} replace />;
|
||||
|
||||
if (frameError) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-background px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<AlertCircle className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-foreground">无法打开 WeKnora 详情</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{frameError}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
|
||||
返回知识库
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (details.error || !details.data) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-background px-6 text-center">
|
||||
@@ -121,22 +169,80 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const canUpload = details.data.can_write && !details.data.is_conversation_deposit;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<iframe
|
||||
key={frameSrc}
|
||||
ref={frameRef}
|
||||
title={`${details.data.name || "WeKnora"} 详情`}
|
||||
src={frameSrc}
|
||||
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => postThemeToFrame(frameRef.current)}
|
||||
/>
|
||||
{canUpload && (
|
||||
<div className="flex h-12 shrink-0 items-center justify-end border-b px-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = "";
|
||||
if (files.length === 0) return;
|
||||
void (async () => {
|
||||
setUploading(true);
|
||||
const failed: string[] = [];
|
||||
try {
|
||||
for (const file of files) {
|
||||
try {
|
||||
await actions.upload.mutateAsync({ id: knowledgeBaseId, file });
|
||||
} catch {
|
||||
failed.push(file.name);
|
||||
}
|
||||
}
|
||||
const succeeded = files.length - failed.length;
|
||||
if (succeeded > 0) {
|
||||
await details.refetch();
|
||||
if (frameRef.current) frameRef.current.src = frameSrc;
|
||||
}
|
||||
if (failed.length === 0) {
|
||||
toast.success(`已上传 ${succeeded} 个文件,正在处理`);
|
||||
} else if (succeeded > 0) {
|
||||
toast.warning(`上传完成:成功 ${succeeded} 个,失败 ${failed.length} 个`);
|
||||
} else {
|
||||
toast.error("文件上传失败");
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
)}
|
||||
上传文件
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{frameSrc && (
|
||||
<iframe
|
||||
key={frameSrc}
|
||||
ref={frameRef}
|
||||
title={`${details.data.name || "WeKnora"} 详情`}
|
||||
src={frameSrc}
|
||||
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => postThemeToFrame(frameRef.current)}
|
||||
/>
|
||||
)}
|
||||
{!frameSrc && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
正在准备 iframe
|
||||
加载中
|
||||
</div>
|
||||
)}
|
||||
{details.isFetching && (
|
||||
|
||||
+5
-5
@@ -97,7 +97,7 @@ export function WeKnoraKnowledgeWorkspace() {
|
||||
|
||||
const removeKnowledgeBase = async (item: LlmWikiKnowledgeBase) => {
|
||||
const warning = item.remote_status === "missing"
|
||||
? `知识库“${item.name}”已不在 WeKnora 中。确定清理 DeerFlow 中的这条失效记录吗?`
|
||||
? `知识库“${item.name}”已不在 WeKnora 中。确定清理 cmzs 中的这条失效记录吗?`
|
||||
: `确定删除知识库“${item.name}”吗?WeKnora 中的文档和索引也会删除。`;
|
||||
if (!window.confirm(warning)) return;
|
||||
try {
|
||||
@@ -128,7 +128,7 @@ export function WeKnoraKnowledgeWorkspace() {
|
||||
<h1 className="text-xl font-semibold">问答梳理</h1>
|
||||
<Badge variant="secondary">WeKnora</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">由 WeKnora 负责解析与检索,用户、权限和发布范围由 DeerFlow 管理。</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">由 WeKnora 负责解析与检索,用户、权限和发布范围由 cmzs 管理。</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
|
||||
{tab !== "settings" && (
|
||||
@@ -236,9 +236,9 @@ function KnowledgeGrid({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{item.remote_status === "missing" ? <span className="text-xs text-muted-foreground">WeKnora 资源不存在,可清理本地记录</span> : <Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>}
|
||||
{item.remote_status !== "missing" && item.is_owner && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
|
||||
{item.remote_status !== "missing" && item.is_owner && !item.is_conversation_deposit && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
|
||||
</div>
|
||||
{item.can_write && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={item.remote_status === "missing" ? `清理失效记录 ${item.name}` : `删除知识库 ${item.name}`} title={item.remote_status === "missing" ? "清理失效记录" : "删除知识库"} disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
|
||||
{item.can_write && !item.is_conversation_deposit && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={item.remote_status === "missing" ? `清理失效记录 ${item.name}` : `删除知识库 ${item.name}`} title={item.remote_status === "missing" ? "清理失效记录" : "删除知识库"} disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -299,7 +299,7 @@ export function ConnectionSettings() {
|
||||
if (settings.error) return <div className="rounded-lg border border-destructive/30 p-5 text-sm text-destructive">{messageOf(settings.error)}</div>;
|
||||
return (
|
||||
<Card className="max-w-3xl">
|
||||
<CardHeader><CardTitle className="flex items-center gap-2 text-base"><Settings2 className="size-5" />WeKnora 连接</CardTitle><CardDescription>这里只管理 DeerFlow 到 WeKnora 的连接地址。数据库、Redis、模型、存储和解析器仍在 WeKnora 自己的配置中维护。</CardDescription></CardHeader>
|
||||
<CardHeader><CardTitle className="flex items-center gap-2 text-base"><Settings2 className="size-5" />WeKnora 连接</CardTitle><CardDescription>这里只管理 cmzs 到 WeKnora 的连接地址。数据库、Redis、模型、存储和解析器仍在 WeKnora 自己的配置中维护。</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="flex flex-wrap gap-2 text-sm"><Badge>{status}</Badge><Badge variant="secondary">服务端代理鉴权</Badge>{settings.data?.override_enabled && <Badge variant="outline">页面配置覆盖中</Badge>}</div>
|
||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="weknora-api-url">WeKnora API 地址</label><Input id="weknora-api-url" value={apiUrl} onChange={(event) => setApiUrl(event.target.value)} placeholder="http://weknora-app:8080" /><p className="text-xs text-muted-foreground">非空启用 WeKnora;保存为空会切回原 LLMWiki。</p></div>
|
||||
|
||||
@@ -924,7 +924,7 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
||||
|
||||
### Conditional WeKnora LLMWiki
|
||||
|
||||
`llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports file, URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`.
|
||||
`llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and `system` owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use `cmzs` branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only `system_role === "admin"` sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call `/api/llmwiki/knowledge-bases/{id}/weknora/session` through the normal DeerFlow API auth path, then load `/platform/knowledge-bases/{weknora_id}` with a signed `deerflow_weknora_embed` cookie for `/platform`, `/assets`, `/locales`, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe `/api/v1/*`, including colliding `/api/v1/auth/*`, through `/api/llmwiki/weknora-embed/api/v1/*`; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors `X-Forwarded-Proto` for HTTPS reverse proxies. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`.
|
||||
|
||||
### Embedded Client (`packages/harness/deerflow/client.py`)
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ Per-thread isolated execution with virtual path translation:
|
||||
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
||||
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
||||
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
||||
|
||||
|
||||
@@ -1618,10 +1618,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
|
||||
# ??? frontend-web/docs/multi-agent-backend-dev.md
|
||||
app.include_router(multi_agent.router) # Step 2?init(SSE) + run/stream(leader|special)
|
||||
app.include_router(intent.router) # Step 1?/api/intent/init + /stream
|
||||
app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
|
||||
# Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
|
||||
|
||||
app.include_router(intent.router) # Step 1?/api/intent/init + /stream
|
||||
app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
|
||||
# Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
|
||||
|
||||
# Health probes are always unauthenticated (see
|
||||
# auth_middleware.PUBLIC_HEALTH_PATHS). Support the bare Gateway path,
|
||||
# API base URL, and LangGraph proxy base URL for deployment monitors.
|
||||
@@ -1633,10 +1633,15 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
|
||||
Returns:
|
||||
Service health status information.
|
||||
"""
|
||||
return {"status": "healthy", "service": "deer-flow-gateway"}
|
||||
|
||||
return app
|
||||
"""
|
||||
return {"status": "healthy", "service": "deer-flow-gateway"}
|
||||
|
||||
# WeKnora's embedded frontend uses root-relative routes such as /platform,
|
||||
# /assets, /locales and /api/v1/*. Register that proxy last so it stays a
|
||||
# true fallback and cannot intercept DeerFlow APIs.
|
||||
app.include_router(llmwiki.proxy_router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Create app instance for uvicorn
|
||||
|
||||
@@ -19,10 +19,15 @@ from starlette.types import ASGIApp
|
||||
|
||||
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_COOKIE,
|
||||
has_valid_weknora_embed_cookie,
|
||||
is_weknora_embed_proxy_path,
|
||||
)
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
|
||||
# Health probes are deliberately public, including the LangGraph-compatible
|
||||
# gateway alias used by some deployments. Keep these as exact paths: health
|
||||
@@ -65,15 +70,31 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
|
||||
"/api/v1/auth/initialize",
|
||||
# 并行多智能体面板专用的「绕过常规登录」取 token 端点(内网部署限制了标准登录时用)。
|
||||
"/api/parallel-agents/auth/token",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_public(path: str) -> bool:
|
||||
stripped = path.rstrip("/")
|
||||
if stripped in _PUBLIC_EXACT_PATHS:
|
||||
return True
|
||||
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_public(path: str) -> bool:
|
||||
stripped = path.rstrip("/")
|
||||
if stripped in _PUBLIC_EXACT_PATHS:
|
||||
return True
|
||||
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
|
||||
|
||||
|
||||
def _is_weknora_embed_proxy_request(request: Request) -> bool:
|
||||
"""Let the WeKnora embed proxy verify its own signed iframe cookie.
|
||||
|
||||
The embedded WeKnora frontend loads root-relative routes (``/platform``,
|
||||
``/assets``, ``/locales`` and ``/api/v1/*``). Those requests do not carry
|
||||
DeerFlow's Authorization header, but the iframe bootstrap has already set a
|
||||
short-lived signed cookie that the proxy router validates against the
|
||||
requested knowledge base.
|
||||
"""
|
||||
|
||||
path = app_relative_path(request)
|
||||
if not is_weknora_embed_proxy_path(path):
|
||||
return False
|
||||
return has_valid_weknora_embed_cookie(request.cookies.get(WEKNORA_EMBED_COOKIE))
|
||||
|
||||
|
||||
# 「发起问答」的 run 创建接口(会真正调用大模型):
|
||||
@@ -114,12 +135,14 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# Match the application-relative path so a reverse-proxy prefix
|
||||
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
|
||||
if _is_public(app_relative_path(request)):
|
||||
return await call_next(request)
|
||||
|
||||
if is_auth_disabled():
|
||||
# Match the application-relative path so a reverse-proxy prefix
|
||||
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
|
||||
if _is_public(app_relative_path(request)):
|
||||
return await call_next(request)
|
||||
if _is_weknora_embed_proxy_request(request):
|
||||
return await call_next(request)
|
||||
|
||||
if is_auth_disabled():
|
||||
user = await get_assumed_user_for_disabled_auth()
|
||||
request.state.user = user
|
||||
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
|
||||
|
||||
@@ -7,16 +7,21 @@ State-changing operations require CSRF protection.
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from fastapi import Request, Response
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_COOKIE,
|
||||
has_valid_weknora_embed_cookie,
|
||||
is_weknora_embed_proxy_path,
|
||||
)
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
CSRF_COOKIE_NAME = "csrf_token"
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_TOKEN_LENGTH = 64 # bytes
|
||||
CSRF_COOKIE_NAME = "csrf_token"
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_TOKEN_LENGTH = 64 # bytes
|
||||
|
||||
|
||||
def is_secure_request(request: Request) -> bool:
|
||||
@@ -29,7 +34,7 @@ def generate_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
|
||||
|
||||
|
||||
def should_check_csrf(request: Request) -> bool:
|
||||
def should_check_csrf(request: Request) -> bool:
|
||||
"""Determine if a request needs CSRF validation.
|
||||
|
||||
CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH).
|
||||
@@ -54,9 +59,18 @@ def should_check_csrf(request: Request) -> bool:
|
||||
return False
|
||||
# 开放问答接口同样是匿名、无会话的(见 auth_middleware._PUBLIC_PATH_PREFIXES),
|
||||
# 双提交 Cookie 既无 token 可比、也无会话可被伪造,豁免之。
|
||||
if path.startswith("/api/open/"):
|
||||
return False
|
||||
return True
|
||||
if path.startswith("/api/open/"):
|
||||
return False
|
||||
if is_weknora_embed_proxy_request(request):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_weknora_embed_proxy_request(request: Request) -> bool:
|
||||
path = app_relative_path(request)
|
||||
if not is_weknora_embed_proxy_path(path):
|
||||
return False
|
||||
return has_valid_weknora_embed_cookie(request.cookies.get(WEKNORA_EMBED_COOKIE))
|
||||
|
||||
|
||||
_AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
|
||||
|
||||
@@ -619,27 +619,34 @@ _cached_local_provider: LocalAuthProvider | None = None
|
||||
_cached_repo: SQLiteUserRepository | None = None
|
||||
|
||||
|
||||
def get_request_access_token(request: Request) -> str | None:
|
||||
"""Extract access token from cookie or Authorization header.
|
||||
|
||||
Priority:
|
||||
1. ``access_token`` cookie (browser session mode)
|
||||
2. ``Authorization: Bearer <token>`` header (API client mode)
|
||||
"""
|
||||
cookie_token = request.cookies.get("access_token")
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
|
||||
authorization = request.headers.get("authorization", "").strip()
|
||||
if not authorization:
|
||||
return None
|
||||
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer":
|
||||
return None
|
||||
|
||||
token = token.strip()
|
||||
return token or None
|
||||
def get_request_access_token(request: Request) -> str | None:
|
||||
"""Extract access token from cookie or Authorization header.
|
||||
|
||||
Priority:
|
||||
1. ``access_token`` cookie (browser session mode)
|
||||
2. ``Authorization: Bearer <token>`` header (API client mode)
|
||||
3. ``access_token`` query parameter for the WeKnora iframe bootstrap only
|
||||
"""
|
||||
cookie_token = request.cookies.get("access_token")
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
|
||||
authorization = request.headers.get("authorization", "").strip()
|
||||
if authorization:
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer":
|
||||
token = token.strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
|
||||
path = app_relative_path(request).rstrip("/")
|
||||
if path.startswith("/api/llmwiki/knowledge-bases/") and path.endswith("/weknora/frame"):
|
||||
query_token = (request.query_params.get("access_token") or "").strip()
|
||||
if query_token:
|
||||
return query_token
|
||||
return None
|
||||
|
||||
|
||||
def get_local_provider() -> LocalAuthProvider:
|
||||
|
||||
@@ -14,6 +14,8 @@ from deerflow.persistence.llmwiki import LlmWikiStore
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONVERSATION_DEPOSIT_KB_NAME = "对话沉淀"
|
||||
CONVERSATION_DEPOSIT_OWNER_USER_ID = "system"
|
||||
CONVERSATION_DEPOSIT_KB_DESCRIPTION = "cmzs 全局问答沉淀知识库。所有用户可见,不默认参与问答检索。"
|
||||
_DEPOSIT_KB_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
@@ -59,7 +61,7 @@ def _title_for_turn(turn: ConversationTurnDeposit) -> str:
|
||||
def _format_markdown(turn: ConversationTurnDeposit, title: str) -> str:
|
||||
kb_line = "、".join(turn.knowledge_base_ids) if turn.knowledge_base_ids else "未显式选择知识库"
|
||||
return f"""---
|
||||
source: DeerFlow
|
||||
source: cmzs
|
||||
type: conversation-deposit
|
||||
thread_id: {turn.thread_id}
|
||||
human_message_id: {turn.human_message_id or ""}
|
||||
@@ -71,7 +73,7 @@ created_by_user_id: {turn.created_by_user_id or ""}
|
||||
|
||||
# {title}
|
||||
|
||||
> 本页由 DeerFlow 在一轮问答结束后自动沉淀,用于后续 LLMWiki 检索、Wiki 索引与图谱构建。
|
||||
> 本页由 cmzs 在一轮问答结束后自动沉淀,用于后续 LLMWiki 检索、Wiki 索引与图谱构建。
|
||||
|
||||
## 摘要
|
||||
|
||||
@@ -95,10 +97,114 @@ created_by_user_id: {turn.created_by_user_id or ""}
|
||||
|
||||
## LLMWiki 标签
|
||||
|
||||
#对话沉淀 #DeerFlow #问答知识
|
||||
#对话沉淀 #cmzs #问答知识
|
||||
"""
|
||||
|
||||
|
||||
async def _sync_conversation_deposit_description(
|
||||
store: LlmWikiStore,
|
||||
client: WeKnoraClient,
|
||||
row: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if str(row.get("description") or "") == CONVERSATION_DEPOSIT_KB_DESCRIPTION:
|
||||
return row
|
||||
try:
|
||||
await client.update_knowledge_base(
|
||||
str(row["weknora_id"]),
|
||||
{"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to update the conversation deposit knowledge-base description", exc_info=True)
|
||||
return row
|
||||
updated = await store.update_mapping(
|
||||
str(row["id"]),
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
)
|
||||
return updated or row
|
||||
|
||||
|
||||
async def ensure_conversation_deposit_mapping(
|
||||
store: LlmWikiStore,
|
||||
client: WeKnoraClient,
|
||||
*,
|
||||
remote_knowledge_bases: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
existing = await store.get_conversation_deposit_mapping()
|
||||
if existing is not None:
|
||||
return await _sync_conversation_deposit_description(store, client, existing)
|
||||
|
||||
async with _DEPOSIT_KB_LOCK:
|
||||
existing = await store.get_conversation_deposit_mapping()
|
||||
if existing is not None:
|
||||
return await _sync_conversation_deposit_description(store, client, existing)
|
||||
|
||||
remote_id = ""
|
||||
try:
|
||||
remote_items = remote_knowledge_bases
|
||||
if remote_items is None:
|
||||
remote_items = await client.list_knowledge_bases()
|
||||
for remote in remote_items:
|
||||
if str(remote.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME and remote.get("id"):
|
||||
remote_id = str(remote["id"])
|
||||
break
|
||||
except WeKnoraError:
|
||||
logger.debug("Could not scan WeKnora knowledge bases before creating deposit base", exc_info=True)
|
||||
|
||||
if remote_id:
|
||||
mapped = await store.get_by_weknora_id(remote_id)
|
||||
if mapped is not None:
|
||||
try:
|
||||
await client.update_knowledge_base(
|
||||
remote_id,
|
||||
{"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to update the existing conversation deposit knowledge-base description", exc_info=True)
|
||||
return await store.adopt_conversation_deposit_mapping(
|
||||
str(mapped["id"]),
|
||||
name=CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
)
|
||||
try:
|
||||
remote = await client.update_knowledge_base(
|
||||
remote_id,
|
||||
{"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to update the existing conversation deposit knowledge-base description", exc_info=True)
|
||||
remote = {
|
||||
"id": remote_id,
|
||||
"name": CONVERSATION_DEPOSIT_KB_NAME,
|
||||
"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
"type": "document",
|
||||
}
|
||||
else:
|
||||
try:
|
||||
remote = await client.create_knowledge_base(
|
||||
name=CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
kb_type="document",
|
||||
wiki_enabled=True,
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to create WeKnora conversation deposit knowledge base", exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
row = await store.create_mapping(
|
||||
weknora_id=str(remote["id"]),
|
||||
owner_user_id=CONVERSATION_DEPOSIT_OWNER_USER_ID,
|
||||
name=CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
kb_type="document",
|
||||
)
|
||||
published = await store.request_publish(str(row["id"]), CONVERSATION_DEPOSIT_OWNER_USER_ID, is_admin=True)
|
||||
return published or row
|
||||
except Exception:
|
||||
logger.exception("Failed to persist conversation deposit knowledge-base mapping")
|
||||
return None
|
||||
|
||||
|
||||
async def ensure_conversation_deposit_knowledge_base(
|
||||
app: Any,
|
||||
*,
|
||||
@@ -108,61 +214,7 @@ async def ensure_conversation_deposit_knowledge_base(
|
||||
client = _client(app)
|
||||
if store is None or client is None:
|
||||
return None
|
||||
|
||||
existing = await store.get_conversation_deposit_mapping()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
async with _DEPOSIT_KB_LOCK:
|
||||
existing = await store.get_conversation_deposit_mapping()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
remote_id = ""
|
||||
try:
|
||||
for remote in await client.list_knowledge_bases():
|
||||
if str(remote.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME and remote.get("id"):
|
||||
remote_id = str(remote["id"])
|
||||
break
|
||||
except WeKnoraError:
|
||||
logger.debug("Could not scan WeKnora knowledge bases before creating deposit base", exc_info=True)
|
||||
|
||||
if remote_id:
|
||||
mapped = await store.get_by_weknora_id(remote_id)
|
||||
if mapped is not None:
|
||||
if mapped.get("publication_status") != "published":
|
||||
return await store.request_publish(
|
||||
str(mapped["id"]),
|
||||
owner_user_id or "system",
|
||||
is_admin=True,
|
||||
)
|
||||
return mapped
|
||||
remote = {"id": remote_id, "name": CONVERSATION_DEPOSIT_KB_NAME, "description": "DeerFlow 全局问答沉淀知识库", "type": "document"}
|
||||
else:
|
||||
try:
|
||||
remote = await client.create_knowledge_base(
|
||||
name=CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description="DeerFlow 全局问答沉淀知识库。所有用户可见,不默认参与问答检索。",
|
||||
kb_type="document",
|
||||
wiki_enabled=True,
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to create WeKnora conversation deposit knowledge base", exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
row = await store.create_mapping(
|
||||
weknora_id=str(remote["id"]),
|
||||
owner_user_id=owner_user_id or "system",
|
||||
name=CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description=str(remote.get("description") or "DeerFlow 全局问答沉淀知识库"),
|
||||
kb_type="document",
|
||||
)
|
||||
published = await store.request_publish(str(row["id"]), owner_user_id or "system", is_admin=True)
|
||||
return published or row
|
||||
except Exception:
|
||||
logger.exception("Failed to persist conversation deposit knowledge-base mapping")
|
||||
return None
|
||||
return await ensure_conversation_deposit_mapping(store, client)
|
||||
|
||||
|
||||
async def deposit_conversation_turn(app: Any, turn: ConversationTurnDeposit) -> None:
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
"""DeerFlow-native LLMWiki BFF backed by an independently deployed WeKnora."""
|
||||
"""cmzs LLMWiki BFF backed by an independently deployed WeKnora."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import replace
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_agent_store, get_optional_user_from_request
|
||||
from app.gateway.llmwiki_deposit import ConversationTurnDeposit, deposit_conversation_turn
|
||||
from app.gateway.csrf_middleware import is_secure_request
|
||||
from app.gateway.deps import get_agent_store, get_optional_user_from_request, get_thread_store
|
||||
from app.gateway.llmwiki_deposit import (
|
||||
CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
CONVERSATION_DEPOSIT_KB_NAME,
|
||||
CONVERSATION_DEPOSIT_OWNER_USER_ID,
|
||||
ConversationTurnDeposit,
|
||||
deposit_conversation_turn,
|
||||
ensure_conversation_deposit_mapping,
|
||||
)
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_COOKIE as _WEKNORA_EMBED_COOKIE,
|
||||
WEKNORA_EMBED_API_PREFIX as _WEKNORA_EMBED_API_PREFIX,
|
||||
WEKNORA_EMBED_TTL_SECONDS as _WEKNORA_EMBED_TTL_SECONDS,
|
||||
WeKnoraEmbedContext,
|
||||
sign_weknora_embed_payload as _sign_embed_payload,
|
||||
verify_weknora_embed_cookie as _verify_embed_cookie,
|
||||
)
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
from deerflow.config.llmwiki_config import LlmWikiRuntimeOverride, normalize_service_url
|
||||
from deerflow.config.system_settings import load_system_settings, save_system_settings
|
||||
@@ -31,11 +44,9 @@ from deerflow.persistence.llmwiki import LlmWikiStore
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["llmwiki"])
|
||||
proxy_router = APIRouter(tags=["llmwiki-proxy"])
|
||||
|
||||
_MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
||||
_WEKNORA_EMBED_COOKIE = "deerflow_weknora_embed"
|
||||
_WEKNORA_EMBED_TTL_SECONDS = 15 * 60
|
||||
_WEKNORA_PROXY_SECRET = os.getenv("DEERFLOW_LLMWIKI_PROXY_SECRET") or secrets.token_urlsafe(48)
|
||||
_WEKNORA_ADMIN_SESSION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_HOP_BY_HOP_RESPONSE_HEADERS = {
|
||||
"connection",
|
||||
@@ -53,16 +64,6 @@ _HOP_BY_HOP_RESPONSE_HEADERS = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeKnoraEmbedContext:
|
||||
mapping_id: str
|
||||
weknora_id: str
|
||||
user_id: str
|
||||
is_admin: bool
|
||||
can_write: bool
|
||||
exp: int
|
||||
|
||||
|
||||
class LlmWikiSettingsUpdate(BaseModel):
|
||||
override_enabled: bool = True
|
||||
api_base_url: str = ""
|
||||
@@ -156,46 +157,6 @@ def _raise_upstream(exc: WeKnoraError) -> None:
|
||||
raise HTTPException(status_code=status, detail=str(exc)) from None
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64url_decode(data: str) -> bytes:
|
||||
padded = data + ("=" * (-len(data) % 4))
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
def _sign_embed_payload(payload: dict[str, Any]) -> str:
|
||||
body = _b64url_encode(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
||||
sig = hmac.new(_WEKNORA_PROXY_SECRET.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest()
|
||||
return f"{body}.{_b64url_encode(sig)}"
|
||||
|
||||
|
||||
def _verify_embed_cookie(raw: str | None) -> WeKnoraEmbedContext:
|
||||
if not raw or "." not in raw:
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is missing")
|
||||
body, sig = raw.rsplit(".", 1)
|
||||
expected = _b64url_encode(
|
||||
hmac.new(_WEKNORA_PROXY_SECRET.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest()
|
||||
)
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid")
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(body))
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid") from None
|
||||
if int(payload.get("exp") or 0) < int(time.time()):
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session has expired")
|
||||
return WeKnoraEmbedContext(
|
||||
mapping_id=str(payload.get("mapping_id") or ""),
|
||||
weknora_id=str(payload.get("weknora_id") or ""),
|
||||
user_id=str(payload.get("user_id") or ""),
|
||||
is_admin=bool(payload.get("is_admin")),
|
||||
can_write=bool(payload.get("can_write")),
|
||||
exp=int(payload.get("exp") or 0),
|
||||
)
|
||||
|
||||
|
||||
def _json_response(payload: Any, *, status_code: int = 200) -> Response:
|
||||
return Response(
|
||||
content=json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
|
||||
@@ -302,8 +263,14 @@ async def _read_embed_context(request: Request) -> WeKnoraEmbedContext:
|
||||
readable = await store.get_authorized(ctx.mapping_id, ctx.user_id, write=False, is_admin=ctx.is_admin)
|
||||
if readable is None or str(readable.get("weknora_id") or "") != ctx.weknora_id:
|
||||
raise HTTPException(status_code=403, detail="WeKnora iframe session is no longer authorized")
|
||||
writable = await store.get_authorized(ctx.mapping_id, ctx.user_id, write=True, is_admin=ctx.is_admin)
|
||||
return replace(ctx, can_write=writable is not None)
|
||||
writable = None
|
||||
if not _is_conversation_deposit_mapping(readable):
|
||||
writable = await store.get_authorized(ctx.mapping_id, ctx.user_id, write=True, is_admin=ctx.is_admin)
|
||||
return replace(
|
||||
ctx,
|
||||
can_write=writable is not None,
|
||||
is_conversation_deposit=_is_conversation_deposit_mapping(readable),
|
||||
)
|
||||
|
||||
|
||||
def _encode_upstream_path(path: str) -> str:
|
||||
@@ -418,7 +385,7 @@ async def _authorize_weknora_api_proxy(
|
||||
read_method = method in {"GET", "HEAD"}
|
||||
write_method = method in {"POST", "PUT", "PATCH", "DELETE"}
|
||||
if write_method and not ctx.can_write:
|
||||
raise HTTPException(status_code=403, detail="Current DeerFlow user cannot write this knowledge base")
|
||||
raise HTTPException(status_code=403, detail="Current cmzs user cannot write this knowledge base")
|
||||
|
||||
segments = _path_segments(path)
|
||||
if len(segments) < 2 or segments[0] != "api" or segments[1] != "v1":
|
||||
@@ -553,6 +520,7 @@ def _weknora_embed_injection(
|
||||
"tenantId": tenant_id,
|
||||
"user": user,
|
||||
"tenant": tenant,
|
||||
"apiPrefix": _WEKNORA_EMBED_API_PREFIX,
|
||||
}
|
||||
data = _escape_script_json(payload)
|
||||
return f"""
|
||||
@@ -567,19 +535,13 @@ html.deerflow-weknora-embed #app {{
|
||||
background: var(--td-bg-color-page) !important;
|
||||
color: var(--td-text-color-primary) !important;
|
||||
}}
|
||||
html.deerflow-weknora-embed:not(.deerflow-weknora-ready) body {{
|
||||
opacity: 0 !important;
|
||||
}}
|
||||
html.deerflow-weknora-embed body {{
|
||||
transition: opacity 120ms ease;
|
||||
opacity: 1 !important;
|
||||
}}
|
||||
html.deerflow-weknora-embed .main {{
|
||||
min-width: 0 !important;
|
||||
background: var(--td-bg-color-page) !important;
|
||||
}}
|
||||
html.deerflow-weknora-embed .main > :first-child:not(.platform-route-outlet) {{
|
||||
display: none !important;
|
||||
}}
|
||||
html.deerflow-weknora-embed .platform-route-outlet,
|
||||
html.deerflow-weknora-embed .knowledge-layout {{
|
||||
width: 100% !important;
|
||||
@@ -657,6 +619,28 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
||||
safeSessionSet(key, "1");
|
||||
}});
|
||||
|
||||
function rewriteApiUrl(value) {{
|
||||
try {{
|
||||
const next = new URL(value, location.origin);
|
||||
if (next.origin !== location.origin || !next.pathname.startsWith("/api/v1/")) return value;
|
||||
next.pathname = cfg.apiPrefix + next.pathname;
|
||||
return next.href;
|
||||
}} catch (_) {{
|
||||
return value;
|
||||
}}
|
||||
}}
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {{
|
||||
if (input instanceof Request) {{
|
||||
return nativeFetch(new Request(rewriteApiUrl(input.url), input), init);
|
||||
}}
|
||||
return nativeFetch(rewriteApiUrl(input), init);
|
||||
}};
|
||||
const nativeXhrOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {{
|
||||
return nativeXhrOpen.call(this, method, rewriteApiUrl(url), ...rest);
|
||||
}};
|
||||
|
||||
function tone(color, alpha) {{
|
||||
return `color-mix(in srgb, ${{color}} ${{Math.round(alpha * 100)}}%, transparent)`;
|
||||
}}
|
||||
@@ -830,6 +814,17 @@ async def _proxy_weknora_request(
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if is_api and request.method.upper() == "GET" and upstream_path.rstrip("/") == "/api/v1/knowledge-bases":
|
||||
content = _filter_knowledge_base_list_response(response, ctx) or content
|
||||
if is_api and ctx.is_conversation_deposit and "application/json" in content_type.lower():
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
else:
|
||||
content = json.dumps(
|
||||
_sanitize_conversation_deposit_value(payload),
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
if not is_api and "text/html" in content_type.lower():
|
||||
allowed_path = f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}"
|
||||
content = _inject_weknora_embed_html(content, allowed_path=allowed_path, tab=tab, session=session, ctx=ctx)
|
||||
@@ -845,18 +840,84 @@ async def _proxy_weknora_request(
|
||||
)
|
||||
|
||||
|
||||
def _is_conversation_deposit_mapping(row: dict[str, Any]) -> bool:
|
||||
return (
|
||||
str(row.get("owner_user_id") or "") == CONVERSATION_DEPOSIT_OWNER_USER_ID
|
||||
and str(row.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME
|
||||
)
|
||||
|
||||
|
||||
_LEGACY_BRAND_PATTERN = re.compile(r"deer[\s_-]*flow", re.IGNORECASE)
|
||||
|
||||
|
||||
def _sanitize_conversation_deposit_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return _LEGACY_BRAND_PATTERN.sub("cmzs", value)
|
||||
if isinstance(value, dict):
|
||||
return {key: _sanitize_conversation_deposit_value(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_sanitize_conversation_deposit_value(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_sanitize_conversation_deposit_value(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
async def _sync_conversation_deposit_view(
|
||||
store: LlmWikiStore,
|
||||
client: WeKnoraClient,
|
||||
row: dict[str, Any],
|
||||
remote: dict[str, Any] | None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
if not _is_conversation_deposit_mapping(row):
|
||||
return row, remote
|
||||
|
||||
current_remote = remote
|
||||
if remote is not None and str(remote.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION:
|
||||
try:
|
||||
updated_remote = await client.update_knowledge_base(
|
||||
str(row["weknora_id"]),
|
||||
{"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
current_remote = {
|
||||
**remote,
|
||||
**updated_remote,
|
||||
"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
}
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to synchronize the cmzs conversation-deposit description", exc_info=True)
|
||||
|
||||
current_row = row
|
||||
if str(row.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION:
|
||||
updated_row = await store.update_mapping(
|
||||
str(row["id"]),
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
)
|
||||
current_row = updated_row or row
|
||||
return current_row, current_remote
|
||||
|
||||
|
||||
def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_user_id: str, is_admin: bool) -> dict[str, Any]:
|
||||
source = remote or {}
|
||||
is_conversation_deposit = _is_conversation_deposit_mapping(row)
|
||||
is_owner = row.get("owner_user_id") == actor_user_id
|
||||
description = (
|
||||
CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
||||
if is_conversation_deposit
|
||||
else source.get("description")
|
||||
if source.get("description") is not None
|
||||
else row.get("description") or ""
|
||||
)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"remote_status": "available" if remote is not None else "missing",
|
||||
"name": source.get("name") or row.get("name") or "",
|
||||
"description": source.get("description") if source.get("description") is not None else row.get("description") or "",
|
||||
"description": description,
|
||||
"type": source.get("type") or row.get("kb_type") or "document",
|
||||
"publication_status": row.get("publication_status") or "private",
|
||||
"publication_status": "published" if is_conversation_deposit else row.get("publication_status") or "private",
|
||||
"owner_user_id": row.get("owner_user_id") if is_admin else None,
|
||||
"is_owner": is_admin or row.get("owner_user_id") == actor_user_id,
|
||||
"can_write": is_admin or row.get("owner_user_id") == actor_user_id,
|
||||
"is_owner": is_owner,
|
||||
"can_write": not is_conversation_deposit and (is_admin or is_owner),
|
||||
"is_conversation_deposit": is_conversation_deposit,
|
||||
"knowledge_count": int(source.get("knowledge_count") or 0),
|
||||
"chunk_count": int(source.get("chunk_count") or 0),
|
||||
"processing_count": int(source.get("processing_count") or 0),
|
||||
@@ -890,6 +951,8 @@ async def _authorized_mapping(
|
||||
row = await _store(request).get_authorized(mapping_id, user_id, write=write, is_admin=is_admin)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||
if write and _is_conversation_deposit_mapping(row):
|
||||
raise HTTPException(status_code=403, detail="Conversation deposit is managed by cmzs")
|
||||
return row, user_id, is_admin
|
||||
|
||||
|
||||
@@ -957,6 +1020,192 @@ def _chunk_view(chunk: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _string_tokens(value: Any) -> list[str]:
|
||||
tokens: list[str] = []
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if raw:
|
||||
tokens.append(raw)
|
||||
for separator in ("#", ":", "/", "\\"):
|
||||
if separator in raw:
|
||||
tokens.extend(part.strip() for part in raw.split(separator) if part.strip())
|
||||
elif isinstance(value, dict):
|
||||
for key in ("id", "chunk_id", "knowledge_id", "document_id", "source_id", "ref_id", "slug"):
|
||||
tokens.extend(_string_tokens(value.get(key)))
|
||||
return list(dict.fromkeys(tokens))
|
||||
|
||||
|
||||
def _source_ref_tokens(value: Any) -> set[str]:
|
||||
refs: set[str] = set()
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
refs.update(_string_tokens(item))
|
||||
else:
|
||||
refs.update(_string_tokens(value))
|
||||
return refs
|
||||
|
||||
|
||||
def _candidate_chunk_ref_tokens(chunk: dict[str, Any], chunk_id: str) -> set[str]:
|
||||
refs = {chunk_id, str(chunk.get("id") or "")}
|
||||
metadata = chunk.get("metadata") if isinstance(chunk.get("metadata"), dict) else {}
|
||||
for source in (chunk, metadata):
|
||||
for key in (
|
||||
"knowledge_id",
|
||||
"document_id",
|
||||
"source_id",
|
||||
"source_document_id",
|
||||
"knowledge_uuid",
|
||||
"file_id",
|
||||
):
|
||||
refs.update(_string_tokens(source.get(key)))
|
||||
return {item for item in refs if item}
|
||||
|
||||
|
||||
def _candidate_wiki_slugs(chunk: dict[str, Any]) -> list[str]:
|
||||
metadata = chunk.get("metadata") if isinstance(chunk.get("metadata"), dict) else {}
|
||||
candidates: list[str] = []
|
||||
for source in (chunk, metadata):
|
||||
for key in ("wiki_page_slug", "page_slug", "wiki_slug", "llmwiki_slug", "slug"):
|
||||
candidates.extend(_string_tokens(source.get(key)))
|
||||
nested_wiki = metadata.get("wiki")
|
||||
if isinstance(nested_wiki, dict):
|
||||
for key in ("slug", "page_slug"):
|
||||
candidates.extend(_string_tokens(nested_wiki.get(key)))
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _wiki_page_view(page: dict[str, Any]) -> dict[str, Any] | None:
|
||||
slug = str(page.get("slug") or "").strip()
|
||||
title = str(page.get("title") or slug or "").strip()
|
||||
content = str(page.get("content") or "").strip()
|
||||
summary = str(page.get("summary") or "").strip()
|
||||
if not slug and not title and not content and not summary:
|
||||
return None
|
||||
return {
|
||||
"id": str(page.get("id") or ""),
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"page_type": str(page.get("page_type") or "page"),
|
||||
"status": str(page.get("status") or ""),
|
||||
"summary": summary,
|
||||
"content": content,
|
||||
"source_refs": list(page.get("source_refs") or []) if isinstance(page.get("source_refs"), list) else [],
|
||||
}
|
||||
|
||||
|
||||
def _weknora_embed_target_path(weknora_id: str, tab: str) -> str:
|
||||
target_query = urlencode({"tab": tab}) if tab != "documents" else ""
|
||||
target = f"/platform/knowledge-bases/{quote(weknora_id, safe='')}"
|
||||
if target_query:
|
||||
target = f"{target}?{target_query}"
|
||||
return target
|
||||
|
||||
|
||||
def _set_weknora_embed_cookie(request: Request, response: Response, payload: dict[str, Any]) -> None:
|
||||
secure = is_secure_request(request)
|
||||
response.set_cookie(
|
||||
_WEKNORA_EMBED_COOKIE,
|
||||
_sign_embed_payload(payload),
|
||||
max_age=_WEKNORA_EMBED_TTL_SECONDS,
|
||||
httponly=True,
|
||||
secure=secure,
|
||||
samesite="none" if secure else "lax",
|
||||
path="/",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
|
||||
async def _find_wiki_page_for_chunk(
|
||||
client: WeKnoraClient,
|
||||
*,
|
||||
knowledge_base_id: str,
|
||||
chunk_id: str,
|
||||
chunks: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
focus = next((chunk for chunk in chunks if str(chunk.get("id") or "") == chunk_id), None)
|
||||
if focus is None:
|
||||
return None
|
||||
|
||||
for slug in _candidate_wiki_slugs(focus):
|
||||
try:
|
||||
page = await client.get_wiki_page(knowledge_base_id, slug)
|
||||
except WeKnoraError as exc:
|
||||
if exc.status_code != 404:
|
||||
logger.debug("Failed to read WeKnora wiki page slug=%s", slug, exc_info=True)
|
||||
continue
|
||||
view = _wiki_page_view(page)
|
||||
if view and (view.get("content") or view.get("summary")):
|
||||
return view
|
||||
|
||||
candidate_refs = _candidate_chunk_ref_tokens(focus, chunk_id)
|
||||
query = str(focus.get("knowledge_title") or focus.get("title") or focus.get("file_name") or "").strip()
|
||||
try:
|
||||
page_list = await client.list_wiki_pages(knowledge_base_id, page=1, page_size=100, query=query)
|
||||
except WeKnoraError as exc:
|
||||
if exc.status_code != 404:
|
||||
logger.debug("Failed to list WeKnora wiki pages for source preview", exc_info=True)
|
||||
return None
|
||||
|
||||
for page in page_list.get("pages", []):
|
||||
if not isinstance(page, dict):
|
||||
continue
|
||||
source_refs = _source_ref_tokens(page.get("source_refs"))
|
||||
if not source_refs or not candidate_refs.intersection(source_refs):
|
||||
continue
|
||||
slug = str(page.get("slug") or "").strip()
|
||||
try:
|
||||
full_page = await client.get_wiki_page(knowledge_base_id, slug) if slug else page
|
||||
except WeKnoraError as exc:
|
||||
if exc.status_code != 404:
|
||||
logger.debug("Failed to hydrate WeKnora wiki page slug=%s", slug, exc_info=True)
|
||||
full_page = page
|
||||
view = _wiki_page_view(full_page)
|
||||
if view and (view.get("content") or view.get("summary")):
|
||||
return view
|
||||
return None
|
||||
|
||||
|
||||
async def _prepare_weknora_embed_session(
|
||||
request: Request,
|
||||
mapping_id: str,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
_runtime_for_iframe_proxy(request)
|
||||
row, user_id, is_admin = await _authorized_mapping(request, mapping_id, write=False)
|
||||
writable = None
|
||||
if not _is_conversation_deposit_mapping(row):
|
||||
writable = await _store(request).get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
|
||||
weknora_id = str(row["weknora_id"])
|
||||
return weknora_id, {
|
||||
"mapping_id": mapping_id,
|
||||
"weknora_id": weknora_id,
|
||||
"user_id": user_id,
|
||||
"is_admin": is_admin,
|
||||
"can_write": writable is not None,
|
||||
"is_conversation_deposit": _is_conversation_deposit_mapping(row),
|
||||
"exp": int(time.time()) + _WEKNORA_EMBED_TTL_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/weknora/session")
|
||||
async def create_weknora_detail_session(
|
||||
request: Request,
|
||||
mapping_id: str,
|
||||
tab: Literal["documents", "wiki", "graph"] = Query(default="wiki"),
|
||||
) -> JSONResponse:
|
||||
"""Create a signed iframe session using the normal DeerFlow API auth path."""
|
||||
|
||||
weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id)
|
||||
target = _weknora_embed_target_path(weknora_id, tab)
|
||||
response = JSONResponse(
|
||||
{
|
||||
"frame_path": target,
|
||||
"expires_in": _WEKNORA_EMBED_TTL_SECONDS,
|
||||
}
|
||||
)
|
||||
_set_weknora_embed_cookie(request, response, payload)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/weknora/frame")
|
||||
async def open_weknora_detail_frame(
|
||||
request: Request,
|
||||
@@ -965,38 +1214,15 @@ async def open_weknora_detail_frame(
|
||||
) -> RedirectResponse:
|
||||
"""Start a short-lived, DeerFlow-authorized WeKnora detail iframe session."""
|
||||
|
||||
_runtime_for_iframe_proxy(request)
|
||||
row, user_id, is_admin = await _authorized_mapping(request, mapping_id, write=False)
|
||||
writable = await _store(request).get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
|
||||
weknora_id = str(row["weknora_id"])
|
||||
payload = {
|
||||
"mapping_id": mapping_id,
|
||||
"weknora_id": weknora_id,
|
||||
"user_id": user_id,
|
||||
"is_admin": is_admin,
|
||||
"can_write": writable is not None,
|
||||
"exp": int(time.time()) + _WEKNORA_EMBED_TTL_SECONDS,
|
||||
}
|
||||
target_query = urlencode({"tab": tab}) if tab != "documents" else ""
|
||||
target = f"/platform/knowledge-bases/{quote(weknora_id, safe='')}"
|
||||
if target_query:
|
||||
target = f"{target}?{target_query}"
|
||||
weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id)
|
||||
target = _weknora_embed_target_path(weknora_id, tab)
|
||||
response = RedirectResponse(target, status_code=302)
|
||||
response.set_cookie(
|
||||
_WEKNORA_EMBED_COOKIE,
|
||||
_sign_embed_payload(payload),
|
||||
max_age=_WEKNORA_EMBED_TTL_SECONDS,
|
||||
httponly=True,
|
||||
secure=request.url.scheme == "https",
|
||||
samesite="none" if request.url.scheme == "https" else "lax",
|
||||
path="/",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
_set_weknora_embed_cookie(request, response, payload)
|
||||
return response
|
||||
|
||||
|
||||
@router.api_route("/platform", methods=["GET", "HEAD"])
|
||||
@router.api_route("/platform/{proxied_path:path}", methods=["GET", "HEAD"])
|
||||
@proxy_router.api_route("/platform", methods=["GET", "HEAD"])
|
||||
@proxy_router.api_route("/platform/{proxied_path:path}", methods=["GET", "HEAD"])
|
||||
async def proxy_weknora_platform_page(request: Request, proxied_path: str = "") -> Response:
|
||||
ctx = await _read_embed_context(request)
|
||||
requested = f"/platform/{proxied_path}".rstrip("/")
|
||||
@@ -1015,7 +1241,7 @@ async def proxy_weknora_platform_page(request: Request, proxied_path: str = "")
|
||||
)
|
||||
|
||||
|
||||
@router.api_route("/assets/{proxied_path:path}", methods=["GET", "HEAD"])
|
||||
@proxy_router.api_route("/assets/{proxied_path:path}", methods=["GET", "HEAD"])
|
||||
async def proxy_weknora_assets(request: Request, proxied_path: str) -> Response:
|
||||
ctx = await _read_embed_context(request)
|
||||
runtime = _runtime_for_iframe_proxy(request)
|
||||
@@ -1028,7 +1254,7 @@ async def proxy_weknora_assets(request: Request, proxied_path: str) -> Response:
|
||||
)
|
||||
|
||||
|
||||
@router.api_route("/locales/{proxied_path:path}", methods=["GET", "HEAD"])
|
||||
@proxy_router.api_route("/locales/{proxied_path:path}", methods=["GET", "HEAD"])
|
||||
async def proxy_weknora_locales(request: Request, proxied_path: str) -> Response:
|
||||
ctx = await _read_embed_context(request)
|
||||
runtime = _runtime_for_iframe_proxy(request)
|
||||
@@ -1041,10 +1267,11 @@ async def proxy_weknora_locales(request: Request, proxied_path: str) -> Response
|
||||
)
|
||||
|
||||
|
||||
@router.api_route("/favicon.ico", methods=["GET", "HEAD"])
|
||||
@router.api_route("/favicon.svg", methods=["GET", "HEAD"])
|
||||
@router.api_route("/logo.svg", methods=["GET", "HEAD"])
|
||||
@router.api_route("/manifest.webmanifest", methods=["GET", "HEAD"])
|
||||
@proxy_router.api_route("/favicon.ico", methods=["GET", "HEAD"])
|
||||
@proxy_router.api_route("/favicon.svg", methods=["GET", "HEAD"])
|
||||
@proxy_router.api_route("/config.js", methods=["GET", "HEAD"])
|
||||
@proxy_router.api_route("/logo.svg", methods=["GET", "HEAD"])
|
||||
@proxy_router.api_route("/manifest.webmanifest", methods=["GET", "HEAD"])
|
||||
async def proxy_weknora_root_asset(request: Request) -> Response:
|
||||
ctx = await _read_embed_context(request)
|
||||
runtime = _runtime_for_iframe_proxy(request)
|
||||
@@ -1057,7 +1284,20 @@ async def proxy_weknora_root_asset(request: Request) -> Response:
|
||||
)
|
||||
|
||||
|
||||
@router.api_route(
|
||||
@proxy_router.api_route("/tdesign-icons/{proxied_path:path}", methods=["GET", "HEAD"])
|
||||
async def proxy_weknora_tdesign_icons(request: Request, proxied_path: str) -> Response:
|
||||
ctx = await _read_embed_context(request)
|
||||
runtime = _runtime_for_iframe_proxy(request)
|
||||
return await _proxy_weknora_request(
|
||||
request,
|
||||
target_base_url=runtime.web_base_url,
|
||||
upstream_path=f"/tdesign-icons/{proxied_path}",
|
||||
ctx=ctx,
|
||||
is_api=False,
|
||||
)
|
||||
|
||||
|
||||
@proxy_router.api_route(
|
||||
"/api/v1/{proxied_path:path}",
|
||||
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
)
|
||||
@@ -1075,6 +1315,26 @@ async def proxy_weknora_api(request: Request, proxied_path: str) -> Response:
|
||||
)
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}",
|
||||
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
)
|
||||
async def proxy_weknora_embed_api(request: Request, proxied_path: str) -> Response:
|
||||
"""Proxy iframe API calls under a collision-free cmzs namespace."""
|
||||
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return Response(status_code=204)
|
||||
ctx = await _read_embed_context(request)
|
||||
runtime = _runtime_for_iframe_proxy(request)
|
||||
return await _proxy_weknora_request(
|
||||
request,
|
||||
target_base_url=runtime.api_base_url,
|
||||
upstream_path=f"/api/v1/{proxied_path}",
|
||||
ctx=ctx,
|
||||
is_api=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/llmwiki/runtime")
|
||||
async def get_llmwiki_runtime() -> dict[str, Any]:
|
||||
return _runtime_or_legacy()
|
||||
@@ -1145,14 +1405,42 @@ async def list_knowledge_bases(
|
||||
) -> dict[str, Any]:
|
||||
client = _client_or_503()
|
||||
user_id, is_admin = await _actor(request)
|
||||
rows = await _store(request).list_visible(user_id, scope=scope, is_admin=is_admin and scope == "personal")
|
||||
try:
|
||||
remote = {str(item.get("id")): item for item in await client.list_knowledge_bases() if item.get("id")}
|
||||
remote_items = await client.list_knowledge_bases()
|
||||
remote = {str(item.get("id")): item for item in remote_items if item.get("id")}
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
deposit_remote = next(
|
||||
(
|
||||
item
|
||||
for item in remote_items
|
||||
if str(item.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME
|
||||
),
|
||||
None,
|
||||
)
|
||||
if deposit_remote is not None:
|
||||
previous_mapping = await _store(request).get_by_weknora_id(str(deposit_remote.get("id") or ""))
|
||||
deposit_mapping = await ensure_conversation_deposit_mapping(
|
||||
_store(request),
|
||||
client,
|
||||
remote_knowledge_bases=remote_items,
|
||||
)
|
||||
description_was_synchronized = (
|
||||
previous_mapping is None
|
||||
or not _is_conversation_deposit_mapping(previous_mapping)
|
||||
or str(previous_mapping.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
||||
)
|
||||
if deposit_mapping is not None and description_was_synchronized:
|
||||
remote_id = str(deposit_mapping["weknora_id"])
|
||||
remote[remote_id] = {
|
||||
**remote.get(remote_id, deposit_remote),
|
||||
"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
}
|
||||
rows = await _store(request).list_visible(user_id, scope=scope, is_admin=False)
|
||||
knowledge_bases: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
remote_item = remote.get(str(row["weknora_id"]))
|
||||
row, remote_item = await _sync_conversation_deposit_view(_store(request), client, row, remote_item)
|
||||
# Owners need to see stale personal mappings so they can clean them up.
|
||||
# Public/selector views must never expose a resource that no longer exists
|
||||
# in WeKnora.
|
||||
@@ -1197,6 +1485,8 @@ async def list_agent_knowledge_bases(request: Request, agent_id: str) -> dict[st
|
||||
async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) -> dict[str, Any]:
|
||||
client = _client_or_503()
|
||||
user_id, is_admin = await _actor(request)
|
||||
if body.name.strip() == CONVERSATION_DEPOSIT_KB_NAME:
|
||||
raise HTTPException(status_code=409, detail="对话沉淀是 cmzs 系统保留知识库")
|
||||
try:
|
||||
remote = await client.create_knowledge_base(
|
||||
name=body.name.strip(),
|
||||
@@ -1227,10 +1517,12 @@ async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) ->
|
||||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}")
|
||||
async def get_knowledge_base(request: Request, mapping_id: str) -> dict[str, Any]:
|
||||
row, user_id, is_admin = await _authorized_mapping(request, mapping_id, write=False)
|
||||
client = _client_or_503()
|
||||
try:
|
||||
remote = await _client_or_503().get_knowledge_base(str(row["weknora_id"]))
|
||||
remote = await client.get_knowledge_base(str(row["weknora_id"]))
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
row, remote = await _sync_conversation_deposit_view(_store(request), client, row, remote)
|
||||
return _mapping_view(row, remote, actor_user_id=user_id, is_admin=is_admin)
|
||||
|
||||
|
||||
@@ -1394,7 +1686,7 @@ async def upload_document(request: Request, mapping_id: str, file: UploadFile =
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||
content = await file.read(_MAX_UPLOAD_BYTES + 1)
|
||||
if len(content) > _MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File exceeds the 100 MB DeerFlow upload limit")
|
||||
raise HTTPException(status_code=413, detail="File exceeds the 100 MB cmzs upload limit")
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=422, detail="File name is required")
|
||||
try:
|
||||
@@ -1467,7 +1759,7 @@ async def search_llmwiki(request: Request, body: SearchRequest) -> dict[str, Any
|
||||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||
rows.append(row)
|
||||
else:
|
||||
rows = await store.list_visible(user_id, scope=body.scope, is_admin=is_admin and body.scope == "personal")
|
||||
rows = await store.list_visible(user_id, scope=body.scope, is_admin=False)
|
||||
try:
|
||||
results = await _client_or_503().search(body.query, [str(row["weknora_id"]) for row in rows])
|
||||
except WeKnoraError as exc:
|
||||
@@ -1478,10 +1770,15 @@ async def search_llmwiki(request: Request, body: SearchRequest) -> dict[str, Any
|
||||
mapping = local_by_remote.get(str(result.get("knowledge_base_id") or ""))
|
||||
if mapping is None:
|
||||
continue
|
||||
params = urlencode({"knowledge_base_id": mapping["id"], "chunk_id": result.get("chunk_id") or ""})
|
||||
visible_result = (
|
||||
_sanitize_conversation_deposit_value(result)
|
||||
if _is_conversation_deposit_mapping(mapping)
|
||||
else result
|
||||
)
|
||||
params = urlencode({"knowledge_base_id": mapping["id"], "chunk_id": visible_result.get("chunk_id") or ""})
|
||||
visible.append(
|
||||
{
|
||||
**result,
|
||||
**visible_result,
|
||||
"knowledge_base_id": mapping["id"],
|
||||
"knowledge_base_name": mapping.get("name") or "",
|
||||
"source": mapping.get("name") or "LLMWiki",
|
||||
@@ -1501,6 +1798,8 @@ async def create_conversation_deposit(
|
||||
if not runtime.weknora_enabled:
|
||||
return {"queued": False, "provider": runtime.provider}
|
||||
user_id, _ = await _actor(request)
|
||||
if not await get_thread_store(request).check_access(body.thread_id, user_id, require_existing=True):
|
||||
raise HTTPException(status_code=404, detail="Thread not found")
|
||||
app = request.app
|
||||
turn = ConversationTurnDeposit(
|
||||
thread_id=body.thread_id,
|
||||
@@ -1527,6 +1826,8 @@ async def request_publish(request: Request, mapping_id: str) -> dict[str, Any]:
|
||||
)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||
if _is_conversation_deposit_mapping(existing) and existing.get("publication_status") == "published":
|
||||
return {"id": existing["id"], "publication_status": "published"}
|
||||
try:
|
||||
await _client_or_503().get_knowledge_base(str(existing["weknora_id"]))
|
||||
except WeKnoraError as exc:
|
||||
@@ -1540,6 +1841,11 @@ async def request_publish(request: Request, mapping_id: str) -> dict[str, Any]:
|
||||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/unpublish")
|
||||
async def unpublish(request: Request, mapping_id: str) -> dict[str, Any]:
|
||||
user_id, is_admin = await _actor(request)
|
||||
existing = await _store(request).get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||
if _is_conversation_deposit_mapping(existing):
|
||||
raise HTTPException(status_code=403, detail="Conversation deposit is always public")
|
||||
row = await _store(request).unpublish(mapping_id, user_id, is_admin=is_admin)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||
@@ -1553,19 +1859,33 @@ async def get_source_context(
|
||||
chunk_id: str = Query(..., min_length=1, max_length=128),
|
||||
) -> dict[str, Any]:
|
||||
row, _, _ = await _authorized_mapping(request, knowledge_base_id, write=False)
|
||||
client = _client_or_503()
|
||||
try:
|
||||
chunks = await _client_or_503().get_chunk_context(
|
||||
chunks = await client.get_chunk_context(
|
||||
chunk_id,
|
||||
expected_knowledge_base_id=str(row["weknora_id"]),
|
||||
radius=2,
|
||||
)
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
wiki_page = await _find_wiki_page_for_chunk(
|
||||
client,
|
||||
knowledge_base_id=str(row["weknora_id"]),
|
||||
chunk_id=chunk_id,
|
||||
chunks=chunks,
|
||||
)
|
||||
sanitize_deposit = _is_conversation_deposit_mapping(row)
|
||||
if sanitize_deposit and wiki_page is not None:
|
||||
wiki_page = _sanitize_conversation_deposit_value(wiki_page)
|
||||
normalized_chunks = [
|
||||
{
|
||||
"id": str(chunk.get("id") or ""),
|
||||
"chunk_index": int(chunk.get("chunk_index") or 0),
|
||||
"content": str(chunk.get("content") or ""),
|
||||
"content": str(
|
||||
_sanitize_conversation_deposit_value(chunk.get("content") or "")
|
||||
if sanitize_deposit
|
||||
else chunk.get("content") or ""
|
||||
),
|
||||
"is_focus": str(chunk.get("id") or "") == chunk_id,
|
||||
}
|
||||
for chunk in chunks
|
||||
@@ -1577,6 +1897,8 @@ async def get_source_context(
|
||||
"knowledge_base_id": row["id"],
|
||||
"knowledge_base_name": row.get("name") or "",
|
||||
"focus_chunk_id": chunk_id,
|
||||
"display_mode": "wiki" if wiki_page is not None else "chunks",
|
||||
"wiki_page": wiki_page,
|
||||
"chunks": normalized_chunks,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Shared helpers for WeKnora iframe proxy sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
WEKNORA_EMBED_COOKIE = "deerflow_weknora_embed"
|
||||
WEKNORA_EMBED_TTL_SECONDS = 15 * 60
|
||||
WEKNORA_EMBED_API_PREFIX = "/api/llmwiki/weknora-embed"
|
||||
WEKNORA_PROXY_SECRET = os.getenv("DEERFLOW_LLMWIKI_PROXY_SECRET") or secrets.token_urlsafe(48)
|
||||
WEKNORA_ROOT_ASSET_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/favicon.ico",
|
||||
"/favicon.svg",
|
||||
"/config.js",
|
||||
"/logo.svg",
|
||||
"/manifest.webmanifest",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeKnoraEmbedContext:
|
||||
mapping_id: str
|
||||
weknora_id: str
|
||||
user_id: str
|
||||
is_admin: bool
|
||||
can_write: bool
|
||||
exp: int
|
||||
is_conversation_deposit: bool = False
|
||||
|
||||
|
||||
def is_weknora_embed_proxy_path(path: str) -> bool:
|
||||
return (
|
||||
path == "/platform"
|
||||
or path.startswith("/platform/")
|
||||
or path.startswith("/assets/")
|
||||
or path.startswith("/locales/")
|
||||
or path.startswith("/tdesign-icons/")
|
||||
or path in WEKNORA_ROOT_ASSET_PATHS
|
||||
or path.startswith(f"{WEKNORA_EMBED_API_PREFIX}/api/v1/")
|
||||
or (path.startswith("/api/v1/") and not path.startswith("/api/v1/auth/"))
|
||||
)
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64url_decode(data: str) -> bytes:
|
||||
padded = data + ("=" * (-len(data) % 4))
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
def sign_weknora_embed_payload(payload: dict[str, Any]) -> str:
|
||||
body = _b64url_encode(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
||||
sig = hmac.new(WEKNORA_PROXY_SECRET.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest()
|
||||
return f"{body}.{_b64url_encode(sig)}"
|
||||
|
||||
|
||||
def verify_weknora_embed_cookie(raw: str | None) -> WeKnoraEmbedContext:
|
||||
if not raw or "." not in raw:
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is missing")
|
||||
body, sig = raw.rsplit(".", 1)
|
||||
expected = _b64url_encode(
|
||||
hmac.new(WEKNORA_PROXY_SECRET.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest()
|
||||
)
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid")
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(body))
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid") from None
|
||||
if int(payload.get("exp") or 0) < int(time.time()):
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session has expired")
|
||||
return WeKnoraEmbedContext(
|
||||
mapping_id=str(payload.get("mapping_id") or ""),
|
||||
weknora_id=str(payload.get("weknora_id") or ""),
|
||||
user_id=str(payload.get("user_id") or ""),
|
||||
is_admin=bool(payload.get("is_admin")),
|
||||
can_write=bool(payload.get("can_write")),
|
||||
exp=int(payload.get("exp") or 0),
|
||||
is_conversation_deposit=bool(payload.get("is_conversation_deposit")),
|
||||
)
|
||||
|
||||
|
||||
def has_valid_weknora_embed_cookie(raw: str | None) -> bool:
|
||||
try:
|
||||
verify_weknora_embed_cookie(raw)
|
||||
except HTTPException:
|
||||
return False
|
||||
return True
|
||||
+8
-1
@@ -206,11 +206,18 @@ class WeKnoraClient:
|
||||
return dict(value)
|
||||
|
||||
async def update_knowledge_base(self, knowledge_base_id: str, changes: Mapping[str, Any]) -> dict[str, Any]:
|
||||
payload = dict(changes)
|
||||
if not str(payload.get("name") or "").strip():
|
||||
current = await self.get_knowledge_base(knowledge_base_id)
|
||||
current_name = str(current.get("name") or "").strip()
|
||||
if not current_name:
|
||||
raise WeKnoraError("WeKnora knowledge base name is required for updates")
|
||||
payload["name"] = current_name
|
||||
value = _data(
|
||||
await self._request(
|
||||
"PUT",
|
||||
f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}",
|
||||
json=dict(changes),
|
||||
json=payload,
|
||||
)
|
||||
)
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
@@ -24,6 +24,15 @@ class LlmWikiStore(ABC):
|
||||
@abstractmethod
|
||||
async def update_mapping(self, mapping_id: str, *, name: str | None = None, description: str | None = None) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def adopt_conversation_deposit_mapping(
|
||||
self,
|
||||
mapping_id: str,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def delete_mapping(self, mapping_id: str) -> bool: ...
|
||||
|
||||
|
||||
+30
-1
@@ -76,6 +76,31 @@ class MemoryLlmWikiStore(LlmWikiStore):
|
||||
row["updated_at"] = _now()
|
||||
return dict(row)
|
||||
|
||||
async def adopt_conversation_deposit_mapping(
|
||||
self,
|
||||
mapping_id: str,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
) -> dict[str, Any] | None:
|
||||
row = self._rows.get(mapping_id)
|
||||
if row is None:
|
||||
return None
|
||||
now = _now()
|
||||
row.update(
|
||||
{
|
||||
"owner_user_id": "system",
|
||||
"name": name,
|
||||
"description": description,
|
||||
"publication_status": "published",
|
||||
"published_by": "system",
|
||||
"reviewed_by": None,
|
||||
"published_at": row.get("published_at") or now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
async def delete_mapping(self, mapping_id: str) -> bool:
|
||||
return self._rows.pop(mapping_id, None) is not None
|
||||
|
||||
@@ -118,7 +143,11 @@ class MemoryLlmWikiStore(LlmWikiStore):
|
||||
|
||||
async def get_conversation_deposit_mapping(self) -> dict[str, Any] | None:
|
||||
for row in self._rows.values():
|
||||
if row.get("name") == "对话沉淀" and row.get("publication_status") == "published":
|
||||
if (
|
||||
row.get("owner_user_id") == "system"
|
||||
and row.get("name") == "对话沉淀"
|
||||
and row.get("publication_status") == "published"
|
||||
):
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
@@ -95,6 +95,29 @@ class LlmWikiRepository(LlmWikiStore):
|
||||
await session.commit()
|
||||
return _to_dict(row)
|
||||
|
||||
async def adopt_conversation_deposit_mapping(
|
||||
self,
|
||||
mapping_id: str,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
||||
if row is None:
|
||||
return None
|
||||
now = _now()
|
||||
row.owner_user_id = "system"
|
||||
row.name = name
|
||||
row.description = description
|
||||
row.publication_status = "published"
|
||||
row.published_by = "system"
|
||||
row.reviewed_by = None
|
||||
row.published_at = row.published_at or now
|
||||
row.updated_at = now
|
||||
await session.commit()
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_mapping(self, mapping_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
||||
@@ -155,6 +178,7 @@ class LlmWikiRepository(LlmWikiStore):
|
||||
stmt = (
|
||||
select(LlmWikiKnowledgeBaseRow)
|
||||
.where(
|
||||
LlmWikiKnowledgeBaseRow.owner_user_id == "system",
|
||||
LlmWikiKnowledgeBaseRow.name == "对话沉淀",
|
||||
LlmWikiKnowledgeBaseRow.publication_status == "published",
|
||||
)
|
||||
|
||||
+38
-8
@@ -1,8 +1,9 @@
|
||||
"""Authorized read-only WeKnora retrieval for DeerFlow Q&A."""
|
||||
"""Authorized read-only WeKnora retrieval for cmzs Q&A."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
@@ -17,6 +18,30 @@ from deerflow.integrations.weknora.runtime import build_weknora_client, get_llmw
|
||||
from deerflow.runtime.user_context import get_current_user, get_effective_user_id
|
||||
|
||||
|
||||
_CONVERSATION_DEPOSIT_NAME = "对话沉淀"
|
||||
_CONVERSATION_DEPOSIT_OWNER_USER_ID = "system"
|
||||
_LEGACY_BRAND_PATTERN = re.compile(r"deer[\s_-]*flow", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_conversation_deposit_mapping(row: Mapping[str, Any]) -> bool:
|
||||
return (
|
||||
str(row.get("owner_user_id") or "") == _CONVERSATION_DEPOSIT_OWNER_USER_ID
|
||||
and str(row.get("name") or "").strip() == _CONVERSATION_DEPOSIT_NAME
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_conversation_deposit_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return _LEGACY_BRAND_PATTERN.sub("cmzs", value)
|
||||
if isinstance(value, dict):
|
||||
return {key: _sanitize_conversation_deposit_value(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_sanitize_conversation_deposit_value(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_sanitize_conversation_deposit_value(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def selected_knowledge_base_ids(context: Mapping[str, Any] | None) -> list[str] | None:
|
||||
"""Read the conversation selection; ``None`` means legacy/no selection key."""
|
||||
|
||||
@@ -84,7 +109,7 @@ async def llmwiki_search_tool(
|
||||
return f"Knowledge base {mapping_id!r} is not available to the current user."
|
||||
rows.append(row)
|
||||
else:
|
||||
rows = await store.list_visible(user_id, scope="all", is_admin=is_admin)
|
||||
rows = await store.list_visible(user_id, scope="all", is_admin=False)
|
||||
if not rows:
|
||||
return "No authorized LLMWiki knowledge bases are available."
|
||||
|
||||
@@ -102,22 +127,27 @@ async def llmwiki_search_tool(
|
||||
mapping = local_by_remote.get(str(result.get("knowledge_base_id") or ""))
|
||||
if mapping is None:
|
||||
continue
|
||||
visible_result = (
|
||||
_sanitize_conversation_deposit_value(result)
|
||||
if _is_conversation_deposit_mapping(mapping)
|
||||
else result
|
||||
)
|
||||
params = urlencode(
|
||||
{
|
||||
"knowledge_base_id": mapping["id"],
|
||||
"chunk_id": result.get("chunk_id") or "",
|
||||
"chunk_id": visible_result.get("chunk_id") or "",
|
||||
}
|
||||
)
|
||||
citations.append(
|
||||
{
|
||||
"title": result.get("title") or result.get("filename") or mapping.get("name") or "LLMWiki source",
|
||||
"content": result.get("content") or "",
|
||||
"score": result.get("score") or 0,
|
||||
"title": visible_result.get("title") or visible_result.get("filename") or mapping.get("name") or "LLMWiki source",
|
||||
"content": visible_result.get("content") or "",
|
||||
"score": visible_result.get("score") or 0,
|
||||
"knowledge_base_id": mapping["id"],
|
||||
"knowledge_base_name": mapping.get("name") or "",
|
||||
"source": mapping.get("name") or "LLMWiki",
|
||||
"knowledge_id": result.get("knowledge_id") or "",
|
||||
"chunk_id": result.get("chunk_id") or "",
|
||||
"knowledge_id": visible_result.get("knowledge_id") or "",
|
||||
"chunk_id": visible_result.get("chunk_id") or "",
|
||||
"url": f"/weknora-source-preview?{params}",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from starlette.datastructures import Headers, QueryParams
|
||||
from starlette.requests import Request
|
||||
|
||||
from deerflow.config.agents_config import AgentConfig
|
||||
from deerflow.config.llmwiki_config import (
|
||||
@@ -15,7 +16,50 @@ from deerflow.config.llmwiki_config import (
|
||||
)
|
||||
from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError
|
||||
from deerflow.persistence.llmwiki import MemoryLlmWikiStore
|
||||
from deerflow.tools.builtins.llmwiki_search_tool import selected_knowledge_base_ids
|
||||
from deerflow.tools.builtins.llmwiki_search_tool import (
|
||||
_sanitize_conversation_deposit_value as sanitize_tool_deposit_value,
|
||||
selected_knowledge_base_ids,
|
||||
)
|
||||
|
||||
|
||||
def _request(
|
||||
path: str,
|
||||
*,
|
||||
query: str = "",
|
||||
cookies: dict[str, str] | None = None,
|
||||
request_headers: dict[str, str] | None = None,
|
||||
) -> Request:
|
||||
headers: list[tuple[bytes, bytes]] = [
|
||||
(key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items()
|
||||
]
|
||||
if cookies:
|
||||
headers.append(("cookie".encode(), "; ".join(f"{key}={value}" for key, value in cookies.items()).encode()))
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": path,
|
||||
"query_string": query.encode(),
|
||||
"headers": headers,
|
||||
"scheme": "http",
|
||||
"server": ("testserver", 80),
|
||||
"client": ("testclient", 50000),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _first_gateway_match(method: str, path: str) -> str:
|
||||
from app.gateway.app import app as gateway_app
|
||||
|
||||
for route in gateway_app.router.routes:
|
||||
route_contexts = getattr(route, "effective_route_contexts", None)
|
||||
candidates = route_contexts() if callable(route_contexts) else [route]
|
||||
for candidate in candidates:
|
||||
methods = getattr(candidate, "methods", None)
|
||||
path_regex = getattr(candidate, "path_regex", None)
|
||||
if methods is not None and method in methods and path_regex is not None and path_regex.match(path):
|
||||
return str(getattr(candidate, "path", ""))
|
||||
raise AssertionError(f"No route matched {method} {path}")
|
||||
|
||||
|
||||
def test_empty_weknora_address_keeps_legacy_mode() -> None:
|
||||
@@ -84,6 +128,94 @@ def test_conversation_selection_is_deduplicated_and_absence_is_distinct() -> Non
|
||||
assert selected_knowledge_base_ids({"llmwiki_knowledge_base_ids": ["kb-one", "kb-one", "", "kb-two"]}) == ["kb-one", "kb-two"]
|
||||
|
||||
|
||||
def test_weknora_frame_bootstrap_accepts_query_token_only_for_frame_route() -> None:
|
||||
from app.gateway.deps import get_request_access_token
|
||||
|
||||
frame_request = _request(
|
||||
"/api/llmwiki/knowledge-bases/local-kb/weknora/frame",
|
||||
query="access_token=deerflow-token",
|
||||
)
|
||||
normal_request = _request("/api/models", query="access_token=deerflow-token")
|
||||
|
||||
assert get_request_access_token(frame_request) == "deerflow-token"
|
||||
assert get_request_access_token(normal_request) is None
|
||||
|
||||
|
||||
def test_weknora_embed_cookie_bypasses_only_proxy_paths() -> None:
|
||||
from app.gateway import auth_middleware, csrf_middleware
|
||||
from app.gateway.weknora_embed import sign_weknora_embed_payload
|
||||
|
||||
raw_cookie = sign_weknora_embed_payload(
|
||||
{
|
||||
"mapping_id": "local-kb",
|
||||
"weknora_id": "remote-kb",
|
||||
"user_id": "user-a",
|
||||
"is_admin": False,
|
||||
"can_write": False,
|
||||
"exp": 4_102_444_800,
|
||||
}
|
||||
)
|
||||
cookies = {"deerflow_weknora_embed": raw_cookie}
|
||||
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/platform/knowledge-bases/kb", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/assets/index.js", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/config.js", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||
_request("/tdesign-icons/0.4.1/fonts/index.js", cookies=cookies)
|
||||
)
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||
_request("/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies)
|
||||
)
|
||||
assert csrf_middleware.is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies=cookies))
|
||||
assert not auth_middleware._is_weknora_embed_proxy_request(
|
||||
_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"})
|
||||
)
|
||||
assert not csrf_middleware.is_weknora_embed_proxy_request(
|
||||
_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"})
|
||||
)
|
||||
assert not auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/auth/me", cookies=cookies))
|
||||
assert not auth_middleware._is_weknora_embed_proxy_request(_request("/api/models", cookies=cookies))
|
||||
assert not auth_middleware._is_weknora_embed_proxy_request(_request("/platform/knowledge-bases/kb"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
async def authorized_mapping(*_: object, **__: object) -> tuple[dict[str, str], str, bool]:
|
||||
return {"id": "local-kb", "weknora_id": "remote-kb", "name": "KB"}, "user-a", False
|
||||
|
||||
class Store:
|
||||
async def get_authorized(self, *_: object, **__: object) -> dict[str, str]:
|
||||
return {"id": "local-kb", "weknora_id": "remote-kb"}
|
||||
|
||||
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object())
|
||||
monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping)
|
||||
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
||||
|
||||
response = await llmwiki_router.create_weknora_detail_session(
|
||||
_request(
|
||||
"/api/llmwiki/knowledge-bases/local-kb/weknora/session",
|
||||
request_headers={"X-Forwarded-Proto": "https"},
|
||||
),
|
||||
"local-kb",
|
||||
tab="wiki",
|
||||
)
|
||||
|
||||
payload = json.loads(response.body)
|
||||
assert payload["frame_path"] == "/platform/knowledge-bases/remote-kb?tab=wiki"
|
||||
cookie = response.headers["set-cookie"]
|
||||
assert "deerflow_weknora_embed=" in cookie
|
||||
assert "Secure" in cookie
|
||||
assert "SameSite=none" in cookie
|
||||
raw = cookie.split("deerflow_weknora_embed=", 1)[1].split(";", 1)[0]
|
||||
ctx = llmwiki_router._verify_embed_cookie(raw)
|
||||
assert ctx.mapping_id == "local-kb"
|
||||
assert ctx.weknora_id == "remote-kb"
|
||||
assert ctx.can_write is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_client_sends_no_credentials_and_normalizes_search() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
@@ -125,6 +257,32 @@ async def test_weknora_client_sends_no_credentials_and_normalizes_search() -> No
|
||||
assert results[0]["title"] == "Guide"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_client_merges_required_name_for_description_update() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
if request.method == "GET":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"success": True, "data": {"id": "kb-one", "name": "对话沉淀"}},
|
||||
)
|
||||
assert request.method == "PUT"
|
||||
assert json.loads(request.content) == {"name": "对话沉淀", "description": "cmzs description"}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"success": True, "data": {"id": "kb-one", "name": "对话沉淀", "description": "cmzs description"}},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
||||
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
|
||||
updated = await client.update_knowledge_base("kb-one", {"description": "cmzs description"})
|
||||
|
||||
assert updated["description"] == "cmzs description"
|
||||
assert [request.method for request in requests] == ["GET", "PUT"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_client_resolves_default_embedding_model_inside_weknora() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
@@ -299,6 +457,379 @@ async def test_memory_store_enforces_owner_and_public_visibility() -> None:
|
||||
assert await store.get_authorized(created["id"], "user-b", write=True, is_admin=False) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_deposit_mapping_is_system_owned_and_published(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.gateway import llmwiki_deposit
|
||||
|
||||
class FakeClient:
|
||||
async def list_knowledge_bases(self) -> list[dict[str, object]]:
|
||||
return []
|
||||
|
||||
async def create_knowledge_base(self, **_: object) -> dict[str, object]:
|
||||
return {
|
||||
"id": "remote-deposit",
|
||||
"name": "对话沉淀",
|
||||
"description": "global",
|
||||
"type": "document",
|
||||
}
|
||||
|
||||
store = MemoryLlmWikiStore()
|
||||
app = SimpleNamespace(state=SimpleNamespace(llmwiki_store=store))
|
||||
monkeypatch.setattr(llmwiki_deposit, "_client", lambda _: FakeClient())
|
||||
|
||||
row = await llmwiki_deposit.ensure_conversation_deposit_knowledge_base(app, owner_user_id="user-a")
|
||||
|
||||
assert row is not None
|
||||
assert row["owner_user_id"] == "system"
|
||||
assert row["publication_status"] == "published"
|
||||
assert row["description"] == llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
||||
assert row["id"] not in [item["id"] for item in await store.list_visible("user-a", scope="personal")]
|
||||
assert [item["id"] for item in await store.list_visible("user-a", scope="public")] == [row["id"]]
|
||||
|
||||
markdown = llmwiki_deposit._format_markdown(
|
||||
llmwiki_deposit.ConversationTurnDeposit(
|
||||
thread_id="thread-one",
|
||||
question="question",
|
||||
answer="answer",
|
||||
human_message_id="human-one",
|
||||
assistant_message_id="assistant-one",
|
||||
assistant_id=None,
|
||||
knowledge_base_ids=[],
|
||||
created_by_user_id="user-a",
|
||||
),
|
||||
"title",
|
||||
)
|
||||
assert "DeerFlow" not in markdown
|
||||
assert "source: cmzs" in markdown
|
||||
assert "#cmzs" in markdown
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_conversation_deposit_description_is_refreshed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.gateway import llmwiki_deposit
|
||||
|
||||
updates: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
class FakeClient:
|
||||
async def update_knowledge_base(self, knowledge_base_id: str, changes: dict[str, object]) -> dict[str, object]:
|
||||
updates.append((knowledge_base_id, changes))
|
||||
return {"id": knowledge_base_id, **changes}
|
||||
|
||||
store = MemoryLlmWikiStore()
|
||||
created = await store.create_mapping(
|
||||
weknora_id="remote-deposit",
|
||||
owner_user_id="system",
|
||||
name=llmwiki_deposit.CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description="old description",
|
||||
kb_type="document",
|
||||
)
|
||||
await store.request_publish(created["id"], "system", is_admin=True)
|
||||
app = SimpleNamespace(state=SimpleNamespace(llmwiki_store=store))
|
||||
monkeypatch.setattr(llmwiki_deposit, "_client", lambda _: FakeClient())
|
||||
|
||||
row = await llmwiki_deposit.ensure_conversation_deposit_knowledge_base(app, owner_user_id="user-a")
|
||||
|
||||
assert row is not None
|
||||
assert row["description"] == llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
||||
assert updates == [
|
||||
(
|
||||
"remote-deposit",
|
||||
{"description": llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_user_owned_conversation_deposit_is_adopted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.gateway import llmwiki_deposit
|
||||
|
||||
class FakeClient:
|
||||
async def list_knowledge_bases(self) -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"id": "remote-deposit",
|
||||
"name": llmwiki_deposit.CONVERSATION_DEPOSIT_KB_NAME,
|
||||
"description": "DeerFlow legacy description",
|
||||
}
|
||||
]
|
||||
|
||||
async def update_knowledge_base(self, knowledge_base_id: str, changes: dict[str, object]) -> dict[str, object]:
|
||||
return {"id": knowledge_base_id, **changes}
|
||||
|
||||
store = MemoryLlmWikiStore()
|
||||
legacy = await store.create_mapping(
|
||||
weknora_id="remote-deposit",
|
||||
owner_user_id="user-a",
|
||||
name=llmwiki_deposit.CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description="DeerFlow legacy description",
|
||||
kb_type="document",
|
||||
)
|
||||
await store.request_publish(legacy["id"], "user-a", is_admin=False)
|
||||
app = SimpleNamespace(state=SimpleNamespace(llmwiki_store=store))
|
||||
monkeypatch.setattr(llmwiki_deposit, "_client", lambda _: FakeClient())
|
||||
|
||||
row = await llmwiki_deposit.ensure_conversation_deposit_knowledge_base(app, owner_user_id="user-a")
|
||||
|
||||
assert row is not None
|
||||
assert row["id"] == legacy["id"]
|
||||
assert row["owner_user_id"] == "system"
|
||||
assert row["publication_status"] == "published"
|
||||
assert row["description"] == llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
||||
assert await store.list_visible("user-a", scope="personal") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_listing_conversation_deposit_refreshes_remote_description(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.gateway import llmwiki_deposit
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
updates: list[tuple[str, dict[str, object]]] = []
|
||||
store = MemoryLlmWikiStore()
|
||||
created = await store.create_mapping(
|
||||
weknora_id="remote-deposit",
|
||||
owner_user_id="system",
|
||||
name=llmwiki_deposit.CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description="DeerFlow legacy description",
|
||||
kb_type="document",
|
||||
)
|
||||
await store.request_publish(created["id"], "system", is_admin=True)
|
||||
|
||||
class Client:
|
||||
async def list_knowledge_bases(self) -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"id": "remote-deposit",
|
||||
"name": llmwiki_deposit.CONVERSATION_DEPOSIT_KB_NAME,
|
||||
"description": "DeerFlow legacy description",
|
||||
}
|
||||
]
|
||||
|
||||
async def create_knowledge_base(self, **_: object) -> dict[str, object]:
|
||||
raise AssertionError("The existing deposit should be reused")
|
||||
|
||||
async def update_knowledge_base(self, knowledge_base_id: str, changes: dict[str, object]) -> dict[str, object]:
|
||||
updates.append((knowledge_base_id, changes))
|
||||
return {"id": knowledge_base_id, **changes}
|
||||
|
||||
async def actor(_: object) -> tuple[str, bool]:
|
||||
return "user-a", False
|
||||
|
||||
monkeypatch.setattr(llmwiki_router, "_actor", actor)
|
||||
monkeypatch.setattr(llmwiki_router, "_store", lambda _: store)
|
||||
monkeypatch.setattr(llmwiki_router, "_client_or_503", lambda: Client())
|
||||
|
||||
payload = await llmwiki_router.list_knowledge_bases(object(), scope="public")
|
||||
|
||||
assert payload["knowledge_bases"][0]["description"] == llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
||||
assert "DeerFlow" not in json.dumps(payload, ensure_ascii=False)
|
||||
assert updates == [
|
||||
(
|
||||
"remote-deposit",
|
||||
{"description": llmwiki_deposit.CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_conversation_deposit_history_hides_legacy_brand() -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
value = {
|
||||
"content": "This page was generated by DeerFlow.",
|
||||
"metadata": {"source": "deer-flow", "tags": ["DEER_FLOW", "keep"]},
|
||||
}
|
||||
|
||||
gateway_value = llmwiki_router._sanitize_conversation_deposit_value(value)
|
||||
tool_value = sanitize_tool_deposit_value(value)
|
||||
|
||||
assert gateway_value == tool_value
|
||||
assert gateway_value == {
|
||||
"content": "This page was generated by cmzs.",
|
||||
"metadata": {"source": "cmzs", "tags": ["cmzs", "keep"]},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_knowledge_bases_personal_scope_filters_to_actor_for_admin(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
class Store:
|
||||
async def list_visible(self, user_id: str, *, scope: str, is_admin: bool) -> list[dict[str, object]]:
|
||||
calls.append({"user_id": user_id, "scope": scope, "is_admin": is_admin})
|
||||
return [
|
||||
{
|
||||
"id": "local-admin",
|
||||
"weknora_id": "remote-admin",
|
||||
"owner_user_id": user_id,
|
||||
"name": "Admin private",
|
||||
"description": "",
|
||||
"kb_type": "document",
|
||||
"publication_status": "private",
|
||||
}
|
||||
]
|
||||
|
||||
class Client:
|
||||
async def list_knowledge_bases(self) -> list[dict[str, object]]:
|
||||
return [{"id": "remote-admin", "name": "Admin private"}]
|
||||
|
||||
async def actor(_: object) -> tuple[str, bool]:
|
||||
return "admin-id", True
|
||||
|
||||
monkeypatch.setattr(llmwiki_router, "_actor", actor)
|
||||
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
||||
monkeypatch.setattr(llmwiki_router, "_client_or_503", lambda: Client())
|
||||
|
||||
payload = await llmwiki_router.list_knowledge_bases(object(), scope="personal")
|
||||
|
||||
assert calls == [{"user_id": "admin-id", "scope": "personal", "is_admin": False}]
|
||||
assert payload["knowledge_bases"][0]["is_owner"] is True
|
||||
|
||||
|
||||
def test_mapping_view_distinguishes_owner_from_admin_write_power() -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
view = llmwiki_router._mapping_view(
|
||||
{
|
||||
"id": "local-other",
|
||||
"weknora_id": "remote-other",
|
||||
"owner_user_id": "user-b",
|
||||
"name": "Other private",
|
||||
"description": "",
|
||||
"kb_type": "document",
|
||||
"publication_status": "private",
|
||||
},
|
||||
{"id": "remote-other", "name": "Other private"},
|
||||
actor_user_id="admin-id",
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
assert view["is_owner"] is False
|
||||
assert view["can_write"] is True
|
||||
|
||||
|
||||
def test_only_system_owned_named_base_is_treated_as_conversation_deposit() -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
personal = {
|
||||
"id": "personal-named-deposit",
|
||||
"owner_user_id": "user-a",
|
||||
"name": "对话沉淀",
|
||||
"publication_status": "private",
|
||||
}
|
||||
system = {
|
||||
"id": "system-deposit",
|
||||
"owner_user_id": "system",
|
||||
"name": "对话沉淀",
|
||||
"publication_status": "published",
|
||||
}
|
||||
|
||||
assert not llmwiki_router._is_conversation_deposit_mapping(personal)
|
||||
assert llmwiki_router._is_conversation_deposit_mapping(system)
|
||||
system_view = llmwiki_router._mapping_view(
|
||||
system,
|
||||
{
|
||||
"id": "remote-system",
|
||||
"name": "对话沉淀",
|
||||
"description": "DeerFlow legacy description",
|
||||
},
|
||||
actor_user_id="admin-id",
|
||||
is_admin=True,
|
||||
)
|
||||
assert system_view["can_write"] is False
|
||||
assert system_view["description"] == "cmzs 全局问答沉淀知识库。所有用户可见,不默认参与问答检索。"
|
||||
assert "DeerFlow" not in system_view["description"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_deposit_requires_thread_access(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
class ThreadStore:
|
||||
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool) -> bool:
|
||||
assert thread_id == "thread-other"
|
||||
assert user_id == "user-a"
|
||||
assert require_existing is True
|
||||
return False
|
||||
|
||||
async def actor(_: object) -> tuple[str, bool]:
|
||||
return "user-a", False
|
||||
|
||||
monkeypatch.setattr(llmwiki_router, "_actor", actor)
|
||||
monkeypatch.setattr(llmwiki_router, "get_thread_store", lambda _: ThreadStore())
|
||||
monkeypatch.setattr(
|
||||
llmwiki_router,
|
||||
"get_resolved_llmwiki_runtime",
|
||||
lambda _: SimpleNamespace(weknora_enabled=True, provider="weknora"),
|
||||
)
|
||||
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(config=object())))
|
||||
body = llmwiki_router.ConversationDepositCreate(
|
||||
thread_id="thread-other",
|
||||
question="question",
|
||||
answer="answer",
|
||||
assistant_message_id="assistant-one",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await llmwiki_router.create_conversation_deposit(
|
||||
request,
|
||||
SimpleNamespace(add_task=lambda *_: None),
|
||||
body,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_context_prefers_wiki_page_over_raw_chunks(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
class Client:
|
||||
async def get_chunk_context(self, chunk_id: str, *, expected_knowledge_base_id: str, radius: int) -> list[dict[str, object]]:
|
||||
assert chunk_id == "chunk-one"
|
||||
assert expected_knowledge_base_id == "remote-kb"
|
||||
return [
|
||||
{
|
||||
"id": "chunk-one",
|
||||
"chunk_index": 0,
|
||||
"content": "raw split chunk",
|
||||
"metadata": {"wiki_page_slug": "people/alice"},
|
||||
}
|
||||
]
|
||||
|
||||
async def get_wiki_page(self, knowledge_base_id: str, slug: str) -> dict[str, object]:
|
||||
assert knowledge_base_id == "remote-kb"
|
||||
assert slug == "people/alice"
|
||||
return {
|
||||
"id": "wiki-one",
|
||||
"slug": "people/alice",
|
||||
"title": "Alice",
|
||||
"summary": "clean summary",
|
||||
"content": "clean llmwiki page",
|
||||
"source_refs": ["chunk-one"],
|
||||
}
|
||||
|
||||
async def authorized_mapping(*_: object, **__: object) -> tuple[dict[str, str], str, bool]:
|
||||
return {"id": "local-kb", "weknora_id": "remote-kb", "name": "Public LLMWiki"}, "user-a", False
|
||||
|
||||
monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping)
|
||||
monkeypatch.setattr(llmwiki_router, "_client_or_503", lambda: Client())
|
||||
|
||||
payload = await llmwiki_router.get_source_context(object(), knowledge_base_id="local-kb", chunk_id="chunk-one")
|
||||
|
||||
assert payload["data"]["display_mode"] == "wiki"
|
||||
assert payload["data"]["wiki_page"]["content"] == "clean llmwiki page"
|
||||
assert payload["data"]["chunks"][0]["content"] == "raw split chunk"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_cleans_owner_mapping_when_remote_is_already_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
@@ -349,6 +880,16 @@ def test_weknora_iframe_cookie_is_signed_and_short_lived() -> None:
|
||||
llmwiki_router._verify_embed_cookie(raw + "tampered")
|
||||
|
||||
|
||||
def test_weknora_iframe_proxy_is_fallback_after_deerflow_api_routes() -> None:
|
||||
assert _first_gateway_match("POST", "/api/v1/auth/login/username") == "/api/v1/auth/login/username"
|
||||
assert _first_gateway_match("POST", "/api/v1/knowledge-search") == "/api/v1/{proxied_path:path}"
|
||||
assert (
|
||||
_first_gateway_match("GET", "/api/llmwiki/weknora-embed/api/v1/auth/me")
|
||||
== "/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}"
|
||||
)
|
||||
assert _first_gateway_match("GET", "/platform/knowledge-bases/remote-kb") == "/platform/{proxied_path:path}"
|
||||
|
||||
|
||||
def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
@@ -378,6 +919,68 @@ def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None:
|
||||
assert payload["data"] == [{"id": "remote-kb", "name": "Allowed"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_deposit_iframe_sanitizes_legacy_brand(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
async def fake_session(_: object, *, force_refresh: bool = False) -> dict[str, object]:
|
||||
return {"token": "token", "tenant": {}}
|
||||
|
||||
async def authorize(*_: object) -> None:
|
||||
return None
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, **_: object) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def request(self, *_: object, **__: object) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"success": True, "data": {"summary": "Generated by DeerFlow", "source": "deer-flow"}},
|
||||
)
|
||||
|
||||
class FakeRequest:
|
||||
method = "GET"
|
||||
headers = Headers({})
|
||||
query_params = QueryParams("")
|
||||
|
||||
async def body(self) -> bytes:
|
||||
return b""
|
||||
|
||||
ctx = llmwiki_router.WeKnoraEmbedContext(
|
||||
mapping_id="local-deposit",
|
||||
weknora_id="remote-deposit",
|
||||
user_id="user-a",
|
||||
is_admin=False,
|
||||
can_write=False,
|
||||
exp=4_102_444_800,
|
||||
is_conversation_deposit=True,
|
||||
)
|
||||
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: SimpleNamespace())
|
||||
monkeypatch.setattr(llmwiki_router, "_get_weknora_admin_session", fake_session)
|
||||
monkeypatch.setattr(llmwiki_router, "_authorize_weknora_api_proxy", authorize)
|
||||
monkeypatch.setattr(llmwiki_router.httpx, "AsyncClient", FakeAsyncClient)
|
||||
|
||||
response = await llmwiki_router._proxy_weknora_request(
|
||||
FakeRequest(),
|
||||
target_base_url="http://weknora.local",
|
||||
upstream_path="/api/v1/knowledge-bases/remote-deposit",
|
||||
ctx=ctx,
|
||||
is_api=True,
|
||||
)
|
||||
|
||||
payload = json.loads(response.body)
|
||||
assert payload["data"] == {"summary": "Generated by cmzs", "source": "cmzs"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_iframe_proxy_blocks_other_knowledge_bases() -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
Reference in new issue
Block a user