Compare commits

...
25 Commits
Author SHA1 Message Date
Stephen Zhou 8a732b5c38 refactor(dataset): remove unused response limit path 2026-07-17 01:06:14 +08:00
Stephen Zhou 7dc3b3e361 chore(dataset): remove unrelated diff noise 2026-07-17 01:00:01 +08:00
Stephen Zhou 7753413230 chore(dataset): update KnowledgeFS contract pin 2026-07-17 00:47:39 +08:00
Stephen Zhou 2de8d78126 fix(dataset): align KnowledgeFS proxy contract 2026-07-17 00:47:31 +08:00
Stephen Zhou 1aa0b17e16 chore(dataset): update KnowledgeFS contract pin 2026-07-17 00:27:31 +08:00
Stephen Zhou 3317b573ab fix(dataset): consolidate KnowledgeFS proxy safeguards 2026-07-17 00:26:40 +08:00
Stephen Zhou 07076ad3cb fix(dataset): bind KnowledgeFS access to accounts 2026-07-16 23:24:35 +08:00
Stephen Zhou 91294e7a61 fix(dataset): enforce KnowledgeFS security boundaries 2026-07-16 22:22:48 +08:00
Stephen Zhou e14fbe2069 fix(dataset): align KnowledgeFS stream safeguards 2026-07-16 21:24:29 +08:00
Stephen Zhou 7ee11ae7a1 fix(dataset): harden KnowledgeFS transport contract 2026-07-16 21:19:23 +08:00
Stephen Zhou 522d3a9d6a chore(dataset): update KnowledgeFS contract pin 2026-07-16 19:15:17 +08:00
Stephen Zhou ee21c0a34c fix(dataset): publish complete KnowledgeFS contract 2026-07-16 19:12:13 +08:00
Stephen Zhou 370232fb26 fix(dataset): reject compressed KnowledgeFS responses 2026-07-16 18:02:51 +08:00
Stephen Zhou 6315069b54 fix(dataset): bound KnowledgeFS proxy responses 2026-07-16 17:56:52 +08:00
Stephen Zhou e09b51bde2 fix(dataset): harden KnowledgeFS proxy boundary 2026-07-16 17:27:13 +08:00
Stephen Zhou 225678a98d refactor(dataset): simplify KnowledgeFS authentication 2026-07-16 16:57:59 +08:00
Stephen Zhou 9e4bf2a664 feat(dataset): authenticate KnowledgeFS per workspace 2026-07-16 16:47:42 +08:00
Stephen Zhou e208980725 fix(dataset): stabilize KnowledgeFS integration contract 2026-07-16 15:19:01 +08:00
Stephen Zhou 4c7f06e229 refactor(dataset): use a generic KnowledgeFS proxy 2026-07-16 13:16:25 +08:00
Stephen Zhou 7c5f062931 refactor(dataset): proxy KnowledgeFS requests 2026-07-16 12:51:41 +08:00
Stephen Zhou 0e1277ea7e refactor(dataset): simplify KnowledgeFS bridge 2026-07-16 11:47:52 +08:00
Stephen Zhou d5e0eefb93 fix(dataset): align KnowledgeFS integration contract 2026-07-16 11:09:11 +08:00
autofix-ci[bot]andGitHub edddd27cdd [autofix.ci] apply automated fixes 2026-07-16 02:13:46 +00:00
Stephen Zhou 1de331f700 Merge branch 'main' into 0715-new-rag 2026-07-16 09:51:39 +08:00
Stephen Zhou bf2ecd97f7 feat(dataset): add KnowledgeFS-backed Dataset 2.0 2026-07-15 22:49:42 +08:00
56 changed files with 23851 additions and 26 deletions
+1
View File
@@ -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
+7
View File
@@ -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
+2
View File
@@ -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,
):
+52
View File
@@ -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
+2
View File
@@ -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)
+91 -2
View File
@@ -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
+369
View File
@@ -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
+7
View File
@@ -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',
},
],
})
+12 -1
View File
@@ -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 />
}
+13
View File
@@ -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>
)
}
+194
View File
@@ -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>
)
}
+11
View File
@@ -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": "فقرة",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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": "پاراگراف",
+11
View File
@@ -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 nest 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 dunion simples.",
"newRag.slugLabel": "Slug",
"newRag.slugPlaceholder": "p. ex. product-support",
"noExternalKnowledge": "Il ny a pas encore dAPI de connaissances externes, cliquez ici pour créer",
"parentMode.fullDoc": "Doc complet",
"parentMode.paragraph": "Paragraphe",
+11
View File
@@ -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": "अनुच्‍छेद",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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": "段落",
+11
View File
@@ -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": "단락",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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": "Параграф",
+11
View File
@@ -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",
+11
View File
@@ -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": "วรรค",
+11
View File
@@ -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",
+11
View File
@@ -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": "Абзац",
+11
View File
@@ -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",
+11
View File
@@ -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": "段落",
+11
View File
@@ -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": "段",
+30
View File
@@ -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 -1
View File
@@ -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)
})
})
+6
View File
@@ -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 -1
View File
@@ -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'