fix: harden WeKnora proxy and configurable search

This commit is contained in:
Administrator committed 2026-08-13 23:44:47 +08:00
1 parent b527bc94bb
commit 11e54550ed
10 files changed
+721 -72

No files matched your search

+4
View File
@@ -107,6 +107,10 @@ frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
frontend-web-node_modules-offline-*.tar.gz
# Local dependency caches and backend scratch archives
/.pnpm-store/
/offline-backend-20260512/backend/backend*.zip
.codegraph
codegraph.mdc
+28 -28
View File
@@ -5,22 +5,22 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import sourceIdentifierPlugin from "vite-plugin-source-identifier";
const rootDir = fileURLToPath(new URL(".", import.meta.url));
// Tailwind 4 emits CSS cascade layers, which require Chrome 99+. Chrome 100
// is therefore the practical lower bound for this Win7 legacy build.
const LEGACY_CHROME = 100 << 16;
const rootDir = fileURLToPath(new URL(".", import.meta.url));
// Tailwind 4 emits CSS cascade layers, which require Chrome 99+. Chrome 100
// is therefore the practical lower bound for this Win7 legacy build.
const LEGACY_CHROME = 100 << 16;
export default defineConfig({
plugins: [sourceIdentifierPlugin(), react()],
css: {
// Also used by the production CSS minifier. Development CSS is handled
// by the matching PostCSS compatibility pass in postcss.config.js.
lightningcss: {
targets: {
chrome: LEGACY_CHROME,
},
},
},
export default defineConfig({
plugins: [sourceIdentifierPlugin(), react()],
css: {
// Also used by the production CSS minifier. Development CSS is handled
// by the matching PostCSS compatibility pass in postcss.config.js.
lightningcss: {
targets: {
chrome: LEGACY_CHROME,
},
},
},
resolve: {
alias: [
{
@@ -45,19 +45,19 @@ export default defineConfig({
},
],
},
// Keep the current portable-file default, while allowing container builds to
// emit absolute asset URLs. The latter is required for BrowserRouter pages
// behind Nginx: a request to /page/... must still load /assets/... rather
// than /page/assets/....
base: process.env.VITE_ASSET_BASE ?? "./",
build: {
cssMinify: "lightningcss",
target: "chrome100",
},
// Keep the current portable-file default, while allowing container builds to
// emit absolute asset URLs. The latter is required for BrowserRouter pages
// behind Nginx: a request to /page/... must still load /assets/... rather
// than /page/assets/....
base: process.env.VITE_ASSET_BASE ?? "./",
build: {
cssMinify: "lightningcss",
target: "chrome100",
},
server: {
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
port: 5173,
// 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。
cors: true,
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
port: 5173,
// 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。
cors: true,
},
});
@@ -1153,6 +1153,19 @@ Both the `view_image` tool and `ViewImageMiddleware` are registered whenever `vi
## 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_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
- **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.
- **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
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
@@ -1646,8 +1646,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# WeKnora's embedded frontend uses root-relative routes such as /platform,
# /assets, /locales and /api/v1/*. Register that proxy last so it stays a
# true fallback and cannot intercept DeerFlow APIs.
app.include_router(llmwiki.proxy_router)
# 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)
return app
@@ -49,6 +49,83 @@ proxy_router = APIRouter(tags=["llmwiki-proxy"])
_MAX_UPLOAD_BYTES = 100 * 1024 * 1024
_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 = {
"connection",
"content-encoding",
@@ -399,6 +476,9 @@ async def _authorize_weknora_api_proxy(
return
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 len(tail) == 1:
if method == "GET":
@@ -535,30 +615,151 @@ def _without_public_prefix(prefix: str, path: str) -> str:
return path
def _rewrite_weknora_html_public_paths(html: str, public_prefix: str) -> str:
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 (
"/platform",
"/assets",
"/locales",
"/tdesign-icons",
"/api/v1",
"/favicon.ico",
"/favicon.svg",
"/config.js",
"/logo.svg",
"/manifest.webmanifest",
):
for path in _WEKNORA_PUBLIC_ROOT_PATHS:
prefixed = f"{public_prefix}{path}"
html = re.sub(
rf'(?P<q>["\'(=]){re.escape(path)}(?P<next>[/?"\'\)])',
rf"\g<q>{prefixed}\g<next>",
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(
*,
allowed_path: str,
@@ -1058,6 +1259,7 @@ async def _proxy_weknora_request(
content = response.content
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":
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():
@@ -1072,7 +1274,6 @@ async def _proxy_weknora_request(
separators=(",", ":"),
).encode("utf-8")
if not is_api and "text/html" in content_type.lower():
public_prefix = _request_public_prefix(request)
allowed_path = _with_public_prefix(
public_prefix,
f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}",
@@ -1086,8 +1287,35 @@ async def _proxy_weknora_request(
public_prefix=public_prefix,
)
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)
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"
return Response(
content=content,
@@ -1493,6 +1721,16 @@ async def proxy_weknora_platform_page(request: Request, proxied_path: str = "")
requested = f"/platform/{proxied_path}".rstrip("/")
allowed_path = f"/platform/knowledge-bases/{ctx.weknora_id}"
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"})
public_target = _with_public_prefix(_request_public_prefix(request), allowed_path)
return RedirectResponse(f"{public_target}?{target_query}", status_code=302)
@@ -1588,6 +1826,10 @@ async def proxy_weknora_api(request: Request, proxied_path: str) -> Response:
"/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}",
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:
"""Proxy iframe API calls under a collision-free cmzs namespace."""
@@ -1604,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")
async def get_llmwiki_runtime() -> dict[str, Any]:
return _runtime_or_legacy()
@@ -28,6 +28,18 @@ WEKNORA_ROOT_ASSET_PATHS: frozenset[str] = frozenset(
"/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:
@@ -71,9 +83,7 @@ def is_weknora_embed_proxy_path(path: str) -> bool:
return (
path == "/platform"
or path.startswith("/platform/")
or path.startswith("/assets/")
or path.startswith("/locales/")
or path.startswith("/tdesign-icons/")
or path.startswith(WEKNORA_STATIC_PATH_PREFIXES)
or path in WEKNORA_ROOT_ASSET_PATHS
or path.startswith(f"{WEKNORA_EMBED_API_PREFIX}/api/v1/")
or (path.startswith("/api/v1/") and not path.startswith("/api/v1/auth/"))
@@ -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
import json
import logging
import re
from copy import deepcopy
from dataclasses import dataclass
from datetime import datetime
from datetime import date, datetime, timedelta
from typing import Any
from urllib.parse import quote
from zoneinfo import ZoneInfo
@@ -19,11 +20,10 @@ from deerflow.config.tool_config import ToolConfig
logger = logging.getLogger(__name__)
_BEIJING_TZ = ZoneInfo("Asia/Shanghai")
def _current_time_hint() -> str:
now = datetime.now(_BEIJING_TZ)
return now.strftime("%Y-%m-%d %H:%M:%S, %A, 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)")
_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)")
_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)")
@dataclass(frozen=True)
@@ -112,6 +112,86 @@ def _looks_like_url(value: str) -> bool:
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:
return json.dumps(
{
@@ -183,6 +263,10 @@ async def execute_search(
query: str,
settings: ConfigurableSearchSettings,
*,
start_date: str = "",
end_date: str = "",
top_k: str | int = "",
today: date | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> str:
"""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")
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["start_date"] = resolved_start
payload["end_date"] = resolved_end
payload["top_k"] = _resolve_top_k(normalized_query, top_k)
try:
async with httpx.AsyncClient(
@@ -234,19 +327,27 @@ async def execute_search(
@tool("web_search", parse_docstring=True)
async def web_search_tool(query: str) -> str:
"""Search the configured information service for current facts and source passages.
async def web_search_tool(query: str, start_date: str = "", end_date: str = "", top_k: str = "") -> str:
"""Search the internal knowledge base and return source passages.
Args:
query: Focused search keywords or a natural-language retrieval question.
Interpret relative time expressions against the current time in
Asia/Shanghai, and include concrete dates/date ranges in the query
when the user asks about recent periods.
query: Knowledge-base retrieval query. Keep the user's subject and time
requirement in this text. If the user says recent/近期/最近, this tool
will search the latest three months. If the user says near days/近几天/
最近几天/这几天/近一周, 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:
settings = ConfigurableSearchSettings.from_tool_config(get_app_config().get_tool_config("web_search"))
except ValueError as 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(grounded_query, settings)
return await execute_search(query, settings, start_date=start_date, end_date=end_date, top_k=top_k)
@@ -1,6 +1,7 @@
from __future__ import annotations
from __future__ import annotations
import json
from datetime import date
import httpx
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:
requests.append(request)
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(
200,
json={
"results": [
{
"recUuid": "record/123",
"content": "检索内容",
"title": "标题",
"content": "test content",
"title": "test title",
"url": "https://original.example.test/one",
},
{
@@ -60,7 +66,7 @@ async def test_configurable_search_overrides_query_and_normalizes_nullable_resul
settings = _settings()
result = json.loads(
await execute_search(
" 测试检索词 ",
" test query ",
settings,
transport=httpx.MockTransport(handler),
)
@@ -69,13 +75,13 @@ async def test_configurable_search_overrides_query_and_normalizes_nullable_resul
assert len(requests) == 1
assert settings.payload == {"query": "configured value", "xxx": "xxxx"}
assert result == {
"query": "测试检索词",
"query": "test query",
"total_results": 2,
"results": [
{
"recUuid": "record/123",
"content": "检索内容",
"title": "标题",
"content": "test content",
"title": "test title",
"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": []}
@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
async def test_configurable_search_accepts_root_array_results() -> None:
async def handler(_: httpx.Request) -> httpx.Response:
@@ -5,7 +5,7 @@ from types import SimpleNamespace
import httpx
import pytest
from fastapi import HTTPException
from fastapi import FastAPI, HTTPException
from starlette.datastructures import Headers, QueryParams
from starlette.requests import Request
@@ -178,6 +178,18 @@ def test_weknora_embed_cookie_bypasses_only_proxy_paths() -> None:
)
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("/tdesign-icons/0.4.1/fonts/index.js", cookies=cookies)
@@ -1054,7 +1066,175 @@ def test_weknora_iframe_proxy_is_fallback_after_deerflow_api_routes() -> None:
== "/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:
@@ -1225,6 +1405,15 @@ async def test_weknora_iframe_proxy_blocks_other_knowledge_bases() -> None:
"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:
await llmwiki_router._authorize_weknora_api_proxy(
FakeRequest(),