Compare commits
25
Commits
main
...
0715-new-rag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a732b5c38 | ||
|
|
7dc3b3e361 | ||
|
|
7753413230 | ||
|
|
2de8d78126 | ||
|
|
1aa0b17e16 | ||
|
|
3317b573ab | ||
|
|
07076ad3cb | ||
|
|
91294e7a61 | ||
|
|
e14fbe2069 | ||
|
|
7ee11ae7a1 | ||
|
|
522d3a9d6a | ||
|
|
ee21c0a34c | ||
|
|
370232fb26 | ||
|
|
6315069b54 | ||
|
|
e09b51bde2 | ||
|
|
225678a98d | ||
|
|
9e4bf2a664 | ||
|
|
e208980725 | ||
|
|
4c7f06e229 | ||
|
|
7c5f062931 | ||
|
|
0e1277ea7e | ||
|
|
d5e0eefb93 | ||
|
|
edddd27cdd | ||
|
|
1de331f700 | ||
|
|
bf2ecd97f7 |
@@ -9,3 +9,4 @@
|
||||
# Codegen output must stay byte-identical across platforms so
|
||||
# `pnpm tree:check` in CI does not trip on CRLF rewrites.
|
||||
*.generated.ts text eol=lf
|
||||
*.gen.ts text eol=lf
|
||||
|
||||
@@ -670,6 +670,13 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y
|
||||
# Dify Agent backend
|
||||
AGENT_BACKEND_BASE_URL=http://localhost:5050
|
||||
|
||||
# KnowledgeFS (Dataset 2.0)
|
||||
KNOWLEDGE_FS_BASE_URL=
|
||||
# Shared with KnowledgeFS; use at least 32 random characters.
|
||||
KNOWLEDGE_FS_JWT_SECRET=
|
||||
KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS=300
|
||||
KNOWLEDGE_FS_TIMEOUT_SECONDS=10
|
||||
|
||||
# Marketplace configuration
|
||||
MARKETPLACE_ENABLED=true
|
||||
MARKETPLACE_API_URL=https://marketplace.dify.ai
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from configs.extra.agent_backend_config import AgentBackendConfig
|
||||
from configs.extra.archive_config import ArchiveStorageConfig
|
||||
from configs.extra.knowledge_fs_config import KnowledgeFSConfig
|
||||
from configs.extra.notion_config import NotionConfig
|
||||
from configs.extra.sentry_config import SentryConfig
|
||||
|
||||
@@ -8,6 +9,7 @@ class ExtraServiceConfig(
|
||||
# place the configs in alphabet order
|
||||
AgentBackendConfig,
|
||||
ArchiveStorageConfig,
|
||||
KnowledgeFSConfig,
|
||||
NotionConfig,
|
||||
SentryConfig,
|
||||
):
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Configuration for the optional KnowledgeFS Console bridge."""
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import Field, PositiveFloat, SecretStr, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class KnowledgeFSConfig(BaseSettings):
|
||||
"""Server-only settings for the KnowledgeFS production connection."""
|
||||
|
||||
KNOWLEDGE_FS_BASE_URL: str | None = Field(default=None, description="KnowledgeFS gateway base URL.")
|
||||
KNOWLEDGE_FS_JWT_SECRET: SecretStr | None = Field(
|
||||
default=None,
|
||||
min_length=32,
|
||||
description="Shared secret used to sign short-lived KnowledgeFS service JWTs.",
|
||||
)
|
||||
KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS: PositiveFloat = Field(default=300.0, le=3600.0, allow_inf_nan=False)
|
||||
KNOWLEDGE_FS_TIMEOUT_SECONDS: PositiveFloat = Field(default=10.0, le=60.0, allow_inf_nan=False)
|
||||
|
||||
@field_validator(
|
||||
"KNOWLEDGE_FS_BASE_URL",
|
||||
"KNOWLEDGE_FS_JWT_SECRET",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def normalize_optional_string(cls, value: object) -> object:
|
||||
if isinstance(value, SecretStr):
|
||||
normalized = value.get_secret_value().strip()
|
||||
return SecretStr(normalized) if normalized else None
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
return value
|
||||
|
||||
@field_validator("KNOWLEDGE_FS_BASE_URL")
|
||||
@classmethod
|
||||
def validate_base_url(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError("KNOWLEDGE_FS_BASE_URL must be an absolute HTTP(S) URL")
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError("KNOWLEDGE_FS_BASE_URL must not include credentials, query, or fragment")
|
||||
return value.rstrip("/")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_complete_connection(self) -> "KnowledgeFSConfig":
|
||||
if bool(self.KNOWLEDGE_FS_BASE_URL) != bool(self.KNOWLEDGE_FS_JWT_SECRET):
|
||||
raise ValueError("KNOWLEDGE_FS_BASE_URL and KNOWLEDGE_FS_JWT_SECRET must be configured together")
|
||||
return self
|
||||
@@ -38,6 +38,7 @@ from . import (
|
||||
feature,
|
||||
human_input_form,
|
||||
init_validate,
|
||||
knowledge_fs_proxy,
|
||||
notification,
|
||||
ping,
|
||||
setup,
|
||||
@@ -195,6 +196,7 @@ __all__ = [
|
||||
"human_input_form",
|
||||
"init_validate",
|
||||
"installed_app",
|
||||
"knowledge_fs_proxy",
|
||||
"load_balancing_config",
|
||||
"login",
|
||||
"mcp_server",
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Authenticated transport adapter for the Console-to-KnowledgeFS proxy.
|
||||
|
||||
These raw Blueprint routes deliberately stay outside Dify's OpenAPI surface:
|
||||
KnowledgeFS owns the wire contract consumed by the frontend. The catch-all path
|
||||
avoids resource-specific Dify controllers, while the forwarding module consumes
|
||||
the exact operation templates generated from the pinned KnowledgeFS contract.
|
||||
Console auth and contract-specific dataset RBAC run before forwarding. Request
|
||||
bodies are capped at 64 MiB, JSON and binary responses have separate bounds,
|
||||
SSE responses remain streaming with a bounded idle read timeout, and only safe
|
||||
response headers are exposed. Upstream 401 responses become 502 so they cannot
|
||||
trigger Dify browser-session recovery; resource-level 403 responses remain 403.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Iterator
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
from typing import NoReturn, cast
|
||||
|
||||
import httpx
|
||||
from flask import Response, request, stream_with_context
|
||||
from flask.typing import ResponseReturnValue
|
||||
from werkzeug.exceptions import (
|
||||
BadGateway,
|
||||
Forbidden,
|
||||
GatewayTimeout,
|
||||
MethodNotAllowed,
|
||||
NotFound,
|
||||
RequestEntityTooLarge,
|
||||
ServiceUnavailable,
|
||||
)
|
||||
|
||||
from controllers.console import api, bp
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_rate_limit_check,
|
||||
setup_required,
|
||||
)
|
||||
from core.helper import ssrf_proxy
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from services.knowledge_fs_proxy import (
|
||||
KnowledgeFSAccessDeniedError,
|
||||
KnowledgeFSConfigurationError,
|
||||
KnowledgeFSMethod,
|
||||
KnowledgeFSRouteNotAllowedError,
|
||||
KnowledgeFSTimeoutError,
|
||||
KnowledgeFSTransportError,
|
||||
KnowledgeFSUpstreamResponse,
|
||||
get_knowledge_fs_operation,
|
||||
proxy_knowledge_fs_request,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_PROXY_BODY_BYTES = 64 * 1024 * 1024
|
||||
_RESPONSE_HEADER_ALLOWLIST = (
|
||||
"Cache-Control",
|
||||
"Content-Disposition",
|
||||
"Content-Type",
|
||||
"Retry-After",
|
||||
"X-Trace-Id",
|
||||
)
|
||||
_RESPONSE_HEADER_DENYLIST = frozenset(
|
||||
{
|
||||
"authorization",
|
||||
"connection",
|
||||
"cookie",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _console_api_errors[**P](
|
||||
view: Callable[P, ResponseReturnValue],
|
||||
) -> Callable[P, ResponseReturnValue]:
|
||||
"""Route raw Blueprint exceptions through the Console API JSON handlers."""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs) -> ResponseReturnValue:
|
||||
try:
|
||||
return view(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
return api.handle_error(exc)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def _translate_proxy_error(exc: Exception, *, tenant_id: str) -> NoReturn:
|
||||
"""Map forwarding failures to the stable Console HTTP error surface."""
|
||||
if isinstance(exc, KnowledgeFSRouteNotAllowedError):
|
||||
raise NotFound() from exc
|
||||
if isinstance(exc, KnowledgeFSAccessDeniedError):
|
||||
raise Forbidden() from exc
|
||||
if isinstance(exc, KnowledgeFSConfigurationError):
|
||||
logger.error("KnowledgeFS request was blocked by invalid configuration for tenant_id=%s", tenant_id)
|
||||
raise ServiceUnavailable("KnowledgeFS integration is misconfigured") from exc
|
||||
if isinstance(exc, KnowledgeFSTimeoutError):
|
||||
raise GatewayTimeout("KnowledgeFS request timed out") from exc
|
||||
if isinstance(exc, KnowledgeFSTransportError):
|
||||
logger.warning("KnowledgeFS transport request failed for tenant_id=%s", tenant_id)
|
||||
raise BadGateway("KnowledgeFS is unavailable") from exc
|
||||
raise exc
|
||||
|
||||
|
||||
def _request_body() -> bytes:
|
||||
"""Read the raw body up to the proxy limit or raise RequestEntityTooLarge."""
|
||||
body = request.stream.read(_MAX_PROXY_BODY_BYTES + 1)
|
||||
if len(body) > _MAX_PROXY_BODY_BYTES:
|
||||
raise RequestEntityTooLarge("KnowledgeFS proxy request body is too large")
|
||||
return body
|
||||
|
||||
|
||||
def _stream_response_body(
|
||||
upstream: httpx.Response,
|
||||
*,
|
||||
tenant_id: str,
|
||||
max_response_bytes: int,
|
||||
) -> Iterator[bytes]:
|
||||
"""Yield one bounded SSE response and always release its pooled connection."""
|
||||
total_bytes = 0
|
||||
try:
|
||||
for chunk in upstream.iter_bytes():
|
||||
total_bytes += len(chunk)
|
||||
if total_bytes > max_response_bytes:
|
||||
logger.warning("KnowledgeFS stream exceeded the proxy limit for tenant_id=%s", tenant_id)
|
||||
raise ssrf_proxy.ResponseTooLargeError(f"response exceeded {max_response_bytes} bytes")
|
||||
yield chunk
|
||||
finally:
|
||||
upstream.close()
|
||||
|
||||
|
||||
def _proxy_response(
|
||||
upstream_result: KnowledgeFSUpstreamResponse,
|
||||
*,
|
||||
tenant_id: str,
|
||||
contract_response_headers: tuple[str, ...],
|
||||
max_response_bytes: int,
|
||||
) -> Response:
|
||||
"""Expose raw content, status, and allowlisted headers from KnowledgeFS.
|
||||
|
||||
Raises:
|
||||
BadGateway: KnowledgeFS rejects the configured server credential.
|
||||
Forbidden: KnowledgeFS denies the account access to the requested resource.
|
||||
"""
|
||||
upstream = upstream_result.response
|
||||
if upstream.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
upstream.close()
|
||||
logger.error(
|
||||
"KnowledgeFS rejected the Dify server credential with HTTP %s for tenant_id=%s",
|
||||
upstream.status_code,
|
||||
tenant_id,
|
||||
)
|
||||
raise BadGateway("KnowledgeFS authentication failed")
|
||||
if upstream.status_code == HTTPStatus.FORBIDDEN:
|
||||
upstream.close()
|
||||
raise Forbidden()
|
||||
|
||||
allowed_header_names = dict.fromkeys(
|
||||
name.lower() for name in (*_RESPONSE_HEADER_ALLOWLIST, *contract_response_headers)
|
||||
)
|
||||
headers = {
|
||||
name: value
|
||||
for name in allowed_header_names
|
||||
if name not in _RESPONSE_HEADER_DENYLIST
|
||||
if (value := upstream.headers.get(name)) is not None
|
||||
}
|
||||
if upstream_result.response_kind == "stream":
|
||||
response = Response(
|
||||
stream_with_context( # pyrefly: ignore[no-matching-overload]
|
||||
_stream_response_body(
|
||||
upstream,
|
||||
tenant_id=tenant_id,
|
||||
max_response_bytes=max_response_bytes,
|
||||
)
|
||||
),
|
||||
status=upstream.status_code,
|
||||
headers=headers,
|
||||
)
|
||||
response.call_on_close(upstream.close)
|
||||
return response
|
||||
|
||||
try:
|
||||
content = upstream.content
|
||||
finally:
|
||||
upstream.close()
|
||||
return Response(content, status=upstream.status_code, headers=headers)
|
||||
|
||||
|
||||
def _proxy_request(method: KnowledgeFSMethod, upstream_path: str) -> Response:
|
||||
"""Forward the current raw request and return its filtered upstream response.
|
||||
|
||||
The call performs one outbound KnowledgeFS request. Integration failures are
|
||||
converted to Console HTTP exceptions for the outer JSON error adapter.
|
||||
"""
|
||||
current_user, tenant_id = current_account_with_tenant()
|
||||
try:
|
||||
proxy_result = proxy_knowledge_fs_request(
|
||||
account=current_user,
|
||||
method=method,
|
||||
path=upstream_path,
|
||||
tenant_id=tenant_id,
|
||||
accept=request.headers.get("Accept"),
|
||||
content_type=request.content_type,
|
||||
query=request.query_string or None,
|
||||
body=_request_body() if method != "GET" else None,
|
||||
request_headers=request.headers,
|
||||
)
|
||||
except (
|
||||
KnowledgeFSConfigurationError,
|
||||
KnowledgeFSAccessDeniedError,
|
||||
KnowledgeFSRouteNotAllowedError,
|
||||
KnowledgeFSTimeoutError,
|
||||
KnowledgeFSTransportError,
|
||||
) as exc:
|
||||
_translate_proxy_error(exc, tenant_id=tenant_id)
|
||||
return _proxy_response(
|
||||
proxy_result,
|
||||
tenant_id=tenant_id,
|
||||
contract_response_headers=proxy_result.operation.response_headers,
|
||||
max_response_bytes=proxy_result.operation.max_response_bytes,
|
||||
)
|
||||
|
||||
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def _proxy_knowledge_fs_non_get(
|
||||
method: KnowledgeFSMethod,
|
||||
upstream_path: str,
|
||||
) -> ResponseReturnValue:
|
||||
"""Apply knowledge billing checks to one allowlisted non-GET operation."""
|
||||
return _proxy_request(method, upstream_path)
|
||||
|
||||
|
||||
@bp.route("/knowledge-fs/<path:upstream_path>", methods=["GET"])
|
||||
@_console_api_errors
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def proxy_knowledge_fs_get(upstream_path: str) -> ResponseReturnValue:
|
||||
"""Forward one authenticated, dataset-readable GET request.
|
||||
|
||||
Args:
|
||||
upstream_path: Relative KFS path captured after the Console proxy prefix.
|
||||
|
||||
Returns:
|
||||
The filtered raw KnowledgeFS response or a Console JSON error response.
|
||||
"""
|
||||
if request.method != "GET":
|
||||
raise MethodNotAllowed(valid_methods=["GET"])
|
||||
return _proxy_request("GET", upstream_path)
|
||||
|
||||
|
||||
@bp.route("/knowledge-fs/<path:upstream_path>", methods=["DELETE", "PATCH", "POST", "PUT"])
|
||||
@_console_api_errors
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def proxy_knowledge_fs_write(upstream_path: str) -> ResponseReturnValue:
|
||||
"""Forward one authenticated non-GET request under its contract access policy.
|
||||
|
||||
Args:
|
||||
upstream_path: Relative KFS path captured after the Console proxy prefix.
|
||||
|
||||
Returns:
|
||||
The filtered raw KnowledgeFS response or a Console JSON error response.
|
||||
"""
|
||||
method = cast(KnowledgeFSMethod, request.method)
|
||||
try:
|
||||
get_knowledge_fs_operation(method, upstream_path)
|
||||
except KnowledgeFSRouteNotAllowedError as exc:
|
||||
raise NotFound() from exc
|
||||
return _proxy_knowledge_fs_non_get(method, upstream_path)
|
||||
@@ -47,6 +47,24 @@ class MaxRetriesExceededError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ResponseLimitError(ValueError):
|
||||
"""Base error for responses that cannot be safely bounded."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ResponseTooLargeError(ResponseLimitError):
|
||||
"""Raised when an identity response exceeds the configured byte limit."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UnsupportedResponseEncodingError(ResponseLimitError):
|
||||
"""Raised when response encoding prevents safe decoded-size enforcement."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
request_error = httpx.RequestError
|
||||
max_retries_exceeded_error = MaxRetriesExceededError
|
||||
|
||||
@@ -142,7 +160,31 @@ def _inject_trace_headers(headers: Headers | None) -> Headers:
|
||||
return headers
|
||||
|
||||
|
||||
def make_request(method: str, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETRIES, **kwargs: Any) -> httpx.Response:
|
||||
def make_request(
|
||||
method: str,
|
||||
url: str,
|
||||
max_retries: int = SSRF_DEFAULT_MAX_RETRIES,
|
||||
stream_response: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> httpx.Response:
|
||||
"""Send one SSRF-protected request with optional streaming.
|
||||
|
||||
Args:
|
||||
method: HTTP method sent through the configured SSRF client.
|
||||
url: Absolute request URL.
|
||||
max_retries: Number of retry attempts after the initial request.
|
||||
stream_response: Return an open streaming response that the caller must close.
|
||||
**kwargs: Additional keyword arguments forwarded to ``httpx.Client``.
|
||||
|
||||
Returns:
|
||||
A buffered response, or an open response when ``stream_response`` is true.
|
||||
|
||||
Raises:
|
||||
ToolSSRFError: The configured SSRF proxy rejects the destination.
|
||||
MaxRetriesExceededError: All configured request attempts fail.
|
||||
httpx.RequestError: A request fails while retries are disabled.
|
||||
ValueError: The SSL verification option or request headers are invalid.
|
||||
"""
|
||||
# Convert requests-style allow_redirects to httpx-style follow_redirects
|
||||
if "allow_redirects" in kwargs:
|
||||
allow_redirects = kwargs.pop("allow_redirects")
|
||||
@@ -175,6 +217,11 @@ def make_request(method: str, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETR
|
||||
# When using a forward proxy, httpx may override the Host header based on the URL.
|
||||
# We extract and preserve any explicitly set Host header to support virtual hosting.
|
||||
user_provided_host = _get_user_provided_host_header(headers)
|
||||
send_kwargs: dict[str, Any] = {}
|
||||
if "auth" in kwargs:
|
||||
send_kwargs["auth"] = kwargs.pop("auth")
|
||||
if "follow_redirects" in kwargs:
|
||||
send_kwargs["follow_redirects"] = kwargs.pop("follow_redirects")
|
||||
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
@@ -185,7 +232,11 @@ def make_request(method: str, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETR
|
||||
if user_provided_host is not None:
|
||||
headers["host"] = user_provided_host
|
||||
kwargs["headers"] = headers
|
||||
response = client.request(method=method, url=url, **kwargs)
|
||||
request = client.build_request(method=method, url=url, **kwargs)
|
||||
if stream_response:
|
||||
response = client.send(request, stream=True, **send_kwargs)
|
||||
else:
|
||||
response = client.send(request, **send_kwargs)
|
||||
|
||||
# Check for SSRF protection by Squid proxy
|
||||
if response.status_code in (401, 403):
|
||||
@@ -195,6 +246,7 @@ def make_request(method: str, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETR
|
||||
|
||||
# Squid typically identifies itself in Server or Via headers
|
||||
if "squid" in server_header or "squid" in via_header:
|
||||
response.close()
|
||||
raise ToolSSRFError(
|
||||
f"Access to '{url}' was blocked by SSRF protection. "
|
||||
f"The URL may point to a private or local network address. "
|
||||
@@ -208,6 +260,7 @@ def make_request(method: str, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETR
|
||||
response.status_code,
|
||||
url,
|
||||
)
|
||||
response.close()
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.warning("Request to URL %s failed on attempt %s: %s", url, retries + 1, e)
|
||||
@@ -220,6 +273,42 @@ def make_request(method: str, url: str, max_retries: int = SSRF_DEFAULT_MAX_RETR
|
||||
raise MaxRetriesExceededError(f"Reached maximum retries ({max_retries}) for URL {url}")
|
||||
|
||||
|
||||
def buffer_response(response: httpx.Response, *, max_response_bytes: int) -> httpx.Response:
|
||||
"""Consume one open identity response under a decoded byte limit and close its stream."""
|
||||
if max_response_bytes <= 0:
|
||||
raise ValueError("max_response_bytes must be positive")
|
||||
|
||||
try:
|
||||
content_encoding = response.headers.get("content-encoding", "identity").strip().lower()
|
||||
if content_encoding not in {"", "identity"}:
|
||||
raise UnsupportedResponseEncodingError(f"content encoding {content_encoding} cannot be safely bounded")
|
||||
content = bytearray()
|
||||
for chunk in response.iter_bytes():
|
||||
if len(content) + len(chunk) > max_response_bytes:
|
||||
raise ResponseTooLargeError(f"response exceeded {max_response_bytes} bytes")
|
||||
content.extend(chunk)
|
||||
decoded_headers = {
|
||||
name: value
|
||||
for name, value in response.headers.items()
|
||||
if name.lower() not in {"content-encoding", "content-length", "transfer-encoding"}
|
||||
}
|
||||
try:
|
||||
request = response.request
|
||||
except RuntimeError:
|
||||
request = None
|
||||
return httpx.Response(
|
||||
response.status_code,
|
||||
headers=decoded_headers,
|
||||
content=bytes(content),
|
||||
request=request,
|
||||
extensions=response.extensions,
|
||||
history=response.history,
|
||||
default_encoding=response.default_encoding,
|
||||
)
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
def get(url: str, max_retries: int = SSRF_DEFAULT_MAX_RETRIES, **kwargs: Any) -> httpx.Response:
|
||||
return make_request("GET", url, max_retries=max_retries, **kwargs)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
||||
"""Transport-only forwarding for the allowlisted KnowledgeFS Console routes.
|
||||
|
||||
KnowledgeFS owns the request and response contract. This module binds short-lived
|
||||
account and workspace identities, enforces Dify's coarse workspace policy, and
|
||||
normalizes transport failures. The dedicated
|
||||
request path uses Dify's shared SSRF policy, accepts only exact contract
|
||||
operations, never follows redirects, bounds buffered identity responses, and
|
||||
rejects compressed responses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from typing import Literal, NamedTuple, Protocol, cast
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
from configs import dify_config
|
||||
from core.helper import ssrf_proxy
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
from core.tools.errors import ToolSSRFError
|
||||
from models import Account
|
||||
from services.enterprise.rbac_service import RBACService
|
||||
from services.knowledge_fs_contract_routes import KNOWLEDGE_FS_CONTRACT_OPERATIONS
|
||||
|
||||
type KnowledgeFSMethod = Literal["DELETE", "GET", "PATCH", "POST", "PUT"]
|
||||
type KnowledgeFSResponseKind = Literal["binary", "buffered", "stream"]
|
||||
type KnowledgeFSAccess = Literal["read", "write"]
|
||||
type KnowledgeFSRBACPermission = Literal[
|
||||
"dataset_access_config",
|
||||
"dataset_api_key_manage",
|
||||
"dataset_create_and_management",
|
||||
"dataset_document_download",
|
||||
"dataset_edit",
|
||||
"dataset_external_connect",
|
||||
"dataset_readonly",
|
||||
]
|
||||
|
||||
_JWT_AUDIENCE = "knowledge-fs"
|
||||
_JWT_ISSUER = "dify"
|
||||
_JWT_TTL_SECONDS = 60
|
||||
_MAX_BUFFERED_RESPONSE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class KnowledgeFSOperation(NamedTuple):
|
||||
method: KnowledgeFSMethod
|
||||
path: str
|
||||
response_kind: KnowledgeFSResponseKind
|
||||
access: KnowledgeFSAccess
|
||||
rbac_permission: KnowledgeFSRBACPermission
|
||||
max_response_bytes: int
|
||||
request_headers: tuple[str, ...]
|
||||
response_headers: tuple[str, ...]
|
||||
response_media_types: tuple[str, ...]
|
||||
|
||||
|
||||
class KnowledgeFSUpstreamResponse(NamedTuple):
|
||||
response: httpx.Response
|
||||
response_kind: KnowledgeFSResponseKind
|
||||
operation: KnowledgeFSOperation
|
||||
|
||||
|
||||
class _RequestHeaders(Protocol):
|
||||
def items(self) -> Iterable[tuple[str, str]]: ...
|
||||
|
||||
|
||||
class KnowledgeFSConfigurationError(RuntimeError):
|
||||
"""KnowledgeFS is incompletely configured or blocked by outbound policy."""
|
||||
|
||||
|
||||
class KnowledgeFSTimeoutError(RuntimeError):
|
||||
"""KnowledgeFS exceeded the configured request timeout."""
|
||||
|
||||
|
||||
class KnowledgeFSTransportError(RuntimeError):
|
||||
"""KnowledgeFS could not be reached or returned a response outside safety bounds."""
|
||||
|
||||
|
||||
class KnowledgeFSRouteNotAllowedError(RuntimeError):
|
||||
"""The requested path is outside the Console-visible KnowledgeFS surface."""
|
||||
|
||||
|
||||
class KnowledgeFSAccessDeniedError(RuntimeError):
|
||||
"""The Dify account lacks the workspace permission required by the operation."""
|
||||
|
||||
|
||||
def authorize_knowledge_fs_request(
|
||||
*,
|
||||
account: Account,
|
||||
tenant_id: str,
|
||||
method: KnowledgeFSMethod,
|
||||
operation: KnowledgeFSOperation,
|
||||
) -> None:
|
||||
"""Enforce Dify's workspace policy before KFS performs resource authorization.
|
||||
|
||||
Args:
|
||||
account: Authenticated Dify account with its current workspace role.
|
||||
tenant_id: Current Dify workspace identifier.
|
||||
method: Allowlisted upstream HTTP method.
|
||||
operation: Generated KnowledgeFS operation metadata.
|
||||
|
||||
Raises:
|
||||
KnowledgeFSAccessDeniedError: The account lacks a required legacy or enterprise permission.
|
||||
"""
|
||||
permission = RBACPermission(operation.rbac_permission)
|
||||
if permission == RBACPermission.DATASET_ACCESS_CONFIG and not account.is_admin_or_owner:
|
||||
raise KnowledgeFSAccessDeniedError("KnowledgeFS access-policy changes require a workspace administrator")
|
||||
if permission == RBACPermission.DATASET_API_KEY_MANAGE and (
|
||||
(method == "DELETE" and not account.is_admin_or_owner)
|
||||
or (method != "DELETE" and not account.has_edit_permission)
|
||||
):
|
||||
raise KnowledgeFSAccessDeniedError("KnowledgeFS API-key changes require elevated workspace access")
|
||||
if permission == RBACPermission.DATASET_EXTERNAL_CONNECT and not account.has_edit_permission:
|
||||
raise KnowledgeFSAccessDeniedError("KnowledgeFS connections require workspace edit access")
|
||||
if operation.access == "write" and not account.is_dataset_editor:
|
||||
raise KnowledgeFSAccessDeniedError("KnowledgeFS mutations require dataset edit access")
|
||||
if not RBACService.CheckAccess.check(
|
||||
tenant_id,
|
||||
account.id,
|
||||
scene=permission.value,
|
||||
resource_type=RBACResourceScope.DATASET.value,
|
||||
):
|
||||
raise KnowledgeFSAccessDeniedError("KnowledgeFS operation is denied by workspace RBAC")
|
||||
|
||||
|
||||
def proxy_knowledge_fs_request(
|
||||
*,
|
||||
account: Account,
|
||||
method: KnowledgeFSMethod,
|
||||
path: str,
|
||||
tenant_id: str,
|
||||
accept: str | None = None,
|
||||
content_type: str | None = None,
|
||||
query: bytes | None = None,
|
||||
body: bytes | None = None,
|
||||
request_headers: _RequestHeaders | None = None,
|
||||
) -> KnowledgeFSUpstreamResponse:
|
||||
"""Authorize and forward one allowlisted KnowledgeFS request as a single use case."""
|
||||
operation = get_knowledge_fs_operation(method, path)
|
||||
authorize_knowledge_fs_request(
|
||||
account=account,
|
||||
tenant_id=tenant_id,
|
||||
method=method,
|
||||
operation=operation,
|
||||
)
|
||||
incoming_request_headers = {name.lower(): value for name, value in (request_headers or {}).items()}
|
||||
contract_request_headers = {
|
||||
name: incoming_request_headers[name] for name in operation.request_headers if name in incoming_request_headers
|
||||
}
|
||||
return _forward_knowledge_fs_request(
|
||||
account_id=account.id,
|
||||
method=method,
|
||||
path=path,
|
||||
tenant_id=tenant_id,
|
||||
accept=accept,
|
||||
content_type=content_type,
|
||||
query=query,
|
||||
body=body,
|
||||
request_headers=contract_request_headers,
|
||||
)
|
||||
|
||||
|
||||
def _forward_knowledge_fs_request(
|
||||
*,
|
||||
account_id: str,
|
||||
method: KnowledgeFSMethod,
|
||||
path: str,
|
||||
tenant_id: str,
|
||||
accept: str | None = None,
|
||||
content_type: str | None = None,
|
||||
query: bytes | None = None,
|
||||
body: bytes | None = None,
|
||||
request_headers: Mapping[str, str] | None = None,
|
||||
) -> KnowledgeFSUpstreamResponse:
|
||||
"""Forward one fixed-route request without parsing its KnowledgeFS payload.
|
||||
|
||||
Args:
|
||||
account_id: Current Dify account used as the KFS member identity.
|
||||
method: Allowlisted upstream HTTP method.
|
||||
path: Relative KnowledgeFS path under an allowlisted product surface.
|
||||
tenant_id: Current Dify workspace used as the KFS tenant identity.
|
||||
accept: Original Accept header, when present.
|
||||
content_type: Original request Content-Type header, when present.
|
||||
query: Original encoded query string from the Console request.
|
||||
body: Original request body, when present.
|
||||
request_headers: Contract-declared request headers forwarded by the Console adapter.
|
||||
|
||||
Returns:
|
||||
The KnowledgeFS response and its actual transport kind. Non-success responses are buffered.
|
||||
|
||||
Raises:
|
||||
KnowledgeFSConfigurationError: The connection is incomplete or blocked by outbound policy.
|
||||
KnowledgeFSRouteNotAllowedError: The path is outside the allowlisted product surface.
|
||||
KnowledgeFSTimeoutError: KnowledgeFS exceeds the configured timeout.
|
||||
KnowledgeFSTransportError: The request fails or its response cannot be safely bounded.
|
||||
|
||||
Each request is bound to stable Dify account and workspace principals with a short expiration.
|
||||
"""
|
||||
operation = get_knowledge_fs_operation(method, path)
|
||||
base_url = dify_config.KNOWLEDGE_FS_BASE_URL
|
||||
jwt_secret = dify_config.KNOWLEDGE_FS_JWT_SECRET
|
||||
if base_url is None or jwt_secret is None:
|
||||
raise KnowledgeFSConfigurationError("KnowledgeFS connection configuration is incomplete")
|
||||
now = datetime.now(UTC)
|
||||
token = jwt.encode(
|
||||
{
|
||||
"aud": _JWT_AUDIENCE,
|
||||
"caller_kind": "interactive",
|
||||
"dify_account_id": f"dify-account:{account_id}",
|
||||
"exp": now + timedelta(seconds=_JWT_TTL_SECONDS),
|
||||
"iat": now,
|
||||
"iss": _JWT_ISSUER,
|
||||
"scopes": [f"knowledge-spaces:{operation.access}"],
|
||||
"sub": f"dify-workspace:{tenant_id}",
|
||||
"tenant_id": tenant_id,
|
||||
},
|
||||
jwt_secret.get_secret_value(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
headers = {
|
||||
"Accept": accept or "application/json",
|
||||
"Accept-Encoding": "identity",
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = content_type or "application/json"
|
||||
allowed_request_headers = set(operation.request_headers)
|
||||
for name, value in (request_headers or {}).items():
|
||||
normalized_name = name.lower()
|
||||
if normalized_name not in allowed_request_headers:
|
||||
raise KnowledgeFSRouteNotAllowedError("KnowledgeFS request header is not allowed")
|
||||
headers[normalized_name] = value
|
||||
|
||||
try:
|
||||
upstream_url = httpx.URL(f"{base_url}/").join(operation.path)
|
||||
response = ssrf_proxy.make_request(
|
||||
method=operation.method,
|
||||
url=str(upstream_url),
|
||||
params=query,
|
||||
content=body,
|
||||
headers=headers,
|
||||
timeout=dify_config.KNOWLEDGE_FS_TIMEOUT_SECONDS,
|
||||
follow_redirects=False,
|
||||
max_retries=0,
|
||||
stream_response=True,
|
||||
)
|
||||
response_kind = _classify_response(operation, response)
|
||||
if response_kind == "stream":
|
||||
content_encoding = response.headers.get("content-encoding", "identity").strip().lower()
|
||||
if content_encoding not in {"", "identity"}:
|
||||
response.close()
|
||||
raise KnowledgeFSTransportError("KnowledgeFS streaming response used an unsupported encoding")
|
||||
_set_response_read_timeout(response, dify_config.KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS)
|
||||
return KnowledgeFSUpstreamResponse(response, response_kind, operation)
|
||||
|
||||
max_response_bytes = (
|
||||
operation.max_response_bytes
|
||||
if HTTPStatus.OK <= response.status_code < HTTPStatus.MULTIPLE_CHOICES
|
||||
else _MAX_BUFFERED_RESPONSE_BYTES
|
||||
)
|
||||
buffered_response = ssrf_proxy.buffer_response(response, max_response_bytes=max_response_bytes)
|
||||
if buffered_response.content and not buffered_response.headers.get("content-type", "").strip():
|
||||
buffered_response.close()
|
||||
raise KnowledgeFSTransportError("KnowledgeFS buffered response used an unsupported media type")
|
||||
return KnowledgeFSUpstreamResponse(buffered_response, response_kind, operation)
|
||||
except ssrf_proxy.ResponseLimitError as exc:
|
||||
raise KnowledgeFSTransportError("KnowledgeFS response violated the proxy limit") from exc
|
||||
except ToolSSRFError as exc:
|
||||
raise KnowledgeFSConfigurationError("KnowledgeFS origin was blocked by outbound policy") from exc
|
||||
except httpx.TimeoutException as exc:
|
||||
raise KnowledgeFSTimeoutError("KnowledgeFS request timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise KnowledgeFSTransportError("KnowledgeFS transport request failed") from exc
|
||||
|
||||
|
||||
def get_knowledge_fs_operation(method: KnowledgeFSMethod, path: str) -> KnowledgeFSOperation:
|
||||
"""Resolve an exact operation and its transport/access contract metadata."""
|
||||
for (
|
||||
allowed_method,
|
||||
template,
|
||||
response_kind,
|
||||
access,
|
||||
rbac_permission,
|
||||
max_response_bytes,
|
||||
request_headers,
|
||||
response_headers,
|
||||
response_media_types,
|
||||
) in KNOWLEDGE_FS_CONTRACT_OPERATIONS:
|
||||
if method == allowed_method and _matches_route_template(template, path):
|
||||
return KnowledgeFSOperation(
|
||||
method,
|
||||
path,
|
||||
response_kind=cast(KnowledgeFSResponseKind, response_kind),
|
||||
access=cast(KnowledgeFSAccess, access),
|
||||
rbac_permission=cast(KnowledgeFSRBACPermission, rbac_permission),
|
||||
max_response_bytes=max_response_bytes,
|
||||
request_headers=request_headers,
|
||||
response_headers=response_headers,
|
||||
response_media_types=response_media_types,
|
||||
)
|
||||
raise KnowledgeFSRouteNotAllowedError("KnowledgeFS route is not allowed")
|
||||
|
||||
|
||||
def _classify_response(operation: KnowledgeFSOperation, response: httpx.Response) -> KnowledgeFSResponseKind:
|
||||
"""Resolve the actual response kind from status and Content-Type before reading its body."""
|
||||
content_type = response.headers.get("content-type", "").partition(";")[0].strip().lower()
|
||||
is_success = HTTPStatus.OK <= response.status_code < HTTPStatus.MULTIPLE_CHOICES
|
||||
if not is_success:
|
||||
if content_type and not _is_json_content_type(content_type):
|
||||
response.close()
|
||||
raise KnowledgeFSTransportError("KnowledgeFS error response used an unsupported media type")
|
||||
return "buffered"
|
||||
|
||||
if operation.response_kind == "stream":
|
||||
if content_type != "text/event-stream":
|
||||
response.close()
|
||||
raise KnowledgeFSTransportError("KnowledgeFS stream response used an unsupported media type")
|
||||
return "stream"
|
||||
if operation.response_kind == "binary":
|
||||
if content_type not in operation.response_media_types:
|
||||
response.close()
|
||||
raise KnowledgeFSTransportError("KnowledgeFS binary response used an unsupported media type")
|
||||
return "binary"
|
||||
if content_type and not _is_json_content_type(content_type):
|
||||
response.close()
|
||||
raise KnowledgeFSTransportError("KnowledgeFS buffered response used an unsupported media type")
|
||||
return "buffered"
|
||||
|
||||
|
||||
def _is_json_content_type(content_type: str) -> bool:
|
||||
return content_type == "application/json" or content_type.endswith("+json")
|
||||
|
||||
|
||||
def _set_response_read_timeout(response: httpx.Response, timeout_seconds: float | None) -> None:
|
||||
"""Set the body-read timeout after headers identify a valid SSE response."""
|
||||
try:
|
||||
request = response.request
|
||||
except RuntimeError:
|
||||
return
|
||||
timeout = request.extensions.get("timeout")
|
||||
if isinstance(timeout, dict):
|
||||
timeout["read"] = timeout_seconds
|
||||
|
||||
|
||||
def _matches_route_template(template: str, path: str) -> bool:
|
||||
"""Match path parameters without permitting encoded or traversal-like segments."""
|
||||
template_segments = template.split("/")
|
||||
path_segments = path.split("/")
|
||||
if len(template_segments) != len(path_segments):
|
||||
return False
|
||||
|
||||
for template_segment, path_segment in zip(template_segments, path_segments, strict=True):
|
||||
if template_segment.startswith("{") and template_segment.endswith("}"):
|
||||
if (
|
||||
not path_segment
|
||||
or path_segment in {".", ".."}
|
||||
or "\\" in path_segment
|
||||
or "%" in path_segment
|
||||
or "?" in path_segment
|
||||
or "#" in path_segment
|
||||
):
|
||||
return False
|
||||
continue
|
||||
if template_segment != path_segment:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
from configs.extra.knowledge_fs_config import KnowledgeFSConfig
|
||||
|
||||
_REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
|
||||
_KNOWLEDGE_FS_DOCKER_VARIABLES = (
|
||||
"KNOWLEDGE_FS_BASE_URL",
|
||||
"KNOWLEDGE_FS_JWT_SECRET",
|
||||
"KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS",
|
||||
"KNOWLEDGE_FS_TIMEOUT_SECONDS",
|
||||
)
|
||||
|
||||
|
||||
def test_knowledge_fs_config_normalizes_complete_connection() -> None:
|
||||
config = KnowledgeFSConfig(
|
||||
KNOWLEDGE_FS_BASE_URL=" https://knowledge-fs.test/ ",
|
||||
KNOWLEDGE_FS_JWT_SECRET=" production-secret-with-at-least-32-bytes ",
|
||||
)
|
||||
|
||||
assert config.KNOWLEDGE_FS_BASE_URL == "https://knowledge-fs.test"
|
||||
assert isinstance(config.KNOWLEDGE_FS_JWT_SECRET, SecretStr)
|
||||
assert config.KNOWLEDGE_FS_JWT_SECRET.get_secret_value() == "production-secret-with-at-least-32-bytes"
|
||||
assert "production-secret" not in repr(config)
|
||||
assert "production-secret" not in config.model_dump_json()
|
||||
assert config.KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS == 300.0
|
||||
|
||||
|
||||
def test_knowledge_fs_config_treats_blank_connection_as_disabled() -> None:
|
||||
config = KnowledgeFSConfig(
|
||||
KNOWLEDGE_FS_BASE_URL=" ",
|
||||
KNOWLEDGE_FS_JWT_SECRET="",
|
||||
)
|
||||
|
||||
assert config.KNOWLEDGE_FS_BASE_URL is None
|
||||
assert config.KNOWLEDGE_FS_JWT_SECRET is None
|
||||
|
||||
|
||||
def test_knowledge_fs_docker_config_is_not_shadowed_by_root_env() -> None:
|
||||
root_env_example = (_REPOSITORY_ROOT / "docker/.env.example").read_text(encoding="utf-8")
|
||||
api_env_example = (_REPOSITORY_ROOT / "docker/envs/core-services/api.env.example").read_text(encoding="utf-8")
|
||||
|
||||
for variable in _KNOWLEDGE_FS_DOCKER_VARIABLES:
|
||||
assert f"{variable}=" not in root_env_example
|
||||
assert f"{variable}=" in api_env_example
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("base_url", "jwt_secret"),
|
||||
[
|
||||
("https://knowledge-fs.test", None),
|
||||
(None, "production-secret-with-at-least-32-bytes"),
|
||||
],
|
||||
)
|
||||
def test_knowledge_fs_config_rejects_partial_connection(base_url: str | None, jwt_secret: str | None) -> None:
|
||||
with pytest.raises(ValidationError, match="must be configured together"):
|
||||
KnowledgeFSConfig(
|
||||
KNOWLEDGE_FS_BASE_URL=base_url,
|
||||
KNOWLEDGE_FS_JWT_SECRET=jwt_secret,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_url", ["knowledge-fs.test", "ftp://knowledge-fs.test", "http:///missing-host"])
|
||||
def test_knowledge_fs_config_rejects_non_http_absolute_urls(base_url: str) -> None:
|
||||
with pytest.raises(ValidationError, match="absolute HTTP\\(S\\) URL"):
|
||||
KnowledgeFSConfig(
|
||||
KNOWLEDGE_FS_BASE_URL=base_url,
|
||||
KNOWLEDGE_FS_JWT_SECRET="production-secret-with-at-least-32-bytes",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_url",
|
||||
[
|
||||
"https://user:password@knowledge-fs.test",
|
||||
"https://knowledge-fs.test?region=us",
|
||||
"https://knowledge-fs.test#gateway",
|
||||
],
|
||||
)
|
||||
def test_knowledge_fs_config_rejects_unsafe_base_url_components(base_url: str) -> None:
|
||||
with pytest.raises(ValidationError, match="must not include credentials, query, or fragment"):
|
||||
KnowledgeFSConfig(
|
||||
KNOWLEDGE_FS_BASE_URL=base_url,
|
||||
KNOWLEDGE_FS_JWT_SECRET="production-secret-with-at-least-32-bytes",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout_seconds", [float("inf"), float("nan"), 60.0001])
|
||||
def test_knowledge_fs_config_rejects_unbounded_timeouts(timeout_seconds: float) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
KnowledgeFSConfig(KNOWLEDGE_FS_TIMEOUT_SECONDS=timeout_seconds)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout_seconds", [float("inf"), float("nan"), 3600.0001])
|
||||
def test_knowledge_fs_config_rejects_unbounded_sse_read_timeouts(timeout_seconds: float) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
KnowledgeFSConfig(KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS=timeout_seconds)
|
||||
@@ -0,0 +1,748 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
from collections.abc import Iterator
|
||||
from inspect import unwrap
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from flask import Flask, Response
|
||||
from werkzeug.exceptions import (
|
||||
BadGateway,
|
||||
Forbidden,
|
||||
MethodNotAllowed,
|
||||
NotFound,
|
||||
RequestEntityTooLarge,
|
||||
ServiceUnavailable,
|
||||
)
|
||||
|
||||
from controllers.console import bp
|
||||
from controllers.console.knowledge_fs_proxy import (
|
||||
_console_api_errors,
|
||||
_proxy_knowledge_fs_non_get,
|
||||
_proxy_response,
|
||||
proxy_knowledge_fs_get,
|
||||
proxy_knowledge_fs_write,
|
||||
)
|
||||
from controllers.console.wraps import RBACPermission
|
||||
from core.helper import ssrf_proxy
|
||||
from services.knowledge_fs_proxy import (
|
||||
KnowledgeFSAccessDeniedError,
|
||||
KnowledgeFSConfigurationError,
|
||||
KnowledgeFSOperation,
|
||||
KnowledgeFSResponseKind,
|
||||
KnowledgeFSRouteNotAllowedError,
|
||||
KnowledgeFSUpstreamResponse,
|
||||
get_knowledge_fs_operation,
|
||||
)
|
||||
|
||||
|
||||
def _upstream(
|
||||
response: httpx.Response,
|
||||
kind: KnowledgeFSResponseKind = "buffered",
|
||||
*,
|
||||
max_response_bytes: int | None = None,
|
||||
) -> KnowledgeFSUpstreamResponse:
|
||||
operation = KnowledgeFSOperation(
|
||||
method="GET",
|
||||
path="test",
|
||||
response_kind=kind,
|
||||
access="read",
|
||||
rbac_permission="dataset_readonly",
|
||||
max_response_bytes=max_response_bytes
|
||||
or (64 * 1024 * 1024 if kind == "stream" else 25 * 1024 * 1024 if kind == "binary" else 1024 * 1024),
|
||||
request_headers=(),
|
||||
response_headers=(
|
||||
"content-security-policy",
|
||||
"x-content-type-options",
|
||||
"x-query-run-id",
|
||||
"x-session-id",
|
||||
),
|
||||
response_media_types=(),
|
||||
)
|
||||
return KnowledgeFSUpstreamResponse(response, kind, operation)
|
||||
|
||||
|
||||
class _EventStream(httpx.SyncByteStream):
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self._content = content
|
||||
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
yield self._content
|
||||
|
||||
|
||||
class _FailingEventStream(httpx.SyncByteStream):
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
yield b"event: delta\ndata: first\n\n"
|
||||
request = httpx.Request("POST", "http://knowledge-fs.test/queries")
|
||||
raise httpx.ReadTimeout("stream timed out", request=request)
|
||||
|
||||
|
||||
def _set_current_workspace(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
editor: bool = True,
|
||||
has_edit_permission: bool = True,
|
||||
admin_or_owner: bool = True,
|
||||
) -> None:
|
||||
account = MagicMock(
|
||||
id="account-1",
|
||||
has_edit_permission=has_edit_permission,
|
||||
is_admin_or_owner=admin_or_owner,
|
||||
is_dataset_editor=editor,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.current_account_with_tenant",
|
||||
lambda: (account, "tenant-1"),
|
||||
)
|
||||
|
||||
|
||||
def _bypass_policy_wrappers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy._proxy_knowledge_fs_non_get",
|
||||
unwrap(_proxy_knowledge_fs_non_get),
|
||||
)
|
||||
|
||||
|
||||
def test_console_blueprint_registers_generic_knowledge_fs_routes() -> None:
|
||||
app = Flask("knowledge-fs-route-registration")
|
||||
app.register_blueprint(bp)
|
||||
adapter = app.url_map.bind("localhost")
|
||||
|
||||
get_endpoint, get_values = adapter.match(
|
||||
"/console/api/knowledge-fs/knowledge-spaces",
|
||||
method="GET",
|
||||
)
|
||||
assert get_endpoint.endswith("proxy_knowledge_fs_get")
|
||||
assert get_values == {"upstream_path": "knowledge-spaces"}
|
||||
for method in ("DELETE", "PATCH", "POST", "PUT"):
|
||||
write_endpoint, write_values = adapter.match(
|
||||
"/console/api/knowledge-fs/knowledge-spaces/space-1",
|
||||
method=method,
|
||||
)
|
||||
assert write_endpoint.endswith("proxy_knowledge_fs_write")
|
||||
assert write_values == {"upstream_path": "knowledge-spaces/space-1"}
|
||||
|
||||
|
||||
def test_generic_get_rejects_flasks_implicit_head_route(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
proxy = MagicMock(return_value=Response(status=200))
|
||||
monkeypatch.setattr("controllers.console.knowledge_fs_proxy._proxy_request", proxy)
|
||||
route = unwrap(proxy_knowledge_fs_get)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/knowledge-fs/knowledge-spaces",
|
||||
method="HEAD",
|
||||
):
|
||||
with pytest.raises(MethodNotAllowed):
|
||||
route("knowledge-spaces")
|
||||
|
||||
proxy.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("route", "method", "path", "permission"),
|
||||
[
|
||||
(proxy_knowledge_fs_get, "GET", "knowledge-spaces", RBACPermission.DATASET_READONLY),
|
||||
(
|
||||
proxy_knowledge_fs_write,
|
||||
"POST",
|
||||
"queries",
|
||||
RBACPermission.DATASET_READONLY,
|
||||
),
|
||||
(
|
||||
proxy_knowledge_fs_write,
|
||||
"POST",
|
||||
"knowledge-spaces",
|
||||
RBACPermission.DATASET_CREATE_AND_MANAGEMENT,
|
||||
),
|
||||
(proxy_knowledge_fs_write, "DELETE", "knowledge-spaces/space-1", RBACPermission.DATASET_EDIT),
|
||||
(
|
||||
proxy_knowledge_fs_write,
|
||||
"PATCH",
|
||||
"knowledge-spaces/space-1/access-policy",
|
||||
RBACPermission.DATASET_ACCESS_CONFIG,
|
||||
),
|
||||
(
|
||||
proxy_knowledge_fs_write,
|
||||
"POST",
|
||||
"knowledge-spaces/space-1/api-keys",
|
||||
RBACPermission.DATASET_API_KEY_MANAGE,
|
||||
),
|
||||
(
|
||||
proxy_knowledge_fs_get,
|
||||
"GET",
|
||||
"knowledge-spaces/space-1/documents/document-1/multimodal/item-1/asset",
|
||||
RBACPermission.DATASET_DOCUMENT_DOWNLOAD,
|
||||
),
|
||||
(
|
||||
proxy_knowledge_fs_write,
|
||||
"POST",
|
||||
"knowledge-spaces/space-1/source-connections",
|
||||
RBACPermission.DATASET_EXTERNAL_CONNECT,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_generic_routes_delegate_to_the_authorized_service_use_case(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
route,
|
||||
method: str,
|
||||
path: str,
|
||||
permission: RBACPermission,
|
||||
) -> None:
|
||||
response_kind: KnowledgeFSResponseKind = (
|
||||
"binary" if permission == RBACPermission.DATASET_DOCUMENT_DOWNLOAD else "buffered"
|
||||
)
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
content=b"asset" if response_kind == "binary" else b"{}",
|
||||
headers={"Content-Type": "application/octet-stream" if response_kind == "binary" else "application/json"},
|
||||
)
|
||||
proxy = MagicMock(return_value=_upstream(upstream, response_kind))
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
proxy,
|
||||
)
|
||||
_set_current_workspace(monkeypatch)
|
||||
_bypass_policy_wrappers(monkeypatch)
|
||||
raw_route = unwrap(route)
|
||||
|
||||
with app.test_request_context(f"/console/api/knowledge-fs/{path}", method=method, data=b"{}"):
|
||||
response = raw_route(path)
|
||||
|
||||
assert isinstance(response, Response)
|
||||
assert proxy.call_args.kwargs["account"].id == "account-1"
|
||||
assert proxy.call_args.kwargs["tenant_id"] == "tenant-1"
|
||||
assert proxy.call_args.kwargs["method"] == method
|
||||
assert proxy.call_args.kwargs["path"] == path
|
||||
assert get_knowledge_fs_operation(method, path).rbac_permission == permission.value
|
||||
|
||||
|
||||
def test_read_post_applies_knowledge_rate_limit_once(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("controllers.common.wraps.dify_config.RBAC_ENABLED", False)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.wraps.current_account_with_tenant",
|
||||
lambda: (MagicMock(id="account-1"), "tenant-1"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.wraps.FeatureService.get_knowledge_rate_limit",
|
||||
MagicMock(return_value=MagicMock(enabled=True, limit=10)),
|
||||
)
|
||||
zadd = MagicMock()
|
||||
monkeypatch.setattr("controllers.console.wraps.redis_client.zadd", zadd)
|
||||
monkeypatch.setattr("controllers.console.wraps.redis_client.zremrangebyscore", MagicMock())
|
||||
monkeypatch.setattr("controllers.console.wraps.redis_client.zcard", MagicMock(return_value=1))
|
||||
proxy = MagicMock(return_value=Response(status=200))
|
||||
monkeypatch.setattr("controllers.console.knowledge_fs_proxy._proxy_request", proxy)
|
||||
|
||||
with app.test_request_context("/console/api/knowledge-fs/queries", method="POST"):
|
||||
response = _proxy_knowledge_fs_non_get("POST", "queries")
|
||||
|
||||
assert isinstance(response, Response)
|
||||
zadd.assert_called_once()
|
||||
proxy.assert_called_once_with("POST", "queries")
|
||||
|
||||
|
||||
def test_generic_get_forwards_path_query_and_raw_response(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
forward = MagicMock(
|
||||
return_value=_upstream(
|
||||
httpx.Response(
|
||||
200,
|
||||
content=gzip.compress(b'{"items":[],"nextCursor":null}'),
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Encoding": "gzip",
|
||||
"Content-Disposition": 'attachment; filename="result.json"',
|
||||
"Content-Type": "application/json",
|
||||
"Retry-After": "3",
|
||||
"Set-Cookie": "kfs=secret",
|
||||
"X-Trace-Id": "trace-1",
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
forward,
|
||||
)
|
||||
_set_current_workspace(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_get)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/knowledge-fs/knowledge-spaces",
|
||||
query_string=[("limit", "20"), ("cursor", "first"), ("cursor", "second")],
|
||||
):
|
||||
response = route("knowledge-spaces")
|
||||
|
||||
request = forward.call_args.kwargs
|
||||
assert request["account"].id == "account-1"
|
||||
assert request["method"] == "GET"
|
||||
assert request["path"] == "knowledge-spaces"
|
||||
assert request["tenant_id"] == "tenant-1"
|
||||
assert request["accept"] is None
|
||||
assert request["content_type"] is None
|
||||
assert request["query"] == b"limit=20&cursor=first&cursor=second"
|
||||
assert request["body"] is None
|
||||
assert isinstance(response, Response)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"items": [], "nextCursor": None}
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
assert response.headers["Content-Disposition"] == 'attachment; filename="result.json"'
|
||||
assert response.headers["Retry-After"] == "3"
|
||||
assert response.headers["X-Trace-Id"] == "trace-1"
|
||||
assert "Content-Encoding" not in response.headers
|
||||
assert "Set-Cookie" not in response.headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
[
|
||||
("DELETE", "knowledge-spaces/space-1"),
|
||||
("PATCH", "knowledge-spaces/space-1"),
|
||||
("POST", "knowledge-spaces"),
|
||||
("PUT", "knowledge-spaces/space-1/retrieval-profile"),
|
||||
],
|
||||
)
|
||||
def test_generic_write_forwards_path_raw_body_and_current_tenant(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method: str,
|
||||
path: str,
|
||||
) -> None:
|
||||
forward = MagicMock(
|
||||
return_value=_upstream(
|
||||
httpx.Response(
|
||||
201,
|
||||
content=b'{"id":"space-1","tenantId":"tenant-1"}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
forward,
|
||||
)
|
||||
_set_current_workspace(monkeypatch)
|
||||
_bypass_policy_wrappers(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_write)
|
||||
body = b'{"idempotencyKey":"create-product-docs","name":"Product docs"}'
|
||||
|
||||
with app.test_request_context(
|
||||
f"/console/api/knowledge-fs/{path}",
|
||||
method=method,
|
||||
data=body,
|
||||
content_type="application/json",
|
||||
):
|
||||
response = route(path)
|
||||
|
||||
request = forward.call_args.kwargs
|
||||
assert request["account"].id == "account-1"
|
||||
assert request["method"] == method
|
||||
assert request["path"] == path
|
||||
assert request["tenant_id"] == "tenant-1"
|
||||
assert request["accept"] is None
|
||||
assert request["content_type"] == "application/json"
|
||||
assert request["query"] is None
|
||||
assert request["body"] == body
|
||||
assert isinstance(response, Response)
|
||||
assert response.status_code == 201
|
||||
assert response.get_json()["tenantId"] == "tenant-1"
|
||||
|
||||
|
||||
def test_generic_write_forwards_contract_declared_request_headers(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
forward = MagicMock(
|
||||
return_value=_upstream(
|
||||
httpx.Response(202, content=b'{"status":"accepted"}', headers={"Content-Type": "application/json"})
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
forward,
|
||||
)
|
||||
_set_current_workspace(monkeypatch)
|
||||
_bypass_policy_wrappers(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_write)
|
||||
body = b'{"challenge":"delete-space","expectedRevision":1}'
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/knowledge-fs/knowledge-spaces/space-1",
|
||||
method="DELETE",
|
||||
data=body,
|
||||
content_type="application/json",
|
||||
headers={"Idempotency-Key": "delete-space-1"},
|
||||
):
|
||||
response = route("knowledge-spaces/space-1")
|
||||
|
||||
assert isinstance(response, Response)
|
||||
assert response.status_code == 202
|
||||
assert forward.call_args.kwargs["request_headers"].get("Idempotency-Key") == "delete-space-1"
|
||||
|
||||
|
||||
def test_processing_events_forwards_last_event_id(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
stream=_EventStream(b"event: done\ndata: {}\n\n"),
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
forward = MagicMock(return_value=_upstream(upstream, "stream"))
|
||||
monkeypatch.setattr("controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request", forward)
|
||||
_set_current_workspace(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_get)
|
||||
path = "knowledge-spaces/space-1/documents/document-1/processing-tasks/task-1/events"
|
||||
|
||||
with app.test_request_context(
|
||||
f"/console/api/knowledge-fs/{path}",
|
||||
headers={"Last-Event-ID": "42"},
|
||||
):
|
||||
response = route(path)
|
||||
assert isinstance(response, Response)
|
||||
response.close()
|
||||
|
||||
assert forward.call_args.kwargs["request_headers"].get("Last-Event-ID") == "42"
|
||||
|
||||
|
||||
def test_contract_response_headers_cannot_bypass_the_proxy_denylist() -> None:
|
||||
denied_headers = (
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
)
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
content=b"{}",
|
||||
headers=dict.fromkeys(denied_headers, "blocked"),
|
||||
)
|
||||
|
||||
response = _proxy_response(
|
||||
_upstream(upstream),
|
||||
tenant_id="tenant-1",
|
||||
contract_response_headers=denied_headers,
|
||||
max_response_bytes=1024 * 1024,
|
||||
)
|
||||
|
||||
for name in denied_headers:
|
||||
assert name not in response.headers
|
||||
|
||||
|
||||
def test_contract_response_headers_forward_binary_hardening_headers() -> None:
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
content=b"asset",
|
||||
headers={
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"Content-Type": "image/png",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
response = _proxy_response(
|
||||
_upstream(upstream, "binary"),
|
||||
tenant_id="tenant-1",
|
||||
contract_response_headers=("content-security-policy", "x-content-type-options"),
|
||||
max_response_bytes=25 * 1024 * 1024,
|
||||
)
|
||||
|
||||
assert response.headers["Content-Security-Policy"] == "sandbox; default-src 'none'"
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
|
||||
def test_authorized_service_denial_is_exposed_as_forbidden(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
MagicMock(side_effect=KnowledgeFSAccessDeniedError("workspace access denied")),
|
||||
)
|
||||
_set_current_workspace(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_get)
|
||||
|
||||
with app.test_request_context("/console/api/knowledge-fs/knowledge-spaces"):
|
||||
with pytest.raises(Forbidden):
|
||||
route("knowledge-spaces")
|
||||
|
||||
|
||||
def test_read_post_allows_non_editor_and_streams_sse(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
stream=_EventStream(b"event: delta\ndata: first\n\nevent: done\ndata: {}\n\n"),
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "text/event-stream",
|
||||
"X-Query-Run-Id": "query-run-1",
|
||||
"X-Session-Id": "session-1",
|
||||
},
|
||||
)
|
||||
forward = MagicMock(return_value=_upstream(upstream, "stream"))
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
forward,
|
||||
)
|
||||
_set_current_workspace(monkeypatch, editor=False)
|
||||
_bypass_policy_wrappers(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_write)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/knowledge-fs/queries",
|
||||
method="POST",
|
||||
data=b'{"knowledgeSpaceId":"space-1","query":"hello"}',
|
||||
content_type="application/json",
|
||||
):
|
||||
response = route("queries")
|
||||
assert isinstance(response, Response)
|
||||
assert response.get_data() == b"event: delta\ndata: first\n\nevent: done\ndata: {}\n\n"
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Type"].startswith("text/event-stream")
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
assert response.headers["X-Query-Run-Id"] == "query-run-1"
|
||||
assert response.headers["X-Session-Id"] == "session-1"
|
||||
assert upstream.is_closed
|
||||
forward.assert_called_once()
|
||||
|
||||
|
||||
def test_unconsumed_sse_response_closes_upstream(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
stream=_EventStream(b"event: done\ndata: {}\n\n"),
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
MagicMock(return_value=_upstream(upstream, "stream")),
|
||||
)
|
||||
_set_current_workspace(monkeypatch, editor=False)
|
||||
_bypass_policy_wrappers(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_write)
|
||||
|
||||
with app.test_request_context("/console/api/knowledge-fs/queries", method="POST", data=b"{}"):
|
||||
response = route("queries")
|
||||
assert isinstance(response, Response)
|
||||
response.close()
|
||||
|
||||
assert upstream.is_closed
|
||||
|
||||
|
||||
def test_sse_response_uses_the_generated_operation_limit(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
stream=_EventStream(b"event: done\ndata: {}\n\n"),
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
MagicMock(return_value=_upstream(upstream, "stream", max_response_bytes=1)),
|
||||
)
|
||||
_set_current_workspace(monkeypatch, editor=False)
|
||||
_bypass_policy_wrappers(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_write)
|
||||
|
||||
with app.test_request_context("/console/api/knowledge-fs/queries", method="POST", data=b"{}"):
|
||||
response = route("queries")
|
||||
assert isinstance(response, Response)
|
||||
with pytest.raises(ssrf_proxy.ResponseTooLargeError, match="response exceeded 1 bytes"):
|
||||
response.get_data()
|
||||
|
||||
assert upstream.is_closed
|
||||
|
||||
|
||||
def test_sse_transport_failure_is_visible_to_the_client(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
stream=_FailingEventStream(),
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
MagicMock(return_value=_upstream(upstream, "stream")),
|
||||
)
|
||||
_set_current_workspace(monkeypatch, editor=False)
|
||||
_bypass_policy_wrappers(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_write)
|
||||
|
||||
with app.test_request_context("/console/api/knowledge-fs/queries", method="POST", data=b"{}"):
|
||||
response = route("queries")
|
||||
assert isinstance(response, Response)
|
||||
with pytest.raises(httpx.ReadTimeout, match="stream timed out"):
|
||||
response.get_data()
|
||||
|
||||
assert upstream.is_closed
|
||||
|
||||
|
||||
def test_generic_post_rejects_oversized_body(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
forward = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
forward,
|
||||
)
|
||||
_set_current_workspace(monkeypatch)
|
||||
_bypass_policy_wrappers(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_write)
|
||||
|
||||
monkeypatch.setattr("controllers.console.knowledge_fs_proxy._MAX_PROXY_BODY_BYTES", 8)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/knowledge-fs/knowledge-spaces",
|
||||
method="POST",
|
||||
data=b"x" * 9,
|
||||
content_type="application/json",
|
||||
):
|
||||
with pytest.raises(RequestEntityTooLarge):
|
||||
route("knowledge-spaces")
|
||||
|
||||
forward.assert_not_called()
|
||||
|
||||
|
||||
def test_server_credential_rejection_is_not_exposed_as_browser_auth_failure(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
MagicMock(
|
||||
return_value=_upstream(
|
||||
httpx.Response(
|
||||
401,
|
||||
content=b'{"error":"invalid server credential"}',
|
||||
headers={"Content-Type": "application/json", "WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
_set_current_workspace(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_get)
|
||||
|
||||
with app.test_request_context("/console/api/knowledge-fs/knowledge-spaces"):
|
||||
with pytest.raises(BadGateway, match="authentication failed"):
|
||||
route("knowledge-spaces")
|
||||
|
||||
|
||||
def test_resource_authorization_rejection_is_exposed_as_forbidden(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
MagicMock(
|
||||
return_value=_upstream(
|
||||
httpx.Response(
|
||||
403,
|
||||
content=b'{"error":"resource access denied"}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
_set_current_workspace(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_get)
|
||||
|
||||
with app.test_request_context("/console/api/knowledge-fs/knowledge-spaces"):
|
||||
with pytest.raises(Forbidden):
|
||||
route("knowledge-spaces")
|
||||
|
||||
|
||||
def test_contract_response_headers_are_deduplicated_case_insensitively() -> None:
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
content=b"asset",
|
||||
headers={
|
||||
"Cache-Control": "private",
|
||||
"Content-Disposition": 'inline; filename="asset.png"',
|
||||
"Content-Type": "image/png",
|
||||
},
|
||||
)
|
||||
|
||||
response = _proxy_response(
|
||||
_upstream(upstream, "binary"),
|
||||
tenant_id="tenant-1",
|
||||
contract_response_headers=("cache-control", "content-disposition"),
|
||||
max_response_bytes=25 * 1024 * 1024,
|
||||
)
|
||||
|
||||
assert response.headers.getlist("Cache-Control") == ["private"]
|
||||
assert response.headers.getlist("Content-Disposition") == ['inline; filename="asset.png"']
|
||||
|
||||
|
||||
def test_configuration_error_is_reported_as_unavailable(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
MagicMock(side_effect=KnowledgeFSConfigurationError("missing token")),
|
||||
)
|
||||
_set_current_workspace(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_get)
|
||||
|
||||
with app.test_request_context("/console/api/knowledge-fs/knowledge-spaces"):
|
||||
with pytest.raises(ServiceUnavailable, match="misconfigured"):
|
||||
route("knowledge-spaces")
|
||||
|
||||
|
||||
def test_disallowed_kfs_route_is_hidden_as_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.knowledge_fs_proxy.proxy_knowledge_fs_request",
|
||||
MagicMock(side_effect=KnowledgeFSRouteNotAllowedError("blocked")),
|
||||
)
|
||||
_set_current_workspace(monkeypatch)
|
||||
route = unwrap(proxy_knowledge_fs_get)
|
||||
|
||||
with app.test_request_context("/console/api/knowledge-fs/openapi.json"):
|
||||
with pytest.raises(NotFound):
|
||||
route("openapi.json")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["PATCH", "POST"])
|
||||
def test_disallowed_non_get_route_is_hidden_as_not_found(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method: str,
|
||||
) -> None:
|
||||
route = unwrap(proxy_knowledge_fs_write)
|
||||
|
||||
with app.test_request_context("/console/api/knowledge-fs/not-a-route", method=method):
|
||||
with pytest.raises(NotFound):
|
||||
route("not-a-route")
|
||||
|
||||
|
||||
def test_raw_route_uses_console_json_error_handler(app: Flask) -> None:
|
||||
def forbidden(_upstream_path: str) -> Response:
|
||||
raise Forbidden("blocked")
|
||||
|
||||
route = _console_api_errors(forbidden)
|
||||
with app.test_request_context("/console/api/knowledge-fs/knowledge-spaces"):
|
||||
response = app.make_response(route("knowledge-spaces"))
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.is_json
|
||||
assert response.get_json() == {"code": "forbidden", "message": "blocked", "status": 403}
|
||||
@@ -1,3 +1,4 @@
|
||||
import gzip
|
||||
from unittest.mock import ANY, MagicMock, call, patch
|
||||
|
||||
import httpx
|
||||
@@ -5,10 +6,13 @@ import pytest
|
||||
|
||||
from core.helper.ssrf_proxy import (
|
||||
SSRF_DEFAULT_MAX_RETRIES,
|
||||
ResponseTooLargeError,
|
||||
SSRFProxy,
|
||||
UnsupportedResponseEncodingError,
|
||||
_build_ssrf_client,
|
||||
_get_user_provided_host_header,
|
||||
_to_graphon_http_response,
|
||||
buffer_response,
|
||||
graphon_ssrf_proxy,
|
||||
make_request,
|
||||
max_retries_exceeded_error,
|
||||
@@ -21,12 +25,90 @@ def test_successful_request(mock_get_client):
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client.send.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = make_request("GET", "http://example.com")
|
||||
assert response.status_code == 200
|
||||
mock_client.request.assert_called_once()
|
||||
mock_client.build_request.assert_called_once()
|
||||
mock_client.send.assert_called_once()
|
||||
|
||||
|
||||
def test_buffer_response_rejects_encoded_response_before_decoding() -> None:
|
||||
payload = b"x" * (8 * 1024 * 1024)
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
content=gzip.compress(payload),
|
||||
headers={"Content-Encoding": "gzip"},
|
||||
request=request,
|
||||
)
|
||||
)
|
||||
|
||||
with httpx.Client(transport=transport) as client:
|
||||
response = client.send(client.build_request("GET", "http://example.com"), stream=True)
|
||||
with pytest.raises(UnsupportedResponseEncodingError, match="content encoding gzip"):
|
||||
buffer_response(response, max_response_bytes=1024 * 1024)
|
||||
|
||||
|
||||
def test_buffer_response_returns_response_within_decoded_byte_limit() -> None:
|
||||
payload = b"response-body"
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
content=payload,
|
||||
request=request,
|
||||
)
|
||||
)
|
||||
|
||||
with httpx.Client(transport=transport) as client:
|
||||
streaming_response = client.send(client.build_request("GET", "http://example.com"), stream=True)
|
||||
response = buffer_response(streaming_response, max_response_bytes=32)
|
||||
|
||||
assert response.content == payload
|
||||
assert str(response.request.url) == "http://example.com"
|
||||
|
||||
|
||||
def test_buffer_response_rejects_identity_response_exceeding_byte_limit() -> None:
|
||||
payload = b"response-body"
|
||||
transport = httpx.MockTransport(lambda request: httpx.Response(200, content=payload, request=request))
|
||||
|
||||
with httpx.Client(transport=transport) as client:
|
||||
response = client.send(client.build_request("GET", "http://example.com"), stream=True)
|
||||
with pytest.raises(ResponseTooLargeError, match="response exceeded 8 bytes"):
|
||||
buffer_response(response, max_response_bytes=8)
|
||||
|
||||
|
||||
def test_request_can_return_an_open_stream_the_caller_closes() -> None:
|
||||
class EventStream(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield b"event: delta\ndata: first\n\n"
|
||||
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
stream=EventStream(),
|
||||
request=request,
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
httpx.Client(transport=transport) as client,
|
||||
patch("core.helper.ssrf_proxy._get_ssrf_client", return_value=client),
|
||||
):
|
||||
response = make_request(
|
||||
"GET",
|
||||
"http://example.com/events",
|
||||
max_retries=0,
|
||||
stream_response=True,
|
||||
)
|
||||
|
||||
assert response.is_stream_consumed is False
|
||||
assert response.is_closed is False
|
||||
assert b"".join(response.iter_bytes()) == b"event: delta\ndata: first\n\n"
|
||||
response.close()
|
||||
|
||||
assert response.is_closed
|
||||
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client", autospec=True)
|
||||
@@ -34,7 +116,7 @@ def test_retry_exceed_max_retries(mock_get_client):
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client.send.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
with pytest.raises(Exception) as e:
|
||||
@@ -47,13 +129,13 @@ def test_force_list_response_returns_when_retries_disabled(mock_get_client):
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client.send.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = make_request("GET", "http://example.com", max_retries=0)
|
||||
|
||||
assert response is mock_response
|
||||
mock_client.request.assert_called_once()
|
||||
mock_client.send.assert_called_once()
|
||||
|
||||
|
||||
def test_build_ssrf_client_passes_ssl_verify_to_proxy_mount_transports():
|
||||
@@ -126,15 +208,15 @@ def test_host_header_preservation_with_user_header(mock_get_client):
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client.send.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
custom_host = "custom.example.com:8080"
|
||||
response = make_request("GET", "http://example.com", headers={"Host": custom_host})
|
||||
|
||||
assert response.status_code == 200
|
||||
# Verify client.request was called with the host header preserved (lowercase)
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
# Verify the request was built with the host header preserved (lowercase)
|
||||
call_kwargs = mock_client.build_request.call_args.kwargs
|
||||
assert call_kwargs["headers"]["host"] == custom_host
|
||||
|
||||
|
||||
@@ -145,36 +227,36 @@ def test_host_header_preservation_case_insensitive(mock_get_client, host_key):
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client.send.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
response = make_request("GET", "http://example.com", headers={host_key: "api.example.com"})
|
||||
|
||||
assert response.status_code == 200
|
||||
# Host header should be normalized to lowercase "host"
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
call_kwargs = mock_client.build_request.call_args.kwargs
|
||||
assert call_kwargs["headers"]["host"] == "api.example.com"
|
||||
|
||||
|
||||
class TestFollowRedirectsParameter:
|
||||
"""Tests for follow_redirects parameter handling.
|
||||
|
||||
These tests verify that follow_redirects is correctly passed to client.request().
|
||||
These tests verify that follow_redirects is correctly passed to client.send().
|
||||
"""
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client", autospec=True)
|
||||
def test_follow_redirects_passed_to_request(self, mock_get_client):
|
||||
"""Verify follow_redirects IS passed to client.request()."""
|
||||
"""Verify follow_redirects IS passed to client.send()."""
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client.send.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
make_request("GET", "http://example.com", follow_redirects=True)
|
||||
|
||||
# Verify follow_redirects was passed to request
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
# Verify follow_redirects was passed to send
|
||||
call_kwargs = mock_client.send.call_args.kwargs
|
||||
assert call_kwargs.get("follow_redirects") is True
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client", autospec=True)
|
||||
@@ -183,14 +265,14 @@ class TestFollowRedirectsParameter:
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client.send.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
# Use allow_redirects (requests-style parameter)
|
||||
make_request("GET", "http://example.com", allow_redirects=True)
|
||||
|
||||
# Verify it was converted to follow_redirects
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
call_kwargs = mock_client.send.call_args.kwargs
|
||||
assert call_kwargs.get("follow_redirects") is True
|
||||
assert "allow_redirects" not in call_kwargs
|
||||
|
||||
@@ -200,13 +282,13 @@ class TestFollowRedirectsParameter:
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client.send.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
make_request("GET", "http://example.com")
|
||||
|
||||
# follow_redirects should not be in kwargs, letting httpx use its default
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
call_kwargs = mock_client.send.call_args.kwargs
|
||||
assert "follow_redirects" not in call_kwargs
|
||||
|
||||
@patch("core.helper.ssrf_proxy._get_ssrf_client", autospec=True)
|
||||
@@ -215,13 +297,13 @@ class TestFollowRedirectsParameter:
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_client.request.return_value = mock_response
|
||||
mock_client.send.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
# Both specified - follow_redirects should take precedence
|
||||
make_request("GET", "http://example.com", allow_redirects=False, follow_redirects=True)
|
||||
|
||||
call_kwargs = mock_client.request.call_args.kwargs
|
||||
call_kwargs = mock_client.send.call_args.kwargs
|
||||
assert call_kwargs.get("follow_redirects") is True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from core.helper import ssrf_proxy
|
||||
from core.tools.errors import ToolSSRFError
|
||||
from services.knowledge_fs_contract_routes import KNOWLEDGE_FS_CONTRACT_OPERATIONS
|
||||
from services.knowledge_fs_proxy import (
|
||||
KnowledgeFSAccessDeniedError,
|
||||
KnowledgeFSConfigurationError,
|
||||
KnowledgeFSMethod,
|
||||
KnowledgeFSRouteNotAllowedError,
|
||||
KnowledgeFSTimeoutError,
|
||||
KnowledgeFSTransportError,
|
||||
authorize_knowledge_fs_request,
|
||||
get_knowledge_fs_operation,
|
||||
proxy_knowledge_fs_request,
|
||||
)
|
||||
from services.knowledge_fs_proxy import (
|
||||
_forward_knowledge_fs_request as forward_knowledge_fs_request,
|
||||
)
|
||||
|
||||
_JWT_SECRET = "production-secret-with-at-least-32-bytes"
|
||||
|
||||
|
||||
def _set_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
base_url: str | None = "http://knowledge-fs.test",
|
||||
timeout_seconds: float = 7.5,
|
||||
sse_read_timeout_seconds: float = 300.0,
|
||||
jwt_secret: str | None = _JWT_SECRET,
|
||||
) -> None:
|
||||
values = {
|
||||
"KNOWLEDGE_FS_BASE_URL": base_url,
|
||||
"KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS": sse_read_timeout_seconds,
|
||||
"KNOWLEDGE_FS_TIMEOUT_SECONDS": timeout_seconds,
|
||||
"KNOWLEDGE_FS_JWT_SECRET": SecretStr(jwt_secret) if jwt_secret is not None else None,
|
||||
}
|
||||
for name, value in values.items():
|
||||
monkeypatch.setattr(f"services.knowledge_fs_proxy.dify_config.{name}", value, raising=False)
|
||||
|
||||
|
||||
def test_unconfigured_kfs_is_rejected_before_external_io(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch, base_url=None, jwt_secret=None)
|
||||
request = MagicMock()
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
with pytest.raises(KnowledgeFSConfigurationError, match="incomplete"):
|
||||
forward_knowledge_fs_request(
|
||||
account_id="account-dev", method="GET", path="knowledge-spaces", tenant_id="tenant-dev"
|
||||
)
|
||||
|
||||
request.assert_not_called()
|
||||
|
||||
|
||||
def test_partial_configuration_is_rejected_before_external_io(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch, jwt_secret=None)
|
||||
request = MagicMock()
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
with pytest.raises(KnowledgeFSConfigurationError, match="incomplete"):
|
||||
forward_knowledge_fs_request(
|
||||
account_id="account-dev", method="GET", path="knowledge-spaces", tenant_id="tenant-dev"
|
||||
)
|
||||
|
||||
request.assert_not_called()
|
||||
|
||||
|
||||
def test_get_forwards_raw_query(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch, base_url="http://knowledge-fs.test/gateway")
|
||||
response = httpx.Response(200, content=b'{"items":[]}', headers={"Content-Type": "application/json"})
|
||||
request = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
result = forward_knowledge_fs_request(
|
||||
account_id="account-dev",
|
||||
method="GET",
|
||||
path="knowledge-spaces",
|
||||
tenant_id="tenant-dev",
|
||||
query=b"limit=20&cursor=first&cursor=second",
|
||||
)
|
||||
|
||||
assert result.response.content == response.content
|
||||
assert result.response_kind == "buffered"
|
||||
request.assert_called_once()
|
||||
assert request.call_args.kwargs["method"] == "GET"
|
||||
assert request.call_args.kwargs["url"] == "http://knowledge-fs.test/gateway/knowledge-spaces"
|
||||
assert request.call_args.kwargs["params"] == b"limit=20&cursor=first&cursor=second"
|
||||
assert request.call_args.kwargs["content"] is None
|
||||
assert request.call_args.kwargs["headers"]["Accept"] == "application/json"
|
||||
assert request.call_args.kwargs["headers"]["Accept-Encoding"] == "identity"
|
||||
assert request.call_args.kwargs["headers"]["Authorization"].startswith("Bearer ")
|
||||
assert request.call_args.kwargs["timeout"] == 7.5
|
||||
assert request.call_args.kwargs["follow_redirects"] is False
|
||||
assert request.call_args.kwargs["max_retries"] == 0
|
||||
assert "max_response_bytes" not in request.call_args.kwargs
|
||||
assert request.call_args.kwargs["stream_response"] is True
|
||||
|
||||
|
||||
def test_post_forwards_raw_json_without_parsing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
response = httpx.Response(201, content=b'{"id":"space-1"}', headers={"Content-Type": "application/json"})
|
||||
request = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
body = b'{"idempotencyKey":"create-product-docs","name":"Product docs"}'
|
||||
|
||||
result = forward_knowledge_fs_request(
|
||||
account_id="account-dev",
|
||||
method="POST",
|
||||
path="knowledge-spaces",
|
||||
tenant_id="tenant-dev",
|
||||
body=body,
|
||||
)
|
||||
|
||||
assert result.response.content == response.content
|
||||
assert result.response_kind == "buffered"
|
||||
request.assert_called_once()
|
||||
assert request.call_args.kwargs["method"] == "POST"
|
||||
assert request.call_args.kwargs["url"] == "http://knowledge-fs.test/knowledge-spaces"
|
||||
assert request.call_args.kwargs["params"] is None
|
||||
assert request.call_args.kwargs["content"] == body
|
||||
assert request.call_args.kwargs["headers"]["Accept"] == "application/json"
|
||||
assert request.call_args.kwargs["headers"]["Accept-Encoding"] == "identity"
|
||||
assert request.call_args.kwargs["headers"]["Authorization"].startswith("Bearer ")
|
||||
assert request.call_args.kwargs["headers"]["Content-Type"] == "application/json"
|
||||
assert request.call_args.kwargs["timeout"] == 7.5
|
||||
assert request.call_args.kwargs["follow_redirects"] is False
|
||||
assert request.call_args.kwargs["max_retries"] == 0
|
||||
assert "max_response_bytes" not in request.call_args.kwargs
|
||||
assert request.call_args.kwargs["stream_response"] is True
|
||||
|
||||
|
||||
def test_multipart_upload_preserves_content_negotiation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
response = httpx.Response(
|
||||
202,
|
||||
content=b'{"status":"accepted"}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
request = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
content_type = "multipart/form-data; boundary=knowledge-fs-upload"
|
||||
|
||||
result = forward_knowledge_fs_request(
|
||||
account_id="account-dev",
|
||||
method="POST",
|
||||
path="knowledge-spaces/space-1/documents",
|
||||
tenant_id="tenant-dev",
|
||||
accept="application/json",
|
||||
content_type=content_type,
|
||||
body=b"multipart-body",
|
||||
)
|
||||
|
||||
assert result.response.content == response.content
|
||||
assert request.call_args.kwargs["headers"]["Accept"] == "application/json"
|
||||
assert request.call_args.kwargs["headers"]["Content-Type"] == content_type
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
[
|
||||
("DELETE", "knowledge-spaces/space-1"),
|
||||
("GET", "source-providers"),
|
||||
("PATCH", "knowledge-spaces/space-1"),
|
||||
("POST", "knowledge-spaces/space-1/source-connections"),
|
||||
("PUT", "knowledge-spaces/space-1/retrieval-profile"),
|
||||
],
|
||||
)
|
||||
def test_generated_contract_routes_are_forwarded(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method: KnowledgeFSMethod,
|
||||
path: str,
|
||||
) -> None:
|
||||
_set_config(monkeypatch)
|
||||
response = httpx.Response(200, content=b"{}", headers={"Content-Type": "application/json"})
|
||||
request = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
result = forward_knowledge_fs_request(account_id="account-dev", method=method, path=path, tenant_id="tenant-dev")
|
||||
|
||||
assert result.response.content == response.content
|
||||
assert request.call_args.kwargs["method"] == method
|
||||
assert request.call_args.kwargs["url"] == f"http://knowledge-fs.test/{path}"
|
||||
|
||||
|
||||
def test_generated_proxy_allowlist_contains_every_contract_operation() -> None:
|
||||
assert len(KNOWLEDGE_FS_CONTRACT_OPERATIONS) == 176
|
||||
|
||||
|
||||
def test_generated_proxy_operations_preserve_transport_metadata() -> None:
|
||||
delete_space = get_knowledge_fs_operation("DELETE", "knowledge-spaces/space-1")
|
||||
query = get_knowledge_fs_operation("POST", "queries")
|
||||
processing_events = get_knowledge_fs_operation(
|
||||
"GET",
|
||||
"knowledge-spaces/space-1/documents/document-1/processing-tasks/task-1/events",
|
||||
)
|
||||
asset = get_knowledge_fs_operation(
|
||||
"GET",
|
||||
"knowledge-spaces/space-1/documents/document-1/multimodal/item-1/asset",
|
||||
)
|
||||
parse_artifact = get_knowledge_fs_operation(
|
||||
"GET",
|
||||
"knowledge-spaces/space-1/documents/document-1/parse-artifacts/1",
|
||||
)
|
||||
|
||||
assert delete_space.request_headers == ("idempotency-key",)
|
||||
assert query.response_headers == ("x-query-run-id", "x-session-id", "x-trace-id")
|
||||
assert query.max_response_bytes == 64 * 1024 * 1024
|
||||
assert processing_events.request_headers == ("last-event-id",)
|
||||
assert asset.response_headers == (
|
||||
"cache-control",
|
||||
"content-disposition",
|
||||
"content-security-policy",
|
||||
"x-content-type-options",
|
||||
"x-document-multimodal-asset-variant",
|
||||
"x-document-multimodal-item-id",
|
||||
)
|
||||
assert asset.response_media_types == (
|
||||
"application/octet-stream",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
)
|
||||
assert parse_artifact.max_response_bytes == 1024 * 1024
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "expected_permission"),
|
||||
[
|
||||
("GET", "knowledge-spaces", "dataset_readonly"),
|
||||
("POST", "knowledge-spaces", "dataset_create_and_management"),
|
||||
("DELETE", "knowledge-spaces/space-1", "dataset_edit"),
|
||||
("PATCH", "knowledge-spaces/space-1", "dataset_edit"),
|
||||
("POST", "knowledge-spaces/space-1/documents", "dataset_edit"),
|
||||
("PATCH", "knowledge-spaces/space-1/access-policy", "dataset_access_config"),
|
||||
("POST", "knowledge-spaces/space-1/api-keys", "dataset_api_key_manage"),
|
||||
(
|
||||
"GET",
|
||||
"knowledge-spaces/space-1/documents/document-1/multimodal/item-1/asset",
|
||||
"dataset_document_download",
|
||||
),
|
||||
("POST", "knowledge-spaces/space-1/source-connections", "dataset_external_connect"),
|
||||
],
|
||||
)
|
||||
def test_generated_proxy_operations_preserve_dify_permissions(
|
||||
method: KnowledgeFSMethod,
|
||||
path: str,
|
||||
expected_permission: str,
|
||||
) -> None:
|
||||
operation = get_knowledge_fs_operation(method, path)
|
||||
|
||||
assert operation.rbac_permission == expected_permission
|
||||
|
||||
|
||||
def test_authorization_rejects_workspace_rbac_denial(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
account = MagicMock(
|
||||
id="account-1",
|
||||
has_edit_permission=True,
|
||||
is_admin_or_owner=True,
|
||||
is_dataset_editor=True,
|
||||
)
|
||||
check_access = MagicMock(return_value=False)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access)
|
||||
operation = get_knowledge_fs_operation("GET", "knowledge-spaces")
|
||||
|
||||
with pytest.raises(KnowledgeFSAccessDeniedError):
|
||||
authorize_knowledge_fs_request(
|
||||
account=account,
|
||||
tenant_id="tenant-1",
|
||||
method="GET",
|
||||
operation=operation,
|
||||
)
|
||||
|
||||
check_access.assert_called_once_with(
|
||||
"tenant-1",
|
||||
"account-1",
|
||||
scene="dataset_readonly",
|
||||
resource_type="dataset",
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_use_case_authorizes_before_external_io(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
account = MagicMock(
|
||||
id="account-1",
|
||||
has_edit_permission=True,
|
||||
is_admin_or_owner=True,
|
||||
is_dataset_editor=True,
|
||||
)
|
||||
request = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"services.knowledge_fs_proxy.RBACService.CheckAccess.check",
|
||||
MagicMock(return_value=False),
|
||||
)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
with pytest.raises(KnowledgeFSAccessDeniedError):
|
||||
proxy_knowledge_fs_request(
|
||||
account=account,
|
||||
method="GET",
|
||||
path="knowledge-spaces",
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
request.assert_not_called()
|
||||
|
||||
|
||||
def test_proxy_use_case_forwards_only_contract_declared_headers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
account = MagicMock(
|
||||
id="account-1",
|
||||
has_edit_permission=True,
|
||||
is_admin_or_owner=True,
|
||||
is_dataset_editor=True,
|
||||
)
|
||||
upstream = MagicMock()
|
||||
forward = MagicMock(return_value=upstream)
|
||||
monkeypatch.setattr(
|
||||
"services.knowledge_fs_proxy.RBACService.CheckAccess.check",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy._forward_knowledge_fs_request", forward)
|
||||
|
||||
result = proxy_knowledge_fs_request(
|
||||
account=account,
|
||||
method="DELETE",
|
||||
path="knowledge-spaces/space-1",
|
||||
tenant_id="tenant-1",
|
||||
request_headers={
|
||||
"Authorization": "browser-secret",
|
||||
"Idempotency-Key": "delete-space-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert result is upstream
|
||||
assert forward.call_args.kwargs["request_headers"] == {"idempotency-key": "delete-space-1"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "account_attributes"),
|
||||
[
|
||||
("POST", "knowledge-spaces", {"is_dataset_editor": False}),
|
||||
("PATCH", "knowledge-spaces/space-1/access-policy", {"is_admin_or_owner": False}),
|
||||
("POST", "knowledge-spaces/space-1/api-keys", {"has_edit_permission": False}),
|
||||
("DELETE", "knowledge-spaces/space-1/api-keys/key-1", {"is_admin_or_owner": False}),
|
||||
("POST", "knowledge-spaces/space-1/source-connections", {"has_edit_permission": False}),
|
||||
],
|
||||
)
|
||||
def test_authorization_rejects_insufficient_workspace_role(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method: KnowledgeFSMethod,
|
||||
path: str,
|
||||
account_attributes: dict[str, bool],
|
||||
) -> None:
|
||||
attributes = {
|
||||
"id": "account-1",
|
||||
"has_edit_permission": True,
|
||||
"is_admin_or_owner": True,
|
||||
"is_dataset_editor": True,
|
||||
**account_attributes,
|
||||
}
|
||||
account = MagicMock(**attributes)
|
||||
check_access = MagicMock(return_value=True)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access)
|
||||
|
||||
with pytest.raises(KnowledgeFSAccessDeniedError):
|
||||
authorize_knowledge_fs_request(
|
||||
account=account,
|
||||
tenant_id="tenant-1",
|
||||
method=method,
|
||||
operation=get_knowledge_fs_operation(method, path),
|
||||
)
|
||||
|
||||
check_access.assert_not_called()
|
||||
|
||||
|
||||
def test_binary_response_uses_the_runtime_asset_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
response = httpx.Response(200, content=b"binary asset", headers={"Content-Type": "application/octet-stream"})
|
||||
request = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
result = forward_knowledge_fs_request(
|
||||
account_id="account-dev",
|
||||
method="GET",
|
||||
path="knowledge-spaces/space-1/documents/document-1/multimodal/item-1/asset",
|
||||
tenant_id="tenant-dev",
|
||||
)
|
||||
|
||||
assert result.response.content == response.content
|
||||
assert result.response_kind == "binary"
|
||||
assert "max_response_bytes" not in request.call_args.kwargs
|
||||
assert request.call_args.kwargs["stream_response"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content_type", ["text/html", "image/svg+xml", "application/pdf"])
|
||||
def test_binary_response_rejects_uncontracted_media_types(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
content_type: str,
|
||||
) -> None:
|
||||
_set_config(monkeypatch)
|
||||
response = httpx.Response(200, content=b"untrusted asset", headers={"Content-Type": content_type})
|
||||
monkeypatch.setattr(
|
||||
"services.knowledge_fs_proxy.ssrf_proxy.make_request",
|
||||
MagicMock(return_value=response),
|
||||
)
|
||||
|
||||
with pytest.raises(KnowledgeFSTransportError, match="unsupported media type"):
|
||||
forward_knowledge_fs_request(
|
||||
account_id="account-dev",
|
||||
method="GET",
|
||||
path="knowledge-spaces/space-1/documents/document-1/multimodal/item-1/asset",
|
||||
tenant_id="tenant-dev",
|
||||
)
|
||||
|
||||
assert response.is_closed
|
||||
|
||||
|
||||
def test_buffered_response_rejects_non_empty_body_without_content_type(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
response = httpx.Response(200, content=b"<script>alert('unexpected html')</script>")
|
||||
monkeypatch.setattr(
|
||||
"services.knowledge_fs_proxy.ssrf_proxy.make_request",
|
||||
MagicMock(return_value=response),
|
||||
)
|
||||
|
||||
with pytest.raises(KnowledgeFSTransportError, match="unsupported media type"):
|
||||
forward_knowledge_fs_request(
|
||||
account_id="account-dev",
|
||||
method="GET",
|
||||
path="knowledge-spaces",
|
||||
tenant_id="tenant-dev",
|
||||
)
|
||||
|
||||
assert response.is_closed
|
||||
|
||||
|
||||
def test_binary_response_larger_than_json_limit_remains_callable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
payload = b"x" * (2 * 1024 * 1024)
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
content=payload,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
request=request,
|
||||
)
|
||||
)
|
||||
|
||||
with httpx.Client(transport=transport) as client:
|
||||
monkeypatch.setattr("core.helper.ssrf_proxy._get_ssrf_client", lambda _verify: client)
|
||||
result = forward_knowledge_fs_request(
|
||||
account_id="account-dev",
|
||||
method="GET",
|
||||
path="knowledge-spaces/space-1/documents/document-1/multimodal/item-1/asset",
|
||||
tenant_id="tenant-dev",
|
||||
)
|
||||
|
||||
assert result.response_kind == "binary"
|
||||
assert result.response.content == payload
|
||||
|
||||
|
||||
def test_sse_response_sets_a_bounded_body_read_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch, sse_read_timeout_seconds=45.0)
|
||||
upstream_request = httpx.Request(
|
||||
"POST",
|
||||
"http://knowledge-fs.test/queries",
|
||||
extensions={"timeout": {"read": 7.5}},
|
||||
)
|
||||
response = httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
request=upstream_request,
|
||||
)
|
||||
request = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
result = forward_knowledge_fs_request(
|
||||
account_id="account-dev",
|
||||
method="POST",
|
||||
path="queries",
|
||||
tenant_id="tenant-dev",
|
||||
body=b"{}",
|
||||
)
|
||||
|
||||
assert result.response is response
|
||||
assert result.response_kind == "stream"
|
||||
assert request.call_args.kwargs["timeout"] == 7.5
|
||||
assert result.response.request.extensions["timeout"]["read"] == 45.0
|
||||
assert request.call_args.kwargs["stream_response"] is True
|
||||
|
||||
|
||||
def test_sse_operation_buffers_json_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
response = httpx.Response(
|
||||
400,
|
||||
content=b'{"error":"Invalid query request"}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
request = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
result = forward_knowledge_fs_request(
|
||||
account_id="account-dev",
|
||||
method="POST",
|
||||
path="queries",
|
||||
tenant_id="tenant-dev",
|
||||
body=b"{}",
|
||||
)
|
||||
|
||||
assert result.response_kind == "buffered"
|
||||
assert result.response.content == b'{"error":"Invalid query request"}'
|
||||
assert result.response.is_closed
|
||||
|
||||
|
||||
def test_parse_artifact_rejects_json_over_the_contract_response_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
payload = b'{"text":"' + b"x" * (2 * 1024 * 1024) + b'"}'
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
content=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
request=request,
|
||||
)
|
||||
)
|
||||
|
||||
with httpx.Client(transport=transport) as client:
|
||||
monkeypatch.setattr("core.helper.ssrf_proxy._get_ssrf_client", lambda _verify: client)
|
||||
with pytest.raises(KnowledgeFSTransportError, match="violated the proxy limit"):
|
||||
forward_knowledge_fs_request(
|
||||
account_id="account-dev",
|
||||
method="GET",
|
||||
path="knowledge-spaces/space-1/documents/document-1/parse-artifacts/1",
|
||||
tenant_id="tenant-dev",
|
||||
)
|
||||
|
||||
|
||||
def test_sse_response_rejects_compression_and_closes_the_stream(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
response = httpx.Response(
|
||||
200,
|
||||
headers={"Content-Encoding": "gzip", "Content-Type": "text/event-stream"},
|
||||
)
|
||||
request = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
with pytest.raises(KnowledgeFSTransportError, match="unsupported encoding"):
|
||||
forward_knowledge_fs_request(
|
||||
account_id="account-dev",
|
||||
method="POST",
|
||||
path="queries",
|
||||
tenant_id="tenant-dev",
|
||||
body=b"{}",
|
||||
)
|
||||
|
||||
assert response.is_closed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "expected_scope"),
|
||||
[
|
||||
("DELETE", "knowledge-spaces/space-1", "knowledge-spaces:write"),
|
||||
("GET", "knowledge-spaces", "knowledge-spaces:read"),
|
||||
("PATCH", "knowledge-spaces/space-1", "knowledge-spaces:write"),
|
||||
("POST", "queries", "knowledge-spaces:read"),
|
||||
("POST", "research-tasks/plan", "knowledge-spaces:read"),
|
||||
("POST", "agent-workspace-snapshots/snapshot-1/replay", "knowledge-spaces:write"),
|
||||
("POST", "knowledge-spaces", "knowledge-spaces:write"),
|
||||
("PUT", "knowledge-spaces/space-1/retrieval-profile", "knowledge-spaces:write"),
|
||||
],
|
||||
)
|
||||
def test_auth_signs_current_account_and_workspace_principal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method: KnowledgeFSMethod,
|
||||
path: str,
|
||||
expected_scope: str,
|
||||
) -> None:
|
||||
_set_config(monkeypatch)
|
||||
if path == "queries":
|
||||
response = httpx.Response(200, headers={"Content-Type": "text/event-stream"})
|
||||
else:
|
||||
response = httpx.Response(200, content=b'{"items":[]}', headers={"Content-Type": "application/json"})
|
||||
request = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
result = forward_knowledge_fs_request(
|
||||
account_id="account-1",
|
||||
method=method,
|
||||
path=path,
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
assert result.response.status_code == response.status_code
|
||||
authorization = request.call_args.kwargs["headers"]["Authorization"]
|
||||
claims = jwt.decode(
|
||||
authorization.removeprefix("Bearer "),
|
||||
_JWT_SECRET,
|
||||
algorithms=["HS256"],
|
||||
audience="knowledge-fs",
|
||||
issuer="dify",
|
||||
)
|
||||
assert claims["dify_account_id"] == "dify-account:account-1"
|
||||
assert claims["sub"] == "dify-workspace:tenant-1"
|
||||
assert claims["tenant_id"] == "tenant-1"
|
||||
assert claims["scopes"] == [expected_scope]
|
||||
assert claims["caller_kind"] == "interactive"
|
||||
assert claims["exp"] - claims["iat"] == 60
|
||||
|
||||
|
||||
def test_timeout_is_normalized(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
upstream_request = httpx.Request("GET", "http://knowledge-fs.test/knowledge-spaces")
|
||||
request = MagicMock(side_effect=httpx.ReadTimeout("timed out", request=upstream_request))
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
with pytest.raises(KnowledgeFSTimeoutError):
|
||||
forward_knowledge_fs_request(
|
||||
account_id="account-dev", method="GET", path="knowledge-spaces", tenant_id="tenant-dev"
|
||||
)
|
||||
|
||||
|
||||
def test_transport_error_is_normalized(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
upstream_request = httpx.Request("GET", "http://knowledge-fs.test/knowledge-spaces")
|
||||
request = MagicMock(side_effect=httpx.ConnectError("unavailable", request=upstream_request))
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
with pytest.raises(KnowledgeFSTransportError):
|
||||
forward_knowledge_fs_request(
|
||||
account_id="account-dev", method="GET", path="knowledge-spaces", tenant_id="tenant-dev"
|
||||
)
|
||||
|
||||
|
||||
def test_oversized_response_is_normalized(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
request = MagicMock(side_effect=ssrf_proxy.ResponseTooLargeError("response exceeded 1048576 bytes"))
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
with pytest.raises(KnowledgeFSTransportError, match="response violated the proxy limit"):
|
||||
forward_knowledge_fs_request(
|
||||
account_id="account-dev", method="GET", path="knowledge-spaces", tenant_id="tenant-dev"
|
||||
)
|
||||
|
||||
|
||||
def test_ssrf_policy_rejection_is_reported_as_invalid_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch)
|
||||
request = MagicMock(side_effect=ToolSSRFError("blocked by SSRF policy"))
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
with pytest.raises(KnowledgeFSConfigurationError, match="blocked by outbound policy"):
|
||||
forward_knowledge_fs_request(
|
||||
account_id="account-dev", method="GET", path="knowledge-spaces", tenant_id="tenant-dev"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
[
|
||||
("GET", "openapi.json"),
|
||||
("GET", "knowledge-spaces/../health"),
|
||||
("GET", "knowledge-spaces/space-1/documents/.."),
|
||||
("PATCH", "knowledge-spaces"),
|
||||
("POST", "knowledge-spaces/fs/write"),
|
||||
],
|
||||
)
|
||||
def test_disallowed_route_is_rejected_before_external_io(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method: KnowledgeFSMethod,
|
||||
path: str,
|
||||
) -> None:
|
||||
_set_config(monkeypatch)
|
||||
request = MagicMock()
|
||||
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
|
||||
|
||||
with pytest.raises(KnowledgeFSRouteNotAllowedError):
|
||||
forward_knowledge_fs_request(account_id="account-dev", method=method, path=path, tenant_id="tenant-dev")
|
||||
|
||||
request.assert_not_called()
|
||||
@@ -11,3 +11,7 @@ PLUGIN_REMOTE_INSTALL_PORT=5003
|
||||
PLUGIN_MAX_PACKAGE_SIZE=52428800
|
||||
PLUGIN_DAEMON_TIMEOUT=600.0
|
||||
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
||||
KNOWLEDGE_FS_BASE_URL=
|
||||
KNOWLEDGE_FS_JWT_SECRET=
|
||||
KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS=300
|
||||
KNOWLEDGE_FS_TIMEOUT_SECONDS=10
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { consoleRouterContract as generatedConsoleRouterContract } from './generated/api/console/router.gen'
|
||||
import { contract as knowledgeFsContract } from './generated/knowledge-fs/orpc.gen'
|
||||
|
||||
export const consoleRouterContract = {
|
||||
...generatedConsoleRouterContract,
|
||||
knowledgeFs: knowledgeFsContract,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"commit": "c8f37cc2703922301f410938158a47870dfcab10",
|
||||
"generatedSha256": "d9a6feebb30b271d70994be2e36331b71ad26247c9dd2de07af17f87e41a64b9",
|
||||
"openapiSha256": "934e32ceead797a60072bf0225dfc9535a7780ba1b082a2a0511e98c87bd25f7",
|
||||
"proxyRoutesSha256": "ce41ba698f5af14bd19fa8d6afd949799c8cfff51cf5b93f643d72d9ed54fd83",
|
||||
"repository": "https://github.com/langgenius/knowledge-fs"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { contract } from './generated/knowledge-fs/orpc.gen'
|
||||
import {
|
||||
zGetKnowledgeSpacesByIdDocumentsByDocumentIdMultimodalByItemIdAssetResponse,
|
||||
zPostKnowledgeSpacesByIdDocumentsBody,
|
||||
zPostKnowledgeSpacesByIdDocumentsBulkBody,
|
||||
} from './generated/knowledge-fs/zod.gen'
|
||||
|
||||
describe('KnowledgeFS generated contract', () => {
|
||||
it('publishes every gateway operation', () => {
|
||||
expect(Object.keys(contract)).toHaveLength(176)
|
||||
expect(contract).toHaveProperty('getSourceProviders')
|
||||
expect(contract).toHaveProperty('postKnowledgeSpacesByIdSourceConnections')
|
||||
expect(contract).toHaveProperty('postKnowledgeSpacesByIdDocuments')
|
||||
})
|
||||
|
||||
it('accepts callable binary upload inputs', () => {
|
||||
const blob = new Blob(['content'], { type: 'text/markdown' })
|
||||
const file = new File(['content'], 'document.md', { type: 'text/markdown' })
|
||||
|
||||
expect(zPostKnowledgeSpacesByIdDocumentsBody.safeParse({ file: blob }).success).toBe(true)
|
||||
expect(zPostKnowledgeSpacesByIdDocumentsBody.safeParse({ file }).success).toBe(true)
|
||||
expect(zPostKnowledgeSpacesByIdDocumentsBulkBody.safeParse({ files: [file] }).success).toBe(
|
||||
true,
|
||||
)
|
||||
expect(zPostKnowledgeSpacesByIdDocumentsBulkBody.safeParse({ files: [] }).success).toBe(false)
|
||||
expect(zPostKnowledgeSpacesByIdDocumentsBody.safeParse({ file: 'not-binary' }).success).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
zPostKnowledgeSpacesByIdDocumentsBulkBody.safeParse({ files: ['not-binary'] }).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
zPostKnowledgeSpacesByIdDocumentsBody.safeParse({ documentId: 'invalid', file }).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
zGetKnowledgeSpacesByIdDocumentsByDocumentIdMultimodalByItemIdAssetResponse.safeParse(blob)
|
||||
.success,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { $, defineConfig } from '@hey-api/openapi-ts'
|
||||
|
||||
const proxyPrefix = '/knowledge-fs'
|
||||
const input = process.env.KNOWLEDGE_FS_OPENAPI
|
||||
const outputPath = process.env.KNOWLEDGE_FS_OUTPUT ?? 'generated/knowledge-fs'
|
||||
|
||||
if (!input) throw new Error('KNOWLEDGE_FS_OPENAPI must point to the pinned local export')
|
||||
|
||||
export default defineConfig({
|
||||
input,
|
||||
logs: {
|
||||
file: false,
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
entryFile: false,
|
||||
fileName: {
|
||||
suffix: '.gen',
|
||||
},
|
||||
path: outputPath,
|
||||
},
|
||||
parser: {
|
||||
patch: {
|
||||
input: (spec) => {
|
||||
const paths = spec.paths as Record<string, unknown> | undefined
|
||||
if (!paths) return
|
||||
|
||||
for (const [path, pathItem] of Object.entries(paths)) {
|
||||
delete paths[path]
|
||||
paths[`${proxyPrefix}${path}`] = pathItem
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
comments: false,
|
||||
name: '@hey-api/typescript',
|
||||
},
|
||||
{
|
||||
name: 'zod',
|
||||
'~resolvers': {
|
||||
string: (context) => {
|
||||
if (context.schema.format !== 'binary') return
|
||||
const isBinary = $.func()
|
||||
.param('value')
|
||||
.do(
|
||||
$('Blob')
|
||||
.attr('prototype')
|
||||
.attr('isPrototypeOf')
|
||||
.call($('Object').call($('value')))
|
||||
.return(),
|
||||
)
|
||||
return $(context.symbols.z)
|
||||
.attr('custom')
|
||||
.call(isBinary)
|
||||
.generic($.type.or($.type('Blob'), $.type('File')))
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
contracts: {
|
||||
strategy: 'single',
|
||||
},
|
||||
name: 'orpc',
|
||||
validator: 'zod',
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -8,6 +8,10 @@
|
||||
"types": "./marketplace.ts",
|
||||
"import": "./marketplace.ts"
|
||||
},
|
||||
"./console": {
|
||||
"types": "./console.ts",
|
||||
"import": "./console.ts"
|
||||
},
|
||||
"./api/*": {
|
||||
"types": "./generated/api/*.ts",
|
||||
"import": "./generated/api/*.ts"
|
||||
@@ -15,12 +19,19 @@
|
||||
"./enterprise/*": {
|
||||
"types": "./generated/enterprise/*.ts",
|
||||
"import": "./generated/enterprise/*.ts"
|
||||
},
|
||||
"./knowledge-fs/*": {
|
||||
"types": "./generated/knowledge-fs/*.ts",
|
||||
"import": "./generated/knowledge-fs/*.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api",
|
||||
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts",
|
||||
"test": "vp test openapi-yaml.test.ts",
|
||||
"gen-knowledge-fs-contract": "node scripts/knowledge-fs-contract.mjs",
|
||||
"update-knowledge-fs-contract": "node scripts/knowledge-fs-contract.mjs --update-lock",
|
||||
"check-knowledge-fs-contract": "node scripts/knowledge-fs-contract.mjs --check",
|
||||
"test": "vp test openapi-yaml.test.ts knowledge-fs-contract.test.ts && node scripts/knowledge-fs-contract.mjs --verify-generated",
|
||||
"type-check": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const workspaceRoot = resolve(packageRoot, '../..')
|
||||
const generatedDirectory = join(packageRoot, 'generated/knowledge-fs')
|
||||
const lockPath = join(packageRoot, 'knowledge-fs-contract.lock.json')
|
||||
const proxyRoutesPath = join(workspaceRoot, 'api/services/knowledge_fs_contract_routes.py')
|
||||
const defaultKnowledgeFsRepository = resolve(workspaceRoot, '../knowledge-fs')
|
||||
|
||||
const mode = process.argv[2]
|
||||
const checkOnly = mode === '--check'
|
||||
const updateLock = mode === '--update-lock'
|
||||
const verifyGeneratedOnly = mode === '--verify-generated'
|
||||
|
||||
if (mode && !checkOnly && !updateLock && !verifyGeneratedOnly)
|
||||
throw new Error(`Unknown KnowledgeFS contract option: ${mode}`)
|
||||
|
||||
const lock = JSON.parse(await readFile(lockPath, 'utf8'))
|
||||
|
||||
if (verifyGeneratedOnly) {
|
||||
await assertGeneratedHash(lock.generatedSha256)
|
||||
await assertProxyRoutesHash(lock.proxyRoutesSha256)
|
||||
console.log(`Verified KnowledgeFS generated contract from ${lock.commit}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const knowledgeFsRepository = resolve(process.env.KNOWLEDGE_FS_REPO ?? defaultKnowledgeFsRepository)
|
||||
const trackedChanges = run(
|
||||
'git',
|
||||
['status', '--porcelain', '--untracked-files=no'],
|
||||
knowledgeFsRepository,
|
||||
).trim()
|
||||
if (trackedChanges) {
|
||||
throw new Error('KnowledgeFS checkout must not contain tracked changes during contract export')
|
||||
}
|
||||
const commit = run('git', ['rev-parse', 'HEAD'], knowledgeFsRepository).trim()
|
||||
|
||||
if (!updateLock && commit !== lock.commit) {
|
||||
throw new Error(
|
||||
`KnowledgeFS checkout mismatch: expected ${lock.commit}, received ${commit}. ` +
|
||||
'Use the pinned commit or run with --update-lock intentionally.',
|
||||
)
|
||||
}
|
||||
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'dify-knowledge-fs-contract-'))
|
||||
|
||||
try {
|
||||
const openapiPath = join(temporaryDirectory, 'knowledge-fs.openapi.json')
|
||||
run('pnpm', ['openapi:export', '--', '--output', openapiPath], knowledgeFsRepository, true)
|
||||
const openapiContent = await readFile(openapiPath)
|
||||
const openapiSha256 = sha256(openapiContent)
|
||||
|
||||
if (!updateLock && openapiSha256 !== lock.openapiSha256) {
|
||||
throw new Error(
|
||||
`KnowledgeFS OpenAPI hash mismatch: expected ${lock.openapiSha256}, received ${openapiSha256}`,
|
||||
)
|
||||
}
|
||||
|
||||
const outputDirectory = checkOnly
|
||||
? join(temporaryDirectory, 'generated/knowledge-fs')
|
||||
: generatedDirectory
|
||||
run(
|
||||
'pnpm',
|
||||
['exec', 'openapi-ts', '-f', 'openapi-ts.knowledge-fs.config.ts'],
|
||||
packageRoot,
|
||||
true,
|
||||
{
|
||||
KNOWLEDGE_FS_OPENAPI: openapiPath,
|
||||
KNOWLEDGE_FS_OUTPUT: outputDirectory,
|
||||
},
|
||||
)
|
||||
run('pnpm', ['exec', 'vp', 'fmt', outputDirectory], packageRoot, true)
|
||||
|
||||
const generatedSha256 = await hashDirectory(outputDirectory)
|
||||
const proxyRoutesOutputPath = checkOnly
|
||||
? join(temporaryDirectory, 'knowledge_fs_contract_routes.py')
|
||||
: proxyRoutesPath
|
||||
const proxyRoutesContent = renderProxyRoutes(JSON.parse(openapiContent.toString('utf8')))
|
||||
await writeFile(proxyRoutesOutputPath, proxyRoutesContent)
|
||||
const proxyRoutesSha256 = sha256(proxyRoutesContent)
|
||||
if (updateLock) {
|
||||
await writeFile(
|
||||
lockPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
commit,
|
||||
generatedSha256,
|
||||
openapiSha256,
|
||||
proxyRoutesSha256,
|
||||
repository: lock.repository,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
)
|
||||
console.log(`Updated KnowledgeFS contract lock to ${commit}`)
|
||||
} else {
|
||||
if (generatedSha256 !== lock.generatedSha256) {
|
||||
throw new Error(
|
||||
`Generated KnowledgeFS contract drifted: expected ${lock.generatedSha256}, received ${generatedSha256}`,
|
||||
)
|
||||
}
|
||||
if (proxyRoutesSha256 !== lock.proxyRoutesSha256) {
|
||||
throw new Error(
|
||||
`KnowledgeFS proxy routes drifted: expected ${lock.proxyRoutesSha256}, received ${proxyRoutesSha256}`,
|
||||
)
|
||||
}
|
||||
if (checkOnly) {
|
||||
await assertGeneratedHash(generatedSha256)
|
||||
await assertProxyRoutesHash(proxyRoutesSha256)
|
||||
}
|
||||
console.log(`${checkOnly ? 'Checked' : 'Generated'} KnowledgeFS contract from ${commit}`)
|
||||
}
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
async function assertGeneratedHash(expectedHash) {
|
||||
const actualHash = await hashDirectory(generatedDirectory)
|
||||
if (actualHash !== expectedHash) {
|
||||
throw new Error(
|
||||
`Committed KnowledgeFS contract drifted: expected ${expectedHash}, received ${actualHash}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function assertProxyRoutesHash(expectedHash) {
|
||||
const actualHash = sha256(await readFile(proxyRoutesPath))
|
||||
if (actualHash !== expectedHash) {
|
||||
throw new Error(
|
||||
`Committed KnowledgeFS proxy routes drifted: expected ${expectedHash}, received ${actualHash}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function renderProxyRoutes(document) {
|
||||
const openApiMethods = ['delete', 'get', 'head', 'options', 'patch', 'post', 'put', 'trace']
|
||||
const proxyMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
|
||||
const routes = []
|
||||
|
||||
for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
|
||||
if (!path.startsWith('/')) throw new Error(`KnowledgeFS OpenAPI path must be absolute: ${path}`)
|
||||
for (const method of openApiMethods) {
|
||||
if (!pathItem[method]) continue
|
||||
if (!proxyMethods.has(method)) {
|
||||
throw new Error(`KnowledgeFS proxy does not support ${method.toUpperCase()} ${path}`)
|
||||
}
|
||||
routes.push([
|
||||
method.toUpperCase(),
|
||||
path.slice(1),
|
||||
responseKind(pathItem[method]),
|
||||
requiredAccess(pathItem[method]),
|
||||
rbacPermission(method, path, pathItem[method]),
|
||||
requiredMaxResponseBytes(pathItem[method]),
|
||||
requestHeaderNames(pathItem, pathItem[method]),
|
||||
responseHeaderNames(pathItem[method]),
|
||||
responseMediaTypes(pathItem[method]),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
routes.sort(([leftMethod, leftPath], [rightMethod, rightPath]) =>
|
||||
leftPath === rightPath
|
||||
? leftMethod.localeCompare(rightMethod)
|
||||
: leftPath.localeCompare(rightPath),
|
||||
)
|
||||
const entries = routes
|
||||
.map(
|
||||
([
|
||||
method,
|
||||
path,
|
||||
kind,
|
||||
access,
|
||||
permission,
|
||||
maxResponseBytes,
|
||||
requestHeaders,
|
||||
responseHeaders,
|
||||
responseMediaTypes,
|
||||
]) =>
|
||||
` (\n ${JSON.stringify(method)},\n ${JSON.stringify(path)},\n ${JSON.stringify(kind)},\n ${JSON.stringify(access)},\n ${JSON.stringify(permission)},\n ${maxResponseBytes},\n ${pythonTuple(requestHeaders)},\n ${pythonTuple(responseHeaders)},\n ${pythonTuple(responseMediaTypes)},\n ),`,
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `"""Generated KnowledgeFS operations used by the Console proxy."""\n\nfrom typing import Final\n\n# Generated by packages/contracts/scripts/knowledge-fs-contract.mjs. Do not edit.\nContractOperation = tuple[str, str, str, str, str, int, tuple[str, ...], tuple[str, ...], tuple[str, ...]]\nKNOWLEDGE_FS_CONTRACT_OPERATIONS: Final[frozenset[ContractOperation]] = frozenset(\n {\n${entries}\n }\n)\n`
|
||||
}
|
||||
|
||||
function responseKind(operation) {
|
||||
const mediaTypes = responseMediaTypes(operation)
|
||||
if (mediaTypes.includes('text/event-stream')) return 'stream'
|
||||
if (mediaTypes.includes('application/octet-stream')) return 'binary'
|
||||
return 'buffered'
|
||||
}
|
||||
|
||||
function responseMediaTypes(operation) {
|
||||
return Object.entries(operation.responses ?? {})
|
||||
.filter(([status]) => status === '2XX' || /^2\d\d$/.test(status))
|
||||
.flatMap(([, response]) => Object.keys(response.content ?? {}))
|
||||
.filter((mediaType, index, mediaTypes) => mediaTypes.indexOf(mediaType) === index)
|
||||
.sort()
|
||||
}
|
||||
|
||||
function requiredAccess(operation) {
|
||||
const scope = operation['x-knowledge-fs-required-scope']
|
||||
if (scope === 'knowledge-spaces:read') return 'read'
|
||||
if (scope === 'knowledge-spaces:write') return 'write'
|
||||
// Console proxy routes remain authenticated even when the upstream operation is anonymous.
|
||||
if (Array.isArray(operation.security) && operation.security.length === 0) return 'read'
|
||||
throw new Error(`KnowledgeFS operation has no supported required scope: ${scope}`)
|
||||
}
|
||||
|
||||
function rbacPermission(method, path, operation) {
|
||||
const normalizedMethod = method.toUpperCase()
|
||||
const isMutation = normalizedMethod !== 'GET'
|
||||
|
||||
if (responseKind(operation) === 'binary') return 'dataset_document_download'
|
||||
if (isMutation && /^\/knowledge-spaces\/\{id\}\/api-keys(?:\/\{keyId\})?$/.test(path))
|
||||
return 'dataset_api_key_manage'
|
||||
if (
|
||||
isMutation &&
|
||||
(path === '/knowledge-spaces/{id}/access-bootstrap' ||
|
||||
path === '/knowledge-spaces/{id}/access-policy' ||
|
||||
path === '/knowledge-spaces/{id}/api-access' ||
|
||||
/^\/knowledge-spaces\/\{id\}\/members(?:\/\{subjectId\})?$/.test(path))
|
||||
)
|
||||
return 'dataset_access_config'
|
||||
if (
|
||||
isMutation &&
|
||||
(path === '/source-oauth/callback' ||
|
||||
path.startsWith('/knowledge-spaces/{id}/source-') ||
|
||||
path.startsWith('/knowledge-spaces/{id}/sources'))
|
||||
)
|
||||
return 'dataset_external_connect'
|
||||
if (isMutation && path.startsWith('/knowledge-spaces/{id}')) return 'dataset_edit'
|
||||
return requiredAccess(operation) === 'read' ? 'dataset_readonly' : 'dataset_create_and_management'
|
||||
}
|
||||
|
||||
function requiredMaxResponseBytes(operation) {
|
||||
const value = operation['x-knowledge-fs-max-response-bytes']
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`KnowledgeFS operation has no valid response byte limit: ${value}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function requestHeaderNames(pathItem, operation) {
|
||||
return [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])]
|
||||
.flatMap((parameter) => {
|
||||
if ('$ref' in parameter) {
|
||||
throw new Error(
|
||||
`KnowledgeFS request header references are not supported: ${parameter.$ref}`,
|
||||
)
|
||||
}
|
||||
return parameter.in === 'header' ? [parameter.name.toLowerCase()] : []
|
||||
})
|
||||
.filter((name, index, names) => names.indexOf(name) === index)
|
||||
.sort()
|
||||
}
|
||||
|
||||
function responseHeaderNames(operation) {
|
||||
return Object.values(operation.responses ?? {})
|
||||
.flatMap((response) => Object.keys(response.headers ?? {}))
|
||||
.map((name) => name.toLowerCase())
|
||||
.filter((name, index, names) => names.indexOf(name) === index)
|
||||
.sort()
|
||||
}
|
||||
|
||||
function pythonTuple(values) {
|
||||
if (values.length === 0) return '()'
|
||||
const entries = values.map((value) => JSON.stringify(value)).join(', ')
|
||||
const tuple = `(${entries}${values.length === 1 ? ',' : ''})`
|
||||
if (tuple.length <= 96) return tuple
|
||||
const multilineEntries = values
|
||||
.map((value) => ` ${JSON.stringify(value)},`)
|
||||
.join('\n')
|
||||
return `(\n${multilineEntries}\n )`
|
||||
}
|
||||
|
||||
function sha256(content) {
|
||||
return createHash('sha256').update(content).digest('hex')
|
||||
}
|
||||
|
||||
async function hashDirectory(directory) {
|
||||
const files = await listFiles(directory)
|
||||
const hash = createHash('sha256')
|
||||
for (const file of files) {
|
||||
hash.update(relative(directory, file).replaceAll('\\', '/'))
|
||||
hash.update('\0')
|
||||
hash.update(await readFile(file))
|
||||
hash.update('\0')
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function listFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
return entry.isDirectory() ? listFiles(path) : [path]
|
||||
}),
|
||||
)
|
||||
return files.flat().sort()
|
||||
}
|
||||
|
||||
function run(command, args, cwd, inherit = false, extraEnv = {}) {
|
||||
return (
|
||||
execFileSync(command, args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...extraEnv },
|
||||
stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'inherit'],
|
||||
}) ?? ''
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DatasetsV2Page } from '@/features/new-rag/datasets-v2-page'
|
||||
|
||||
export default function DatasetV2ListPage() {
|
||||
return <DatasetsV2Page />
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# New RAG
|
||||
|
||||
Dataset 2.0 knowledge-space listing and empty knowledge creation backed by the generated KnowledgeFS contract
|
||||
over the authenticated Console proxy.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
- `create-knowledge-space-dialog`: Owns the empty knowledge creation form and cache refresh behavior.
|
||||
|
||||
## External Modules
|
||||
|
||||
- `app/components/base/skeleton`: Provides scoped loading placeholders for the knowledge-space grid.
|
||||
- `hooks/use-document-title`: Keeps the browser title aligned with the Knowledge surface.
|
||||
@@ -0,0 +1,394 @@
|
||||
import type { KnowledgeSpaceList } from '@dify/contracts/knowledge-fs/types.gen'
|
||||
import type { InfiniteData } from '@tanstack/react-query'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DatasetsV2Page } from '../datasets-v2-page'
|
||||
|
||||
const queryMock = vi.hoisted(() => ({
|
||||
data: undefined as InfiniteData<KnowledgeSpaceList> | undefined,
|
||||
error: null as Error | null,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchNextPageError: false,
|
||||
isFetchingNextPage: false,
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}))
|
||||
|
||||
const mutationMock = vi.hoisted(() => ({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
}))
|
||||
|
||||
const queryClientMock = vi.hoisted(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
}))
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
}))
|
||||
|
||||
const permissionStateMock = vi.hoisted(() => ({
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
workspacePermissionKeysAtom: Symbol('workspacePermissionKeysAtom'),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...original,
|
||||
useInfiniteQuery: () => queryMock,
|
||||
useMutation: () => mutationMock,
|
||||
useQueryClient: () => queryClientMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('jotai', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('jotai')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
useAtomValue: (atom: unknown) => {
|
||||
if (atom === permissionStateMock.workspacePermissionKeysAtom)
|
||||
return permissionStateMock.workspacePermissionKeys
|
||||
|
||||
return original.useAtomValue(atom as Parameters<typeof original.useAtomValue>[0])
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: toastMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/permission-state', () => ({
|
||||
workspacePermissionKeysAtom: permissionStateMock.workspacePermissionKeysAtom,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
knowledgeFs: {
|
||||
listKnowledgeSpaces: {
|
||||
infiniteOptions: vi.fn(() => ({})),
|
||||
key: vi.fn(({ type }: { type?: 'infinite' | 'query' } = {}) => [
|
||||
'console',
|
||||
'knowledgeFs',
|
||||
'listKnowledgeSpaces',
|
||||
...(type ? [type] : []),
|
||||
]),
|
||||
},
|
||||
createKnowledgeSpace: {
|
||||
mutationOptions: vi.fn(() => ({})),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function setResolvedPage({
|
||||
items = [],
|
||||
hasMore = false,
|
||||
}: {
|
||||
items?: NonNullable<typeof queryMock.data>['pages'][number]['items']
|
||||
hasMore?: boolean
|
||||
} = {}) {
|
||||
queryMock.data = {
|
||||
pageParams: [null],
|
||||
pages: [
|
||||
{
|
||||
items,
|
||||
...(hasMore ? { nextCursor: 'next-page' } : {}),
|
||||
},
|
||||
],
|
||||
}
|
||||
queryMock.hasNextPage = hasMore
|
||||
}
|
||||
|
||||
describe('DatasetsV2Page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryMock.data = undefined
|
||||
queryMock.error = null
|
||||
queryMock.hasNextPage = false
|
||||
queryMock.isFetchNextPageError = false
|
||||
queryMock.isFetchingNextPage = false
|
||||
queryMock.isPending = false
|
||||
mutationMock.isPending = false
|
||||
permissionStateMock.workspacePermissionKeys = ['dataset.create_and_management']
|
||||
})
|
||||
|
||||
it('shows a scoped loading state while the first page is loading', () => {
|
||||
queryMock.isPending = true
|
||||
|
||||
render(<DatasetsV2Page />)
|
||||
|
||||
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets the user retry after the list request fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
queryMock.error = new Error('request failed')
|
||||
|
||||
render(<DatasetsV2Page />)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
|
||||
expect(queryMock.refetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows an empty state when Dataset 2.0 has no knowledge bases', () => {
|
||||
setResolvedPage()
|
||||
|
||||
render(<DatasetsV2Page />)
|
||||
|
||||
expect(screen.getByText('dataset.filterEmpty.noKnowledge')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.newRag.emptyDescription')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render a nested main landmark inside the common layout main landmark', () => {
|
||||
setResolvedPage()
|
||||
|
||||
render(<DatasetsV2Page />)
|
||||
|
||||
expect(screen.queryByRole('main')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the create action from read-only users', () => {
|
||||
permissionStateMock.workspacePermissionKeys = []
|
||||
setResolvedPage()
|
||||
|
||||
render(<DatasetsV2Page />)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.create' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders knowledge-space cards and loads the next cursor page', async () => {
|
||||
const user = userEvent.setup()
|
||||
setResolvedPage({
|
||||
items: [
|
||||
{
|
||||
createdAt: '2026-07-15T00:00:00Z',
|
||||
description: 'Support materials',
|
||||
id: 'space-1',
|
||||
name: 'Support knowledge',
|
||||
revision: 1,
|
||||
slug: 'support-knowledge',
|
||||
tenantId: 'tenant-1',
|
||||
updatedAt: '2026-07-15T00:00:00Z',
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
})
|
||||
|
||||
render(<DatasetsV2Page />)
|
||||
|
||||
const list = screen.getByRole('list', { name: 'dataset.knowledge' })
|
||||
expect(within(list).getByText('Support knowledge')).toBeInTheDocument()
|
||||
expect(within(list).getByText('support-knowledge')).toBeInTheDocument()
|
||||
expect(within(list).getByText('Support materials')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newRag.loadMore' }))
|
||||
expect(queryMock.fetchNextPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps loaded cards visible and retries locally when the next page fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
setResolvedPage({
|
||||
items: [
|
||||
{
|
||||
createdAt: '2026-07-15T00:00:00Z',
|
||||
description: 'Support materials',
|
||||
id: 'space-1',
|
||||
name: 'Support knowledge',
|
||||
revision: 1,
|
||||
slug: 'support-knowledge',
|
||||
tenantId: 'tenant-1',
|
||||
updatedAt: '2026-07-15T00:00:00Z',
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
})
|
||||
queryMock.error = new Error('next page failed')
|
||||
queryMock.isFetchNextPageError = true
|
||||
|
||||
render(<DatasetsV2Page />)
|
||||
|
||||
expect(screen.getByText('Support knowledge')).toBeInTheDocument()
|
||||
const paginationAlert = screen.getByRole('alert')
|
||||
expect(within(paginationAlert).getByText('dataset.unknownError')).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
within(paginationAlert).getByRole('button', { name: 'common.operation.retry' }),
|
||||
)
|
||||
|
||||
expect(queryMock.fetchNextPage).toHaveBeenCalledOnce()
|
||||
expect(queryMock.refetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a non-Latin knowledge base with a server-generated slug and refreshes the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
setResolvedPage()
|
||||
render(<DatasetsV2Page />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'dataset.newRag.createDialogTitle',
|
||||
})
|
||||
const nameInput = within(dialog).getByRole('textbox', { name: 'datasetSettings.form.name' })
|
||||
|
||||
expect(
|
||||
within(dialog).queryByRole('textbox', { name: 'dataset.newRag.slugLabel' }),
|
||||
).not.toBeInTheDocument()
|
||||
await user.type(nameInput, '产品知识库')
|
||||
await user.type(
|
||||
within(dialog).getByRole('textbox', { name: /dataset\.externalKnowledgeDescription/ }),
|
||||
'Answers for customers',
|
||||
)
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' }))
|
||||
|
||||
expect(mutationMock.mutate).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {
|
||||
description: 'Answers for customers',
|
||||
idempotencyKey: expect.any(String),
|
||||
name: '产品知识库',
|
||||
},
|
||||
},
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
)
|
||||
|
||||
const callbacks = mutationMock.mutate.mock.calls[0]?.[1]
|
||||
await act(async () => {
|
||||
callbacks.onSuccess()
|
||||
})
|
||||
|
||||
expect(toastMock.success).toHaveBeenCalledWith('dataset.newRag.createSuccess')
|
||||
expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['console', 'knowledgeFs', 'listKnowledgeSpaces', 'infinite'],
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'dataset.newRag.createDialogTitle' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the create dialog open and reuses its idempotency key when creation is retried', async () => {
|
||||
const user = userEvent.setup()
|
||||
setResolvedPage()
|
||||
render(<DatasetsV2Page />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'dataset.newRag.createDialogTitle',
|
||||
})
|
||||
const nameInput = within(dialog).getByRole('textbox', { name: 'datasetSettings.form.name' })
|
||||
await user.type(nameInput, 'Product Support')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' }))
|
||||
|
||||
const callbacks = mutationMock.mutate.mock.calls[0]?.[1]
|
||||
await act(async () => {
|
||||
callbacks.onError()
|
||||
})
|
||||
|
||||
expect(toastMock.error).toHaveBeenCalledWith('dataset.newRag.createFailed')
|
||||
expect(screen.getByRole('dialog', { name: 'dataset.newRag.createDialogTitle' })).toBeVisible()
|
||||
expect(nameInput).toHaveValue('Product Support')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' }))
|
||||
|
||||
expect(mutationMock.mutate).toHaveBeenCalledTimes(2)
|
||||
const firstKey = mutationMock.mutate.mock.calls[0]?.[0].body.idempotencyKey
|
||||
const retryKey = mutationMock.mutate.mock.calls[1]?.[0].body.idempotencyKey
|
||||
expect(firstKey).toEqual(expect.any(String))
|
||||
expect(retryKey).toBe(firstKey)
|
||||
})
|
||||
|
||||
it('keeps the create dialog open while creation is pending', async () => {
|
||||
const user = userEvent.setup()
|
||||
mutationMock.isPending = true
|
||||
setResolvedPage()
|
||||
render(<DatasetsV2Page />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'dataset.newRag.createDialogTitle',
|
||||
})
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
it('uses a new idempotency key when a failed creation is retried with edited content', async () => {
|
||||
const user = userEvent.setup()
|
||||
setResolvedPage()
|
||||
render(<DatasetsV2Page />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'dataset.newRag.createDialogTitle',
|
||||
})
|
||||
const nameInput = within(dialog).getByRole('textbox', { name: 'datasetSettings.form.name' })
|
||||
await user.type(nameInput, 'Product Support')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' }))
|
||||
|
||||
const callbacks = mutationMock.mutate.mock.calls[0]?.[1]
|
||||
await act(async () => {
|
||||
callbacks.onError()
|
||||
})
|
||||
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'Customer Support')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' }))
|
||||
|
||||
expect(mutationMock.mutate).toHaveBeenCalledTimes(2)
|
||||
const firstKey = mutationMock.mutate.mock.calls[0]?.[0].body.idempotencyKey
|
||||
const editedKey = mutationMock.mutate.mock.calls[1]?.[0].body.idempotencyKey
|
||||
expect(editedKey).toEqual(expect.any(String))
|
||||
expect(editedKey).not.toBe(firstKey)
|
||||
})
|
||||
|
||||
it('uses a new idempotency key after the create dialog is closed and reopened', async () => {
|
||||
const user = userEvent.setup()
|
||||
setResolvedPage()
|
||||
render(<DatasetsV2Page />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
const firstDialog = await screen.findByRole('dialog', {
|
||||
name: 'dataset.newRag.createDialogTitle',
|
||||
})
|
||||
await user.type(
|
||||
within(firstDialog).getByRole('textbox', { name: 'datasetSettings.form.name' }),
|
||||
'Product Support',
|
||||
)
|
||||
await user.click(within(firstDialog).getByRole('button', { name: 'common.operation.create' }))
|
||||
|
||||
const callbacks = mutationMock.mutate.mock.calls[0]?.[1]
|
||||
await act(async () => {
|
||||
callbacks.onError()
|
||||
})
|
||||
await user.click(within(firstDialog).getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
const reopenedDialog = await screen.findByRole('dialog', {
|
||||
name: 'dataset.newRag.createDialogTitle',
|
||||
})
|
||||
await user.type(
|
||||
within(reopenedDialog).getByRole('textbox', { name: 'datasetSettings.form.name' }),
|
||||
'Product Support',
|
||||
)
|
||||
await user.click(
|
||||
within(reopenedDialog).getByRole('button', { name: 'common.operation.create' }),
|
||||
)
|
||||
|
||||
expect(mutationMock.mutate).toHaveBeenCalledTimes(2)
|
||||
const firstKey = mutationMock.mutate.mock.calls[0]?.[0].body.idempotencyKey
|
||||
const reopenedKey = mutationMock.mutate.mock.calls[1]?.[0].body.idempotencyKey
|
||||
expect(reopenedKey).toEqual(expect.any(String))
|
||||
expect(reopenedKey).not.toBe(firstKey)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import type { CreateKnowledgeSpace } from '@dify/contracts/knowledge-fs/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { Field, FieldControl, FieldError, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type CreateKnowledgeSpaceFormValues = {
|
||||
description?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
type CreateKnowledgeSpaceAttempt = {
|
||||
description: string
|
||||
idempotencyKey: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function createIdempotencyKey() {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
||||
|
||||
return `knowledge-space-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function CreateKnowledgeSpaceDialog() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { t: tDatasetSettings } = useTranslation('datasetSettings')
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const createAttemptRef = useRef<CreateKnowledgeSpaceAttempt | null>(null)
|
||||
const createKnowledgeSpaceMutation = useMutation(
|
||||
consoleQuery.knowledgeFs.createKnowledgeSpace.mutationOptions(),
|
||||
)
|
||||
|
||||
const resetForm = () => {
|
||||
createAttemptRef.current = null
|
||||
setName('')
|
||||
setDescription('')
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen && createKnowledgeSpaceMutation.isPending) return
|
||||
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) resetForm()
|
||||
}
|
||||
|
||||
const handleSubmit = (values: CreateKnowledgeSpaceFormValues) => {
|
||||
if (createKnowledgeSpaceMutation.isPending) return
|
||||
|
||||
const description = values.description?.trim() ?? ''
|
||||
const name = values.name?.trim() ?? ''
|
||||
const previousAttempt = createAttemptRef.current
|
||||
const idempotencyKey =
|
||||
previousAttempt?.description === description && previousAttempt.name === name
|
||||
? previousAttempt.idempotencyKey
|
||||
: createIdempotencyKey()
|
||||
createAttemptRef.current = { description, idempotencyKey, name }
|
||||
|
||||
const body = {
|
||||
description,
|
||||
idempotencyKey,
|
||||
name,
|
||||
} satisfies CreateKnowledgeSpace
|
||||
|
||||
createKnowledgeSpaceMutation.mutate(
|
||||
{ body },
|
||||
{
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['newRag.createFailed']))
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t(($) => $['newRag.createSuccess']))
|
||||
closeDialog()
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.knowledgeFs.listKnowledgeSpaces.key({ type: 'infinite' }),
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
|
||||
<DialogTrigger render={<Button variant="primary" size="medium" className="gap-1 px-3" />}>
|
||||
<span aria-hidden className="i-ri-add-line size-4" />
|
||||
{tCommon(($) => $['operation.create'])}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[520px] overflow-hidden! p-0!">
|
||||
<DialogCloseButton
|
||||
aria-label={tCommon(($) => $['operation.close'])}
|
||||
disabled={createKnowledgeSpaceMutation.isPending}
|
||||
/>
|
||||
<div className="pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['newRag.createDialogTitle'])}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t(($) => $['newRag.createDialogDescription'])}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Form<CreateKnowledgeSpaceFormValues> onFormSubmit={handleSubmit}>
|
||||
<div className="space-y-5 px-6 py-3">
|
||||
<Field
|
||||
name="name"
|
||||
validate={(value) => {
|
||||
if (typeof value === 'string' && value.length > 0 && !value.trim())
|
||||
return tCommon(($) => $['errorMsg.fieldRequired'], {
|
||||
field: tDatasetSettings(($) => $['form.name']),
|
||||
})
|
||||
|
||||
return null
|
||||
}}
|
||||
>
|
||||
<FieldLabel>{tDatasetSettings(($) => $['form.name'])}</FieldLabel>
|
||||
<FieldControl
|
||||
autoComplete="off"
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus -- The dialog opens from an explicit create command and name is its primary input.
|
||||
autoFocus
|
||||
maxLength={160}
|
||||
onValueChange={setName}
|
||||
placeholder={tDatasetSettings(($) => $['form.namePlaceholder'])}
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
<FieldError match="valueMissing">
|
||||
{tCommon(($) => $['errorMsg.fieldRequired'], {
|
||||
field: tDatasetSettings(($) => $['form.name']),
|
||||
})}
|
||||
</FieldError>
|
||||
<FieldError match="customError" />
|
||||
</Field>
|
||||
|
||||
<Field name="description">
|
||||
<FieldLabel>
|
||||
{t(($) => $.externalKnowledgeDescription)}
|
||||
<span className="ml-1 system-xs-regular text-text-tertiary">
|
||||
{tCommon(($) => $['label.optional'])}
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
className="h-24 resize-none"
|
||||
maxLength={2000}
|
||||
onValueChange={setDescription}
|
||||
placeholder={t(($) => $.externalKnowledgeDescriptionPlaceholder)}
|
||||
value={description}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-6 pt-5 pb-6">
|
||||
<Button
|
||||
type="button"
|
||||
className="min-w-18"
|
||||
disabled={createKnowledgeSpaceMutation.isPending}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="min-w-18"
|
||||
loading={createKnowledgeSpaceMutation.isPending}
|
||||
>
|
||||
{tCommon(($) => $['operation.create'])}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import type { KnowledgeSpace } from '@dify/contracts/knowledge-fs/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { CreateKnowledgeSpaceDialog } from './create-knowledge-space-dialog'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
function KnowledgeSpaceSkeleton({ label }: { label: string }) {
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-3"
|
||||
role="status"
|
||||
aria-label={label}
|
||||
aria-live="polite"
|
||||
>
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-44 rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4 shadow-xs shadow-shadow-shadow-3"
|
||||
>
|
||||
<SkeletonContainer className="h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonRectangle className="size-10 animate-pulse rounded-lg" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<SkeletonRectangle className="h-4 w-2/3 animate-pulse" />
|
||||
<SkeletonRectangle className="h-3 w-1/3 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-2">
|
||||
<SkeletonRectangle className="h-3 w-full animate-pulse" />
|
||||
<SkeletonRectangle className="h-3 w-4/5 animate-pulse" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PageState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-80 flex-col items-center justify-center rounded-xl border border-dashed border-divider-subtle bg-background-default-subtle px-6 py-12 text-center">
|
||||
<span className="mb-4 i-ri-book-open-line size-7 text-text-quaternary" aria-hidden />
|
||||
<h2 className="system-md-semibold text-text-primary">{title}</h2>
|
||||
{description ? (
|
||||
<p className="mt-1 max-w-120 system-sm-regular text-text-tertiary">{description}</p>
|
||||
) : null}
|
||||
{action ? <div className="mt-5">{action}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KnowledgeSpaceCard({ knowledgeSpace }: { knowledgeSpace: KnowledgeSpace }) {
|
||||
return (
|
||||
<li className="min-h-44 rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4 shadow-xs shadow-shadow-shadow-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-components-panel-border bg-background-default-subtle">
|
||||
<span className="i-ri-book-2-line size-5 text-text-tertiary" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate system-md-semibold text-text-primary">{knowledgeSpace.name}</h2>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-text-tertiary">
|
||||
{knowledgeSpace.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{knowledgeSpace.description && (
|
||||
<p className="mt-5 line-clamp-3 system-sm-regular text-text-secondary">
|
||||
{knowledgeSpace.description}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function DatasetsV2Page() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetchNextPageError,
|
||||
isPending,
|
||||
refetch,
|
||||
} = useInfiniteQuery(
|
||||
consoleQuery.knowledgeFs.listKnowledgeSpaces.infiniteOptions({
|
||||
input: (pageParam) => ({
|
||||
query: {
|
||||
limit: PAGE_SIZE,
|
||||
...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
|
||||
},
|
||||
}),
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
initialPageParam: null as string | null,
|
||||
}),
|
||||
)
|
||||
|
||||
const isInitialError = Boolean(error && !data)
|
||||
const knowledgeSpaces = data?.pages.flatMap((page) => page.items) ?? []
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
|
||||
|
||||
useDocumentTitle(t(($) => $.knowledge))
|
||||
|
||||
return (
|
||||
<div className="relative flex grow flex-col overflow-y-auto bg-background-body">
|
||||
<header className="sticky top-0 z-10 flex items-center justify-between gap-4 bg-background-body px-8 pt-4 pb-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="truncate text-[18px]/[21.6px] font-semibold text-text-primary">
|
||||
{t(($) => $.knowledge)}
|
||||
</h1>
|
||||
<span className="shrink-0 rounded-md border border-divider-subtle bg-background-default-subtle px-1.5 py-0.5 system-xs-medium text-text-tertiary">
|
||||
{t(($) => $['newRag.badge'])}
|
||||
</span>
|
||||
</div>
|
||||
{!isPending && !isInitialError && canCreateDataset && <CreateKnowledgeSpaceDialog />}
|
||||
</header>
|
||||
|
||||
<div className="px-8 pt-2 pb-8">
|
||||
{isPending ? (
|
||||
<KnowledgeSpaceSkeleton label={tCommon(($) => $.loading)} />
|
||||
) : isInitialError ? (
|
||||
<PageState
|
||||
title={t(($) => $.unknownError)}
|
||||
action={
|
||||
<Button type="button" onClick={() => void refetch()}>
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : knowledgeSpaces.length === 0 ? (
|
||||
<PageState
|
||||
title={t(($) => $['filterEmpty.noKnowledge'])}
|
||||
description={t(($) => $['newRag.emptyDescription'])}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ul
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-3"
|
||||
aria-label={t(($) => $.knowledge)}
|
||||
>
|
||||
{knowledgeSpaces.map((knowledgeSpace) => (
|
||||
<KnowledgeSpaceCard key={knowledgeSpace.id} knowledgeSpace={knowledgeSpace} />
|
||||
))}
|
||||
</ul>
|
||||
{isFetchNextPageError ? (
|
||||
<div className="mt-6 flex items-center justify-center gap-3" role="alert">
|
||||
<p className="system-sm-regular text-text-secondary">{t(($) => $.unknownError)}</p>
|
||||
<Button
|
||||
type="button"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={() => void fetchNextPage()}
|
||||
>
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
</div>
|
||||
) : hasNextPage ? (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={() => void fetchNextPage()}
|
||||
>
|
||||
{t(($) => $['newRag.loadMore'])}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "مطلوب نموذج إعادة الترتيب لخلط قواعد المعرفة عالية الجودة والاقتصادية.",
|
||||
"mixtureInternalAndExternalTip": "مطلوب نموذج إعادة الترتيب لخلط المعرفة الداخلية والخارجية.",
|
||||
"multimodal": "متعدد الوسائط",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "أنشئ قاعدة معرفة فارغة. يمكنك إضافة المصادر وضبط إعدادات الاسترجاع لاحقًا.",
|
||||
"newRag.createDialogTitle": "إنشاء قاعدة معرفة في Dataset 2.0",
|
||||
"newRag.createFailed": "تعذر إنشاء قاعدة المعرفة",
|
||||
"newRag.createSuccess": "تم إنشاء قاعدة المعرفة",
|
||||
"newRag.disabledDescription": "لم تتم تهيئة Dataset 2.0 لمساحة العمل هذه.",
|
||||
"newRag.emptyDescription": "أنشئ قاعدة معرفة فارغة لبدء استخدام Dataset 2.0.",
|
||||
"newRag.loadMore": "تحميل المزيد",
|
||||
"newRag.slugInvalid": "استخدم فقط الأحرف اللاتينية الصغيرة والأرقام والواصلات المفردة.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "مثال: product-support",
|
||||
"noExternalKnowledge": "لا توجد واجهة برمجة تطبيقات معرفة خارجية حتى الآن، انقر هنا لإنشاء",
|
||||
"parentMode.fullDoc": "مستند كامل",
|
||||
"parentMode.paragraph": "فقرة",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Für die Mischung von hochwertigen und wirtschaftlichen Wissensbasen ist das Rerank-Modell erforderlich.",
|
||||
"mixtureInternalAndExternalTip": "Das Rerank-Modell ist für die Mischung von internem und externem Wissen erforderlich.",
|
||||
"multimodal": "Multimodal",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Erstellen Sie eine leere Wissensdatenbank. Sie können später Quellen hinzufügen und den Abruf konfigurieren.",
|
||||
"newRag.createDialogTitle": "Wissensdatenbank mit Dataset 2.0 erstellen",
|
||||
"newRag.createFailed": "Wissensdatenbank konnte nicht erstellt werden",
|
||||
"newRag.createSuccess": "Wissensdatenbank erstellt",
|
||||
"newRag.disabledDescription": "Dataset 2.0 ist für diesen Arbeitsbereich nicht konfiguriert.",
|
||||
"newRag.emptyDescription": "Erstellen Sie eine leere Wissensdatenbank, um mit Dataset 2.0 zu beginnen.",
|
||||
"newRag.loadMore": "Mehr laden",
|
||||
"newRag.slugInvalid": "Verwenden Sie nur Kleinbuchstaben, Zahlen und einzelne Bindestriche.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "z. B. product-support",
|
||||
"noExternalKnowledge": "Es gibt noch keine External Knowledge API, klicken Sie hier, um zu erstellen",
|
||||
"parentMode.fullDoc": "Vollständiges Dokument",
|
||||
"parentMode.paragraph": "Absatz",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "The Rerank model is required for mixture of high quality and economical knowledge bases.",
|
||||
"mixtureInternalAndExternalTip": "The Rerank model is required for mixture of internal and external knowledge.",
|
||||
"multimodal": "Multimodal",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Create an empty knowledge base. You can add sources and configure retrieval later.",
|
||||
"newRag.createDialogTitle": "Create Dataset 2.0 knowledge",
|
||||
"newRag.createFailed": "Failed to create knowledge",
|
||||
"newRag.createSuccess": "Knowledge created",
|
||||
"newRag.disabledDescription": "Dataset 2.0 is not configured for this workspace.",
|
||||
"newRag.emptyDescription": "Create an empty knowledge base to start building with Dataset 2.0.",
|
||||
"newRag.loadMore": "Load more",
|
||||
"newRag.slugInvalid": "Use lowercase letters, numbers, and single hyphens only.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "e.g. product-support",
|
||||
"noExternalKnowledge": "There is no External Knowledge API yet, click here to create",
|
||||
"parentMode.fullDoc": "Full-doc",
|
||||
"parentMode.paragraph": "Paragraph",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Se requiere el modelo de reclasificación para la mezcla de bases de conocimiento de alta calidad y económicas.",
|
||||
"mixtureInternalAndExternalTip": "El modelo de Rerank es necesario para la mezcla de conocimiento interno y externo.",
|
||||
"multimodal": "Multimodal",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Crea una base de conocimiento vacía. Puedes añadir fuentes y configurar la recuperación más adelante.",
|
||||
"newRag.createDialogTitle": "Crear una base de conocimiento de Dataset 2.0",
|
||||
"newRag.createFailed": "No se pudo crear la base de conocimiento",
|
||||
"newRag.createSuccess": "Base de conocimiento creada",
|
||||
"newRag.disabledDescription": "Dataset 2.0 no está configurado para este espacio de trabajo.",
|
||||
"newRag.emptyDescription": "Crea una base de conocimiento vacía para empezar a trabajar con Dataset 2.0.",
|
||||
"newRag.loadMore": "Cargar más",
|
||||
"newRag.slugInvalid": "Usa solo letras minúsculas, números y guiones simples.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "p. ej. product-support",
|
||||
"noExternalKnowledge": "Todavía no hay una API de conocimiento externo, haga clic aquí para crear",
|
||||
"parentMode.fullDoc": "Documento completo",
|
||||
"parentMode.paragraph": "Párrafo",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "مدل دوباره رتبهبندی برای ترکیب پایگاههای دانش با کیفیت بالا و اقتصادی لازم است.",
|
||||
"mixtureInternalAndExternalTip": "مدل Rerank برای آمیختگی دانش درونی و بیرونی مورد نیاز است.",
|
||||
"multimodal": "چندوجهی",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "یک پایگاه دانش خالی ایجاد کنید. بعداً میتوانید منابع را اضافه و تنظیمات بازیابی را پیکربندی کنید.",
|
||||
"newRag.createDialogTitle": "ایجاد پایگاه دانش Dataset 2.0",
|
||||
"newRag.createFailed": "ایجاد پایگاه دانش ناموفق بود",
|
||||
"newRag.createSuccess": "پایگاه دانش ایجاد شد",
|
||||
"newRag.disabledDescription": "Dataset 2.0 برای این فضای کاری پیکربندی نشده است.",
|
||||
"newRag.emptyDescription": "برای شروع کار با Dataset 2.0، یک پایگاه دانش خالی ایجاد کنید.",
|
||||
"newRag.loadMore": "بارگذاری بیشتر",
|
||||
"newRag.slugInvalid": "فقط از حروف کوچک لاتین، اعداد و خط تیرههای تکی استفاده کنید.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "مثلاً product-support",
|
||||
"noExternalKnowledge": "هنوز هیچ API دانش خارجی وجود ندارد، برای ایجاد اینجا را کلیک کنید",
|
||||
"parentMode.fullDoc": "مستند کامل",
|
||||
"parentMode.paragraph": "پاراگراف",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Le modèle de reclassement est nécessaire pour le mélange de bases de connaissances de haute qualité et économiques.",
|
||||
"mixtureInternalAndExternalTip": "Le modèle Rerank est nécessaire pour mélanger les connaissances internes et externes.",
|
||||
"multimodal": "Multimodal",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Créez une base de connaissances vide. Vous pourrez ajouter des sources et configurer la récupération ultérieurement.",
|
||||
"newRag.createDialogTitle": "Créer une base de connaissances Dataset 2.0",
|
||||
"newRag.createFailed": "Échec de la création de la base de connaissances",
|
||||
"newRag.createSuccess": "Base de connaissances créée",
|
||||
"newRag.disabledDescription": "Dataset 2.0 n’est pas configuré pour cet espace de travail.",
|
||||
"newRag.emptyDescription": "Créez une base de connaissances vide pour commencer à utiliser Dataset 2.0.",
|
||||
"newRag.loadMore": "Charger plus",
|
||||
"newRag.slugInvalid": "Utilisez uniquement des lettres minuscules, des chiffres et des traits d’union simples.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "p. ex. product-support",
|
||||
"noExternalKnowledge": "Il n’y a pas encore d’API de connaissances externes, cliquez ici pour créer",
|
||||
"parentMode.fullDoc": "Doc complet",
|
||||
"parentMode.paragraph": "Paragraphe",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "उच्च गुणवत्ता और किफायती ज्ञान आधारों के मिश्रण के लिए पुनः रैंकिंग मॉडल आवश्यक है।",
|
||||
"mixtureInternalAndExternalTip": "आंतरिक और बाहरी ज्ञान के मिश्रण के लिए रीरैंक मॉडल की आवश्यकता होती है।",
|
||||
"multimodal": "बहु-मोडल",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "एक खाली नॉलेज बेस बनाएँ। आप बाद में स्रोत जोड़ सकते हैं और पुनर्प्राप्ति सेटिंग कॉन्फ़िगर कर सकते हैं।",
|
||||
"newRag.createDialogTitle": "Dataset 2.0 नॉलेज बेस बनाएँ",
|
||||
"newRag.createFailed": "नॉलेज बेस नहीं बनाया जा सका",
|
||||
"newRag.createSuccess": "नॉलेज बेस बनाया गया",
|
||||
"newRag.disabledDescription": "इस कार्यस्थान के लिए Dataset 2.0 कॉन्फ़िगर नहीं किया गया है।",
|
||||
"newRag.emptyDescription": "Dataset 2.0 का उपयोग शुरू करने के लिए एक खाली नॉलेज बेस बनाएँ।",
|
||||
"newRag.loadMore": "और लोड करें",
|
||||
"newRag.slugInvalid": "केवल अंग्रेज़ी के छोटे अक्षरों, अंकों और एकल हाइफ़न का उपयोग करें।",
|
||||
"newRag.slugLabel": "स्लग",
|
||||
"newRag.slugPlaceholder": "उदाहरण: product-support",
|
||||
"noExternalKnowledge": "अभी तक कोई बाहरी ज्ञान एपीआई नहीं है, बनाने के लिए यहां क्लिक करें",
|
||||
"parentMode.fullDoc": "पूर्ण-दस्तावेज़",
|
||||
"parentMode.paragraph": "अनुच्छेद",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Model Rerank diperlukan untuk campuran basis pengetahuan berkualitas tinggi dan ekonomis.",
|
||||
"mixtureInternalAndExternalTip": "Model Rerank diperlukan untuk campuran pengetahuan internal dan eksternal.",
|
||||
"multimodal": "Multimodal",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Buat basis pengetahuan kosong. Anda dapat menambahkan sumber dan mengonfigurasi pengambilan nanti.",
|
||||
"newRag.createDialogTitle": "Buat basis pengetahuan Dataset 2.0",
|
||||
"newRag.createFailed": "Gagal membuat basis pengetahuan",
|
||||
"newRag.createSuccess": "Basis pengetahuan berhasil dibuat",
|
||||
"newRag.disabledDescription": "Dataset 2.0 belum dikonfigurasi untuk ruang kerja ini.",
|
||||
"newRag.emptyDescription": "Buat basis pengetahuan kosong untuk mulai menggunakan Dataset 2.0.",
|
||||
"newRag.loadMore": "Muat lebih banyak",
|
||||
"newRag.slugInvalid": "Gunakan hanya huruf kecil, angka, dan tanda hubung tunggal.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "mis. product-support",
|
||||
"noExternalKnowledge": "Belum ada API Pengetahuan Eksternal, klik di sini untuk membuat",
|
||||
"parentMode.fullDoc": "Dokumen lengkap",
|
||||
"parentMode.paragraph": "Paragraf",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Il modello di riclassificazione è necessario per la miscela di basi di conoscenza di alta qualità ed economiche.",
|
||||
"mixtureInternalAndExternalTip": "Il modello Rerank è necessario per la combinazione di conoscenze interne ed esterne.",
|
||||
"multimodal": "Multimodale",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Crea una knowledge base vuota. Potrai aggiungere fonti e configurare il recupero in seguito.",
|
||||
"newRag.createDialogTitle": "Crea una knowledge base Dataset 2.0",
|
||||
"newRag.createFailed": "Impossibile creare la knowledge base",
|
||||
"newRag.createSuccess": "Knowledge base creata",
|
||||
"newRag.disabledDescription": "Dataset 2.0 non è configurato per questo spazio di lavoro.",
|
||||
"newRag.emptyDescription": "Crea una knowledge base vuota per iniziare a usare Dataset 2.0.",
|
||||
"newRag.loadMore": "Carica altro",
|
||||
"newRag.slugInvalid": "Usa solo lettere minuscole, numeri e trattini singoli.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "es. product-support",
|
||||
"noExternalKnowledge": "Non esiste ancora un'API di conoscenza esterna, fai clic qui per creare",
|
||||
"parentMode.fullDoc": "Full-doc",
|
||||
"parentMode.paragraph": "Paragrafo",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "高品質なナレッジベースとコスト重視のナレッジベースを混在させるには、Rerank モデルが必要です。",
|
||||
"mixtureInternalAndExternalTip": "内部と外部のナレッジベースを混在させる場合、Rerank モデルが必要です。",
|
||||
"multimodal": "マルチモーダル",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "空のナレッジベースを作成します。ソースの追加や検索設定は後から行えます。",
|
||||
"newRag.createDialogTitle": "Dataset 2.0 ナレッジベースの作成",
|
||||
"newRag.createFailed": "ナレッジベースを作成できませんでした",
|
||||
"newRag.createSuccess": "ナレッジベースを作成しました",
|
||||
"newRag.disabledDescription": "このワークスペースでは Dataset 2.0 が設定されていません。",
|
||||
"newRag.emptyDescription": "Dataset 2.0 を使い始めるには、空のナレッジベースを作成してください。",
|
||||
"newRag.loadMore": "さらに読み込む",
|
||||
"newRag.slugInvalid": "半角英小文字、数字、連続しないハイフンのみを使用してください。",
|
||||
"newRag.slugLabel": "スラッグ",
|
||||
"newRag.slugPlaceholder": "例:product-support",
|
||||
"noExternalKnowledge": "外部ナレッジベース連携 API がありません。ここをクリックして作成してください",
|
||||
"parentMode.fullDoc": "全体",
|
||||
"parentMode.paragraph": "段落",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "고품질과 경제적 지식 베이스의 혼합을 위해서는 재순위 모델이 필요합니다.",
|
||||
"mixtureInternalAndExternalTip": "리랭크 모델은 내부 및 외부 지식의 혼합에 필요합니다.",
|
||||
"multimodal": "멀티모달",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "빈 지식 베이스를 만드세요. 소스 추가와 검색 설정은 나중에 할 수 있습니다.",
|
||||
"newRag.createDialogTitle": "Dataset 2.0 지식 베이스 만들기",
|
||||
"newRag.createFailed": "지식 베이스를 만들지 못했습니다",
|
||||
"newRag.createSuccess": "지식 베이스를 만들었습니다",
|
||||
"newRag.disabledDescription": "이 워크스페이스에는 Dataset 2.0이 구성되어 있지 않습니다.",
|
||||
"newRag.emptyDescription": "Dataset 2.0 사용을 시작하려면 빈 지식 베이스를 만드세요.",
|
||||
"newRag.loadMore": "더 보기",
|
||||
"newRag.slugInvalid": "영문 소문자, 숫자, 연속되지 않은 하이픈만 사용하세요.",
|
||||
"newRag.slugLabel": "슬러그",
|
||||
"newRag.slugPlaceholder": "예: product-support",
|
||||
"noExternalKnowledge": "아직 외부 지식 API 가 없으므로 여기를 클릭하여 생성하십시오.",
|
||||
"parentMode.fullDoc": "전체 문서",
|
||||
"parentMode.paragraph": "단락",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "The Rerank model is required for mixture of high quality and economical knowledge bases.",
|
||||
"mixtureInternalAndExternalTip": "The Rerank model is required for mixture of internal and external knowledge.",
|
||||
"multimodal": "Multimodal",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Maak een lege kennisbank. Je kunt later bronnen toevoegen en de zoekfunctie configureren.",
|
||||
"newRag.createDialogTitle": "Dataset 2.0-kennisbank maken",
|
||||
"newRag.createFailed": "Kennisbank kon niet worden gemaakt",
|
||||
"newRag.createSuccess": "Kennisbank gemaakt",
|
||||
"newRag.disabledDescription": "Dataset 2.0 is niet geconfigureerd voor deze werkruimte.",
|
||||
"newRag.emptyDescription": "Maak een lege kennisbank om aan de slag te gaan met Dataset 2.0.",
|
||||
"newRag.loadMore": "Meer laden",
|
||||
"newRag.slugInvalid": "Gebruik alleen kleine letters, cijfers en enkele koppeltekens.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "bijv. product-support",
|
||||
"noExternalKnowledge": "There is no External Knowledge API yet, click here to create",
|
||||
"parentMode.fullDoc": "Full-doc",
|
||||
"parentMode.paragraph": "Paragraph",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Model ponownego rankingu jest wymagany dla mieszanki wysokiej jakości i ekonomicznych baz wiedzy.",
|
||||
"mixtureInternalAndExternalTip": "Model Rerank jest wymagany do połączenia wiedzy wewnętrznej i zewnętrznej.",
|
||||
"multimodal": "Multimodalny",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Utwórz pustą bazę wiedzy. Później możesz dodać źródła i skonfigurować wyszukiwanie.",
|
||||
"newRag.createDialogTitle": "Utwórz bazę wiedzy Dataset 2.0",
|
||||
"newRag.createFailed": "Nie udało się utworzyć bazy wiedzy",
|
||||
"newRag.createSuccess": "Utworzono bazę wiedzy",
|
||||
"newRag.disabledDescription": "Dataset 2.0 nie jest skonfigurowany dla tego obszaru roboczego.",
|
||||
"newRag.emptyDescription": "Utwórz pustą bazę wiedzy, aby rozpocząć pracę z Dataset 2.0.",
|
||||
"newRag.loadMore": "Załaduj więcej",
|
||||
"newRag.slugInvalid": "Używaj wyłącznie małych liter, cyfr i pojedynczych łączników.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "np. product-support",
|
||||
"noExternalKnowledge": "Nie ma jeszcze interfejsu API wiedzy zewnętrznej, kliknij tutaj, aby utworzyć",
|
||||
"parentMode.fullDoc": "Pełna wersja dokumentu",
|
||||
"parentMode.paragraph": "Akapit",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "O modelo de reclassificação é necessário para a mistura de bases de conhecimento de alta qualidade e econômicas.",
|
||||
"mixtureInternalAndExternalTip": "O modelo de Reclassificação é necessário para a mistura de conhecimento interno e externo.",
|
||||
"multimodal": "Multimodal",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Crie uma base de conhecimento vazia. Você poderá adicionar fontes e configurar a recuperação depois.",
|
||||
"newRag.createDialogTitle": "Criar base de conhecimento do Dataset 2.0",
|
||||
"newRag.createFailed": "Falha ao criar a base de conhecimento",
|
||||
"newRag.createSuccess": "Base de conhecimento criada",
|
||||
"newRag.disabledDescription": "O Dataset 2.0 não está configurado para este espaço de trabalho.",
|
||||
"newRag.emptyDescription": "Crie uma base de conhecimento vazia para começar a usar o Dataset 2.0.",
|
||||
"newRag.loadMore": "Carregar mais",
|
||||
"newRag.slugInvalid": "Use apenas letras minúsculas, números e hifens simples.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "ex.: product-support",
|
||||
"noExternalKnowledge": "Ainda não existe uma API de conhecimento externo, clique aqui para criar",
|
||||
"parentMode.fullDoc": "Documento completo",
|
||||
"parentMode.paragraph": "Parágrafo",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Modelul de reclasificare este necesar pentru amestecul de baze de cunoștințe de înaltă calitate și economice.",
|
||||
"mixtureInternalAndExternalTip": "Modelul Rerank este necesar pentru amestecul de cunoștințe interne și externe.",
|
||||
"multimodal": "Multimodal",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Creează o bază de cunoștințe goală. Poți adăuga surse și configura regăsirea mai târziu.",
|
||||
"newRag.createDialogTitle": "Creează o bază de cunoștințe Dataset 2.0",
|
||||
"newRag.createFailed": "Baza de cunoștințe nu a putut fi creată",
|
||||
"newRag.createSuccess": "Bază de cunoștințe creată",
|
||||
"newRag.disabledDescription": "Dataset 2.0 nu este configurat pentru acest spațiu de lucru.",
|
||||
"newRag.emptyDescription": "Creează o bază de cunoștințe goală pentru a începe să lucrezi cu Dataset 2.0.",
|
||||
"newRag.loadMore": "Încarcă mai multe",
|
||||
"newRag.slugInvalid": "Folosește numai litere mici, cifre și cratime individuale.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "de ex. product-support",
|
||||
"noExternalKnowledge": "Nu există încă un API de cunoștințe externe, faceți clic aici pentru a crea",
|
||||
"parentMode.fullDoc": "Documentar complet",
|
||||
"parentMode.paragraph": "Paragraf",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Для смешивания высококачественных и экономичных баз знаний требуется модель переранжирования.",
|
||||
"mixtureInternalAndExternalTip": "Модель Rerank необходима для смешивания внутренних и внешних знаний.",
|
||||
"multimodal": "Мультимодальный",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Создайте пустую базу знаний. Источники можно добавить, а поиск — настроить позже.",
|
||||
"newRag.createDialogTitle": "Создать базу знаний Dataset 2.0",
|
||||
"newRag.createFailed": "Не удалось создать базу знаний",
|
||||
"newRag.createSuccess": "База знаний создана",
|
||||
"newRag.disabledDescription": "Dataset 2.0 не настроен для этого рабочего пространства.",
|
||||
"newRag.emptyDescription": "Создайте пустую базу знаний, чтобы начать работу с Dataset 2.0.",
|
||||
"newRag.loadMore": "Загрузить ещё",
|
||||
"newRag.slugInvalid": "Используйте только строчные латинские буквы, цифры и одиночные дефисы.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "например, product-support",
|
||||
"noExternalKnowledge": "У нас еще нет External Knowledge API, нажмите здесь, чтобы создать",
|
||||
"parentMode.fullDoc": "Полный документ",
|
||||
"parentMode.paragraph": "Параграф",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Model za ponovno razvrščanje je potreben za mešanico baz znanja visoke kakovosti in varčnih baz.",
|
||||
"mixtureInternalAndExternalTip": "Model za ponovno razvrščanje je potreben za mešanico notranjega in zunanjega znanja.",
|
||||
"multimodal": "Multimodalen",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Ustvarite prazno bazo znanja. Vire lahko dodate in iskanje konfigurirate pozneje.",
|
||||
"newRag.createDialogTitle": "Ustvari bazo znanja Dataset 2.0",
|
||||
"newRag.createFailed": "Baze znanja ni bilo mogoče ustvariti",
|
||||
"newRag.createSuccess": "Baza znanja ustvarjena",
|
||||
"newRag.disabledDescription": "Dataset 2.0 ni konfiguriran za ta delovni prostor.",
|
||||
"newRag.emptyDescription": "Ustvarite prazno bazo znanja, da začnete delati z Dataset 2.0.",
|
||||
"newRag.loadMore": "Naloži več",
|
||||
"newRag.slugInvalid": "Uporabite samo male črke, številke in posamezne vezaje.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "npr. product-support",
|
||||
"noExternalKnowledge": "Zunanjega API-ja za znanje še ni, kliknite tukaj za ustvarjanje",
|
||||
"parentMode.fullDoc": "Celoten dokument",
|
||||
"parentMode.paragraph": "Odstavek",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "โมเดล Rerank จําเป็นสําหรับการผสมผสานระหว่างฐานความรู้คุณภาพสูงและประหยัด",
|
||||
"mixtureInternalAndExternalTip": "โมเดล Rerank จําเป็นสําหรับการผสมผสานระหว่างความรู้ภายในและภายนอก",
|
||||
"multimodal": "หลายรูปแบบ",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "สร้างฐานความรู้เปล่า คุณสามารถเพิ่มแหล่งข้อมูลและกำหนดค่าการดึงข้อมูลภายหลังได้",
|
||||
"newRag.createDialogTitle": "สร้างฐานความรู้ Dataset 2.0",
|
||||
"newRag.createFailed": "ไม่สามารถสร้างฐานความรู้ได้",
|
||||
"newRag.createSuccess": "สร้างฐานความรู้แล้ว",
|
||||
"newRag.disabledDescription": "ยังไม่ได้กำหนดค่า Dataset 2.0 สำหรับพื้นที่ทำงานนี้",
|
||||
"newRag.emptyDescription": "สร้างฐานความรู้เปล่าเพื่อเริ่มใช้งาน Dataset 2.0",
|
||||
"newRag.loadMore": "โหลดเพิ่มเติม",
|
||||
"newRag.slugInvalid": "ใช้เฉพาะตัวอักษรภาษาอังกฤษพิมพ์เล็ก ตัวเลข และเครื่องหมายยัติภังค์เดี่ยวเท่านั้น",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "เช่น product-support",
|
||||
"noExternalKnowledge": "ยังไม่มี External Knowledge API คลิกที่นี่เพื่อสร้าง",
|
||||
"parentMode.fullDoc": "เอกสารฉบับเต็ม",
|
||||
"parentMode.paragraph": "วรรค",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Yüksek kaliteli ve ekonomik bilgi tabanlarının karışımı için Yeniden Sıralama modeli gereklidir.",
|
||||
"mixtureInternalAndExternalTip": "Yeniden Sıralama modeli, iç ve dış bilgilerin karışımı için gereklidir.",
|
||||
"multimodal": "Çok Modlu",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Boş bir bilgi tabanı oluşturun. Kaynakları daha sonra ekleyebilir ve geri alımı yapılandırabilirsiniz.",
|
||||
"newRag.createDialogTitle": "Dataset 2.0 bilgi tabanı oluştur",
|
||||
"newRag.createFailed": "Bilgi tabanı oluşturulamadı",
|
||||
"newRag.createSuccess": "Bilgi tabanı oluşturuldu",
|
||||
"newRag.disabledDescription": "Dataset 2.0 bu çalışma alanı için yapılandırılmamış.",
|
||||
"newRag.emptyDescription": "Dataset 2.0 ile çalışmaya başlamak için boş bir bilgi tabanı oluşturun.",
|
||||
"newRag.loadMore": "Daha fazla yükle",
|
||||
"newRag.slugInvalid": "Yalnızca küçük harf, rakam ve tek tire kullanın.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "örn. product-support",
|
||||
"noExternalKnowledge": "Henüz Harici Bilgi API'si yok, oluşturmak için buraya tıklayın",
|
||||
"parentMode.fullDoc": "Tam doküman",
|
||||
"parentMode.paragraph": "Paragraf",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Модель перерангування потрібна для суміші високоякісних та економічних баз знань.",
|
||||
"mixtureInternalAndExternalTip": "Модель Rerank необхідна для поєднання внутрішніх і зовнішніх знань.",
|
||||
"multimodal": "Мультимодальний",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Створіть порожню базу знань. Джерела можна додати, а пошук — налаштувати пізніше.",
|
||||
"newRag.createDialogTitle": "Створити базу знань Dataset 2.0",
|
||||
"newRag.createFailed": "Не вдалося створити базу знань",
|
||||
"newRag.createSuccess": "Базу знань створено",
|
||||
"newRag.disabledDescription": "Для цього робочого простору не налаштовано Dataset 2.0.",
|
||||
"newRag.emptyDescription": "Створіть порожню базу знань, щоб почати роботу з Dataset 2.0.",
|
||||
"newRag.loadMore": "Завантажити ще",
|
||||
"newRag.slugInvalid": "Використовуйте лише малі латинські літери, цифри та одиночні дефіси.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "наприклад, product-support",
|
||||
"noExternalKnowledge": "API зовнішніх знань поки що не існує, натисніть тут, щоб створити",
|
||||
"parentMode.fullDoc": "Повний документ",
|
||||
"parentMode.paragraph": "Абзац",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "Mô hình xếp hạng lại là cần thiết cho sự kết hợp của các cơ sở kiến thức chất lượng cao và tiết kiệm.",
|
||||
"mixtureInternalAndExternalTip": "Mô hình Rerank là cần thiết cho sự kết hợp giữa kiến thức bên trong và bên ngoài.",
|
||||
"multimodal": "Đa phương thức",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "Tạo một cơ sở kiến thức trống. Bạn có thể thêm nguồn dữ liệu và cấu hình truy xuất sau.",
|
||||
"newRag.createDialogTitle": "Tạo cơ sở kiến thức Dataset 2.0",
|
||||
"newRag.createFailed": "Không thể tạo cơ sở kiến thức",
|
||||
"newRag.createSuccess": "Đã tạo cơ sở kiến thức",
|
||||
"newRag.disabledDescription": "Dataset 2.0 chưa được cấu hình cho không gian làm việc này.",
|
||||
"newRag.emptyDescription": "Tạo một cơ sở kiến thức trống để bắt đầu sử dụng Dataset 2.0.",
|
||||
"newRag.loadMore": "Tải thêm",
|
||||
"newRag.slugInvalid": "Chỉ sử dụng chữ cái Latin viết thường, chữ số và dấu gạch nối đơn.",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "ví dụ: product-support",
|
||||
"noExternalKnowledge": "Chưa có API Kiến thức Bên ngoài, hãy nhấp vào đây để tạo",
|
||||
"parentMode.fullDoc": "Tài liệu đầy đủ",
|
||||
"parentMode.paragraph": "Đoạn",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "混合使用高质量和经济型知识库需要配置 Rerank 模型。",
|
||||
"mixtureInternalAndExternalTip": "混合使用内部和外部知识时需要配置 Rerank 模型。",
|
||||
"multimodal": "多模态",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "创建一个空知识库。你可以稍后添加数据源并配置检索。",
|
||||
"newRag.createDialogTitle": "创建 Dataset 2.0 知识库",
|
||||
"newRag.createFailed": "创建知识库失败",
|
||||
"newRag.createSuccess": "知识库已创建",
|
||||
"newRag.disabledDescription": "此工作区尚未配置 Dataset 2.0。",
|
||||
"newRag.emptyDescription": "创建一个空知识库,开始使用 Dataset 2.0。",
|
||||
"newRag.loadMore": "加载更多",
|
||||
"newRag.slugInvalid": "仅可使用小写字母、数字和单个连字符。",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "例如 product-support",
|
||||
"noExternalKnowledge": "还没有外部知识库 API,点击此处创建",
|
||||
"parentMode.fullDoc": "全文",
|
||||
"parentMode.paragraph": "段落",
|
||||
|
||||
@@ -130,6 +130,17 @@
|
||||
"mixtureHighQualityAndEconomicTip": "混合高品質和經濟知識庫需要重新排序模型。",
|
||||
"mixtureInternalAndExternalTip": "Rerank 模型是內部和外部知識的混合所必需的。",
|
||||
"multimodal": "多模態",
|
||||
"newRag.badge": "Dataset 2.0",
|
||||
"newRag.createDialogDescription": "建立空白知識庫。您可以稍後新增資料來源並設定檢索方式。",
|
||||
"newRag.createDialogTitle": "建立 Dataset 2.0 知識庫",
|
||||
"newRag.createFailed": "建立知識庫失敗",
|
||||
"newRag.createSuccess": "知識庫已建立",
|
||||
"newRag.disabledDescription": "此工作區尚未設定 Dataset 2.0。",
|
||||
"newRag.emptyDescription": "建立空白知識庫,開始使用 Dataset 2.0。",
|
||||
"newRag.loadMore": "載入更多",
|
||||
"newRag.slugInvalid": "僅可使用小寫英文字母、數字和單一連字號。",
|
||||
"newRag.slugLabel": "Slug",
|
||||
"newRag.slugPlaceholder": "例如:product-support",
|
||||
"noExternalKnowledge": "目前還沒有外部知識 API,按兩下此處創建",
|
||||
"parentMode.fullDoc": "完整文件",
|
||||
"parentMode.paragraph": "段",
|
||||
|
||||
@@ -358,6 +358,36 @@ describe('consoleQuery transport context', () => {
|
||||
)
|
||||
expect(request.mock.calls[0]![0]).not.toContain('ids%5B0%5D')
|
||||
})
|
||||
|
||||
it('should route the KnowledgeFS contract through the Dify Console API', async () => {
|
||||
const request = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ items: [], nextCursor: 'next-page' }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const consoleQuery = await loadConsoleQueryWithRequest(request)
|
||||
const queryOptions = consoleQuery.knowledgeFs.listKnowledgeSpaces.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
cursor: 'current-page',
|
||||
limit: 30,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await queryOptions.queryFn({
|
||||
signal: new AbortController().signal,
|
||||
} as QueryFunctionContext)
|
||||
|
||||
const requestedURL = new URL(request.mock.calls[0]![0])
|
||||
expect(requestedURL.pathname).toBe('/console/api/knowledge-fs/knowledge-spaces')
|
||||
expect(requestedURL.searchParams.get('cursor')).toBe('current-page')
|
||||
expect(requestedURL.searchParams.get('limit')).toBe('30')
|
||||
expect(result).toEqual({ items: [], nextCursor: 'next-page' })
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: console OpenAPI query arrays follow backend parser expectations.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentAppPagination } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { consoleRouterContract } from '@dify/contracts/api/console/router.gen'
|
||||
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
|
||||
import type { consoleRouterContract } from '@dify/contracts/console'
|
||||
import type {
|
||||
GetReleaseResponse,
|
||||
ListReleasesResponse,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { notification } from '@dify/contracts/api/console/notification/orpc.gen'
|
||||
import { tags } from '@dify/contracts/api/console/tags/orpc.gen'
|
||||
import { workspaces } from '@dify/contracts/api/console/workspaces/orpc.gen'
|
||||
import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen'
|
||||
import { contract as knowledgeFsContract } from '@dify/contracts/knowledge-fs/orpc.gen'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { loadConsoleContractForSegment } from './console-router-loader'
|
||||
|
||||
@@ -32,4 +33,10 @@ describe('loadConsoleContractForSegment', () => {
|
||||
|
||||
expect(contract).toHaveProperty('enterprise', enterpriseContract)
|
||||
})
|
||||
|
||||
it('loads the KnowledgeFS contract through the Console client', async () => {
|
||||
const contract = await loadConsoleContractForSegment('knowledgeFs')
|
||||
|
||||
expect(contract).toHaveProperty('knowledgeFs', knowledgeFsContract)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,8 +16,14 @@ async function loadEnterpriseContract(): Promise<AnyContractRouter> {
|
||||
return { enterprise: contract }
|
||||
}
|
||||
|
||||
async function loadKnowledgeFsContract(): Promise<AnyContractRouter> {
|
||||
const { contract } = await import('@dify/contracts/knowledge-fs/orpc.gen')
|
||||
return { knowledgeFs: contract }
|
||||
}
|
||||
|
||||
export async function loadConsoleContractForSegment(segment: string) {
|
||||
if (segment === 'enterprise') return loadEnterpriseContract()
|
||||
if (segment === 'knowledgeFs') return loadKnowledgeFsContract()
|
||||
|
||||
const generatedContract = await loadGeneratedConsoleContract(segment)
|
||||
if (generatedContract) return generatedContract
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { consoleRouterContract } from '@dify/contracts/api/console/router.gen'
|
||||
import type { consoleRouterContract } from '@dify/contracts/console'
|
||||
import type { ClientLink } from '@orpc/client'
|
||||
import type { AnyContractRouter, ContractRouterClient } from '@orpc/contract'
|
||||
import type { JsonifiedClient } from '@orpc/openapi-client'
|
||||
|
||||
Reference in New Issue
Block a user