From 3aeb6ac5ce35730230ec566f6c8042e719dc9d25 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 13 Aug 2026 17:41:30 +0800 Subject: [PATCH 1/5] fix: authenticate WeKnora API client --- .../harness/deerflow/config/llmwiki_config.py | 8 +- .../deerflow/integrations/weknora/client.py | 141 +++++++++++++++--- .../deerflow/integrations/weknora/runtime.py | 2 + .../backend/tests/test_llmwiki_weknora.py | 73 ++++++++- 4 files changed, 200 insertions(+), 24 deletions(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/llmwiki_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/llmwiki_config.py index 5429edb0..5c1afd4b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/llmwiki_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/llmwiki_config.py @@ -38,16 +38,16 @@ class WeKnoraConnectionConfig(BaseModel): admin_email: str = Field( default="", description=( - "Optional WeKnora administrator email used only by DeerFlow's " - "server-side detail-page iframe proxy. Prefer $ENV references." + "Optional WeKnora administrator email used by DeerFlow's server-side " + "WeKnora API client and detail-page iframe proxy. Prefer $ENV references." ), ) admin_password: str = Field( default="", repr=False, description=( - "Optional WeKnora administrator password used only by DeerFlow's " - "server-side detail-page iframe proxy. Prefer $ENV references." + "Optional WeKnora administrator password used by DeerFlow's server-side " + "WeKnora API client and detail-page iframe proxy. Prefer $ENV references." ), ) model_config = ConfigDict(extra="ignore") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py b/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py index 3e475b2a..a6cca5dd 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence +import time from typing import Any from urllib.parse import quote @@ -41,9 +42,10 @@ class WeKnoraClient: """Async client for a trusted-network WeKnora endpoint. The caller may inject an ``httpx.AsyncClient`` for connection pooling and - tests. When omitted, each public method creates and closes a short-lived - client. DeerFlow deliberately sends no WeKnora credential; the independently - deployed service or its reverse proxy must allow DeerFlow's internal traffic. + tests. When omitted, each public method creates and closes a short-lived client. + If admin credentials are configured, DeerFlow authenticates to WeKnora with a + cached bearer token; otherwise it keeps the legacy trusted-network anonymous + mode for deployments that allow DeerFlow through a reverse proxy. """ def __init__( @@ -52,13 +54,105 @@ class WeKnoraClient: base_url: str, timeout_seconds: float = 30.0, http_client: httpx.AsyncClient | None = None, + admin_email: str = "", + admin_password: str = "", ) -> None: self.base_url = base_url.rstrip("/") self.timeout_seconds = timeout_seconds 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: 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( self, method: str, @@ -69,19 +163,21 @@ class WeKnoraClient: files: Any = None, data: Mapping[str, Any] | None = None, ) -> Any: - headers = {"Accept": "application/json"} 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.request( - method, - f"{self.base_url}{path}", - headers=headers, - json=json, - params=params, - files=files, - data=data, - ) + for attempt in range(2): + response = await client.request( + method, + f"{self.base_url}{path}", + headers=await self._headers(force_refresh=attempt > 0), + json=json, + params=params, + 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: raise WeKnoraError("WeKnora request timed out", status_code=504) from exc except httpx.HTTPError as exc: @@ -92,6 +188,8 @@ class WeKnoraClient: if response.status_code == 404: raise WeKnoraError("WeKnora resource was not found", status_code=404) 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( "WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy", status_code=502, @@ -296,12 +394,15 @@ class WeKnoraClient: follow_redirects=True, ) try: - response = await client.request( - "GET", - f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview", - headers={"Accept": "*/*"}, - follow_redirects=True, - ) + for attempt in range(2): + response = await client.request( + "GET", + f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview", + 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: raise WeKnoraError("WeKnora preview request timed out", status_code=504) from exc except httpx.HTTPError as exc: @@ -312,6 +413,8 @@ class WeKnoraClient: if response.status_code == 404: raise WeKnoraError("WeKnora document preview was not found", status_code=404) 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( "WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy", status_code=502, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/runtime.py b/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/runtime.py index ceaef736..fcc73402 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/runtime.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/runtime.py @@ -40,4 +40,6 @@ def build_weknora_client( return WeKnoraClient( base_url=runtime.api_base_url, http_client=http_client, + admin_email=runtime.admin_email, + admin_password=runtime.admin_password, ) diff --git a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py index 3791c03b..67806c8b 100644 --- a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +++ b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py @@ -230,7 +230,7 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo @pytest.mark.asyncio -async def test_weknora_client_sends_no_credentials_and_normalizes_search() -> None: +async def test_weknora_client_keeps_anonymous_mode_without_admin_credentials() -> None: requests: list[httpx.Request] = [] async def handler(request: httpx.Request) -> httpx.Response: @@ -270,6 +270,77 @@ async def test_weknora_client_sends_no_credentials_and_normalizes_search() -> No assert results[0]["title"] == "Guide" +@pytest.mark.asyncio +async def test_weknora_client_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 async def test_weknora_client_merges_required_name_for_description_update() -> None: requests: list[httpx.Request] = [] From 6dbaf40f99eb628dc6eed851b34106e8144b20b1 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 13 Aug 2026 19:17:01 +0800 Subject: [PATCH 2/5] fix: preserve WeKnora embed proxy prefix --- .../llmwiki/WeKnoraKnowledgeDetailPage.tsx | 15 ++- .../backend/app/gateway/routers/llmwiki.py | 124 ++++++++++++++++-- .../backend/tests/test_llmwiki_weknora.py | 58 ++++++++ 3 files changed, 180 insertions(+), 17 deletions(-) diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx index 919cb543..ddbff9ac 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx @@ -87,11 +87,16 @@ export function WeKnoraKnowledgeDetailPage() { void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab) .then((session) => { if (cancelled) return; - const base = getBackendBaseURL().replace(/\/+$/, ""); - const path = session.frame_path.startsWith("/") - ? session.frame_path - : `/${session.frame_path}`; - const nextUrl = new URL(`${base}${path}`); + const framePath = session.frame_path || ""; + const backendBase = getBackendBaseURL().replace(/\/+$/, ""); + const backendOrigin = backendBase + ? new URL(backendBase, window.location.origin).origin + : window.location.origin; + const nextUrl = /^https?:\/\//i.test(framePath) + ? new URL(framePath) + : framePath.startsWith("/") + ? new URL(framePath, backendOrigin) + : new URL(`${backendBase || backendOrigin}/${framePath.replace(/^\/+/, "")}`); nextUrl.searchParams.set("theme", collectThemePayload().theme); setFrameSrc(nextUrl.toString()); }) diff --git a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py index a9b542f2..5e0800e1 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py +++ b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py @@ -9,7 +9,7 @@ import re import time from dataclasses import replace from typing import Any, Literal -from urllib.parse import quote, urlencode +from urllib.parse import quote, urlencode, urlsplit import httpx from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile @@ -501,12 +501,67 @@ def _escape_script_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":")).replace(" 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: + headers = request.headers + for key in ("x-forwarded-prefix", "x-script-name"): + prefix = _normalize_public_prefix(headers.get(key)) + if prefix: + return prefix + return _normalize_public_prefix(str(request.scope.get("root_path") or "")) + + +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 _rewrite_weknora_html_public_paths(html: str, public_prefix: str) -> 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", + ): + prefixed = f"{public_prefix}{path}" + html = re.sub( + rf'(?P["\'(=]){re.escape(path)}(?P[/?"\'\)])', + rf"\g{prefixed}\g", + html, + ) + return html + + def _weknora_embed_injection( *, allowed_path: str, tab: str, session: dict[str, Any], ctx: WeKnoraEmbedContext, + public_prefix: str = "", ) -> str: tenant_id = _tenant_id_from_session(session) user = session.get("user") if isinstance(session.get("user"), dict) else {} @@ -520,7 +575,8 @@ def _weknora_embed_injection( "tenantId": tenant_id, "user": user, "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) return f""" @@ -658,8 +714,19 @@ html.deerflow-weknora-embed [class*=" guide__"] {{ function rewriteApiUrl(value) {{ try {{ const next = new URL(value, location.origin); - if (next.origin !== location.origin || !next.pathname.startsWith("/api/v1/")) return value; - next.pathname = cfg.apiPrefix + next.pathname; + if (next.origin !== location.origin) return value; + 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; }} catch (_) {{ return value; @@ -911,9 +978,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") - 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 "" in text: text = text.replace("", f"{injection}", 1) else: @@ -986,8 +1068,19 @@ async def _proxy_weknora_request( separators=(",", ":"), ).encode("utf-8") if not is_api and "text/html" in content_type.lower(): - allowed_path = f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}" - content = _inject_weknora_embed_html(content, allowed_path=allowed_path, tab=tab, session=session, ctx=ctx) + public_prefix = _request_public_prefix(request) + allowed_path = _with_public_prefix( + 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" headers_out = _safe_response_headers(response.headers) @@ -1355,7 +1448,10 @@ async def create_weknora_detail_session( """Create a signed iframe session using the normal DeerFlow API auth path.""" weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id) - target = _weknora_embed_target_path(weknora_id, tab) + target = _with_public_prefix( + _request_public_prefix(request), + _weknora_embed_target_path(weknora_id, tab), + ) response = JSONResponse( { "frame_path": target, @@ -1375,7 +1471,10 @@ async def open_weknora_detail_frame( """Start a short-lived, DeerFlow-authorized WeKnora detail iframe session.""" 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) _set_weknora_embed_cookie(request, response, payload) return response @@ -1389,7 +1488,8 @@ async def proxy_weknora_platform_page(request: Request, proxied_path: str = "") allowed_path = f"/platform/knowledge-bases/{ctx.weknora_id}" if requested != allowed_path: 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) return await _proxy_weknora_request( request, @@ -1438,7 +1538,7 @@ async def proxy_weknora_root_asset(request: Request) -> Response: return await _proxy_weknora_request( request, target_base_url=runtime.web_base_url, - upstream_path=request.url.path, + upstream_path=str(request.scope.get("path") or request.url.path), ctx=ctx, is_api=False, ) diff --git a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py index 67806c8b..474c8cb1 100644 --- a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +++ b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py @@ -29,6 +29,7 @@ def _request( query: str = "", cookies: dict[str, str] | None = None, request_headers: dict[str, str] | None = None, + root_path: str = "", ) -> Request: headers: list[tuple[bytes, bytes]] = [ (key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items() @@ -40,6 +41,7 @@ def _request( "type": "http", "method": "GET", "path": path, + "root_path": root_path, "query_string": query.encode(), "headers": headers, "scheme": "http", @@ -229,6 +231,34 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo assert ctx.can_write is True +@pytest.mark.asyncio +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_client_keeps_anonymous_mode_without_admin_credentials() -> None: requests: list[httpx.Request] = [] @@ -996,6 +1026,34 @@ def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None: 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'fetch("/api/v1/auth/me")' + + 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: from app.gateway.routers import llmwiki as llmwiki_router From 54fd0e7316e136b31c389cf37e61fbe7be46a131 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 13 Aug 2026 20:22:50 +0800 Subject: [PATCH 3/5] fix: route WeKnora embed through backend prefix --- .../llmwiki/WeKnoraKnowledgeDetailPage.tsx | 27 +++++++---- .../backend/app/gateway/app.py | 1 + .../backend/app/gateway/routers/llmwiki.py | 21 +++++--- .../backend/app/gateway/weknora_embed.py | 1 + .../backend/tests/test_llmwiki_weknora.py | 48 ++++++++++++++++++- 5 files changed, 81 insertions(+), 17 deletions(-) diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx index ddbff9ac..2f4610e2 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx @@ -55,6 +55,23 @@ function postThemeToFrame(frame: HTMLIFrameElement | null) { 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() { const { knowledge_base_id: strategyKnowledgeBaseId, @@ -88,15 +105,7 @@ export function WeKnoraKnowledgeDetailPage() { .then((session) => { if (cancelled) return; const framePath = session.frame_path || ""; - const backendBase = getBackendBaseURL().replace(/\/+$/, ""); - const backendOrigin = backendBase - ? new URL(backendBase, window.location.origin).origin - : window.location.origin; - const nextUrl = /^https?:\/\//i.test(framePath) - ? new URL(framePath) - : framePath.startsWith("/") - ? new URL(framePath, backendOrigin) - : new URL(`${backendBase || backendOrigin}/${framePath.replace(/^\/+/, "")}`); + const nextUrl = buildFrameUrl(framePath); nextUrl.searchParams.set("theme", collectThemePayload().theme); setFrameSrc(nextUrl.toString()); }) diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 2b486c2b..1d3233cf 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -1647,6 +1647,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # /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) + app.include_router(llmwiki.proxy_router, prefix="/deerflow") return app diff --git a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py index 5e0800e1..31703a11 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py +++ b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py @@ -48,6 +48,7 @@ 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" _HOP_BY_HOP_RESPONSE_HEADERS = { "connection", "content-encoding", @@ -517,12 +518,7 @@ def _normalize_public_prefix(value: str | None) -> str: def _request_public_prefix(request: Request) -> str: - headers = request.headers - for key in ("x-forwarded-prefix", "x-script-name"): - prefix = _normalize_public_prefix(headers.get(key)) - if prefix: - return prefix - return _normalize_public_prefix(str(request.scope.get("root_path") or "")) + return _WEKNORA_PUBLIC_PREFIX def _with_public_prefix(prefix: str, path: str) -> str: @@ -531,6 +527,14 @@ def _with_public_prefix(prefix: str, path: str) -> str: 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) -> str: if not public_prefix: return html @@ -1538,7 +1542,10 @@ async def proxy_weknora_root_asset(request: Request) -> Response: return await _proxy_weknora_request( request, target_base_url=runtime.web_base_url, - upstream_path=str(request.scope.get("path") or request.url.path), + upstream_path=_without_public_prefix( + _request_public_prefix(request), + str(request.scope.get("path") or request.url.path), + ), ctx=ctx, is_api=False, ) diff --git a/offline-backend-20260512/backend/app/gateway/weknora_embed.py b/offline-backend-20260512/backend/app/gateway/weknora_embed.py index b77070e9..6de5e448 100644 --- a/offline-backend-20260512/backend/app/gateway/weknora_embed.py +++ b/offline-backend-20260512/backend/app/gateway/weknora_embed.py @@ -41,6 +41,7 @@ class WeKnoraEmbedContext: def is_weknora_embed_proxy_path(path: str) -> bool: + path = path.removeprefix("/deerflow") return ( path == "/platform" or path.startswith("/platform/") diff --git a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py index 474c8cb1..a0e3480e 100644 --- a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +++ b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py @@ -173,15 +173,25 @@ def test_weknora_embed_cookie_bypasses_only_proxy_paths() -> None: 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("/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("/deerflow/assets/index.js", cookies=cookies)) assert auth_middleware._is_weknora_embed_proxy_request(_request("/config.js", cookies=cookies)) assert auth_middleware._is_weknora_embed_proxy_request( _request("/tdesign-icons/0.4.1/fonts/index.js", cookies=cookies) ) assert auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies=cookies)) + assert auth_middleware._is_weknora_embed_proxy_request( + _request("/deerflow/api/v1/knowledge-search", cookies=cookies) + ) assert auth_middleware._is_weknora_embed_proxy_request( _request("/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies) ) + assert 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 not auth_middleware._is_weknora_embed_proxy_request( _request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"}) @@ -219,7 +229,7 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo ) 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"] assert "deerflow_weknora_embed=" in cookie assert "Secure" in cookie @@ -259,6 +269,36 @@ async def test_weknora_session_endpoint_preserves_reverse_proxy_prefix(monkeypat 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] = [] @@ -1002,6 +1042,12 @@ def test_weknora_iframe_proxy_is_fallback_after_deerflow_api_routes() -> None: == "/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", "/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/api/v1/knowledge-search") == "/deerflow/api/v1/{proxied_path:path}" def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None: From b527bc94bb8043f70b7925ef03182dd7c97a7f28 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 13 Aug 2026 20:42:24 +0800 Subject: [PATCH 4/5] fix: stabilize WeKnora embed session cookie --- .../backend/app/gateway/routers/llmwiki.py | 20 +++++++------ .../backend/app/gateway/weknora_embed.py | 28 ++++++++++++++++++- .../backend/tests/test_llmwiki_weknora.py | 7 +++++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py index 31703a11..552dbcb5 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py +++ b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py @@ -1360,15 +1360,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: secure = is_secure_request(request) - response.set_cookie( - _WEKNORA_EMBED_COOKIE, - _sign_embed_payload(payload), - max_age=_WEKNORA_EMBED_TTL_SECONDS, - httponly=True, - secure=secure, - samesite="none" if secure else "lax", - path="/", - ) + signed = _sign_embed_payload(payload) + for path in ("/", _WEKNORA_PUBLIC_PREFIX): + response.set_cookie( + _WEKNORA_EMBED_COOKIE, + signed, + max_age=_WEKNORA_EMBED_TTL_SECONDS, + httponly=True, + secure=secure, + samesite="none" if secure else "lax", + path=path, + ) response.headers["Cache-Control"] = "no-store" diff --git a/offline-backend-20260512/backend/app/gateway/weknora_embed.py b/offline-backend-20260512/backend/app/gateway/weknora_embed.py index 6de5e448..9fc26c8e 100644 --- a/offline-backend-20260512/backend/app/gateway/weknora_embed.py +++ b/offline-backend-20260512/backend/app/gateway/weknora_embed.py @@ -14,10 +14,11 @@ from typing import Any from fastapi import HTTPException +from deerflow.config.runtime_paths import runtime_home + WEKNORA_EMBED_COOKIE = "deerflow_weknora_embed" WEKNORA_EMBED_TTL_SECONDS = 15 * 60 WEKNORA_EMBED_API_PREFIX = "/api/llmwiki/weknora-embed" -WEKNORA_PROXY_SECRET = os.getenv("DEERFLOW_LLMWIKI_PROXY_SECRET") or secrets.token_urlsafe(48) WEKNORA_ROOT_ASSET_PATHS: frozenset[str] = frozenset( { "/favicon.ico", @@ -29,6 +30,31 @@ WEKNORA_ROOT_ASSET_PATHS: frozenset[str] = frozenset( ) +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) class WeKnoraEmbedContext: mapping_id: str diff --git a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py index a0e3480e..c670605e 100644 --- a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +++ b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py @@ -234,6 +234,13 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo assert "deerflow_weknora_embed=" in cookie assert "Secure" 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] ctx = llmwiki_router._verify_embed_cookie(raw) assert ctx.mapping_id == "local-kb" From 11e54550ed176e6dbfb769a7a0610385abcf9f9d Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 13 Aug 2026 23:44:47 +0800 Subject: [PATCH 5/5] fix: harden WeKnora proxy and configurable search --- .gitignore | 4 + frontend-web/vite.config.ts | 56 ++-- offline-backend-20260512/backend/CLAUDE.md | 13 + offline-backend-20260512/backend/README.md | 1 + .../backend/app/gateway/app.py | 5 +- .../backend/app/gateway/routers/llmwiki.py | 290 +++++++++++++++++- .../backend/app/gateway/weknora_embed.py | 16 +- .../community/configurable_search/tools.py | 131 +++++++- .../tests/test_configurable_web_search.py | 86 +++++- .../backend/tests/test_llmwiki_weknora.py | 191 +++++++++++- 10 files changed, 721 insertions(+), 72 deletions(-) diff --git a/.gitignore b/.gitignore index 378300aa..b7e4cc9e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/frontend-web/vite.config.ts b/frontend-web/vite.config.ts index ec109d3f..e30386d4 100644 --- a/frontend-web/vite.config.ts +++ b/frontend-web/vite.config.ts @@ -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, }, }); diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index b8933965..af724f6c 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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 diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 1b794434..a5bdbc46 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 1d3233cf..624f713c 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -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 diff --git a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py index 552dbcb5..2b86e743 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py +++ b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py @@ -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["\'(=]){re.escape(path)}(?P[/?"\'\)])', - rf"\g{prefixed}\g", + rf"(?P[\"'`(=:\s]){re.escape(path)}(?=[/?#\"'`),;\]\}}\s>]|$)", + rf"\g{prefixed}", html, ) + escaped_path = rf"\\/{re.escape(path.lstrip('/'))}" + escaped_prefixed = f"\\/{prefixed.lstrip('/')}" + html = re.sub( + rf"(?]|$)", + escaped_prefixed, + html, + ) + if rewrite_relative: + for path in _WEKNORA_PUBLIC_RELATIVE_ROOT_PATHS: + prefixed = f"{public_prefix}/{path}" + html = re.sub( + rf"(?P[\"'`(=:\s])(?:\./)?{re.escape(path)}(?=/)", + rf"\g{prefixed}", + html, + ) + for path in _WEKNORA_PUBLIC_RELATIVE_FILE_PATHS: + prefixed = f"{public_prefix}/{path}" + html = re.sub( + rf"(?P[\"'`(=:\s])(?:\./)?{re.escape(path)}(?=[?#\"'`),;\]\}}\s>]|$)", + rf"\g{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[\"'`]){re.escape(path)}(?=[/?#\"'`),;\]\}}\s]|$)", + rf"\g{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["\'])assets/', + rf"\g{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() diff --git a/offline-backend-20260512/backend/app/gateway/weknora_embed.py b/offline-backend-20260512/backend/app/gateway/weknora_embed.py index 9fc26c8e..a0f499f4 100644 --- a/offline-backend-20260512/backend/app/gateway/weknora_embed.py +++ b/offline-backend-20260512/backend/app/gateway/weknora_embed.py @@ -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/")) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/community/configurable_search/tools.py b/offline-backend-20260512/backend/packages/harness/deerflow/community/configurable_search/tools.py index 875d668c..839fe719 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/community/configurable_search/tools.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/community/configurable_search/tools.py @@ -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\d{4})(?:[-/.]|\u5e74)(?P\d{1,2})(?:[-/.]|\u6708)(?P\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\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) diff --git a/offline-backend-20260512/backend/packages/harness/tests/test_configurable_web_search.py b/offline-backend-20260512/backend/packages/harness/tests/test_configurable_web_search.py index f17e0217..b7cedfb7 100644 --- a/offline-backend-20260512/backend/packages/harness/tests/test_configurable_web_search.py +++ b/offline-backend-20260512/backend/packages/harness/tests/test_configurable_web_search.py @@ -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: diff --git a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py index c670605e..ff358381 100644 --- a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +++ b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py @@ -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=( + '' + '' + '' + ), + 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(),