Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj
This commit is contained in:
commit
c4d244881f
13 files changed
+1166
-79
No files matched your search
@@ -107,6 +107,10 @@ frontend-web/.offline-pack-build/
|
|||||||
frontend-web/.offline-extract-test/
|
frontend-web/.offline-extract-test/
|
||||||
frontend-web-node_modules-offline-*.tar.gz
|
frontend-web-node_modules-offline-*.tar.gz
|
||||||
|
|
||||||
|
# Local dependency caches and backend scratch archives
|
||||||
|
/.pnpm-store/
|
||||||
|
/offline-backend-20260512/backend/backend*.zip
|
||||||
|
|
||||||
.codegraph
|
.codegraph
|
||||||
codegraph.mdc
|
codegraph.mdc
|
||||||
|
|
||||||
|
|||||||
+19
-5
@@ -55,6 +55,23 @@ function postThemeToFrame(frame: HTMLIFrameElement | null) {
|
|||||||
frame.contentWindow.postMessage(collectThemePayload(), targetOrigin);
|
frame.contentWindow.postMessage(collectThemePayload(), targetOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WEKNORA_BACKEND_PREFIX = "/deerflow";
|
||||||
|
|
||||||
|
function buildFrameUrl(framePath: string) {
|
||||||
|
const backendBase = getBackendBaseURL().replace(/\/+$/, "");
|
||||||
|
const backendOrigin = backendBase
|
||||||
|
? new URL(backendBase, window.location.origin).origin
|
||||||
|
: window.location.origin;
|
||||||
|
if (/^https?:\/\//i.test(framePath)) return new URL(framePath);
|
||||||
|
if (framePath.startsWith("/")) {
|
||||||
|
const path = !framePath.startsWith(`${WEKNORA_BACKEND_PREFIX}/`)
|
||||||
|
? `${WEKNORA_BACKEND_PREFIX}${framePath}`
|
||||||
|
: framePath;
|
||||||
|
return new URL(path, backendOrigin);
|
||||||
|
}
|
||||||
|
return new URL(`${backendBase || backendOrigin}/${framePath.replace(/^\/+/, "")}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function WeKnoraKnowledgeDetailPage() {
|
export function WeKnoraKnowledgeDetailPage() {
|
||||||
const {
|
const {
|
||||||
knowledge_base_id: strategyKnowledgeBaseId,
|
knowledge_base_id: strategyKnowledgeBaseId,
|
||||||
@@ -87,11 +104,8 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab)
|
void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab)
|
||||||
.then((session) => {
|
.then((session) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const base = getBackendBaseURL().replace(/\/+$/, "");
|
const framePath = session.frame_path || "";
|
||||||
const path = session.frame_path.startsWith("/")
|
const nextUrl = buildFrameUrl(framePath);
|
||||||
? session.frame_path
|
|
||||||
: `/${session.frame_path}`;
|
|
||||||
const nextUrl = new URL(`${base}${path}`);
|
|
||||||
nextUrl.searchParams.set("theme", collectThemePayload().theme);
|
nextUrl.searchParams.set("theme", collectThemePayload().theme);
|
||||||
setFrameSrc(nextUrl.toString());
|
setFrameSrc(nextUrl.toString());
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1175,6 +1175,19 @@ Both the `view_image` tool and `ViewImageMiddleware` are registered whenever `vi
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
### WeKnora iframe behind `/deerflow`
|
||||||
|
|
||||||
|
The LLMWiki WeKnora detail iframe is publicly served through the Gateway at
|
||||||
|
`/deerflow`, independently of the frontend deployment prefix (for example
|
||||||
|
`/magentweb`). Register `llmwiki.proxy_router` with the `/deerflow` prefix
|
||||||
|
before its unprefixed fallback router, otherwise the unprefixed catch-all
|
||||||
|
captures `/deerflow/*`. HTML root-relative assets, API strings, and Vite's
|
||||||
|
`__vite__mapDeps` table are rewritten for `/deerflow`; generic JavaScript
|
||||||
|
regular-expression literals must not be rewritten. Auth and CSRF bypass only
|
||||||
|
applies when the short-lived signed WeKnora embed cookie is valid. Keep static
|
||||||
|
path recognition in `app/gateway/weknora_embed.py` synchronized with the
|
||||||
|
proxy's static fallback paths in `app/gateway/routers/llmwiki.py`.
|
||||||
|
|
||||||
### Position-roundtable role directory
|
### Position-roundtable role directory
|
||||||
|
|
||||||
`position_roles` is the global shared directory for position-roundtable work
|
`position_roles` is the global shared directory for position-roundtable work
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ Per-thread isolated execution with virtual path translation:
|
|||||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||||
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
- **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).
|
||||||
- **Embedded WeKnora detail chrome**: the proxy hides WeKnora's application sidebar (`.main > .aside_box`) and expands the detail outlet to the full iframe width, while preserving the Wiki page's own index/navigation panel.
|
- **Embedded WeKnora detail chrome**: the proxy hides WeKnora's application sidebar (`.main > .aside_box`) and expands the detail outlet to the full iframe width, while preserving the Wiki page's own index/navigation panel.
|
||||||
|
- **Prefixed WeKnora deployment**: the frontend may be published under a separate prefix such as `/magentweb`, while embedded WeKnora detail traffic is served by the Gateway under `/deerflow`. The Gateway rewrites HTML root assets, WeKnora API URLs, redirects, and Vite dynamic dependency maps so page assets stay under `/deerflow`; the prefixed proxy routes must be registered before the unprefixed fallback. The signed embed cookie covers `/deerflow`, and auth/CSRF recognition includes all supported static directories.
|
||||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
- **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)
|
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
||||||
|
|
||||||
|
|||||||
@@ -1646,6 +1646,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# WeKnora's embedded frontend uses root-relative routes such as /platform,
|
# WeKnora's embedded frontend uses root-relative routes such as /platform,
|
||||||
# /assets, /locales and /api/v1/*. Register that proxy last so it stays a
|
# /assets, /locales and /api/v1/*. Register that proxy last so it stays a
|
||||||
# true fallback and cannot intercept DeerFlow APIs.
|
# true fallback and cannot intercept DeerFlow APIs.
|
||||||
|
# Register the public-prefix routes before the unprefixed static fallback.
|
||||||
|
# Otherwise proxy_router's /{proxied_path:path} route consumes every
|
||||||
|
# /deerflow/* request before the prefixed platform/assets routes can match.
|
||||||
|
app.include_router(llmwiki.proxy_router, prefix="/deerflow")
|
||||||
app.include_router(llmwiki.proxy_router)
|
app.include_router(llmwiki.proxy_router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import re
|
|||||||
import time
|
import time
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from urllib.parse import quote, urlencode
|
from urllib.parse import quote, urlencode, urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile
|
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile
|
||||||
@@ -48,6 +48,84 @@ proxy_router = APIRouter(tags=["llmwiki-proxy"])
|
|||||||
|
|
||||||
_MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
_MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
||||||
_WEKNORA_ADMIN_SESSION_CACHE: dict[str, dict[str, Any]] = {}
|
_WEKNORA_ADMIN_SESSION_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
|
_WEKNORA_PUBLIC_PREFIX = "/deerflow"
|
||||||
|
_WEKNORA_PUBLIC_ROOT_PATHS = (
|
||||||
|
"/platform",
|
||||||
|
"/assets",
|
||||||
|
"/css",
|
||||||
|
"/fonts",
|
||||||
|
"/img",
|
||||||
|
"/images",
|
||||||
|
"/js",
|
||||||
|
"/locales",
|
||||||
|
"/media",
|
||||||
|
"/static",
|
||||||
|
"/tdesign-icons",
|
||||||
|
"/api/v1",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/favicon.svg",
|
||||||
|
"/config.js",
|
||||||
|
"/logo.svg",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
)
|
||||||
|
_WEKNORA_PUBLIC_RELATIVE_ROOT_PATHS = (
|
||||||
|
"assets",
|
||||||
|
"css",
|
||||||
|
"fonts",
|
||||||
|
"img",
|
||||||
|
"images",
|
||||||
|
"js",
|
||||||
|
"locales",
|
||||||
|
"media",
|
||||||
|
"static",
|
||||||
|
"tdesign-icons",
|
||||||
|
)
|
||||||
|
_WEKNORA_PUBLIC_RELATIVE_FILE_PATHS = (
|
||||||
|
"config.js",
|
||||||
|
"favicon.ico",
|
||||||
|
"favicon.svg",
|
||||||
|
"logo.svg",
|
||||||
|
"manifest.webmanifest",
|
||||||
|
)
|
||||||
|
_WEKNORA_STATIC_FILE_SUFFIXES = (
|
||||||
|
".avif",
|
||||||
|
".css",
|
||||||
|
".gif",
|
||||||
|
".ico",
|
||||||
|
".jpeg",
|
||||||
|
".jpg",
|
||||||
|
".js",
|
||||||
|
".json",
|
||||||
|
".map",
|
||||||
|
".mjs",
|
||||||
|
".png",
|
||||||
|
".svg",
|
||||||
|
".ttf",
|
||||||
|
".txt",
|
||||||
|
".webmanifest",
|
||||||
|
".webp",
|
||||||
|
".woff",
|
||||||
|
".woff2",
|
||||||
|
)
|
||||||
|
_WEKNORA_STATIC_ROOT_PREFIXES = (
|
||||||
|
"/assets/",
|
||||||
|
"/css/",
|
||||||
|
"/fonts/",
|
||||||
|
"/img/",
|
||||||
|
"/images/",
|
||||||
|
"/js/",
|
||||||
|
"/locales/",
|
||||||
|
"/media/",
|
||||||
|
"/static/",
|
||||||
|
"/tdesign-icons/",
|
||||||
|
)
|
||||||
|
_WEKNORA_ROOT_ASSET_FILES = (
|
||||||
|
"/config.js",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/favicon.svg",
|
||||||
|
"/logo.svg",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
)
|
||||||
_HOP_BY_HOP_RESPONSE_HEADERS = {
|
_HOP_BY_HOP_RESPONSE_HEADERS = {
|
||||||
"connection",
|
"connection",
|
||||||
"content-encoding",
|
"content-encoding",
|
||||||
@@ -398,6 +476,9 @@ async def _authorize_weknora_api_proxy(
|
|||||||
return
|
return
|
||||||
raise HTTPException(status_code=403, detail="Only WeKnora session introspection is allowed")
|
raise HTTPException(status_code=403, detail="Only WeKnora session introspection is allowed")
|
||||||
|
|
||||||
|
if read_method and tail == ["me", "invitations", "pending-count"]:
|
||||||
|
return
|
||||||
|
|
||||||
if tail[:1] == ["knowledge-bases"]:
|
if tail[:1] == ["knowledge-bases"]:
|
||||||
if len(tail) == 1:
|
if len(tail) == 1:
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
@@ -501,12 +582,191 @@ def _escape_script_json(value: Any) -> str:
|
|||||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_public_prefix(value: str | None) -> str:
|
||||||
|
raw = (value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
if "://" in raw:
|
||||||
|
try:
|
||||||
|
raw = urlsplit(raw).path
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
if not raw.startswith("/"):
|
||||||
|
raw = f"/{raw}"
|
||||||
|
raw = raw.rstrip("/")
|
||||||
|
return "" if raw == "/" else raw
|
||||||
|
|
||||||
|
|
||||||
|
def _request_public_prefix(request: Request) -> str:
|
||||||
|
return _WEKNORA_PUBLIC_PREFIX
|
||||||
|
|
||||||
|
|
||||||
|
def _with_public_prefix(prefix: str, path: str) -> str:
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = f"/{path}"
|
||||||
|
return f"{prefix}{path}" if prefix else path
|
||||||
|
|
||||||
|
|
||||||
|
def _without_public_prefix(prefix: str, path: str) -> str:
|
||||||
|
if prefix and path.startswith(f"{prefix}/"):
|
||||||
|
return path[len(prefix):]
|
||||||
|
if prefix and path == prefix:
|
||||||
|
return "/"
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_weknora_html_public_paths(
|
||||||
|
html: str,
|
||||||
|
public_prefix: str,
|
||||||
|
*,
|
||||||
|
rewrite_relative: bool = True,
|
||||||
|
) -> str:
|
||||||
|
if not public_prefix:
|
||||||
|
return html
|
||||||
|
for path in _WEKNORA_PUBLIC_ROOT_PATHS:
|
||||||
|
prefixed = f"{public_prefix}{path}"
|
||||||
|
html = re.sub(
|
||||||
|
rf"(?P<prefix>[\"'`(=:\s]){re.escape(path)}(?=[/?#\"'`),;\]\}}\s>]|$)",
|
||||||
|
rf"\g<prefix>{prefixed}",
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
escaped_path = rf"\\/{re.escape(path.lstrip('/'))}"
|
||||||
|
escaped_prefixed = f"\\/{prefixed.lstrip('/')}"
|
||||||
|
html = re.sub(
|
||||||
|
rf"(?<!\\/{re.escape(public_prefix.lstrip('/'))}){escaped_path}(?=[\\/?#\"'`),;\]\}}\s>]|$)",
|
||||||
|
escaped_prefixed,
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
if rewrite_relative:
|
||||||
|
for path in _WEKNORA_PUBLIC_RELATIVE_ROOT_PATHS:
|
||||||
|
prefixed = f"{public_prefix}/{path}"
|
||||||
|
html = re.sub(
|
||||||
|
rf"(?P<prefix>[\"'`(=:\s])(?:\./)?{re.escape(path)}(?=/)",
|
||||||
|
rf"\g<prefix>{prefixed}",
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
for path in _WEKNORA_PUBLIC_RELATIVE_FILE_PATHS:
|
||||||
|
prefixed = f"{public_prefix}/{path}"
|
||||||
|
html = re.sub(
|
||||||
|
rf"(?P<prefix>[\"'`(=:\s])(?:\./)?{re.escape(path)}(?=[?#\"'`),;\]\}}\s>]|$)",
|
||||||
|
rf"\g<prefix>{prefixed}",
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_weknora_script_public_paths(text: str, public_prefix: str) -> str:
|
||||||
|
if not public_prefix:
|
||||||
|
return text
|
||||||
|
for path in _WEKNORA_PUBLIC_ROOT_PATHS:
|
||||||
|
prefixed = f"{public_prefix}{path}"
|
||||||
|
text = re.sub(
|
||||||
|
rf"(?P<quote>[\"'`]){re.escape(path)}(?=[/?#\"'`),;\]\}}\s]|$)",
|
||||||
|
rf"\g<quote>{prefixed}",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
return _rewrite_weknora_vite_dependency_map(text, public_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_rewrite_weknora_static_response(content_type: str, upstream_path: str) -> bool:
|
||||||
|
lower_type = content_type.lower()
|
||||||
|
if any(
|
||||||
|
marker in lower_type
|
||||||
|
for marker in (
|
||||||
|
"javascript",
|
||||||
|
"text/css",
|
||||||
|
"application/json",
|
||||||
|
"manifest",
|
||||||
|
"image/svg+xml",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
lower_path = upstream_path.lower()
|
||||||
|
return lower_path.endswith((".js", ".css", ".json", ".webmanifest", ".svg"))
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_weknora_vite_dependency_map(text: str, public_prefix: str) -> str:
|
||||||
|
if not public_prefix or "__vite__mapDeps" not in text:
|
||||||
|
return text
|
||||||
|
marker = "m.f||(m.f=["
|
||||||
|
start = text.find(marker)
|
||||||
|
if start < 0:
|
||||||
|
return text
|
||||||
|
end = text.find("]))", start + len(marker))
|
||||||
|
if end < 0:
|
||||||
|
return text
|
||||||
|
prefix = public_prefix.strip("/")
|
||||||
|
dependency_map = text[start:end]
|
||||||
|
dependency_map = re.sub(
|
||||||
|
r'(?P<quote>["\'])assets/',
|
||||||
|
rf"\g<quote>{prefix}/assets/",
|
||||||
|
dependency_map,
|
||||||
|
)
|
||||||
|
return f"{text[:start]}{dependency_map}{text[end:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _weknora_static_fallback_upstream_path(request: Request) -> str | None:
|
||||||
|
path = _without_public_prefix(
|
||||||
|
_request_public_prefix(request),
|
||||||
|
str(request.scope.get("path") or request.url.path),
|
||||||
|
)
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = f"/{path}"
|
||||||
|
lower_path = path.lower()
|
||||||
|
if lower_path.startswith("/api/"):
|
||||||
|
return None
|
||||||
|
if lower_path.startswith(_WEKNORA_STATIC_ROOT_PREFIXES):
|
||||||
|
return path
|
||||||
|
for root_prefix in _WEKNORA_STATIC_ROOT_PREFIXES:
|
||||||
|
index = lower_path.find(root_prefix)
|
||||||
|
if index > 0:
|
||||||
|
return path[index:]
|
||||||
|
for asset_file in _WEKNORA_ROOT_ASSET_FILES:
|
||||||
|
if lower_path.endswith(asset_file):
|
||||||
|
return asset_file
|
||||||
|
if lower_path.endswith(_WEKNORA_STATIC_FILE_SUFFIXES):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_weknora_location_header(location: str, public_prefix: str, target_base_url: str) -> str:
|
||||||
|
if not location or not public_prefix:
|
||||||
|
return location
|
||||||
|
try:
|
||||||
|
current = urlsplit(location)
|
||||||
|
upstream = urlsplit(target_base_url)
|
||||||
|
except ValueError:
|
||||||
|
return location
|
||||||
|
|
||||||
|
def proxied_path(path: str) -> str:
|
||||||
|
upstream_base_path = upstream.path.rstrip("/")
|
||||||
|
if upstream_base_path and (path == upstream_base_path or path.startswith(f"{upstream_base_path}/")):
|
||||||
|
path = path[len(upstream_base_path) :] or "/"
|
||||||
|
return _without_public_prefix(public_prefix, path)
|
||||||
|
|
||||||
|
suffix = ""
|
||||||
|
if current.query:
|
||||||
|
suffix += f"?{current.query}"
|
||||||
|
if current.fragment:
|
||||||
|
suffix += f"#{current.fragment}"
|
||||||
|
|
||||||
|
if not current.scheme and location.startswith("/"):
|
||||||
|
path = proxied_path(current.path or "/")
|
||||||
|
return f"{public_prefix}{path}{suffix}"
|
||||||
|
|
||||||
|
if current.scheme and current.netloc and current.netloc == upstream.netloc:
|
||||||
|
path = proxied_path(current.path or "/")
|
||||||
|
return f"{public_prefix}{path}{suffix}"
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
def _weknora_embed_injection(
|
def _weknora_embed_injection(
|
||||||
*,
|
*,
|
||||||
allowed_path: str,
|
allowed_path: str,
|
||||||
tab: str,
|
tab: str,
|
||||||
session: dict[str, Any],
|
session: dict[str, Any],
|
||||||
ctx: WeKnoraEmbedContext,
|
ctx: WeKnoraEmbedContext,
|
||||||
|
public_prefix: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
tenant_id = _tenant_id_from_session(session)
|
tenant_id = _tenant_id_from_session(session)
|
||||||
user = session.get("user") if isinstance(session.get("user"), dict) else {}
|
user = session.get("user") if isinstance(session.get("user"), dict) else {}
|
||||||
@@ -520,7 +780,8 @@ def _weknora_embed_injection(
|
|||||||
"tenantId": tenant_id,
|
"tenantId": tenant_id,
|
||||||
"user": user,
|
"user": user,
|
||||||
"tenant": tenant,
|
"tenant": tenant,
|
||||||
"apiPrefix": _WEKNORA_EMBED_API_PREFIX,
|
"publicPrefix": public_prefix,
|
||||||
|
"apiPrefix": _with_public_prefix(public_prefix, _WEKNORA_EMBED_API_PREFIX),
|
||||||
}
|
}
|
||||||
data = _escape_script_json(payload)
|
data = _escape_script_json(payload)
|
||||||
return f"""
|
return f"""
|
||||||
@@ -658,8 +919,19 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
|||||||
function rewriteApiUrl(value) {{
|
function rewriteApiUrl(value) {{
|
||||||
try {{
|
try {{
|
||||||
const next = new URL(value, location.origin);
|
const next = new URL(value, location.origin);
|
||||||
if (next.origin !== location.origin || !next.pathname.startsWith("/api/v1/")) return value;
|
if (next.origin !== location.origin) return value;
|
||||||
next.pathname = cfg.apiPrefix + next.pathname;
|
const publicPrefix = cfg.publicPrefix || "";
|
||||||
|
const apiPath = "/api/v1/";
|
||||||
|
const publicApiPath = publicPrefix + apiPath;
|
||||||
|
let upstreamPath = "";
|
||||||
|
if (next.pathname.startsWith(publicApiPath)) {{
|
||||||
|
upstreamPath = next.pathname.slice(publicPrefix.length);
|
||||||
|
}} else if (next.pathname.startsWith(apiPath)) {{
|
||||||
|
upstreamPath = next.pathname;
|
||||||
|
}} else {{
|
||||||
|
return value;
|
||||||
|
}}
|
||||||
|
next.pathname = cfg.apiPrefix + upstreamPath;
|
||||||
return next.href;
|
return next.href;
|
||||||
}} catch (_) {{
|
}} catch (_) {{
|
||||||
return value;
|
return value;
|
||||||
@@ -911,9 +1183,24 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _inject_weknora_embed_html(html: bytes, *, allowed_path: str, tab: str, session: dict[str, Any], ctx: WeKnoraEmbedContext) -> bytes:
|
def _inject_weknora_embed_html(
|
||||||
|
html: bytes,
|
||||||
|
*,
|
||||||
|
allowed_path: str,
|
||||||
|
tab: str,
|
||||||
|
session: dict[str, Any],
|
||||||
|
ctx: WeKnoraEmbedContext,
|
||||||
|
public_prefix: str = "",
|
||||||
|
) -> bytes:
|
||||||
text = html.decode("utf-8", errors="replace")
|
text = html.decode("utf-8", errors="replace")
|
||||||
injection = _weknora_embed_injection(allowed_path=allowed_path, tab=tab, session=session, ctx=ctx)
|
text = _rewrite_weknora_html_public_paths(text, public_prefix)
|
||||||
|
injection = _weknora_embed_injection(
|
||||||
|
allowed_path=allowed_path,
|
||||||
|
tab=tab,
|
||||||
|
session=session,
|
||||||
|
ctx=ctx,
|
||||||
|
public_prefix=public_prefix,
|
||||||
|
)
|
||||||
if "</head>" in text:
|
if "</head>" in text:
|
||||||
text = text.replace("</head>", f"{injection}</head>", 1)
|
text = text.replace("</head>", f"{injection}</head>", 1)
|
||||||
else:
|
else:
|
||||||
@@ -972,6 +1259,7 @@ async def _proxy_weknora_request(
|
|||||||
|
|
||||||
content = response.content
|
content = response.content
|
||||||
content_type = response.headers.get("content-type", "")
|
content_type = response.headers.get("content-type", "")
|
||||||
|
public_prefix = _request_public_prefix(request)
|
||||||
if is_api and request.method.upper() == "GET" and upstream_path.rstrip("/") == "/api/v1/knowledge-bases":
|
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
|
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():
|
if is_api and ctx.is_conversation_deposit and "application/json" in content_type.lower():
|
||||||
@@ -986,11 +1274,48 @@ async def _proxy_weknora_request(
|
|||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
if not is_api and "text/html" in content_type.lower():
|
if not is_api and "text/html" in content_type.lower():
|
||||||
allowed_path = f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}"
|
allowed_path = _with_public_prefix(
|
||||||
content = _inject_weknora_embed_html(content, allowed_path=allowed_path, tab=tab, session=session, ctx=ctx)
|
public_prefix,
|
||||||
|
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,
|
||||||
|
public_prefix=public_prefix,
|
||||||
|
)
|
||||||
content_type = "text/html; charset=utf-8"
|
content_type = "text/html; charset=utf-8"
|
||||||
|
elif not is_api and _should_rewrite_weknora_static_response(content_type, upstream_path):
|
||||||
|
text = content.decode("utf-8", errors="replace")
|
||||||
|
lower_type = content_type.lower()
|
||||||
|
if "javascript" in lower_type or upstream_path.lower().endswith((".js", ".mjs")):
|
||||||
|
text = _rewrite_weknora_script_public_paths(text, public_prefix)
|
||||||
|
else:
|
||||||
|
text = _rewrite_weknora_html_public_paths(
|
||||||
|
text,
|
||||||
|
public_prefix,
|
||||||
|
rewrite_relative=False,
|
||||||
|
)
|
||||||
|
content = text.encode("utf-8")
|
||||||
|
|
||||||
headers_out = _safe_response_headers(response.headers)
|
headers_out = _safe_response_headers(response.headers)
|
||||||
|
if "location" in headers_out:
|
||||||
|
headers_out["location"] = _rewrite_weknora_location_header(
|
||||||
|
headers_out["location"],
|
||||||
|
public_prefix,
|
||||||
|
target_base_url,
|
||||||
|
)
|
||||||
|
if "Location" in headers_out:
|
||||||
|
headers_out["Location"] = _rewrite_weknora_location_header(
|
||||||
|
headers_out["Location"],
|
||||||
|
public_prefix,
|
||||||
|
target_base_url,
|
||||||
|
)
|
||||||
|
if not is_api:
|
||||||
|
headers_out.pop("etag", None)
|
||||||
|
headers_out.pop("ETag", None)
|
||||||
headers_out["Cache-Control"] = "no-store" if is_api or "text/html" in content_type.lower() else "private, max-age=3600"
|
headers_out["Cache-Control"] = "no-store" if is_api or "text/html" in content_type.lower() else "private, max-age=3600"
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
@@ -1263,15 +1588,17 @@ def _weknora_embed_target_path(weknora_id: str, tab: str) -> str:
|
|||||||
|
|
||||||
def _set_weknora_embed_cookie(request: Request, response: Response, payload: dict[str, Any]) -> None:
|
def _set_weknora_embed_cookie(request: Request, response: Response, payload: dict[str, Any]) -> None:
|
||||||
secure = is_secure_request(request)
|
secure = is_secure_request(request)
|
||||||
response.set_cookie(
|
signed = _sign_embed_payload(payload)
|
||||||
_WEKNORA_EMBED_COOKIE,
|
for path in ("/", _WEKNORA_PUBLIC_PREFIX):
|
||||||
_sign_embed_payload(payload),
|
response.set_cookie(
|
||||||
max_age=_WEKNORA_EMBED_TTL_SECONDS,
|
_WEKNORA_EMBED_COOKIE,
|
||||||
httponly=True,
|
signed,
|
||||||
secure=secure,
|
max_age=_WEKNORA_EMBED_TTL_SECONDS,
|
||||||
samesite="none" if secure else "lax",
|
httponly=True,
|
||||||
path="/",
|
secure=secure,
|
||||||
)
|
samesite="none" if secure else "lax",
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
|
||||||
@@ -1355,7 +1682,10 @@ async def create_weknora_detail_session(
|
|||||||
"""Create a signed iframe session using the normal DeerFlow API auth path."""
|
"""Create a signed iframe session using the normal DeerFlow API auth path."""
|
||||||
|
|
||||||
weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id)
|
weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id)
|
||||||
target = _weknora_embed_target_path(weknora_id, tab)
|
target = _with_public_prefix(
|
||||||
|
_request_public_prefix(request),
|
||||||
|
_weknora_embed_target_path(weknora_id, tab),
|
||||||
|
)
|
||||||
response = JSONResponse(
|
response = JSONResponse(
|
||||||
{
|
{
|
||||||
"frame_path": target,
|
"frame_path": target,
|
||||||
@@ -1375,7 +1705,10 @@ async def open_weknora_detail_frame(
|
|||||||
"""Start a short-lived, DeerFlow-authorized WeKnora detail iframe session."""
|
"""Start a short-lived, DeerFlow-authorized WeKnora detail iframe session."""
|
||||||
|
|
||||||
weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id)
|
weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id)
|
||||||
target = _weknora_embed_target_path(weknora_id, tab)
|
target = _with_public_prefix(
|
||||||
|
_request_public_prefix(request),
|
||||||
|
_weknora_embed_target_path(weknora_id, tab),
|
||||||
|
)
|
||||||
response = RedirectResponse(target, status_code=302)
|
response = RedirectResponse(target, status_code=302)
|
||||||
_set_weknora_embed_cookie(request, response, payload)
|
_set_weknora_embed_cookie(request, response, payload)
|
||||||
return response
|
return response
|
||||||
@@ -1388,8 +1721,19 @@ async def proxy_weknora_platform_page(request: Request, proxied_path: str = "")
|
|||||||
requested = f"/platform/{proxied_path}".rstrip("/")
|
requested = f"/platform/{proxied_path}".rstrip("/")
|
||||||
allowed_path = f"/platform/knowledge-bases/{ctx.weknora_id}"
|
allowed_path = f"/platform/knowledge-bases/{ctx.weknora_id}"
|
||||||
if requested != allowed_path:
|
if requested != allowed_path:
|
||||||
|
static_upstream_path = _weknora_static_fallback_upstream_path(request)
|
||||||
|
if static_upstream_path is not None:
|
||||||
|
runtime = _runtime_for_iframe_proxy(request)
|
||||||
|
return await _proxy_weknora_request(
|
||||||
|
request,
|
||||||
|
target_base_url=runtime.web_base_url,
|
||||||
|
upstream_path=static_upstream_path,
|
||||||
|
ctx=ctx,
|
||||||
|
is_api=False,
|
||||||
|
)
|
||||||
target_query = urlencode({"tab": request.query_params.get("tab") or "wiki"})
|
target_query = urlencode({"tab": request.query_params.get("tab") or "wiki"})
|
||||||
return RedirectResponse(f"{allowed_path}?{target_query}", status_code=302)
|
public_target = _with_public_prefix(_request_public_prefix(request), allowed_path)
|
||||||
|
return RedirectResponse(f"{public_target}?{target_query}", status_code=302)
|
||||||
runtime = _runtime_for_iframe_proxy(request)
|
runtime = _runtime_for_iframe_proxy(request)
|
||||||
return await _proxy_weknora_request(
|
return await _proxy_weknora_request(
|
||||||
request,
|
request,
|
||||||
@@ -1438,7 +1782,10 @@ async def proxy_weknora_root_asset(request: Request) -> Response:
|
|||||||
return await _proxy_weknora_request(
|
return await _proxy_weknora_request(
|
||||||
request,
|
request,
|
||||||
target_base_url=runtime.web_base_url,
|
target_base_url=runtime.web_base_url,
|
||||||
upstream_path=request.url.path,
|
upstream_path=_without_public_prefix(
|
||||||
|
_request_public_prefix(request),
|
||||||
|
str(request.scope.get("path") or request.url.path),
|
||||||
|
),
|
||||||
ctx=ctx,
|
ctx=ctx,
|
||||||
is_api=False,
|
is_api=False,
|
||||||
)
|
)
|
||||||
@@ -1479,6 +1826,10 @@ async def proxy_weknora_api(request: Request, proxied_path: str) -> Response:
|
|||||||
"/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}",
|
"/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}",
|
||||||
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
)
|
)
|
||||||
|
@router.api_route(
|
||||||
|
"/deerflow/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:
|
async def proxy_weknora_embed_api(request: Request, proxied_path: str) -> Response:
|
||||||
"""Proxy iframe API calls under a collision-free cmzs namespace."""
|
"""Proxy iframe API calls under a collision-free cmzs namespace."""
|
||||||
|
|
||||||
@@ -1495,6 +1846,22 @@ async def proxy_weknora_embed_api(request: Request, proxied_path: str) -> Respon
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@proxy_router.api_route("/{proxied_path:path}", methods=["GET", "HEAD"])
|
||||||
|
async def proxy_weknora_static_fallback(request: Request, proxied_path: str) -> Response:
|
||||||
|
upstream_path = _weknora_static_fallback_upstream_path(request)
|
||||||
|
if upstream_path is None:
|
||||||
|
raise HTTPException(status_code=404, detail="WeKnora proxy path not found")
|
||||||
|
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=upstream_path,
|
||||||
|
ctx=ctx,
|
||||||
|
is_api=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/llmwiki/runtime")
|
@router.get("/api/llmwiki/runtime")
|
||||||
async def get_llmwiki_runtime() -> dict[str, Any]:
|
async def get_llmwiki_runtime() -> dict[str, Any]:
|
||||||
return _runtime_or_legacy()
|
return _runtime_or_legacy()
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from deerflow.config.runtime_paths import runtime_home
|
||||||
|
|
||||||
WEKNORA_EMBED_COOKIE = "deerflow_weknora_embed"
|
WEKNORA_EMBED_COOKIE = "deerflow_weknora_embed"
|
||||||
WEKNORA_EMBED_TTL_SECONDS = 15 * 60
|
WEKNORA_EMBED_TTL_SECONDS = 15 * 60
|
||||||
WEKNORA_EMBED_API_PREFIX = "/api/llmwiki/weknora-embed"
|
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(
|
WEKNORA_ROOT_ASSET_PATHS: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
"/favicon.ico",
|
"/favicon.ico",
|
||||||
@@ -27,6 +28,43 @@ WEKNORA_ROOT_ASSET_PATHS: frozenset[str] = frozenset(
|
|||||||
"/manifest.webmanifest",
|
"/manifest.webmanifest",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
WEKNORA_STATIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||||
|
"/assets/",
|
||||||
|
"/css/",
|
||||||
|
"/fonts/",
|
||||||
|
"/img/",
|
||||||
|
"/images/",
|
||||||
|
"/js/",
|
||||||
|
"/locales/",
|
||||||
|
"/media/",
|
||||||
|
"/static/",
|
||||||
|
"/tdesign-icons/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_weknora_proxy_secret() -> str:
|
||||||
|
if secret := os.getenv("DEERFLOW_LLMWIKI_PROXY_SECRET"):
|
||||||
|
return secret
|
||||||
|
secret_path = runtime_home() / "weknora_embed_secret"
|
||||||
|
try:
|
||||||
|
if secret_path.is_file():
|
||||||
|
secret = secret_path.read_text(encoding="utf-8").strip()
|
||||||
|
if secret:
|
||||||
|
return secret
|
||||||
|
secret_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
secret = secrets.token_urlsafe(48)
|
||||||
|
try:
|
||||||
|
fd = os.open(str(secret_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
except FileExistsError:
|
||||||
|
return secret_path.read_text(encoding="utf-8").strip()
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(secret)
|
||||||
|
return secret
|
||||||
|
except OSError:
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
WEKNORA_PROXY_SECRET = _load_weknora_proxy_secret()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -41,12 +79,11 @@ class WeKnoraEmbedContext:
|
|||||||
|
|
||||||
|
|
||||||
def is_weknora_embed_proxy_path(path: str) -> bool:
|
def is_weknora_embed_proxy_path(path: str) -> bool:
|
||||||
|
path = path.removeprefix("/deerflow")
|
||||||
return (
|
return (
|
||||||
path == "/platform"
|
path == "/platform"
|
||||||
or path.startswith("/platform/")
|
or path.startswith("/platform/")
|
||||||
or path.startswith("/assets/")
|
or path.startswith(WEKNORA_STATIC_PATH_PREFIXES)
|
||||||
or path.startswith("/locales/")
|
|
||||||
or path.startswith("/tdesign-icons/")
|
|
||||||
or path in WEKNORA_ROOT_ASSET_PATHS
|
or path in WEKNORA_ROOT_ASSET_PATHS
|
||||||
or path.startswith(f"{WEKNORA_EMBED_API_PREFIX}/api/v1/")
|
or path.startswith(f"{WEKNORA_EMBED_API_PREFIX}/api/v1/")
|
||||||
or (path.startswith("/api/v1/") and not path.startswith("/api/v1/auth/"))
|
or (path.startswith("/api/v1/") and not path.startswith("/api/v1/auth/"))
|
||||||
|
|||||||
+116
-15
@@ -1,12 +1,13 @@
|
|||||||
"""Configurable JSON-over-HTTP implementation of the ``web_search`` tool."""
|
"""Configurable JSON-over-HTTP implementation of the ``web_search`` tool."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
@@ -19,11 +20,10 @@ from deerflow.config.tool_config import ToolConfig
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
_BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
||||||
|
_ISO_DATE_RE = re.compile(r"(?P<year>\d{4})(?:[-/.]|\u5e74)(?P<month>\d{1,2})(?:[-/.]|\u6708)(?P<day>\d{1,2})(?:\u65e5)?")
|
||||||
|
_RECENT_DAYS_RE = re.compile(r"(\u8fd1\u51e0\u5929|\u6700\u8fd1\u51e0\u5929|\u8fd9\u51e0\u5929|\u8fd1\u4e00\u5468|\u6700\u8fd1\u4e00\u5468|\u8fd17\u5929|\u6700\u8fd17\u5929)")
|
||||||
def _current_time_hint() -> str:
|
_RECENT_MONTHS_RE = re.compile(r"(\u8fd1\u671f|\u6700\u8fd1|\u8fd1\u4e09\u4e2a\u6708|\u6700\u8fd1\u4e09\u4e2a\u6708|\u8fd13\u4e2a\u6708|\u6700\u8fd13\u4e2a\u6708)")
|
||||||
now = datetime.now(_BEIJING_TZ)
|
_TOP_K_RE = re.compile(r"(?:(?:\u8fd4\u56de|\u67e5|\u68c0\u7d22|\u53d6|\u524d)\s*)?(?P<count>\d{1,3})\s*(?:\u6761|\u6761\u6570|\u7bc7|\u4efd)")
|
||||||
return now.strftime("%Y-%m-%d %H:%M:%S, %A, Asia/Shanghai")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -112,6 +112,86 @@ def _looks_like_url(value: str) -> bool:
|
|||||||
return value.startswith(("http://", "https://"))
|
return value.startswith(("http://", "https://"))
|
||||||
|
|
||||||
|
|
||||||
|
def _today() -> date:
|
||||||
|
return datetime.now(_BEIJING_TZ).date()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date(value: date) -> str:
|
||||||
|
return value.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _subtract_months(value: date, months: int) -> date:
|
||||||
|
month_index = value.month - 1 - months
|
||||||
|
year = value.year + month_index // 12
|
||||||
|
month = month_index % 12 + 1
|
||||||
|
leap_year = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
||||||
|
month_lengths = [31, 29 if leap_year else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
return value.replace(year=year, month=month, day=min(value.day, month_lengths[month - 1]))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_date(value: str) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
match = _ISO_DATE_RE.search(text)
|
||||||
|
if not match:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return _format_date(
|
||||||
|
date(
|
||||||
|
int(match.group("year")),
|
||||||
|
int(match.group("month")),
|
||||||
|
int(match.group("day")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_query_dates(query: str) -> tuple[str, str]:
|
||||||
|
dates = [_normalize_date(match.group(0)) for match in _ISO_DATE_RE.finditer(query)]
|
||||||
|
dates = [item for item in dates if item]
|
||||||
|
if len(dates) >= 2:
|
||||||
|
return min(dates[0], dates[1]), max(dates[0], dates[1])
|
||||||
|
if len(dates) == 1:
|
||||||
|
return dates[0], dates[0]
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_date_range(
|
||||||
|
query: str,
|
||||||
|
*,
|
||||||
|
start_date: str = "",
|
||||||
|
end_date: str = "",
|
||||||
|
today: date | None = None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
normalized_start = _normalize_date(start_date)
|
||||||
|
normalized_end = _normalize_date(end_date)
|
||||||
|
if normalized_start or normalized_end:
|
||||||
|
return normalized_start, normalized_end
|
||||||
|
|
||||||
|
query_start, query_end = _extract_query_dates(query)
|
||||||
|
if query_start or query_end:
|
||||||
|
return query_start, query_end
|
||||||
|
|
||||||
|
current = today or _today()
|
||||||
|
if _RECENT_DAYS_RE.search(query):
|
||||||
|
return _format_date(current - timedelta(days=7)), _format_date(current)
|
||||||
|
if _RECENT_MONTHS_RE.search(query):
|
||||||
|
return _format_date(_subtract_months(current, 3)), _format_date(current)
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_top_k(query: str, top_k: str | int = "") -> str:
|
||||||
|
explicit = str(top_k).strip() if top_k is not None else ""
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
match = _TOP_K_RE.search(query)
|
||||||
|
if not match:
|
||||||
|
return ""
|
||||||
|
return match.group("count")
|
||||||
|
|
||||||
|
|
||||||
def _error_result(query: str, message: str) -> str:
|
def _error_result(query: str, message: str) -> str:
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
@@ -183,6 +263,10 @@ async def execute_search(
|
|||||||
query: str,
|
query: str,
|
||||||
settings: ConfigurableSearchSettings,
|
settings: ConfigurableSearchSettings,
|
||||||
*,
|
*,
|
||||||
|
start_date: str = "",
|
||||||
|
end_date: str = "",
|
||||||
|
top_k: str | int = "",
|
||||||
|
today: date | None = None,
|
||||||
transport: httpx.AsyncBaseTransport | None = None,
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Execute a search request; ``transport`` exists for network-free tests."""
|
"""Execute a search request; ``transport`` exists for network-free tests."""
|
||||||
@@ -194,7 +278,16 @@ async def execute_search(
|
|||||||
return _error_result(normalized_query, "web_search is disabled")
|
return _error_result(normalized_query, "web_search is disabled")
|
||||||
|
|
||||||
payload = deepcopy(settings.payload)
|
payload = deepcopy(settings.payload)
|
||||||
|
resolved_start, resolved_end = _resolve_date_range(
|
||||||
|
normalized_query,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
today=today,
|
||||||
|
)
|
||||||
payload["query"] = normalized_query
|
payload["query"] = normalized_query
|
||||||
|
payload["start_date"] = resolved_start
|
||||||
|
payload["end_date"] = resolved_end
|
||||||
|
payload["top_k"] = _resolve_top_k(normalized_query, top_k)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -234,19 +327,27 @@ async def execute_search(
|
|||||||
|
|
||||||
|
|
||||||
@tool("web_search", parse_docstring=True)
|
@tool("web_search", parse_docstring=True)
|
||||||
async def web_search_tool(query: str) -> str:
|
async def web_search_tool(query: str, start_date: str = "", end_date: str = "", top_k: str = "") -> str:
|
||||||
"""Search the configured information service for current facts and source passages.
|
"""Search the internal knowledge base and return source passages.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Focused search keywords or a natural-language retrieval question.
|
query: Knowledge-base retrieval query. Keep the user's subject and time
|
||||||
Interpret relative time expressions against the current time in
|
requirement in this text. If the user says recent/近期/最近, this tool
|
||||||
Asia/Shanghai, and include concrete dates/date ranges in the query
|
will search the latest three months. If the user says near days/近几天/
|
||||||
when the user asks about recent periods.
|
最近几天/这几天/近一周, this tool will search the latest seven days.
|
||||||
|
start_date: Optional start date in YYYY-MM-DD. Leave empty by default.
|
||||||
|
Pass this only when the user explicitly asks for a concrete date or
|
||||||
|
date range.
|
||||||
|
end_date: Optional end date in YYYY-MM-DD. Leave empty by default.
|
||||||
|
Pass this only when the user explicitly asks for a concrete date or
|
||||||
|
date range.
|
||||||
|
top_k: Optional maximum number of records to return, such as "15".
|
||||||
|
Leave empty by default. Pass this when the user asks for a specific
|
||||||
|
number of results.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
settings = ConfigurableSearchSettings.from_tool_config(get_app_config().get_tool_config("web_search"))
|
settings = ConfigurableSearchSettings.from_tool_config(get_app_config().get_tool_config("web_search"))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return _error_result(query, str(exc))
|
return _error_result(query, str(exc))
|
||||||
grounded_query = f"{query.strip()}\n当前时间: {_current_time_hint()}" if query.strip() else query
|
return await execute_search(query, settings, start_date=start_date, end_date=end_date, top_k=top_k)
|
||||||
return await execute_search(grounded_query, settings)
|
|
||||||
@@ -38,16 +38,16 @@ class WeKnoraConnectionConfig(BaseModel):
|
|||||||
admin_email: str = Field(
|
admin_email: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description=(
|
description=(
|
||||||
"Optional WeKnora administrator email used only by DeerFlow's "
|
"Optional WeKnora administrator email used by DeerFlow's server-side "
|
||||||
"server-side detail-page iframe proxy. Prefer $ENV references."
|
"WeKnora API client and detail-page iframe proxy. Prefer $ENV references."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
admin_password: str = Field(
|
admin_password: str = Field(
|
||||||
default="",
|
default="",
|
||||||
repr=False,
|
repr=False,
|
||||||
description=(
|
description=(
|
||||||
"Optional WeKnora administrator password used only by DeerFlow's "
|
"Optional WeKnora administrator password used by DeerFlow's server-side "
|
||||||
"server-side detail-page iframe proxy. Prefer $ENV references."
|
"WeKnora API client and detail-page iframe proxy. Prefer $ENV references."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|||||||
+122
-19
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
@@ -41,9 +42,10 @@ class WeKnoraClient:
|
|||||||
"""Async client for a trusted-network WeKnora endpoint.
|
"""Async client for a trusted-network WeKnora endpoint.
|
||||||
|
|
||||||
The caller may inject an ``httpx.AsyncClient`` for connection pooling and
|
The caller may inject an ``httpx.AsyncClient`` for connection pooling and
|
||||||
tests. When omitted, each public method creates and closes a short-lived
|
tests. When omitted, each public method creates and closes a short-lived client.
|
||||||
client. DeerFlow deliberately sends no WeKnora credential; the independently
|
If admin credentials are configured, DeerFlow authenticates to WeKnora with a
|
||||||
deployed service or its reverse proxy must allow DeerFlow's internal traffic.
|
cached bearer token; otherwise it keeps the legacy trusted-network anonymous
|
||||||
|
mode for deployments that allow DeerFlow through a reverse proxy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -52,13 +54,105 @@ class WeKnoraClient:
|
|||||||
base_url: str,
|
base_url: str,
|
||||||
timeout_seconds: float = 30.0,
|
timeout_seconds: float = 30.0,
|
||||||
http_client: httpx.AsyncClient | None = None,
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
admin_email: str = "",
|
||||||
|
admin_password: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.timeout_seconds = timeout_seconds
|
self.timeout_seconds = timeout_seconds
|
||||||
self._http_client = http_client
|
self._http_client = http_client
|
||||||
|
self.admin_email = admin_email.strip()
|
||||||
|
self.admin_password = admin_password
|
||||||
|
self._auth_token = ""
|
||||||
|
self._tenant_id = ""
|
||||||
|
self._auth_expires_at = 0.0
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
raise ValueError("WeKnora base URL is required")
|
raise ValueError("WeKnora base URL is required")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _has_admin_credentials(self) -> bool:
|
||||||
|
return bool(self.admin_email and self.admin_password)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_login_token(payload: Any) -> tuple[str, str]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return "", ""
|
||||||
|
source = payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
||||||
|
token = str(
|
||||||
|
source.get("token")
|
||||||
|
or source.get("access_token")
|
||||||
|
or payload.get("token")
|
||||||
|
or payload.get("access_token")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
tenant = (
|
||||||
|
source.get("tenant")
|
||||||
|
if isinstance(source.get("tenant"), dict)
|
||||||
|
else source.get("active_tenant")
|
||||||
|
if isinstance(source.get("active_tenant"), dict)
|
||||||
|
else payload.get("tenant")
|
||||||
|
)
|
||||||
|
user = source.get("user") if isinstance(source.get("user"), dict) else payload.get("user")
|
||||||
|
tenant_id = ""
|
||||||
|
if isinstance(tenant, dict):
|
||||||
|
tenant_id = str(tenant.get("id") or "")
|
||||||
|
if not tenant_id and isinstance(user, dict):
|
||||||
|
tenant_id = str(user.get("tenant_id") or "")
|
||||||
|
return token, tenant_id
|
||||||
|
|
||||||
|
async def _login(self, *, force_refresh: bool = False) -> None:
|
||||||
|
if not self._has_admin_credentials:
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
if self._auth_token and not force_refresh and self._auth_expires_at > now + 60:
|
||||||
|
return
|
||||||
|
|
||||||
|
owns_client = self._http_client is None
|
||||||
|
client = self._http_client or httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(self.timeout_seconds),
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/api/v1/auth/login",
|
||||||
|
json={"email": self.admin_email, "password": self.admin_password},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise WeKnoraError("WeKnora admin login timed out", status_code=504) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise WeKnoraError("WeKnora admin login is unavailable", status_code=502) from exc
|
||||||
|
finally:
|
||||||
|
if owns_client:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise WeKnoraError(f"WeKnora admin login failed ({response.status_code})", status_code=502)
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise WeKnoraError("WeKnora admin login returned invalid JSON", status_code=502) from exc
|
||||||
|
if isinstance(payload, dict) and payload.get("success") is False:
|
||||||
|
raise WeKnoraError(str(payload.get("message") or "WeKnora admin login failed"), status_code=502)
|
||||||
|
token, tenant_id = self._extract_login_token(payload)
|
||||||
|
if not token:
|
||||||
|
raise WeKnoraError("WeKnora admin login did not return a token", status_code=502)
|
||||||
|
|
||||||
|
self._auth_token = token
|
||||||
|
self._tenant_id = tenant_id
|
||||||
|
# WeKnora login responses do not consistently expose expiry. Refresh
|
||||||
|
# periodically and retry once if upstream says the token is stale.
|
||||||
|
self._auth_expires_at = now + 25 * 60
|
||||||
|
|
||||||
|
async def _headers(self, *, force_refresh: bool = False, accept: str = "application/json") -> dict[str, str]:
|
||||||
|
headers = {"Accept": accept}
|
||||||
|
if self._has_admin_credentials:
|
||||||
|
await self._login(force_refresh=force_refresh)
|
||||||
|
if self._auth_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self._auth_token}"
|
||||||
|
if self._tenant_id:
|
||||||
|
headers["X-Tenant-ID"] = self._tenant_id
|
||||||
|
return headers
|
||||||
|
|
||||||
async def _request(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -69,19 +163,21 @@ class WeKnoraClient:
|
|||||||
files: Any = None,
|
files: Any = None,
|
||||||
data: Mapping[str, Any] | None = None,
|
data: Mapping[str, Any] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
headers = {"Accept": "application/json"}
|
|
||||||
owns_client = self._http_client is None
|
owns_client = self._http_client is None
|
||||||
client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False)
|
client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False)
|
||||||
try:
|
try:
|
||||||
response = await client.request(
|
for attempt in range(2):
|
||||||
method,
|
response = await client.request(
|
||||||
f"{self.base_url}{path}",
|
method,
|
||||||
headers=headers,
|
f"{self.base_url}{path}",
|
||||||
json=json,
|
headers=await self._headers(force_refresh=attempt > 0),
|
||||||
params=params,
|
json=json,
|
||||||
files=files,
|
params=params,
|
||||||
data=data,
|
files=files,
|
||||||
)
|
data=data,
|
||||||
|
)
|
||||||
|
if response.status_code not in {401, 403} or not self._has_admin_credentials or attempt > 0:
|
||||||
|
break
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise WeKnoraError("WeKnora request timed out", status_code=504) from exc
|
raise WeKnoraError("WeKnora request timed out", status_code=504) from exc
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
@@ -92,6 +188,8 @@ class WeKnoraClient:
|
|||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
raise WeKnoraError("WeKnora resource was not found", status_code=404)
|
raise WeKnoraError("WeKnora resource was not found", status_code=404)
|
||||||
if response.status_code in {401, 403}:
|
if response.status_code in {401, 403}:
|
||||||
|
if self._has_admin_credentials:
|
||||||
|
raise WeKnoraError("WeKnora rejected DeerFlow's authenticated internal request", status_code=502)
|
||||||
raise WeKnoraError(
|
raise WeKnoraError(
|
||||||
"WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy",
|
"WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy",
|
||||||
status_code=502,
|
status_code=502,
|
||||||
@@ -296,12 +394,15 @@ class WeKnoraClient:
|
|||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = await client.request(
|
for attempt in range(2):
|
||||||
"GET",
|
response = await client.request(
|
||||||
f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview",
|
"GET",
|
||||||
headers={"Accept": "*/*"},
|
f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview",
|
||||||
follow_redirects=True,
|
headers=await self._headers(force_refresh=attempt > 0, accept="*/*"),
|
||||||
)
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
if response.status_code not in {401, 403} or not self._has_admin_credentials or attempt > 0:
|
||||||
|
break
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise WeKnoraError("WeKnora preview request timed out", status_code=504) from exc
|
raise WeKnoraError("WeKnora preview request timed out", status_code=504) from exc
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
@@ -312,6 +413,8 @@ class WeKnoraClient:
|
|||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
raise WeKnoraError("WeKnora document preview was not found", status_code=404)
|
raise WeKnoraError("WeKnora document preview was not found", status_code=404)
|
||||||
if response.status_code in {401, 403}:
|
if response.status_code in {401, 403}:
|
||||||
|
if self._has_admin_credentials:
|
||||||
|
raise WeKnoraError("WeKnora rejected DeerFlow's authenticated preview request", status_code=502)
|
||||||
raise WeKnoraError(
|
raise WeKnoraError(
|
||||||
"WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy",
|
"WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy",
|
||||||
status_code=502,
|
status_code=502,
|
||||||
|
|||||||
+2
@@ -40,4 +40,6 @@ def build_weknora_client(
|
|||||||
return WeKnoraClient(
|
return WeKnoraClient(
|
||||||
base_url=runtime.api_base_url,
|
base_url=runtime.api_base_url,
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
|
admin_email=runtime.admin_email,
|
||||||
|
admin_password=runtime.admin_password,
|
||||||
)
|
)
|
||||||
+78
-8
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -35,15 +36,20 @@ async def test_configurable_search_overrides_query_and_normalizes_nullable_resul
|
|||||||
async def handler(request: httpx.Request) -> httpx.Response:
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
requests.append(request)
|
requests.append(request)
|
||||||
assert request.method == "POST"
|
assert request.method == "POST"
|
||||||
assert json.loads(request.content) == {"query": "测试检索词", "xxx": "xxxx"}
|
payload = json.loads(request.content)
|
||||||
|
assert payload["query"].strip()
|
||||||
|
assert payload["xxx"] == "xxxx"
|
||||||
|
assert payload["start_date"] == ""
|
||||||
|
assert payload["end_date"] == ""
|
||||||
|
assert payload["top_k"] == ""
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
json={
|
json={
|
||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"recUuid": "record/123",
|
"recUuid": "record/123",
|
||||||
"content": "检索内容",
|
"content": "test content",
|
||||||
"title": "标题",
|
"title": "test title",
|
||||||
"url": "https://original.example.test/one",
|
"url": "https://original.example.test/one",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -60,7 +66,7 @@ async def test_configurable_search_overrides_query_and_normalizes_nullable_resul
|
|||||||
settings = _settings()
|
settings = _settings()
|
||||||
result = json.loads(
|
result = json.loads(
|
||||||
await execute_search(
|
await execute_search(
|
||||||
" 测试检索词 ",
|
" test query ",
|
||||||
settings,
|
settings,
|
||||||
transport=httpx.MockTransport(handler),
|
transport=httpx.MockTransport(handler),
|
||||||
)
|
)
|
||||||
@@ -69,13 +75,13 @@ async def test_configurable_search_overrides_query_and_normalizes_nullable_resul
|
|||||||
assert len(requests) == 1
|
assert len(requests) == 1
|
||||||
assert settings.payload == {"query": "configured value", "xxx": "xxxx"}
|
assert settings.payload == {"query": "configured value", "xxx": "xxxx"}
|
||||||
assert result == {
|
assert result == {
|
||||||
"query": "测试检索词",
|
"query": "test query",
|
||||||
"total_results": 2,
|
"total_results": 2,
|
||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"recUuid": "record/123",
|
"recUuid": "record/123",
|
||||||
"content": "检索内容",
|
"content": "test content",
|
||||||
"title": "标题",
|
"title": "test title",
|
||||||
"url": "https://search.example.test/mindId/xxx?recUuid=record%2F123",
|
"url": "https://search.example.test/mindId/xxx?recUuid=record%2F123",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -104,6 +110,70 @@ async def test_configurable_search_handles_empty_results() -> None:
|
|||||||
assert result == {"query": "nothing", "total_results": 0, "results": []}
|
assert result == {"query": "nothing", "total_results": 0, "results": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("query", "expected_start", "expected_end"),
|
||||||
|
[
|
||||||
|
("近期政策", "2026-05-13", "2026-08-13"),
|
||||||
|
("近几天政策", "2026-08-06", "2026-08-13"),
|
||||||
|
("查询 2026年8月10日 到 2026-08-13 的政策", "2026-08-10", "2026-08-13"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_configurable_search_adds_date_payload(
|
||||||
|
query: str,
|
||||||
|
expected_start: str,
|
||||||
|
expected_end: str,
|
||||||
|
) -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload["start_date"] == expected_start
|
||||||
|
assert payload["end_date"] == expected_end
|
||||||
|
assert payload["top_k"] == ""
|
||||||
|
return httpx.Response(200, json={"results": []})
|
||||||
|
|
||||||
|
await execute_search(
|
||||||
|
query,
|
||||||
|
_settings(),
|
||||||
|
today=date(2026, 8, 13),
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_configurable_search_explicit_date_args_and_top_k_win() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload["start_date"] == "2026-08-10"
|
||||||
|
assert payload["end_date"] == "2026-08-13"
|
||||||
|
assert payload["top_k"] == "15"
|
||||||
|
return httpx.Response(200, json={"results": []})
|
||||||
|
|
||||||
|
await execute_search(
|
||||||
|
"近期政策",
|
||||||
|
_settings(),
|
||||||
|
start_date="2026/8/10",
|
||||||
|
end_date="2026年8月13日",
|
||||||
|
top_k=15,
|
||||||
|
today=date(2026, 8, 13),
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_configurable_search_infers_top_k_from_query() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload["top_k"] == "15"
|
||||||
|
return httpx.Response(200, json={"results": []})
|
||||||
|
|
||||||
|
await execute_search(
|
||||||
|
"近期政策 返回15条",
|
||||||
|
_settings(),
|
||||||
|
today=date(2026, 8, 13),
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_configurable_search_accepts_root_array_results() -> None:
|
async def test_configurable_search_accepts_root_array_results() -> None:
|
||||||
async def handler(_: httpx.Request) -> httpx.Response:
|
async def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from starlette.datastructures import Headers, QueryParams
|
from starlette.datastructures import Headers, QueryParams
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ def _request(
|
|||||||
query: str = "",
|
query: str = "",
|
||||||
cookies: dict[str, str] | None = None,
|
cookies: dict[str, str] | None = None,
|
||||||
request_headers: dict[str, str] | None = None,
|
request_headers: dict[str, str] | None = None,
|
||||||
|
root_path: str = "",
|
||||||
) -> Request:
|
) -> Request:
|
||||||
headers: list[tuple[bytes, bytes]] = [
|
headers: list[tuple[bytes, bytes]] = [
|
||||||
(key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items()
|
(key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items()
|
||||||
@@ -40,6 +41,7 @@ def _request(
|
|||||||
"type": "http",
|
"type": "http",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": path,
|
"path": path,
|
||||||
|
"root_path": root_path,
|
||||||
"query_string": query.encode(),
|
"query_string": query.encode(),
|
||||||
"headers": headers,
|
"headers": headers,
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
@@ -171,15 +173,37 @@ def test_weknora_embed_cookie_bypasses_only_proxy_paths() -> None:
|
|||||||
cookies = {"deerflow_weknora_embed": raw_cookie}
|
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("/platform/knowledge-bases/kb", cookies=cookies))
|
||||||
|
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||||
|
_request("/deerflow/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("/assets/index.js", cookies=cookies))
|
||||||
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/assets/index.js", cookies=cookies))
|
||||||
|
for static_path in (
|
||||||
|
"/deerflow/css/app.css",
|
||||||
|
"/deerflow/fonts/app.woff2",
|
||||||
|
"/deerflow/img/logo.png",
|
||||||
|
"/deerflow/images/empty.svg",
|
||||||
|
"/deerflow/js/app.js",
|
||||||
|
"/deerflow/media/intro.mp4",
|
||||||
|
"/deerflow/static/chunk.js",
|
||||||
|
):
|
||||||
|
request = _request(static_path, cookies=cookies)
|
||||||
|
assert auth_middleware._is_weknora_embed_proxy_request(request)
|
||||||
|
assert csrf_middleware.is_weknora_embed_proxy_request(request)
|
||||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/config.js", cookies=cookies))
|
assert auth_middleware._is_weknora_embed_proxy_request(_request("/config.js", cookies=cookies))
|
||||||
assert auth_middleware._is_weknora_embed_proxy_request(
|
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||||
_request("/tdesign-icons/0.4.1/fonts/index.js", cookies=cookies)
|
_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/v1/knowledge-search", cookies=cookies))
|
||||||
|
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||||
|
_request("/deerflow/api/v1/knowledge-search", cookies=cookies)
|
||||||
|
)
|
||||||
assert auth_middleware._is_weknora_embed_proxy_request(
|
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||||
_request("/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies)
|
_request("/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies)
|
||||||
)
|
)
|
||||||
|
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||||
|
_request("/deerflow/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 csrf_middleware.is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies=cookies))
|
||||||
assert not auth_middleware._is_weknora_embed_proxy_request(
|
assert not auth_middleware._is_weknora_embed_proxy_request(
|
||||||
_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"})
|
_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"})
|
||||||
@@ -217,11 +241,18 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo
|
|||||||
)
|
)
|
||||||
|
|
||||||
payload = json.loads(response.body)
|
payload = json.loads(response.body)
|
||||||
assert payload["frame_path"] == "/platform/knowledge-bases/remote-kb?tab=wiki"
|
assert payload["frame_path"] == "/deerflow/platform/knowledge-bases/remote-kb?tab=wiki"
|
||||||
cookie = response.headers["set-cookie"]
|
cookie = response.headers["set-cookie"]
|
||||||
assert "deerflow_weknora_embed=" in cookie
|
assert "deerflow_weknora_embed=" in cookie
|
||||||
assert "Secure" in cookie
|
assert "Secure" in cookie
|
||||||
assert "SameSite=none" in cookie
|
assert "SameSite=none" in cookie
|
||||||
|
set_cookie_headers = [
|
||||||
|
value.decode("latin-1")
|
||||||
|
for key, value in response.raw_headers
|
||||||
|
if key.lower() == b"set-cookie"
|
||||||
|
]
|
||||||
|
assert any("Path=/" in value for value in set_cookie_headers)
|
||||||
|
assert any("Path=/deerflow" in value for value in set_cookie_headers)
|
||||||
raw = cookie.split("deerflow_weknora_embed=", 1)[1].split(";", 1)[0]
|
raw = cookie.split("deerflow_weknora_embed=", 1)[1].split(";", 1)[0]
|
||||||
ctx = llmwiki_router._verify_embed_cookie(raw)
|
ctx = llmwiki_router._verify_embed_cookie(raw)
|
||||||
assert ctx.mapping_id == "local-kb"
|
assert ctx.mapping_id == "local-kb"
|
||||||
@@ -230,7 +261,65 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_weknora_client_sends_no_credentials_and_normalizes_search() -> None:
|
async def test_weknora_session_endpoint_preserves_reverse_proxy_prefix(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-Prefix": "/deerflow"},
|
||||||
|
),
|
||||||
|
"local-kb",
|
||||||
|
tab="wiki",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
assert payload["frame_path"] == "/deerflow/platform/knowledge-bases/remote-kb?tab=wiki"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_weknora_session_endpoint_uses_backend_prefix_when_frontend_has_different_prefix(
|
||||||
|
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={"Referer": "https://example.test/magentweb/page/workspace/knowledge/local-kb"},
|
||||||
|
),
|
||||||
|
"local-kb",
|
||||||
|
tab="wiki",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
assert payload["frame_path"] == "/deerflow/platform/knowledge-bases/remote-kb?tab=wiki"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_weknora_client_keeps_anonymous_mode_without_admin_credentials() -> None:
|
||||||
requests: list[httpx.Request] = []
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
async def handler(request: httpx.Request) -> httpx.Response:
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
@@ -270,6 +359,77 @@ async def test_weknora_client_sends_no_credentials_and_normalizes_search() -> No
|
|||||||
assert results[0]["title"] == "Guide"
|
assert results[0]["title"] == "Guide"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_weknora_client_uses_admin_token_when_credentials_are_configured() -> None:
|
||||||
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append(request)
|
||||||
|
if request.url.path == "/api/v1/auth/login":
|
||||||
|
assert json.loads(request.content) == {"email": "admin@example.test", "password": "secret"}
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"token": "token-one",
|
||||||
|
"tenant": {"id": "tenant-one"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert request.headers["Authorization"] == "Bearer token-one"
|
||||||
|
assert request.headers["X-Tenant-ID"] == "tenant-one"
|
||||||
|
return httpx.Response(200, json={"success": True, "data": []})
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
||||||
|
client = WeKnoraClient(
|
||||||
|
base_url="http://weknora.local:8080",
|
||||||
|
http_client=http_client,
|
||||||
|
admin_email="admin@example.test",
|
||||||
|
admin_password="secret",
|
||||||
|
)
|
||||||
|
results = await client.search("deployment guide", ["kb-one"])
|
||||||
|
|
||||||
|
assert results == []
|
||||||
|
assert [request.url.path for request in requests] == [
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
"/api/v1/knowledge-search",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_weknora_client_refreshes_admin_token_after_rejection() -> None:
|
||||||
|
login_count = 0
|
||||||
|
search_count = 0
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal login_count, search_count
|
||||||
|
if request.url.path == "/api/v1/auth/login":
|
||||||
|
login_count += 1
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"success": True, "data": {"token": f"token-{login_count}"}},
|
||||||
|
)
|
||||||
|
search_count += 1
|
||||||
|
if search_count == 1:
|
||||||
|
assert request.headers["Authorization"] == "Bearer token-1"
|
||||||
|
return httpx.Response(401, json={"message": "expired"})
|
||||||
|
assert request.headers["Authorization"] == "Bearer token-2"
|
||||||
|
return httpx.Response(200, json={"success": True, "data": []})
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
|
||||||
|
client = WeKnoraClient(
|
||||||
|
base_url="http://weknora.local:8080",
|
||||||
|
http_client=http_client,
|
||||||
|
admin_email="admin@example.test",
|
||||||
|
admin_password="secret",
|
||||||
|
)
|
||||||
|
await client.search("deployment guide", ["kb-one"])
|
||||||
|
|
||||||
|
assert login_count == 2
|
||||||
|
assert search_count == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_weknora_client_merges_required_name_for_description_update() -> None:
|
async def test_weknora_client_merges_required_name_for_description_update() -> None:
|
||||||
requests: list[httpx.Request] = []
|
requests: list[httpx.Request] = []
|
||||||
@@ -901,6 +1061,180 @@ def test_weknora_iframe_proxy_is_fallback_after_deerflow_api_routes() -> None:
|
|||||||
== "/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}"
|
== "/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}"
|
||||||
)
|
)
|
||||||
assert _first_gateway_match("GET", "/platform/knowledge-bases/remote-kb") == "/platform/{proxied_path:path}"
|
assert _first_gateway_match("GET", "/platform/knowledge-bases/remote-kb") == "/platform/{proxied_path:path}"
|
||||||
|
assert (
|
||||||
|
_first_gateway_match("GET", "/deerflow/platform/knowledge-bases/remote-kb")
|
||||||
|
== "/deerflow/platform/{proxied_path:path}"
|
||||||
|
)
|
||||||
|
assert _first_gateway_match("GET", "/deerflow/assets/index.js") == "/deerflow/assets/{proxied_path:path}"
|
||||||
|
assert _first_gateway_match("GET", "/deerflow/js/index.js") == "/deerflow/{proxied_path:path}"
|
||||||
|
assert _first_gateway_match("GET", "/deerflow/css/index.css") == "/deerflow/{proxied_path:path}"
|
||||||
|
assert _first_gateway_match("GET", "/deerflow/fonts/index.woff2") == "/deerflow/{proxied_path:path}"
|
||||||
|
assert _first_gateway_match("GET", "/deerflow/api/v1/knowledge-search") == "/deerflow/api/v1/{proxied_path:path}"
|
||||||
|
assert _first_gateway_match("GET", "/js/index.js") == "/{proxied_path:path}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("location", "target_base_url", "expected"),
|
||||||
|
[
|
||||||
|
("/platform/knowledge-bases/kb?tab=wiki", "http://weknora-web:8080", "/deerflow/platform/knowledge-bases/kb?tab=wiki"),
|
||||||
|
("/deerflow/platform/knowledge-bases/kb", "http://weknora-web:8080", "/deerflow/platform/knowledge-bases/kb"),
|
||||||
|
("/deerflow-api/api/v1/auth/me", "http://gateway/deerflow-api", "/deerflow/api/v1/auth/me"),
|
||||||
|
(
|
||||||
|
"http://gateway/deerflow-api/api/v1/auth/me?next=1#top",
|
||||||
|
"http://gateway/deerflow-api",
|
||||||
|
"/deerflow/api/v1/auth/me?next=1#top",
|
||||||
|
),
|
||||||
|
("https://external.test/login", "http://gateway/deerflow-api", "https://external.test/login"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_weknora_redirect_location_is_normalized_for_public_prefix(
|
||||||
|
location: str,
|
||||||
|
target_base_url: str,
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
|
from app.gateway.routers import llmwiki as llmwiki_router
|
||||||
|
|
||||||
|
assert llmwiki_router._rewrite_weknora_location_header(location, "/deerflow", target_base_url) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prefixed_weknora_proxy_serves_page_static_assets_and_api_without_auth_errors(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
from app.gateway.auth_middleware import AuthMiddleware
|
||||||
|
from app.gateway.csrf_middleware import CSRFMiddleware
|
||||||
|
from app.gateway.routers import llmwiki as llmwiki_router
|
||||||
|
from app.gateway.weknora_embed import WEKNORA_EMBED_COOKIE, sign_weknora_embed_payload
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
async def get_authorized(self, *_: object, **__: object) -> dict[str, str]:
|
||||||
|
return {"id": "local-kb", "weknora_id": "remote-kb"}
|
||||||
|
|
||||||
|
runtime = SimpleNamespace(
|
||||||
|
web_base_url="http://weknora-web.test",
|
||||||
|
api_base_url="http://weknora-api.test",
|
||||||
|
)
|
||||||
|
upstream_requests: list[tuple[str, str, dict[str, str]]] = []
|
||||||
|
|
||||||
|
async def fake_session(_: object, *, force_refresh: bool = False) -> dict[str, object]:
|
||||||
|
return {"token": "admin-token", "tenant": {"id": "tenant-1"}}
|
||||||
|
|
||||||
|
class FakeUpstreamClient:
|
||||||
|
def __init__(self, **_: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> FakeUpstreamClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: dict[str, str],
|
||||||
|
**__: object,
|
||||||
|
) -> httpx.Response:
|
||||||
|
upstream_requests.append((method, url, headers))
|
||||||
|
path = httpx.URL(url).path
|
||||||
|
if url.startswith(runtime.api_base_url):
|
||||||
|
return httpx.Response(200, json={"success": True, "data": {"id": "admin"}})
|
||||||
|
if path == "/platform/knowledge-bases/remote-kb":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
text=(
|
||||||
|
'<html><head><script src="/assets/main.js"></script>'
|
||||||
|
'<link rel="stylesheet" href="css/app.css">'
|
||||||
|
'<script src="/config.js"></script></head><body></body></html>'
|
||||||
|
),
|
||||||
|
headers={"content-type": "text/html; charset=utf-8"},
|
||||||
|
)
|
||||||
|
if path == "/assets/main.js":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
text=(
|
||||||
|
'const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=['
|
||||||
|
'"assets/lazy.css","assets/lazy.js"])))=>i.map(i=>d[i]);'
|
||||||
|
'const chunk="/static/chunk.js",matcher=/platform\\//;fetch("/api/v1/auth/me")'
|
||||||
|
),
|
||||||
|
headers={"content-type": "application/javascript"},
|
||||||
|
)
|
||||||
|
if path == "/css/app.css":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
text='@font-face{src:url("/fonts/app.woff2")}',
|
||||||
|
headers={"content-type": "text/css"},
|
||||||
|
)
|
||||||
|
if path in {"/config.js", "/static/chunk.js", "/assets/deep.js"}:
|
||||||
|
return httpx.Response(200, text=f'window.loaded="{path}"', headers={"content-type": "application/javascript"})
|
||||||
|
if path == "/fonts/app.woff2":
|
||||||
|
return httpx.Response(200, content=b"font", headers={"content-type": "font/woff2"})
|
||||||
|
return httpx.Response(404, text=f"missing upstream path: {path}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: runtime)
|
||||||
|
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
||||||
|
monkeypatch.setattr(llmwiki_router, "_get_weknora_admin_session", fake_session)
|
||||||
|
|
||||||
|
real_async_client = httpx.AsyncClient
|
||||||
|
monkeypatch.setattr(llmwiki_router.httpx, "AsyncClient", FakeUpstreamClient)
|
||||||
|
|
||||||
|
test_app = FastAPI()
|
||||||
|
test_app.add_middleware(AuthMiddleware)
|
||||||
|
test_app.add_middleware(CSRFMiddleware)
|
||||||
|
test_app.include_router(llmwiki_router.router)
|
||||||
|
test_app.include_router(llmwiki_router.proxy_router, prefix="/deerflow")
|
||||||
|
test_app.include_router(llmwiki_router.proxy_router)
|
||||||
|
|
||||||
|
cookie = sign_weknora_embed_payload(
|
||||||
|
{
|
||||||
|
"mapping_id": "local-kb",
|
||||||
|
"weknora_id": "remote-kb",
|
||||||
|
"user_id": "user-a",
|
||||||
|
"is_admin": False,
|
||||||
|
"can_write": True,
|
||||||
|
"exp": 4_102_444_800,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
transport = httpx.ASGITransport(app=test_app)
|
||||||
|
async with real_async_client(transport=transport, base_url="http://deerflow.test") as client:
|
||||||
|
client.cookies.set(WEKNORA_EMBED_COOKIE, cookie)
|
||||||
|
|
||||||
|
page = await client.get("/deerflow/platform/knowledge-bases/remote-kb?tab=wiki")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert 'src="/deerflow/assets/main.js"' in page.text
|
||||||
|
assert 'href="/deerflow/css/app.css"' in page.text
|
||||||
|
assert 'src="/deerflow/config.js"' in page.text
|
||||||
|
|
||||||
|
script = await client.get("/deerflow/assets/main.js")
|
||||||
|
assert script.status_code == 200
|
||||||
|
assert '"/deerflow/static/chunk.js"' in script.text
|
||||||
|
assert '"deerflow/assets/lazy.css"' in script.text
|
||||||
|
assert '"deerflow/assets/lazy.js"' in script.text
|
||||||
|
assert '"/deerflow/assets/lazy.css"' not in script.text
|
||||||
|
assert 'matcher=/platform\\//' in script.text
|
||||||
|
assert 'fetch("/deerflow/api/v1/auth/me")' in script.text
|
||||||
|
|
||||||
|
stylesheet = await client.get("/deerflow/css/app.css")
|
||||||
|
assert stylesheet.status_code == 200
|
||||||
|
assert 'url("/deerflow/fonts/app.woff2")' in stylesheet.text
|
||||||
|
|
||||||
|
for path in (
|
||||||
|
"/deerflow/config.js",
|
||||||
|
"/deerflow/static/chunk.js",
|
||||||
|
"/deerflow/fonts/app.woff2",
|
||||||
|
"/deerflow/platform/knowledge-bases/remote-kb/assets/deep.js",
|
||||||
|
):
|
||||||
|
response = await client.get(path)
|
||||||
|
assert response.status_code == 200, f"{path}: {response.status_code} {response.text}"
|
||||||
|
|
||||||
|
api = await client.get("/deerflow/api/llmwiki/weknora-embed/api/v1/auth/me")
|
||||||
|
assert api.status_code == 200
|
||||||
|
|
||||||
|
api_request = next(request for request in upstream_requests if request[1].startswith(runtime.api_base_url))
|
||||||
|
assert api_request[2]["Authorization"] == "Bearer admin-token"
|
||||||
|
assert api_request[2]["X-Tenant-ID"] == "tenant-1"
|
||||||
|
|
||||||
|
|
||||||
def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None:
|
def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None:
|
||||||
@@ -925,6 +1259,34 @@ def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None:
|
|||||||
assert '".main > .aside_box"' in injection
|
assert '".main > .aside_box"' in injection
|
||||||
|
|
||||||
|
|
||||||
|
def test_weknora_iframe_html_rewrites_public_prefix_paths() -> None:
|
||||||
|
from app.gateway.routers import llmwiki as llmwiki_router
|
||||||
|
|
||||||
|
ctx = llmwiki_router.WeKnoraEmbedContext(
|
||||||
|
mapping_id="local-kb",
|
||||||
|
weknora_id="remote-kb",
|
||||||
|
user_id="user-a",
|
||||||
|
is_admin=False,
|
||||||
|
can_write=False,
|
||||||
|
exp=4_102_444_800,
|
||||||
|
)
|
||||||
|
html = b'<html><head><script src="/assets/index.js"></script></head><body>fetch("/api/v1/auth/me")</body></html>'
|
||||||
|
|
||||||
|
injected = llmwiki_router._inject_weknora_embed_html(
|
||||||
|
html,
|
||||||
|
allowed_path="/deerflow/platform/knowledge-bases/remote-kb",
|
||||||
|
tab="wiki",
|
||||||
|
session={},
|
||||||
|
ctx=ctx,
|
||||||
|
public_prefix="/deerflow",
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
assert 'src="/deerflow/assets/index.js"' in injected
|
||||||
|
assert 'fetch("/deerflow/api/v1/auth/me")' in injected
|
||||||
|
assert '"apiPrefix":"/deerflow/api/llmwiki/weknora-embed"' in injected
|
||||||
|
assert '"allowedPath":"/deerflow/platform/knowledge-bases/remote-kb"' in injected
|
||||||
|
|
||||||
|
|
||||||
def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None:
|
def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None:
|
||||||
from app.gateway.routers import llmwiki as llmwiki_router
|
from app.gateway.routers import llmwiki as llmwiki_router
|
||||||
|
|
||||||
@@ -1043,6 +1405,15 @@ async def test_weknora_iframe_proxy_blocks_other_knowledge_bases() -> None:
|
|||||||
"token",
|
"token",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await llmwiki_router._authorize_weknora_api_proxy(
|
||||||
|
FakeRequest(),
|
||||||
|
ctx,
|
||||||
|
object(),
|
||||||
|
"/api/v1/me/invitations/pending-count",
|
||||||
|
b"",
|
||||||
|
"token",
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
await llmwiki_router._authorize_weknora_api_proxy(
|
await llmwiki_router._authorize_weknora_api_proxy(
|
||||||
FakeRequest(),
|
FakeRequest(),
|
||||||
|
|||||||
Reference in new issue
Block a user