mirror of
https://github.com/langgenius/dify.git
synced 2026-07-07 22:07:35 +08:00
Compare commits
24 Commits
copilot/fi
...
0707-app-c
| Author | SHA1 | Date | |
|---|---|---|---|
| a0d4b7efb3 | |||
| 9dc017052d | |||
| c789d052d3 | |||
| 60640519b5 | |||
| 18a9761a4a | |||
| a4b7b6f6ba | |||
| 691aa5f31d | |||
| 502126516b | |||
| da6e11d807 | |||
| 9adcf51659 | |||
| 724ec31edb | |||
| 2c6ec1a761 | |||
| 31b17513c2 | |||
| fb92e9a347 | |||
| 6922c45489 | |||
| faaa4708a6 | |||
| dd0c4a2296 | |||
| f3ba28463b | |||
| 800c9f4fca | |||
| 5a342f9258 | |||
| dbd3316615 | |||
| 0a3426ea38 | |||
| b9c7199d34 | |||
| fc01d112a0 |
@ -24,6 +24,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
|
||||
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
|
||||
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
|
||||
- Preserve visible keyboard focus states on the final focusable element. Prefer styled `@langgenius/dify-ui/*` controls when available, because components such as `Button` and form/control primitives carry the standard Dify UI `focus-visible` styling. Do not assume every Dify UI export provides visual focus styles: headless anatomy parts and direct Base UI re-exports such as dialog/popover/tooltip/drawer triggers usually only provide behavior and semantics. When using native `button` / `a`, custom trigger `render` props, clickable rows, icon buttons, menu-like items, or direct trigger parts, verify the rendered focusable element has a visible focus state. If it does not, add the standard Dify UI focus style: `outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid`. Do not hide outlines without an equivalent visible `focus-visible` indicator. Component-specific focus styles should follow an existing styled primitive pattern or a concrete design constraint, not a new ad hoc style.
|
||||
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
|
||||
- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README.
|
||||
- Module README whitelist: `@/service/client`, `@/next/*`.
|
||||
|
||||
2
.github/scripts/check-hotfix-cherry-picks.sh
vendored
2
.github/scripts/check-hotfix-cherry-picks.sh
vendored
@ -45,7 +45,7 @@ while IFS= read -r commit_sha; do
|
||||
)
|
||||
|
||||
if [[ -z "$source_sha" ]]; then
|
||||
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
|
||||
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT If version differences prevent using git cherry-pick -x, manually add '(cherry picked from commit <sha>)' to the commit message."
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
@ -188,13 +188,13 @@ def transform_datasource_credentials(environment: str):
|
||||
firecrawl_plugin_id = "langgenius/firecrawl_datasource"
|
||||
jina_plugin_id = "langgenius/jina_datasource"
|
||||
if environment == "online":
|
||||
notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id)
|
||||
firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id)
|
||||
jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id)
|
||||
notion_package_identifier = plugin_migration._fetch_latest_package_identifier(notion_plugin_id)
|
||||
firecrawl_package_identifier = plugin_migration._fetch_latest_package_identifier(firecrawl_plugin_id)
|
||||
jina_package_identifier = plugin_migration._fetch_latest_package_identifier(jina_plugin_id)
|
||||
else:
|
||||
notion_plugin_unique_identifier = None
|
||||
firecrawl_plugin_unique_identifier = None
|
||||
jina_plugin_unique_identifier = None
|
||||
notion_package_identifier = None
|
||||
firecrawl_package_identifier = None
|
||||
jina_package_identifier = None
|
||||
oauth_credential_type = CredentialType.OAUTH2
|
||||
api_key_credential_type = CredentialType.API_KEY
|
||||
|
||||
@ -219,9 +219,9 @@ def transform_datasource_credentials(environment: str):
|
||||
installed_plugins = installer_manager.list_plugins(tenant_id)
|
||||
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
|
||||
if notion_plugin_id not in installed_plugins_ids:
|
||||
if notion_plugin_unique_identifier:
|
||||
if notion_package_identifier:
|
||||
# install notion plugin
|
||||
PluginService.install_from_marketplace_pkg(tenant_id, [notion_plugin_unique_identifier])
|
||||
PluginService.install_from_marketplace_pkg(tenant_id, [notion_package_identifier])
|
||||
auth_count = 0
|
||||
for notion_tenant_credential in notion_tenant_credentials:
|
||||
auth_count += 1
|
||||
@ -279,9 +279,9 @@ def transform_datasource_credentials(environment: str):
|
||||
installed_plugins = installer_manager.list_plugins(tenant_id)
|
||||
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
|
||||
if firecrawl_plugin_id not in installed_plugins_ids:
|
||||
if firecrawl_plugin_unique_identifier:
|
||||
if firecrawl_package_identifier:
|
||||
# install firecrawl plugin
|
||||
PluginService.install_from_marketplace_pkg(tenant_id, [firecrawl_plugin_unique_identifier])
|
||||
PluginService.install_from_marketplace_pkg(tenant_id, [firecrawl_package_identifier])
|
||||
|
||||
auth_count = 0
|
||||
for firecrawl_tenant_credential in firecrawl_tenant_credentials:
|
||||
@ -343,10 +343,10 @@ def transform_datasource_credentials(environment: str):
|
||||
installed_plugins = installer_manager.list_plugins(tenant_id)
|
||||
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
|
||||
if jina_plugin_id not in installed_plugins_ids:
|
||||
if jina_plugin_unique_identifier:
|
||||
if jina_package_identifier:
|
||||
# install jina plugin
|
||||
logger.debug("Installing Jina plugin %s", jina_plugin_unique_identifier)
|
||||
PluginService.install_from_marketplace_pkg(tenant_id, [jina_plugin_unique_identifier])
|
||||
logger.debug("Installing Jina plugin %s", jina_package_identifier)
|
||||
PluginService.install_from_marketplace_pkg(tenant_id, [jina_package_identifier])
|
||||
|
||||
auth_count = 0
|
||||
for jina_tenant_credential in jina_tenant_credentials:
|
||||
|
||||
@ -32,7 +32,9 @@ def get_published_agent_app_feature_dict_and_user_input_form(
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
if not agent.active_config_snapshot_id or not agent.active_config_is_published:
|
||||
# active_config_is_published means the draft has no unpublished edits; the public app
|
||||
# can still read parameters from the active snapshot while a newer draft is pending.
|
||||
if not agent.active_config_snapshot_id:
|
||||
raise AgentAppNotPublishedError("Agent has not been published")
|
||||
|
||||
snapshot = db.session.scalar(
|
||||
|
||||
@ -56,7 +56,7 @@ from models.account import Account
|
||||
from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase
|
||||
from services.errors.account import AccountRegisterError
|
||||
from services.errors.account import AccountRegisterError, RefreshTokenAccountNotFoundError, RefreshTokenNotFoundError
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
@ -359,18 +359,22 @@ class RefreshTokenApi(Resource):
|
||||
|
||||
try:
|
||||
new_token_pair = AccountService.refresh_token(refresh_token, session=db.session)
|
||||
except Unauthorized as exc:
|
||||
return SimpleResultMessageResponse(result="fail", message=exc.description or "Unauthorized.").model_dump(
|
||||
mode="json"
|
||||
), 401
|
||||
except (RefreshTokenNotFoundError, RefreshTokenAccountNotFoundError) as exc:
|
||||
return SimpleResultMessageResponse(result="fail", message=str(exc)).model_dump(mode="json"), 401
|
||||
|
||||
# Create response with new cookies
|
||||
# response-contract:ignore cookie-bearing Flask response
|
||||
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
|
||||
# Create response with new cookies
|
||||
# response-contract:ignore cookie-bearing Flask response
|
||||
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
|
||||
|
||||
# Update cookies with new tokens
|
||||
set_csrf_token_to_cookie(request, response, new_token_pair.csrf_token)
|
||||
set_access_token_to_cookie(request, response, new_token_pair.access_token)
|
||||
set_refresh_token_to_cookie(request, response, new_token_pair.refresh_token)
|
||||
return response
|
||||
except Exception as e:
|
||||
return SimpleResultMessageResponse(result="fail", message=str(e)).model_dump(mode="json"), 401
|
||||
# Update cookies with new tokens
|
||||
set_csrf_token_to_cookie(request, response, new_token_pair.csrf_token)
|
||||
set_access_token_to_cookie(request, response, new_token_pair.access_token)
|
||||
set_refresh_token_to_cookie(request, response, new_token_pair.refresh_token)
|
||||
return response
|
||||
|
||||
|
||||
def _get_account_with_case_fallback(email: str):
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from typing import Any, Union
|
||||
|
||||
from flask import Response
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import select
|
||||
@ -9,7 +9,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.mcp import mcp_ns
|
||||
from core.mcp import types as mcp_types
|
||||
from core.mcp.server.streamable_http import handle_mcp_request
|
||||
from core.mcp.server.streamable_http import handle_mcp_request, negotiate_protocol_version
|
||||
from extensions.ext_database import db
|
||||
from graphon.variables.input_entities import VariableEntity, VariableEntityType
|
||||
from libs import helper
|
||||
@ -68,6 +68,17 @@ class MCPAppApi(Resource):
|
||||
request_id: Union[int, str] | None = args.id
|
||||
mcp_request = self._parse_mcp_request(args.model_dump(exclude_none=True))
|
||||
|
||||
# Resolve the negotiated protocol version from the MCP-Protocol-Version header.
|
||||
is_initialize = isinstance(mcp_request.root, mcp_types.InitializeRequest)
|
||||
header_value = request.headers.get("MCP-Protocol-Version")
|
||||
protocol_version = negotiate_protocol_version(header_value, is_initialize)
|
||||
if protocol_version is None:
|
||||
# A notification never receives a response, even with an unsupported header.
|
||||
if isinstance(mcp_request, mcp_types.ClientNotification):
|
||||
protocol_version = mcp_types.DEFAULT_NEGOTIATED_VERSION
|
||||
else:
|
||||
return self._protocol_version_error_response(request_id, header_value)
|
||||
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
# Get MCP server and app
|
||||
mcp_server, app = self._get_mcp_server_and_app(server_code, session)
|
||||
@ -77,7 +88,28 @@ class MCPAppApi(Resource):
|
||||
user_input_form = self._get_user_input_form(app)
|
||||
|
||||
# Handle notification vs request differently
|
||||
return self._process_mcp_message(mcp_request, request_id, app, mcp_server, user_input_form, session)
|
||||
return self._process_mcp_message(
|
||||
mcp_request, request_id, app, mcp_server, user_input_form, session, protocol_version
|
||||
)
|
||||
|
||||
def _protocol_version_error_response(
|
||||
self, request_id: Union[int, str] | None, header_value: str | None
|
||||
) -> Response:
|
||||
"""Return a JSON-RPC error for an unsupported MCP-Protocol-Version header.
|
||||
|
||||
Per JSON-RPC 2.0, an error whose request id is unknown uses a null id, so we echo the
|
||||
offending request's id directly (None -> null) instead of fabricating a placeholder.
|
||||
"""
|
||||
error_data = mcp_types.ErrorData(
|
||||
code=mcp_types.INVALID_REQUEST,
|
||||
message=f"Unsupported MCP-Protocol-Version: {header_value}",
|
||||
)
|
||||
error_response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": error_data.model_dump(by_alias=True, mode="json", exclude_none=True),
|
||||
}
|
||||
return helper.compact_generate_response(error_response)
|
||||
|
||||
def _get_mcp_server_and_app(self, server_code: str, session: Session) -> tuple[AppMCPServer, App]:
|
||||
"""Get and validate MCP server and app in one query session"""
|
||||
@ -104,12 +136,15 @@ class MCPAppApi(Resource):
|
||||
mcp_server: AppMCPServer,
|
||||
user_input_form: list[VariableEntity],
|
||||
session: Session,
|
||||
protocol_version: str,
|
||||
) -> Response:
|
||||
"""Process MCP message (notification or request)"""
|
||||
if isinstance(mcp_request, mcp_types.ClientNotification):
|
||||
return self._handle_notification(mcp_request)
|
||||
else:
|
||||
return self._handle_request(mcp_request, request_id, app, mcp_server, user_input_form, session)
|
||||
return self._handle_request(
|
||||
mcp_request, request_id, app, mcp_server, user_input_form, session, protocol_version
|
||||
)
|
||||
|
||||
def _handle_notification(self, mcp_request: mcp_types.ClientNotification) -> Response:
|
||||
"""Handle MCP notification"""
|
||||
@ -127,12 +162,15 @@ class MCPAppApi(Resource):
|
||||
mcp_server: AppMCPServer,
|
||||
user_input_form: list[VariableEntity],
|
||||
session: Session,
|
||||
protocol_version: str,
|
||||
) -> Response:
|
||||
"""Handle MCP request"""
|
||||
if request_id is None:
|
||||
raise MCPRequestError(mcp_types.INVALID_REQUEST, "Request ID is required")
|
||||
|
||||
result = self._handle_mcp_request(app, mcp_server, mcp_request, user_input_form, session, request_id)
|
||||
result = self._handle_mcp_request(
|
||||
app, mcp_server, mcp_request, user_input_form, session, request_id, protocol_version
|
||||
)
|
||||
if result is None:
|
||||
# This shouldn't happen for requests, but handle gracefully
|
||||
raise MCPRequestError(mcp_types.INTERNAL_ERROR, "No response generated for request")
|
||||
@ -229,6 +267,7 @@ class MCPAppApi(Resource):
|
||||
user_input_form: list[VariableEntity],
|
||||
session: Session,
|
||||
request_id: Union[int, str],
|
||||
protocol_version: str,
|
||||
) -> mcp_types.JSONRPCResponse | mcp_types.JSONRPCError | None:
|
||||
"""Handle MCP request and return response"""
|
||||
end_user = self._retrieve_end_user(mcp_server.tenant_id, mcp_server.id)
|
||||
@ -238,4 +277,6 @@ class MCPAppApi(Resource):
|
||||
client_name = f"{client_info.name}@{client_info.version}"
|
||||
end_user = self._create_end_user(client_name, app.tenant_id, app.id, mcp_server.id, session)
|
||||
|
||||
return handle_mcp_request(session, app, mcp_request, user_input_form, mcp_server, end_user, request_id)
|
||||
return handle_mcp_request(
|
||||
session, app, mcp_request, user_input_form, mcp_server, end_user, request_id, protocol_version
|
||||
)
|
||||
|
||||
@ -611,7 +611,9 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
"build_draft" if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD else "draft"
|
||||
)
|
||||
return agent, draft.id, config_version_kind, agent_soul
|
||||
if not agent.active_config_snapshot_id or not agent.active_config_is_published:
|
||||
# active_config_is_published tracks whether the editable draft matches the active snapshot.
|
||||
# Public runtime must keep serving the active snapshot even when unpublished draft edits exist.
|
||||
if not agent.active_config_snapshot_id:
|
||||
raise AgentAppNotPublishedError("Agent has not been published")
|
||||
_, snapshot, agent_soul = self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id,
|
||||
|
||||
@ -13,7 +13,7 @@ from core.helper.code_executor.jinja2.jinja2_transformer import Jinja2TemplateTr
|
||||
from core.helper.code_executor.python3.python3_transformer import Python3TemplateTransformer
|
||||
from core.helper.code_executor.template_transformer import TemplateTransformer
|
||||
from core.helper.http_client_pooling import get_pooled_http_client
|
||||
from graphon.nodes.code.entities import CodeLanguage
|
||||
from graphon.nodes.code.entities import CodeLanguage as CodeLanguage # noqa: PLC0414
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
code_execution_endpoint_url = URL(str(dify_config.CODE_EXECUTION_ENDPOINT))
|
||||
@ -133,7 +133,9 @@ class CodeExecutor:
|
||||
return response_code.data.stdout or ""
|
||||
|
||||
@classmethod
|
||||
def execute_workflow_code_template(cls, language: CodeLanguage, code: str, inputs: Mapping[str, Any]):
|
||||
def execute_workflow_code_template(
|
||||
cls, language: CodeLanguage, code: str, inputs: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Execute code
|
||||
:param language: code language
|
||||
|
||||
@ -11,7 +11,7 @@ class Jinja2TemplateTransformer(TemplateTransformer):
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def transform_response(cls, response: str):
|
||||
def transform_response(cls, response: str) -> dict[str, Any]:
|
||||
"""
|
||||
Transform response to dict
|
||||
:param response: response
|
||||
|
||||
@ -36,14 +36,14 @@ class TemplateTransformer(ABC):
|
||||
return runner_script, preload_script
|
||||
|
||||
@classmethod
|
||||
def extract_result_str_from_response(cls, response: str):
|
||||
def extract_result_str_from_response(cls, response: str) -> str:
|
||||
result = re.search(rf"{cls._result_tag}(.*){cls._result_tag}", response, re.DOTALL)
|
||||
if not result:
|
||||
raise ValueError(f"Failed to parse result: no result tag found in response. Response: {response[:200]}...")
|
||||
return result.group(1)
|
||||
|
||||
@classmethod
|
||||
def transform_response(cls, response: str) -> Mapping[str, Any]:
|
||||
def transform_response(cls, response: str) -> dict[str, Any]:
|
||||
"""
|
||||
Transform response to dict
|
||||
:param response: response
|
||||
@ -71,7 +71,7 @@ class TemplateTransformer(ABC):
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _post_process_result(cls, result: dict[Any, Any]) -> dict[Any, Any]:
|
||||
def _post_process_result(cls, result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Post-process the result to convert scientific notation strings back to numbers
|
||||
"""
|
||||
@ -89,7 +89,7 @@ class TemplateTransformer(ABC):
|
||||
return [convert_scientific_notation(v) for v in value]
|
||||
return value
|
||||
|
||||
return convert_scientific_notation(result)
|
||||
return {key: convert_scientific_notation(value) for key, value in result.items()}
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
|
||||
@ -24,7 +24,7 @@ def upload_dsl(dsl_file_bytes: bytes, filename: str = "template.yaml") -> str:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
claim_code = data.get("data", {}).get("claim_code")
|
||||
if not claim_code:
|
||||
if not isinstance(claim_code, str) or not claim_code:
|
||||
raise ValueError("Creators Platform did not return a valid claim_code")
|
||||
return claim_code
|
||||
|
||||
|
||||
@ -45,7 +45,7 @@ def is_credential_exists(credential_id: str, credential_type: "PluginCredentialT
|
||||
|
||||
def runtime_check_credential_policy_compliance(
|
||||
credential_id: str, provider: str, credential_type: "PluginCredentialType", check_existence: bool = True
|
||||
):
|
||||
) -> None:
|
||||
if dify_config.ENTERPRISE_DISABLE_RUNTIME_CREDENTIAL_CHECK:
|
||||
return
|
||||
check_credential_policy_compliance(
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
def download_with_size_limit(url, max_download_size: int, **kwargs):
|
||||
from typing import Any
|
||||
|
||||
|
||||
def download_with_size_limit(url: str, max_download_size: int, **kwargs: Any) -> bytes:
|
||||
from core.file import remote_fetcher
|
||||
|
||||
response = remote_fetcher.make_request("GET", url, follow_redirects=True, **kwargs)
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import base64
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
from libs import rsa
|
||||
|
||||
|
||||
@ -11,13 +13,13 @@ def obfuscated_token(token: str) -> str:
|
||||
return token[:6] + "*" * 12 + token[-2:]
|
||||
|
||||
|
||||
def full_mask_token(token_length=20):
|
||||
def full_mask_token(token_length: int = 20) -> str:
|
||||
return "*" * token_length
|
||||
|
||||
|
||||
def encrypt_token(tenant_id: str, token: str):
|
||||
from extensions.ext_database import db
|
||||
def encrypt_token(tenant_id: str, token: str) -> str:
|
||||
from models.account import Tenant
|
||||
from models.engine import db
|
||||
|
||||
if not (tenant := db.session.get(Tenant, tenant_id)):
|
||||
raise ValueError(f"Tenant with id {tenant_id} not found")
|
||||
@ -30,15 +32,15 @@ def decrypt_token(tenant_id: str, token: str) -> str:
|
||||
return rsa.decrypt(base64.b64decode(token), tenant_id)
|
||||
|
||||
|
||||
def batch_decrypt_token(tenant_id: str, tokens: list[str]):
|
||||
def batch_decrypt_token(tenant_id: str, tokens: list[str]) -> list[str]:
|
||||
rsa_key, cipher_rsa = rsa.get_decrypt_decoding(tenant_id)
|
||||
|
||||
return [rsa.decrypt_token_with_decoding(base64.b64decode(token), rsa_key, cipher_rsa) for token in tokens]
|
||||
|
||||
|
||||
def get_decrypt_decoding(tenant_id: str):
|
||||
def get_decrypt_decoding(tenant_id: str) -> tuple[RSA.RsaKey, object]:
|
||||
return rsa.get_decrypt_decoding(tenant_id)
|
||||
|
||||
|
||||
def decrypt_token_with_decoding(token: str, rsa_key, cipher_rsa):
|
||||
def decrypt_token_with_decoding(token: str, rsa_key: RSA.RsaKey, cipher_rsa: object) -> str:
|
||||
return rsa.decrypt_token_with_decoding(base64.b64decode(token), rsa_key, cipher_rsa)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
@ -21,7 +22,7 @@ def get_plugin_pkg_url(plugin_unique_identifier: str) -> str:
|
||||
return f"{marketplace_api_url / 'api/v1/plugins/download'}?{query}"
|
||||
|
||||
|
||||
def download_plugin_pkg(plugin_unique_identifier: str):
|
||||
def download_plugin_pkg(plugin_unique_identifier: str) -> bytes:
|
||||
return download_with_size_limit(get_plugin_pkg_url(plugin_unique_identifier), dify_config.PLUGIN_MAX_PACKAGE_SIZE)
|
||||
|
||||
|
||||
@ -41,7 +42,7 @@ def batch_fetch_plugin_manifests(plugin_ids: list[str]) -> Sequence[MarketplaceP
|
||||
return [MarketplacePluginDeclaration.model_validate(plugin) for plugin in response.json()["data"]["plugins"]]
|
||||
|
||||
|
||||
def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]:
|
||||
def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict[str, Any]]:
|
||||
if not plugin_ids:
|
||||
return []
|
||||
|
||||
@ -55,10 +56,19 @@ def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]:
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
return data.get("data", {}).get("plugins", [])
|
||||
plugins = data.get("data", {}).get("plugins", [])
|
||||
if not isinstance(plugins, list):
|
||||
raise ValueError("Marketplace did not return a valid plugins list")
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for plugin in plugins:
|
||||
if not isinstance(plugin, dict) or not all(isinstance(key, str) for key in plugin):
|
||||
raise ValueError("Marketplace did not return a valid plugins list")
|
||||
result.append(plugin)
|
||||
return result
|
||||
|
||||
|
||||
def record_install_plugin_event(plugin_unique_identifier: str):
|
||||
def record_install_plugin_event(plugin_unique_identifier: str) -> None:
|
||||
url = str(marketplace_api_url / "api/v1/stats/plugins/install_count")
|
||||
response = httpx.post(url, json={"unique_identifier": plugin_unique_identifier}, timeout=MARKETPLACE_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
|
||||
@ -34,7 +34,7 @@ class ProviderCredentialsCache:
|
||||
else:
|
||||
return None
|
||||
|
||||
def set(self, credentials: dict[str, Any]):
|
||||
def set(self, credentials: dict[str, Any]) -> None:
|
||||
"""
|
||||
Cache model provider credentials.
|
||||
|
||||
@ -43,7 +43,7 @@ class ProviderCredentialsCache:
|
||||
"""
|
||||
redis_client.setex(self.cache_key, 86400, json.dumps(credentials))
|
||||
|
||||
def delete(self):
|
||||
def delete(self) -> None:
|
||||
"""
|
||||
Delete cached model provider credentials.
|
||||
|
||||
|
||||
@ -20,17 +20,18 @@ def import_module_from_source[T: (str, bytes)](
|
||||
raise Exception(f"Failed to load module {module_name} from {py_file_path!r}")
|
||||
else:
|
||||
# Refer to: https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
|
||||
# FIXME: mypy does not support the type of spec.loader
|
||||
spec = importlib.util.spec_from_file_location(module_name, py_file_path) # type: ignore[assignment]
|
||||
if not spec or not spec.loader:
|
||||
new_spec = importlib.util.spec_from_file_location(module_name, py_file_path)
|
||||
if not new_spec or not new_spec.loader:
|
||||
raise Exception(f"Failed to load module {module_name} from {py_file_path!r}")
|
||||
if use_lazy_loader:
|
||||
# Refer to: https://docs.python.org/3/library/importlib.html#implementing-lazy-imports
|
||||
spec.loader = importlib.util.LazyLoader(spec.loader)
|
||||
new_spec.loader = importlib.util.LazyLoader(new_spec.loader)
|
||||
spec = new_spec
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
if not existed_spec:
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
if spec.loader is not None:
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
except Exception as e:
|
||||
logger.exception("Failed to load module %s from script file '%s'", module_name, repr(py_file_path))
|
||||
|
||||
@ -9,11 +9,11 @@ from extensions.ext_redis import redis_client
|
||||
class ProviderCredentialsCache(ABC):
|
||||
"""Base class for provider credentials cache"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.cache_key = self._generate_cache_key(**kwargs)
|
||||
|
||||
@abstractmethod
|
||||
def _generate_cache_key(self, **kwargs) -> str:
|
||||
def _generate_cache_key(self, **kwargs: Any) -> str:
|
||||
"""Generate cache key based on subclass implementation"""
|
||||
pass
|
||||
|
||||
@ -28,11 +28,11 @@ class ProviderCredentialsCache(ABC):
|
||||
return None
|
||||
return None
|
||||
|
||||
def set(self, config: dict[str, Any]):
|
||||
def set(self, config: dict[str, Any]) -> None:
|
||||
"""Cache provider credentials"""
|
||||
redis_client.setex(self.cache_key, 86400, json.dumps(config))
|
||||
|
||||
def delete(self):
|
||||
def delete(self) -> None:
|
||||
"""Delete cached provider credentials"""
|
||||
redis_client.delete(self.cache_key)
|
||||
|
||||
@ -48,7 +48,7 @@ class SingletonProviderCredentialsCache(ProviderCredentialsCache):
|
||||
)
|
||||
|
||||
@override
|
||||
def _generate_cache_key(self, **kwargs) -> str:
|
||||
def _generate_cache_key(self, **kwargs: Any) -> str:
|
||||
tenant_id = kwargs["tenant_id"]
|
||||
provider_type = kwargs["provider_type"]
|
||||
identity_name = kwargs["provider_identity"]
|
||||
@ -63,7 +63,7 @@ class ToolProviderCredentialsCache(ProviderCredentialsCache):
|
||||
super().__init__(tenant_id=tenant_id, provider=provider, credential_id=credential_id)
|
||||
|
||||
@override
|
||||
def _generate_cache_key(self, **kwargs) -> str:
|
||||
def _generate_cache_key(self, **kwargs: Any) -> str:
|
||||
tenant_id = kwargs["tenant_id"]
|
||||
provider = kwargs["provider"]
|
||||
credential_id = kwargs["credential_id"]
|
||||
@ -77,10 +77,10 @@ class NoOpProviderCredentialCache:
|
||||
"""Get cached provider credentials"""
|
||||
return None
|
||||
|
||||
def set(self, config: dict[str, Any]):
|
||||
def set(self, config: dict[str, Any]) -> None:
|
||||
"""Cache provider credentials"""
|
||||
pass
|
||||
|
||||
def delete(self):
|
||||
def delete(self) -> None:
|
||||
"""Delete cached provider credentials"""
|
||||
pass
|
||||
|
||||
@ -125,5 +125,7 @@ class ProviderConfigEncrypter:
|
||||
return data
|
||||
|
||||
|
||||
def create_provider_encrypter(tenant_id: str, config: list[BasicProviderConfig], cache: ProviderConfigCache):
|
||||
def create_provider_encrypter(
|
||||
tenant_id: str, config: list[BasicProviderConfig], cache: ProviderConfigCache
|
||||
) -> tuple[ProviderConfigEncrypter, ProviderConfigCache]:
|
||||
return ProviderConfigEncrypter(tenant_id=tenant_id, config=config, provider_config_cache=cache), cache
|
||||
|
||||
@ -37,11 +37,11 @@ class ToolParameterCache:
|
||||
else:
|
||||
return None
|
||||
|
||||
def set(self, parameters: dict[str, Any]):
|
||||
def set(self, parameters: dict[str, Any]) -> None:
|
||||
"""Cache model provider credentials."""
|
||||
redis_client.setex(self.cache_key, 86400, json.dumps(parameters))
|
||||
|
||||
def delete(self):
|
||||
def delete(self) -> None:
|
||||
"""
|
||||
Delete cached model provider credentials.
|
||||
|
||||
|
||||
@ -61,7 +61,7 @@ def get_external_trace_id(request: Any) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def extract_external_trace_id_from_args(args: Mapping[str, Any]):
|
||||
def extract_external_trace_id_from_args(args: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Extract 'external_trace_id' from args.
|
||||
|
||||
|
||||
@ -15,6 +15,36 @@ from services.app_generate_service import AppGenerateService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Structured tool output (outputSchema + structuredContent) was introduced in MCP 2025-06-18.
|
||||
STRUCTURED_OUTPUT_MIN_VERSION = "2025-06-18"
|
||||
|
||||
|
||||
def _supports_structured_output(protocol_version: str) -> bool:
|
||||
"""Return True when the negotiated protocol version supports structured tool output.
|
||||
|
||||
MCP protocol versions are YYYY-MM-DD strings, so lexical comparison equals chronological.
|
||||
"""
|
||||
return protocol_version >= STRUCTURED_OUTPUT_MIN_VERSION
|
||||
|
||||
|
||||
def negotiate_protocol_version(header_value: str | None, is_initialize: bool) -> str | None:
|
||||
"""Resolve the negotiated protocol version for an incoming MCP request.
|
||||
|
||||
The version is taken from the MCP-Protocol-Version header on post-initialize requests.
|
||||
Returns the version to use for behavior gating, or None when the client sent an explicit
|
||||
but unsupported header (the caller should reply with a JSON-RPC INVALID_REQUEST error).
|
||||
Initialize requests negotiate via the request body, so they always receive
|
||||
DEFAULT_NEGOTIATED_VERSION and their header is never validated or rejected.
|
||||
"""
|
||||
if is_initialize:
|
||||
return mcp_types.DEFAULT_NEGOTIATED_VERSION
|
||||
# Treat an absent or empty header as "not specified" -> default version.
|
||||
if not header_value:
|
||||
return mcp_types.DEFAULT_NEGOTIATED_VERSION
|
||||
if header_value not in mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS:
|
||||
return None
|
||||
return header_value
|
||||
|
||||
|
||||
class ToolParameterSchemaDict(TypedDict):
|
||||
type: str
|
||||
@ -35,6 +65,7 @@ def handle_mcp_request(
|
||||
mcp_server: AppMCPServer,
|
||||
end_user: EndUser | None = None,
|
||||
request_id: int | str = 1,
|
||||
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
|
||||
) -> mcp_types.JSONRPCResponse | mcp_types.JSONRPCError:
|
||||
"""
|
||||
Handle MCP request and return JSON-RPC response
|
||||
@ -77,15 +108,24 @@ def handle_mcp_request(
|
||||
# Dispatch request to appropriate handler based on instance type
|
||||
match request_root:
|
||||
case mcp_types.InitializeRequest():
|
||||
return create_success_response(handle_initialize(mcp_server.description))
|
||||
return create_success_response(
|
||||
handle_initialize(mcp_server.description, request_root.params.protocolVersion)
|
||||
)
|
||||
case mcp_types.ListToolsRequest():
|
||||
return create_success_response(
|
||||
handle_list_tools(
|
||||
app.name, app.mode, user_input_form, mcp_server.description, mcp_server.parameters_dict
|
||||
app.name,
|
||||
app.mode,
|
||||
user_input_form,
|
||||
mcp_server.description,
|
||||
mcp_server.parameters_dict,
|
||||
protocol_version,
|
||||
)
|
||||
)
|
||||
case mcp_types.CallToolRequest():
|
||||
return create_success_response(handle_call_tool(session, app, request, user_input_form, end_user))
|
||||
return create_success_response(
|
||||
handle_call_tool(session, app, request, user_input_form, end_user, protocol_version)
|
||||
)
|
||||
case mcp_types.PingRequest():
|
||||
return create_success_response(handle_ping())
|
||||
case _:
|
||||
@ -104,14 +144,22 @@ def handle_ping() -> mcp_types.EmptyResult:
|
||||
return mcp_types.EmptyResult()
|
||||
|
||||
|
||||
def handle_initialize(description: str) -> mcp_types.InitializeResult:
|
||||
"""Handle initialize request"""
|
||||
def handle_initialize(description: str, requested_version: str | int) -> mcp_types.InitializeResult:
|
||||
"""Handle initialize request, negotiating the protocol version with the client.
|
||||
|
||||
Echoes the client's requested version when the server supports it, otherwise returns the
|
||||
server's latest supported version (per the MCP lifecycle spec).
|
||||
"""
|
||||
negotiated_version: str = mcp_types.SERVER_LATEST_PROTOCOL_VERSION
|
||||
if isinstance(requested_version, str) and requested_version in mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS:
|
||||
negotiated_version = requested_version
|
||||
|
||||
capabilities = mcp_types.ServerCapabilities(
|
||||
tools=mcp_types.ToolsCapability(listChanged=False),
|
||||
)
|
||||
|
||||
return mcp_types.InitializeResult(
|
||||
protocolVersion=mcp_types.SERVER_LATEST_PROTOCOL_VERSION,
|
||||
protocolVersion=negotiated_version,
|
||||
capabilities=capabilities,
|
||||
serverInfo=mcp_types.Implementation(name="Dify", version=dify_config.project.version),
|
||||
instructions=description,
|
||||
@ -124,19 +172,23 @@ def handle_list_tools(
|
||||
user_input_form: list[VariableEntity],
|
||||
description: str,
|
||||
parameters_dict: dict[str, str],
|
||||
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
|
||||
) -> mcp_types.ListToolsResult:
|
||||
"""Handle list tools request"""
|
||||
parameter_schema = build_parameter_schema(app_mode, user_input_form, parameters_dict)
|
||||
supports_structured = _supports_structured_output(protocol_version)
|
||||
|
||||
return mcp_types.ListToolsResult(
|
||||
tools=[
|
||||
mcp_types.Tool(
|
||||
name=app_name,
|
||||
description=description,
|
||||
inputSchema=cast(dict[str, Any], parameter_schema),
|
||||
)
|
||||
],
|
||||
# For 2025-06-18+ clients, expose an explicit display title and a permissive output
|
||||
# schema. Both stay None (and are stripped by exclude_none serialization) for older
|
||||
# clients, so their tool definition is unchanged.
|
||||
tool = mcp_types.Tool(
|
||||
name=app_name,
|
||||
title=app_name if supports_structured else None,
|
||||
description=description,
|
||||
inputSchema=cast(dict[str, Any], parameter_schema),
|
||||
outputSchema={"type": "object"} if supports_structured else None,
|
||||
)
|
||||
return mcp_types.ListToolsResult(tools=[tool])
|
||||
|
||||
|
||||
def handle_call_tool(
|
||||
@ -145,6 +197,7 @@ def handle_call_tool(
|
||||
request: mcp_types.ClientRequest,
|
||||
user_input_form: list[VariableEntity],
|
||||
end_user: EndUser | None,
|
||||
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
|
||||
) -> mcp_types.CallToolResult:
|
||||
"""Handle call tool request"""
|
||||
request_obj = cast(mcp_types.CallToolRequest, request.root)
|
||||
@ -163,7 +216,13 @@ def handle_call_tool(
|
||||
)
|
||||
|
||||
answer = extract_answer_from_response(app, response)
|
||||
return mcp_types.CallToolResult(content=[mcp_types.TextContent(text=answer, type="text")])
|
||||
structured_content = None
|
||||
if _supports_structured_output(protocol_version):
|
||||
structured_content = extract_structured_output(app, response, answer)
|
||||
return mcp_types.CallToolResult(
|
||||
content=[mcp_types.TextContent(text=answer, type="text")],
|
||||
structuredContent=structured_content,
|
||||
)
|
||||
|
||||
|
||||
def build_parameter_schema(
|
||||
@ -204,6 +263,29 @@ def prepare_tool_arguments(app: App, arguments: dict[str, Any]) -> ToolArguments
|
||||
return {"query": query, "inputs": args_copy}
|
||||
|
||||
|
||||
def extract_structured_output(app: App, response: Any, answer: str) -> dict[str, Any] | None:
|
||||
"""Build MCP structured tool output (2025-06-18) from the app response.
|
||||
|
||||
WORKFLOW mode exposes the raw outputs mapping; chat/agent/completion modes expose the
|
||||
answer string under an "answer" key. Returns None when no structured output is available.
|
||||
"""
|
||||
match app.mode:
|
||||
case AppMode.WORKFLOW:
|
||||
if isinstance(response, Mapping):
|
||||
data = response.get("data")
|
||||
if isinstance(data, Mapping):
|
||||
outputs = data.get("outputs")
|
||||
# All three guards use Mapping for consistency; coerce to a concrete dict
|
||||
# because structuredContent must be a JSON object (dict[str, Any]).
|
||||
if isinstance(outputs, Mapping):
|
||||
return dict(outputs)
|
||||
return None
|
||||
case AppMode.ADVANCED_CHAT | AppMode.CHAT | AppMode.AGENT_CHAT | AppMode.COMPLETION:
|
||||
return {"answer": answer}
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def extract_answer_from_response(app: App, response: Any) -> str:
|
||||
"""Extract answer from app generate response"""
|
||||
answer = ""
|
||||
|
||||
@ -22,10 +22,13 @@ for reference.
|
||||
* Define additional model classes instead of using dictionaries. Do this even if they're
|
||||
not separate types in the schema.
|
||||
"""
|
||||
# Client support both version, not support 2025-06-18 yet.
|
||||
# Latest protocol version the Dify MCP client negotiates with upstream MCP servers.
|
||||
LATEST_PROTOCOL_VERSION = "2025-06-18"
|
||||
# Server support 2024-11-05 to allow claude to use.
|
||||
SERVER_LATEST_PROTOCOL_VERSION = "2024-11-05"
|
||||
# Latest protocol version the Dify MCP server advertises to connecting clients.
|
||||
SERVER_LATEST_PROTOCOL_VERSION = "2025-06-18"
|
||||
# Protocol versions the Dify MCP server can negotiate down to (e.g. Claude on 2024-11-05).
|
||||
SERVER_SUPPORTED_PROTOCOL_VERSIONS: frozenset[str] = frozenset({"2024-11-05", "2025-03-26", "2025-06-18"})
|
||||
# Version assumed when a client omits the MCP-Protocol-Version header on post-initialize requests.
|
||||
DEFAULT_NEGOTIATED_VERSION = "2025-03-26"
|
||||
ProgressToken = str | int
|
||||
Cursor = str
|
||||
|
||||
@ -228,7 +228,7 @@ class CredentialType(enum.StrEnum):
|
||||
OAUTH2 = "oauth2"
|
||||
UNAUTHORIZED = "unauthorized"
|
||||
|
||||
def get_name(self):
|
||||
def get_name(self) -> str:
|
||||
if self == CredentialType.API_KEY:
|
||||
return "API KEY"
|
||||
elif self == CredentialType.OAUTH2:
|
||||
|
||||
@ -33,7 +33,6 @@ from models.dataset import Dataset, DatasetCollectionBinding
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qdrant_client.conversions import common_types
|
||||
from qdrant_client.http import models as rest
|
||||
|
||||
type DictFilter = dict[str, str | int | bool | dict | list]
|
||||
type MetadataFilter = DictFilter | common_types.Filter
|
||||
|
||||
@ -41,7 +41,6 @@ from models.enums import TidbAuthBindingStatus
|
||||
if TYPE_CHECKING:
|
||||
from qdrant_client import grpc # noqa
|
||||
from qdrant_client.conversions import common_types
|
||||
from qdrant_client.http import models as rest
|
||||
|
||||
type DictFilter = dict[str, str | int | bool | dict | list]
|
||||
type MetadataFilter = DictFilter | common_types.Filter
|
||||
|
||||
@ -65,6 +65,8 @@ from services.errors.account import (
|
||||
LinkAccountIntegrateError,
|
||||
MemberNotInTenantError,
|
||||
NoPermissionError,
|
||||
RefreshTokenAccountNotFoundError,
|
||||
RefreshTokenNotFoundError,
|
||||
RoleAlreadyAssignedError,
|
||||
TenantNotFoundError,
|
||||
)
|
||||
@ -654,11 +656,11 @@ class AccountService:
|
||||
# Verify the refresh token
|
||||
account_id = redis_client.get(AccountService._get_refresh_token_key(refresh_token))
|
||||
if not account_id:
|
||||
raise ValueError("Invalid refresh token")
|
||||
raise RefreshTokenNotFoundError("Invalid refresh token")
|
||||
|
||||
account = AccountService.load_user(account_id.decode("utf-8"), session)
|
||||
if not account:
|
||||
raise ValueError("Invalid account")
|
||||
raise RefreshTokenAccountNotFoundError("Invalid account")
|
||||
|
||||
# Generate new access token and refresh token
|
||||
new_access_token = AccountService.get_account_jwt_token(account)
|
||||
|
||||
@ -39,6 +39,7 @@ from libs.datetime_utils import naive_utc_now
|
||||
from models import Account, App, AppMode
|
||||
from models.model import AppModelConfig, AppModelConfigDict, IconType
|
||||
from models.workflow import Workflow
|
||||
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
|
||||
from services.dsl_version import check_version_compatibility
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
@ -51,7 +52,6 @@ logger = logging.getLogger(__name__)
|
||||
IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:"
|
||||
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:"
|
||||
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
|
||||
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
CURRENT_DSL_VERSION = CURRENT_APP_DSL_VERSION
|
||||
|
||||
|
||||
@ -131,15 +131,16 @@ class AppDslService:
|
||||
yaml_url = yaml_url.replace("/blob/", "/")
|
||||
response = remote_fetcher.make_request("GET", yaml_url.strip(), follow_redirects=True, timeout=(10, 10))
|
||||
response.raise_for_status()
|
||||
content = response.content.decode()
|
||||
raw_content = response.content
|
||||
|
||||
if len(content) > DSL_MAX_SIZE:
|
||||
if dsl_content_size(raw_content) > DSL_MAX_SIZE:
|
||||
return Import(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="File size exceeds the limit of 10MB",
|
||||
)
|
||||
|
||||
content = raw_content.decode("utf-8")
|
||||
if not content:
|
||||
return Import(
|
||||
id=import_id,
|
||||
@ -160,6 +161,12 @@ class AppDslService:
|
||||
error="yaml_content is required when import_mode is yaml-content",
|
||||
)
|
||||
content = yaml_content
|
||||
if dsl_content_size(content) > DSL_MAX_SIZE:
|
||||
return Import(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="File size exceeds the limit of 10MB",
|
||||
)
|
||||
|
||||
# Process YAML content
|
||||
try:
|
||||
|
||||
9
api/services/dsl_content.py
Normal file
9
api/services/dsl_content.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""Shared DSL content size and decoding rules."""
|
||||
|
||||
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
|
||||
def dsl_content_size(content: str | bytes) -> int:
|
||||
if isinstance(content, bytes):
|
||||
return len(content)
|
||||
return len(content.encode("utf-8"))
|
||||
@ -365,7 +365,6 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
"workspace.member.manage",
|
||||
"api_extension.manage",
|
||||
"plugin.install",
|
||||
"credential.use",
|
||||
|
||||
@ -17,6 +17,14 @@ class AccountPasswordError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class RefreshTokenNotFoundError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class RefreshTokenAccountNotFoundError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class AccountNotLinkTenantError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
@ -307,9 +307,9 @@ class PluginMigration:
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _fetch_plugin_unique_identifier(cls, plugin_id: str) -> str | None:
|
||||
def _fetch_latest_package_identifier(cls, plugin_id: str) -> str | None:
|
||||
"""
|
||||
Fetch plugin unique identifier using plugin id.
|
||||
Fetch the latest marketplace package identifier using a plugin id.
|
||||
"""
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
return None
|
||||
@ -328,7 +328,7 @@ class PluginMigration:
|
||||
|
||||
@classmethod
|
||||
def extract_unique_plugins(cls, extracted_plugins: str) -> ExtractedPluginsDict:
|
||||
plugins: dict[str, str] = {}
|
||||
package_identifier_by_plugin_id: dict[str, str] = {}
|
||||
plugin_ids = []
|
||||
plugin_not_exist = []
|
||||
logger.info("Extracting unique plugins from %s", extracted_plugins)
|
||||
@ -341,19 +341,19 @@ class PluginMigration:
|
||||
|
||||
def fetch_plugin(plugin_id):
|
||||
try:
|
||||
unique_identifier = cls._fetch_plugin_unique_identifier(plugin_id)
|
||||
if unique_identifier:
|
||||
plugins[plugin_id] = unique_identifier
|
||||
latest_package_identifier = cls._fetch_latest_package_identifier(plugin_id)
|
||||
if latest_package_identifier:
|
||||
package_identifier_by_plugin_id[plugin_id] = latest_package_identifier
|
||||
else:
|
||||
plugin_not_exist.append(plugin_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch plugin unique identifier for %s", plugin_id)
|
||||
logger.exception("Failed to fetch latest package identifier for %s", plugin_id)
|
||||
plugin_not_exist.append(plugin_id)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
list(tqdm.tqdm(executor.map(fetch_plugin, plugin_ids), total=len(plugin_ids)))
|
||||
|
||||
return {"plugins": plugins, "plugin_not_exist": plugin_not_exist}
|
||||
return {"plugins": package_identifier_by_plugin_id, "plugin_not_exist": plugin_not_exist}
|
||||
|
||||
@classmethod
|
||||
def install_plugins(cls, extracted_plugins: str, output_file: str, workers: int = 100):
|
||||
@ -362,17 +362,22 @@ class PluginMigration:
|
||||
"""
|
||||
manager = PluginInstaller()
|
||||
|
||||
plugins = cls.extract_unique_plugins(extracted_plugins)
|
||||
extracted = cls.extract_unique_plugins(extracted_plugins)
|
||||
package_identifier_by_plugin_id = extracted["plugins"]
|
||||
not_installed = []
|
||||
plugin_install_failed = []
|
||||
|
||||
# use a fake tenant id to install all the plugins
|
||||
fake_tenant_id = uuid4().hex
|
||||
logger.info("Installing %s plugin instances for fake tenant %s", len(plugins["plugins"]), fake_tenant_id)
|
||||
logger.info(
|
||||
"Installing %s plugin instances for fake tenant %s",
|
||||
len(package_identifier_by_plugin_id),
|
||||
fake_tenant_id,
|
||||
)
|
||||
|
||||
thread_pool = ThreadPoolExecutor(max_workers=workers)
|
||||
|
||||
response = cls.handle_plugin_instance_install(fake_tenant_id, plugins["plugins"])
|
||||
response = cls.handle_plugin_instance_install(fake_tenant_id, package_identifier_by_plugin_id)
|
||||
if response.get("failed"):
|
||||
plugin_install_failed.extend(response.get("failed", []))
|
||||
|
||||
@ -384,21 +389,21 @@ class PluginMigration:
|
||||
# at most 64 plugins one batch
|
||||
for i in range(0, len(plugin_ids), 64):
|
||||
batch_plugin_ids = plugin_ids[i : i + 64]
|
||||
batch_plugin_identifiers = [
|
||||
plugins["plugins"][plugin_id]
|
||||
batch_package_identifiers = [
|
||||
package_identifier_by_plugin_id[plugin_id]
|
||||
for plugin_id in batch_plugin_ids
|
||||
if plugin_id not in installed_plugins_ids and plugin_id in plugins["plugins"]
|
||||
if plugin_id not in installed_plugins_ids and plugin_id in package_identifier_by_plugin_id
|
||||
]
|
||||
if batch_plugin_identifiers:
|
||||
if batch_package_identifiers:
|
||||
manager.install_from_identifiers(
|
||||
tenant_id,
|
||||
batch_plugin_identifiers,
|
||||
batch_package_identifiers,
|
||||
PluginInstallationSource.Marketplace,
|
||||
metas=[
|
||||
{
|
||||
"plugin_unique_identifier": identifier,
|
||||
"plugin_unique_identifier": package_identifier,
|
||||
}
|
||||
for identifier in batch_plugin_identifiers
|
||||
for package_identifier in batch_package_identifiers
|
||||
],
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
@ -412,10 +417,8 @@ class PluginMigration:
|
||||
tenant_id = data["tenant_id"]
|
||||
plugin_ids = data["plugins"]
|
||||
plugin_not_exist: list[str] = []
|
||||
# get plugin unique identifier
|
||||
for plugin_id in plugin_ids:
|
||||
unique_identifier = plugins.get(plugin_id)
|
||||
if unique_identifier:
|
||||
if plugin_id not in package_identifier_by_plugin_id:
|
||||
plugin_not_exist.append(plugin_id)
|
||||
|
||||
if plugin_not_exist:
|
||||
@ -459,36 +462,44 @@ class PluginMigration:
|
||||
"""
|
||||
manager = PluginInstaller()
|
||||
|
||||
plugins = cls.extract_unique_plugins(extracted_plugins)
|
||||
extracted = cls.extract_unique_plugins(extracted_plugins)
|
||||
package_identifier_by_plugin_id = extracted["plugins"]
|
||||
plugin_install_failed = []
|
||||
|
||||
# use a fake tenant id to install all the plugins
|
||||
fake_tenant_id = uuid4().hex
|
||||
logger.info("Installing %s plugin instances for fake tenant %s", len(plugins["plugins"]), fake_tenant_id)
|
||||
logger.info(
|
||||
"Installing %s plugin instances for fake tenant %s",
|
||||
len(package_identifier_by_plugin_id),
|
||||
fake_tenant_id,
|
||||
)
|
||||
|
||||
thread_pool = ThreadPoolExecutor(max_workers=workers)
|
||||
|
||||
response = cls.handle_plugin_instance_install(fake_tenant_id, plugins["plugins"])
|
||||
response = cls.handle_plugin_instance_install(fake_tenant_id, package_identifier_by_plugin_id)
|
||||
if response.get("failed"):
|
||||
plugin_install_failed.extend(response.get("failed", []))
|
||||
|
||||
def install(
|
||||
tenant_id: str, plugin_ids: dict[str, str], total_success_tenant: int, total_failed_tenant: int
|
||||
tenant_id: str,
|
||||
package_identifier_by_plugin_id: dict[str, str],
|
||||
total_success_tenant: int,
|
||||
total_failed_tenant: int,
|
||||
) -> None:
|
||||
logger.info("Installing %s plugins for tenant %s", len(plugin_ids), tenant_id)
|
||||
logger.info("Installing %s plugins for tenant %s", len(package_identifier_by_plugin_id), tenant_id)
|
||||
try:
|
||||
# fetch plugin already installed
|
||||
installed_plugins = manager.list_plugins(tenant_id)
|
||||
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
|
||||
# at most 64 plugins one batch
|
||||
for i in range(0, len(plugin_ids), 64):
|
||||
batch_plugin_ids = list(plugin_ids.keys())[i : i + 64]
|
||||
batch_plugin_identifiers = [
|
||||
plugin_ids[plugin_id]
|
||||
for i in range(0, len(package_identifier_by_plugin_id), 64):
|
||||
batch_plugin_ids = list(package_identifier_by_plugin_id.keys())[i : i + 64]
|
||||
batch_package_identifiers = [
|
||||
package_identifier_by_plugin_id[plugin_id]
|
||||
for plugin_id in batch_plugin_ids
|
||||
if plugin_id not in installed_plugins_ids and plugin_id in plugin_ids
|
||||
if plugin_id not in installed_plugins_ids and plugin_id in package_identifier_by_plugin_id
|
||||
]
|
||||
PluginService.install_from_marketplace_pkg(tenant_id, batch_plugin_identifiers)
|
||||
PluginService.install_from_marketplace_pkg(tenant_id, batch_package_identifiers)
|
||||
|
||||
total_success_tenant += 1
|
||||
except Exception:
|
||||
@ -510,7 +521,7 @@ class PluginMigration:
|
||||
thread_pool.submit(
|
||||
install,
|
||||
tenant_id,
|
||||
plugins.get("plugins", {}),
|
||||
package_identifier_by_plugin_id,
|
||||
total_success_tenant,
|
||||
total_failed_tenant,
|
||||
)
|
||||
@ -542,12 +553,12 @@ class PluginMigration:
|
||||
|
||||
@classmethod
|
||||
def handle_plugin_instance_install(
|
||||
cls, tenant_id: str, plugin_identifiers_map: Mapping[str, str]
|
||||
cls, tenant_id: str, package_identifier_by_plugin_id: Mapping[str, str]
|
||||
) -> PluginInstallResultDict:
|
||||
"""
|
||||
Install plugins for a tenant.
|
||||
"""
|
||||
if plugin_identifiers_map and not dify_config.MARKETPLACE_ENABLED:
|
||||
if package_identifier_by_plugin_id and not dify_config.MARKETPLACE_ENABLED:
|
||||
raise ValueError(
|
||||
"Marketplace disabled in offline mode; cannot bulk-install plugins. "
|
||||
"Pre-upload plugin packages via Console first."
|
||||
@ -558,17 +569,17 @@ class PluginMigration:
|
||||
thread_pool = ThreadPoolExecutor(max_workers=10)
|
||||
futures = []
|
||||
|
||||
for plugin_id, plugin_identifier in plugin_identifiers_map.items():
|
||||
for plugin_id, package_identifier in package_identifier_by_plugin_id.items():
|
||||
|
||||
def download_and_upload(tenant_id, plugin_id, plugin_identifier):
|
||||
plugin_package = marketplace.download_plugin_pkg(plugin_identifier)
|
||||
def download_and_upload(tenant_id, plugin_id, package_identifier):
|
||||
plugin_package = marketplace.download_plugin_pkg(package_identifier)
|
||||
if not plugin_package:
|
||||
raise Exception(f"Failed to download plugin {plugin_identifier}")
|
||||
raise Exception(f"Failed to download plugin {package_identifier}")
|
||||
|
||||
# upload
|
||||
manager.upload_pkg(tenant_id, plugin_package, verify_signature=True)
|
||||
|
||||
futures.append(thread_pool.submit(download_and_upload, tenant_id, plugin_id, plugin_identifier))
|
||||
futures.append(thread_pool.submit(download_and_upload, tenant_id, plugin_id, package_identifier))
|
||||
|
||||
# Wait for all downloads to complete
|
||||
for future in futures:
|
||||
@ -578,33 +589,33 @@ class PluginMigration:
|
||||
success = []
|
||||
failed = []
|
||||
|
||||
reverse_map = {v: k for k, v in plugin_identifiers_map.items()}
|
||||
plugin_id_by_package_identifier = {v: k for k, v in package_identifier_by_plugin_id.items()}
|
||||
|
||||
# at most 8 plugins one batch
|
||||
for i in range(0, len(plugin_identifiers_map), 8):
|
||||
batch_plugin_ids = list(plugin_identifiers_map.keys())[i : i + 8]
|
||||
batch_plugin_identifiers = [plugin_identifiers_map[plugin_id] for plugin_id in batch_plugin_ids]
|
||||
for i in range(0, len(package_identifier_by_plugin_id), 8):
|
||||
batch_plugin_ids = list(package_identifier_by_plugin_id.keys())[i : i + 8]
|
||||
batch_package_identifiers = [package_identifier_by_plugin_id[plugin_id] for plugin_id in batch_plugin_ids]
|
||||
|
||||
try:
|
||||
response = manager.install_from_identifiers(
|
||||
tenant_id=tenant_id,
|
||||
identifiers=batch_plugin_identifiers,
|
||||
identifiers=batch_package_identifiers,
|
||||
source=PluginInstallationSource.Marketplace,
|
||||
metas=[
|
||||
{
|
||||
"plugin_unique_identifier": identifier,
|
||||
"plugin_unique_identifier": package_identifier,
|
||||
}
|
||||
for identifier in batch_plugin_identifiers
|
||||
for package_identifier in batch_package_identifiers
|
||||
],
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
except Exception:
|
||||
# add to failed
|
||||
failed.extend(batch_plugin_identifiers)
|
||||
failed.extend(batch_plugin_ids)
|
||||
continue
|
||||
|
||||
if response.all_installed:
|
||||
success.extend(batch_plugin_identifiers)
|
||||
success.extend(batch_plugin_ids)
|
||||
continue
|
||||
|
||||
task_id = response.task_id
|
||||
@ -614,10 +625,13 @@ class PluginMigration:
|
||||
if status.status in [PluginInstallTaskStatus.Failed, PluginInstallTaskStatus.Success]:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
for plugin in status.plugins:
|
||||
plugin_id = plugin_id_by_package_identifier.get(
|
||||
plugin.plugin_unique_identifier, plugin.plugin_unique_identifier.split(":", 1)[0]
|
||||
)
|
||||
if plugin.status == PluginInstallTaskStatus.Success:
|
||||
success.append(reverse_map[plugin.plugin_unique_identifier])
|
||||
success.append(plugin_id)
|
||||
else:
|
||||
failed.append(reverse_map[plugin.plugin_unique_identifier])
|
||||
failed.append(plugin_id)
|
||||
logger.error(
|
||||
"Failed to install plugin %s, error: %s",
|
||||
plugin.plugin_unique_identifier,
|
||||
|
||||
@ -36,6 +36,7 @@ from models import Account
|
||||
from models.dataset import Dataset, DatasetCollectionBinding, Pipeline
|
||||
from models.enums import CollectionBindingType, DatasetRuntimeMode
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
|
||||
from services.dsl_version import check_version_compatibility
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import (
|
||||
@ -50,7 +51,6 @@ logger = logging.getLogger(__name__)
|
||||
IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:"
|
||||
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:"
|
||||
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
|
||||
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
CURRENT_DSL_VERSION = "0.1.0"
|
||||
|
||||
|
||||
@ -127,15 +127,16 @@ class RagPipelineDslService:
|
||||
yaml_url = yaml_url.replace("/blob/", "/")
|
||||
response = remote_fetcher.make_request("GET", yaml_url.strip(), follow_redirects=True, timeout=(10, 10))
|
||||
response.raise_for_status()
|
||||
content = response.content.decode()
|
||||
raw_content = response.content
|
||||
|
||||
if len(content) > DSL_MAX_SIZE:
|
||||
if dsl_content_size(raw_content) > DSL_MAX_SIZE:
|
||||
return RagPipelineImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="File size exceeds the limit of 10MB",
|
||||
)
|
||||
|
||||
content = raw_content.decode("utf-8")
|
||||
if not content:
|
||||
return RagPipelineImportInfo(
|
||||
id=import_id,
|
||||
@ -156,6 +157,12 @@ class RagPipelineDslService:
|
||||
error="yaml_content is required when import_mode is yaml-content",
|
||||
)
|
||||
content = yaml_content
|
||||
if dsl_content_size(content) > DSL_MAX_SIZE:
|
||||
return RagPipelineImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="File size exceeds the limit of 10MB",
|
||||
)
|
||||
|
||||
# Process YAML content
|
||||
try:
|
||||
|
||||
@ -269,11 +269,13 @@ class RagPipelineTransformService:
|
||||
|
||||
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
|
||||
dependencies = pipeline_yaml.get("dependencies", [])
|
||||
need_install_plugin_unique_identifiers = []
|
||||
package_identifiers_to_install = []
|
||||
for dependency in dependencies:
|
||||
if dependency.get("type") == "marketplace":
|
||||
plugin_unique_identifier = dependency.get("value", {}).get("plugin_unique_identifier")
|
||||
plugin_id = plugin_unique_identifier.split(":")[0]
|
||||
package_identifier = dependency.get("value", {}).get("plugin_unique_identifier")
|
||||
if not package_identifier:
|
||||
continue
|
||||
plugin_id = package_identifier.split(":", 1)[0]
|
||||
if plugin_id not in installed_plugins_ids:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.warning(
|
||||
@ -282,12 +284,12 @@ class RagPipelineTransformService:
|
||||
plugin_id,
|
||||
)
|
||||
continue
|
||||
plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(plugin_id) # type: ignore
|
||||
if plugin_unique_identifier:
|
||||
need_install_plugin_unique_identifiers.append(plugin_unique_identifier)
|
||||
if need_install_plugin_unique_identifiers:
|
||||
logger.debug("Installing missing pipeline plugins %s", need_install_plugin_unique_identifiers)
|
||||
PluginService.install_from_marketplace_pkg(tenant_id, need_install_plugin_unique_identifiers)
|
||||
latest_package_identifier = plugin_migration._fetch_latest_package_identifier(plugin_id) # type: ignore
|
||||
if latest_package_identifier:
|
||||
package_identifiers_to_install.append(latest_package_identifier)
|
||||
if package_identifiers_to_install:
|
||||
logger.debug("Installing missing pipeline plugins %s", package_identifiers_to_install)
|
||||
PluginService.install_from_marketplace_pkg(tenant_id, package_identifiers_to_install)
|
||||
|
||||
def _transform_to_empty_pipeline(self, dataset: Dataset, session: Session):
|
||||
pipeline = Pipeline(
|
||||
|
||||
@ -3,12 +3,10 @@ import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
from packaging import version
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@ -20,6 +18,9 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from models import Account
|
||||
from models.snippet import CustomizedSnippet, SnippetType
|
||||
from models.workflow import Workflow
|
||||
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
|
||||
from services.dsl_version import check_version_compatibility
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus
|
||||
from services.plugin.dependencies_analysis import DependenciesAnalysisService
|
||||
from services.snippet_service import SNIPPET_FORBIDDEN_NODE_TYPES, SnippetService
|
||||
|
||||
@ -28,22 +29,9 @@ logger = logging.getLogger(__name__)
|
||||
IMPORT_INFO_REDIS_KEY_PREFIX = "snippet_import_info:"
|
||||
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "snippet_check_dependencies:"
|
||||
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
|
||||
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
CURRENT_DSL_VERSION = "0.1.0"
|
||||
|
||||
|
||||
class ImportMode(StrEnum):
|
||||
YAML_CONTENT = "yaml-content"
|
||||
YAML_URL = "yaml-url"
|
||||
|
||||
|
||||
class ImportStatus(StrEnum):
|
||||
COMPLETED = "completed"
|
||||
COMPLETED_WITH_WARNINGS = "completed-with-warnings"
|
||||
PENDING = "pending"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class SnippetImportInfo(BaseModel):
|
||||
id: str
|
||||
status: ImportStatus
|
||||
@ -53,32 +41,9 @@ class SnippetImportInfo(BaseModel):
|
||||
error: str = ""
|
||||
|
||||
|
||||
class CheckDependenciesResult(BaseModel):
|
||||
leaked_dependencies: list[PluginDependency] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _check_version_compatibility(imported_version: str) -> ImportStatus:
|
||||
"""Determine import status based on version comparison"""
|
||||
try:
|
||||
current_ver = version.parse(CURRENT_DSL_VERSION)
|
||||
imported_ver = version.parse(imported_version)
|
||||
except version.InvalidVersion:
|
||||
return ImportStatus.FAILED
|
||||
|
||||
# If imported version is newer than current, always return PENDING
|
||||
if imported_ver > current_ver:
|
||||
return ImportStatus.PENDING
|
||||
|
||||
# If imported version is older than current's major, return PENDING
|
||||
if imported_ver.major < current_ver.major:
|
||||
return ImportStatus.PENDING
|
||||
|
||||
# If imported version is older than current's minor, return COMPLETED_WITH_WARNINGS
|
||||
if imported_ver.minor < current_ver.minor:
|
||||
return ImportStatus.COMPLETED_WITH_WARNINGS
|
||||
|
||||
# If imported version equals or is older than current's micro, return COMPLETED
|
||||
return ImportStatus.COMPLETED
|
||||
"""Determine import status based on version comparison."""
|
||||
return check_version_compatibility(imported_version, CURRENT_DSL_VERSION)
|
||||
|
||||
|
||||
class SnippetPendingData(BaseModel):
|
||||
@ -145,13 +110,14 @@ class SnippetDslService:
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"Failed to fetch YAML from URL: {response.status_code}",
|
||||
)
|
||||
content = response.text
|
||||
if len(content) > DSL_MAX_SIZE:
|
||||
raw_content = response.content
|
||||
if dsl_content_size(raw_content) > DSL_MAX_SIZE:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"YAML content size exceeds maximum limit of {DSL_MAX_SIZE} bytes",
|
||||
)
|
||||
content = raw_content.decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch YAML from URL")
|
||||
return SnippetImportInfo(
|
||||
@ -167,7 +133,7 @@ class SnippetDslService:
|
||||
error="yaml_content is required when import_mode is yaml-content",
|
||||
)
|
||||
content = yaml_content
|
||||
if len(content) > DSL_MAX_SIZE:
|
||||
if dsl_content_size(content) > DSL_MAX_SIZE:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
|
||||
@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Response
|
||||
from flask import Flask, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
import controllers.mcp.mcp as module
|
||||
@ -37,12 +37,15 @@ class DummyServer:
|
||||
self.app_id = app_id
|
||||
self.tenant_id = tenant_id
|
||||
self.id = server_id
|
||||
self.description = "Test server"
|
||||
self.parameters_dict = {}
|
||||
|
||||
|
||||
class DummyApp:
|
||||
def __init__(self, mode, workflow=None, app_model_config=None):
|
||||
self.id = _APP_ID
|
||||
self.tenant_id = _TENANT_ID
|
||||
self.name = "test_app"
|
||||
self.mode = mode
|
||||
self.workflow = workflow
|
||||
self.app_model_config = app_model_config
|
||||
@ -494,3 +497,220 @@ class TestMCPAppApi:
|
||||
with pytest.raises(module.MCPRequestError) as exc_info:
|
||||
post_fn("server-1")
|
||||
assert "Invalid user_input_form" in str(exc_info.value)
|
||||
|
||||
|
||||
_UNSUPPORTED_VERSION = "1999-01-01"
|
||||
|
||||
|
||||
def _initialize_payload(protocol_version: str = "2024-11-05") -> dict[str, object]:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialize",
|
||||
"id": 1,
|
||||
"params": {
|
||||
"protocolVersion": protocol_version,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "test-client", "version": "1.0"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tools_list_payload(request_id: int | None = 1) -> dict[str, object]:
|
||||
payload: dict[str, object] = {"jsonrpc": "2.0", "method": "tools/list", "params": {}}
|
||||
if request_id is not None:
|
||||
payload["id"] = request_id
|
||||
return payload
|
||||
|
||||
|
||||
def _tools_call_payload() -> dict[str, object]:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/call",
|
||||
"id": 1,
|
||||
"params": {"name": "test_app", "arguments": {"query": "test question"}},
|
||||
}
|
||||
|
||||
|
||||
class TestMCPProtocolVersionNegotiationApi:
|
||||
"""MCP protocol version negotiation exercised through the HTTP controller layer.
|
||||
|
||||
Covers the MCP-Protocol-Version header contract (resolution, rejection, threading)
|
||||
and the serialized JSON responses seen by modern (2025-06-18) vs legacy (2024-11-05)
|
||||
clients, including the back-compat guarantee that legacy responses carry none of the
|
||||
structured-output fields.
|
||||
"""
|
||||
|
||||
def _make_api(self) -> module.MCPAppApi:
|
||||
server = DummyServer(status=module.AppMCPServerStatus.ACTIVE)
|
||||
app = DummyApp(mode=module.AppMode.CHAT, app_model_config=DummyConfig())
|
||||
api = module.MCPAppApi()
|
||||
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
|
||||
api._retrieve_end_user = MagicMock(return_value=MagicMock())
|
||||
return api
|
||||
|
||||
def _post(
|
||||
self, flask_app: Flask, api: module.MCPAppApi, payload: dict[str, object], headers: dict[str, str] | None = None
|
||||
) -> Response:
|
||||
fake_payload(payload)
|
||||
post_fn = unwrap(api.post)
|
||||
with flask_app.test_request_context(headers=headers):
|
||||
return post_fn("server-1")
|
||||
|
||||
@pytest.mark.parametrize("version", sorted(module.mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS))
|
||||
def test_initialize_echoes_supported_body_version(self, flask_app_with_containers, version):
|
||||
"""Initialize echoes every supported client-requested version back unchanged."""
|
||||
api = self._make_api()
|
||||
|
||||
response = self._post(flask_app_with_containers, api, _initialize_payload(version))
|
||||
|
||||
body = response.get_json()
|
||||
assert body["result"]["protocolVersion"] == version
|
||||
|
||||
def test_initialize_falls_back_for_unsupported_body_version(self, flask_app_with_containers):
|
||||
"""An unsupported requested version falls back to the server latest."""
|
||||
api = self._make_api()
|
||||
|
||||
response = self._post(flask_app_with_containers, api, _initialize_payload(_UNSUPPORTED_VERSION))
|
||||
|
||||
body = response.get_json()
|
||||
assert body["result"]["protocolVersion"] == module.mcp_types.SERVER_LATEST_PROTOCOL_VERSION
|
||||
|
||||
def test_initialize_ignores_unsupported_header(self, flask_app_with_containers):
|
||||
"""Initialize negotiates via the request body, so its header is never rejected."""
|
||||
api = self._make_api()
|
||||
|
||||
response = self._post(
|
||||
flask_app_with_containers,
|
||||
api,
|
||||
_initialize_payload("2024-11-05"),
|
||||
headers={"MCP-Protocol-Version": _UNSUPPORTED_VERSION},
|
||||
)
|
||||
|
||||
body = response.get_json()
|
||||
assert "error" not in body
|
||||
assert body["result"]["protocolVersion"] == "2024-11-05"
|
||||
|
||||
@pytest.mark.parametrize("request_id", [5, None])
|
||||
def test_unsupported_header_returns_invalid_request_error(self, flask_app_with_containers, request_id):
|
||||
"""An unsupported header gets a JSON-RPC error echoing the request id (missing id -> null)."""
|
||||
api = self._make_api()
|
||||
|
||||
with patch.object(module, "handle_mcp_request", autospec=True) as mock_handle:
|
||||
response = self._post(
|
||||
flask_app_with_containers,
|
||||
api,
|
||||
_tools_list_payload(request_id=request_id),
|
||||
headers={"MCP-Protocol-Version": _UNSUPPORTED_VERSION},
|
||||
)
|
||||
|
||||
body = response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert body["jsonrpc"] == "2.0"
|
||||
assert body["id"] == request_id
|
||||
assert body["error"]["code"] == module.mcp_types.INVALID_REQUEST
|
||||
assert _UNSUPPORTED_VERSION in body["error"]["message"]
|
||||
mock_handle.assert_not_called()
|
||||
|
||||
def test_notification_with_unsupported_header_is_accepted(self, flask_app_with_containers):
|
||||
"""A notification is accepted (202, no body) even with an unsupported header."""
|
||||
api = self._make_api()
|
||||
|
||||
response = self._post(
|
||||
flask_app_with_containers,
|
||||
api,
|
||||
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
|
||||
headers={"MCP-Protocol-Version": _UNSUPPORTED_VERSION},
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
|
||||
@pytest.mark.parametrize("version", sorted(module.mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS))
|
||||
def test_supported_header_is_threaded_to_handler(self, flask_app_with_containers, version):
|
||||
"""Every supported header value is passed through to handle_mcp_request."""
|
||||
api = self._make_api()
|
||||
|
||||
with patch.object(module, "handle_mcp_request", return_value=DummyResult(), autospec=True) as mock_handle:
|
||||
self._post(
|
||||
flask_app_with_containers,
|
||||
api,
|
||||
_tools_list_payload(),
|
||||
headers={"MCP-Protocol-Version": version},
|
||||
)
|
||||
|
||||
assert mock_handle.call_args.args[-1] == version
|
||||
|
||||
def test_absent_header_defaults_to_back_compat_version(self, flask_app_with_containers):
|
||||
"""An absent header resolves to the spec's default version (2025-03-26)."""
|
||||
api = self._make_api()
|
||||
|
||||
with patch.object(module, "handle_mcp_request", return_value=DummyResult(), autospec=True) as mock_handle:
|
||||
self._post(flask_app_with_containers, api, _tools_list_payload())
|
||||
|
||||
assert mock_handle.call_args.args[-1] == module.mcp_types.DEFAULT_NEGOTIATED_VERSION
|
||||
|
||||
def test_tools_list_json_advertises_structured_output_for_modern_client(self, flask_app_with_containers):
|
||||
"""A 2025-06-18 client sees outputSchema and title in the serialized tool JSON."""
|
||||
api = self._make_api()
|
||||
|
||||
response = self._post(
|
||||
flask_app_with_containers,
|
||||
api,
|
||||
_tools_list_payload(),
|
||||
headers={"MCP-Protocol-Version": "2025-06-18"},
|
||||
)
|
||||
|
||||
tool = response.get_json()["result"]["tools"][0]
|
||||
assert tool["outputSchema"] == {"type": "object"}
|
||||
assert tool["title"] == "test_app"
|
||||
|
||||
def test_tools_list_json_unchanged_for_legacy_client(self, flask_app_with_containers):
|
||||
"""A 2024-11-05 client sees exactly the pre-upgrade tool JSON keys."""
|
||||
api = self._make_api()
|
||||
|
||||
response = self._post(
|
||||
flask_app_with_containers,
|
||||
api,
|
||||
_tools_list_payload(),
|
||||
headers={"MCP-Protocol-Version": "2024-11-05"},
|
||||
)
|
||||
|
||||
tool = response.get_json()["result"]["tools"][0]
|
||||
assert set(tool) == {"name", "description", "inputSchema"}
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_tools_call_json_includes_structured_content_for_modern_client(
|
||||
self, mock_app_generate, flask_app_with_containers
|
||||
):
|
||||
"""A 2025-06-18 client receives structuredContent alongside the text content."""
|
||||
api = self._make_api()
|
||||
mock_app_generate.generate.return_value = {"answer": "test answer"}
|
||||
|
||||
response = self._post(
|
||||
flask_app_with_containers,
|
||||
api,
|
||||
_tools_call_payload(),
|
||||
headers={"MCP-Protocol-Version": "2025-06-18"},
|
||||
)
|
||||
|
||||
result = response.get_json()["result"]
|
||||
assert result["structuredContent"] == {"answer": "test answer"}
|
||||
assert result["content"][0]["text"] == "test answer"
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_tools_call_json_omits_structured_content_for_legacy_client(
|
||||
self, mock_app_generate, flask_app_with_containers
|
||||
):
|
||||
"""A 2024-11-05 client receives the pre-upgrade tools/call JSON without structuredContent."""
|
||||
api = self._make_api()
|
||||
mock_app_generate.generate.return_value = {"answer": "test answer"}
|
||||
|
||||
response = self._post(
|
||||
flask_app_with_containers,
|
||||
api,
|
||||
_tools_call_payload(),
|
||||
headers={"MCP-Protocol-Version": "2024-11-05"},
|
||||
)
|
||||
|
||||
result = response.get_json()["result"]
|
||||
assert "structuredContent" not in result
|
||||
assert result["content"][0]["text"] == "test answer"
|
||||
|
||||
@ -32,15 +32,6 @@ from tests.helpers.legacy_model_type_migration import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_engine(tmp_path: Path) -> sa.Engine:
|
||||
engine = sa.create_engine(f"sqlite:///{tmp_path / 'legacy_model_type_migration.sqlite'}")
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dirty_fixture(sqlite_engine: sa.Engine):
|
||||
return seed_legacy_model_type_dirty_data(sqlite_engine)
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
# Getting the absolute path of the current file's directory
|
||||
ABS_PATH = os.path.dirname(os.path.abspath(__file__))
|
||||
@ -34,6 +37,7 @@ os.environ.setdefault("STORAGE_TYPE", "opendal")
|
||||
|
||||
from core.db.session_factory import configure_session_factory, session_factory
|
||||
from extensions import ext_redis
|
||||
from models.base import TypeBase
|
||||
|
||||
|
||||
def _patch_redis_clients_on_loaded_modules():
|
||||
@ -113,6 +117,29 @@ def _unit_test_engine():
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_engine() -> Iterator[Engine]:
|
||||
"""Create an isolated in-memory SQLite engine for tests that need a disposable database."""
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session(request: pytest.FixtureRequest, sqlite_engine: Engine) -> Iterator[Session]:
|
||||
"""Yield a SQLite session after creating the model tables passed through ``request.param``."""
|
||||
|
||||
models: tuple[type[TypeBase], ...] = request.param
|
||||
tables = [model.metadata.tables[model.__tablename__] for model in models]
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=tables)
|
||||
session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configure_session_factory(_unit_test_engine):
|
||||
try:
|
||||
|
||||
@ -85,15 +85,13 @@ def test_published_agent_app_parameters_requires_existing_active_agent(monkeypat
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("active_config_snapshot_id", "active_config_is_published"),
|
||||
"active_config_is_published",
|
||||
[
|
||||
(None, True),
|
||||
("snapshot-1", False),
|
||||
True,
|
||||
False,
|
||||
],
|
||||
)
|
||||
def test_published_agent_app_parameters_requires_published_agent(
|
||||
monkeypatch, active_config_snapshot_id, active_config_is_published
|
||||
):
|
||||
def test_published_agent_app_parameters_requires_published_agent(monkeypatch, active_config_is_published):
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
@ -101,7 +99,7 @@ def test_published_agent_app_parameters_requires_published_agent(
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id=active_config_snapshot_id,
|
||||
active_config_snapshot_id=None,
|
||||
active_config_is_published=active_config_is_published,
|
||||
)
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: agent)
|
||||
@ -110,6 +108,27 @@ def test_published_agent_app_parameters_requires_published_agent(
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_allows_unpublished_draft_with_active_snapshot(monkeypatch):
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
app_model_config=None,
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
active_config_is_published=False,
|
||||
)
|
||||
snapshot = SimpleNamespace(config_snapshot_dict={})
|
||||
query_results = iter([agent, snapshot])
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: next(query_results))
|
||||
|
||||
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
assert features_dict["file_upload"]["enabled"] is True
|
||||
assert user_input_form == []
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_requires_published_snapshot(monkeypatch):
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
|
||||
@ -13,8 +13,10 @@ from unittest.mock import ANY, MagicMock, patch
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask_restx import Api
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from controllers.console.auth.login import RefreshTokenApi
|
||||
from services.errors.account import RefreshTokenAccountNotFoundError, RefreshTokenNotFoundError
|
||||
|
||||
|
||||
class TestRefreshTokenApi:
|
||||
@ -98,18 +100,19 @@ class TestRefreshTokenApi:
|
||||
|
||||
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
|
||||
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)
|
||||
def test_refresh_fails_with_invalid_token(self, mock_refresh_token, mock_extract_token, app: Flask):
|
||||
def test_refresh_returns_unauthorized_for_invalid_refresh_token(
|
||||
self, mock_refresh_token, mock_extract_token, app: Flask
|
||||
):
|
||||
"""
|
||||
Test token refresh failure with invalid refresh token.
|
||||
Test token refresh maps invalid refresh tokens to unauthorized responses.
|
||||
|
||||
Verifies that:
|
||||
- Exception is caught when token is invalid
|
||||
- 401 status code is returned
|
||||
- Error message is included in response
|
||||
- Invalid refresh token validation failures return 401
|
||||
- The failure response preserves the validation message
|
||||
"""
|
||||
# Arrange
|
||||
mock_extract_token.return_value = "invalid_refresh_token"
|
||||
mock_refresh_token.side_effect = Exception("Invalid refresh token")
|
||||
mock_refresh_token.side_effect = RefreshTokenNotFoundError("Invalid refresh token")
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/refresh-token", method="POST"):
|
||||
@ -119,22 +122,21 @@ class TestRefreshTokenApi:
|
||||
# Assert
|
||||
assert status_code == 401
|
||||
assert response["result"] == "fail"
|
||||
assert "Invalid refresh token" in response["message"]
|
||||
assert response["message"] == "Invalid refresh token"
|
||||
|
||||
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
|
||||
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)
|
||||
def test_refresh_fails_with_expired_token(self, mock_refresh_token, mock_extract_token, app: Flask):
|
||||
def test_refresh_returns_unauthorized_for_invalid_account(self, mock_refresh_token, mock_extract_token, app: Flask):
|
||||
"""
|
||||
Test token refresh failure with expired refresh token.
|
||||
Test token refresh maps missing accounts to unauthorized responses.
|
||||
|
||||
Verifies that:
|
||||
- Expired tokens are rejected
|
||||
- 401 status code is returned
|
||||
- Appropriate error handling
|
||||
- Invalid account validation failures return 401
|
||||
- The failure response preserves the validation message
|
||||
"""
|
||||
# Arrange
|
||||
mock_extract_token.return_value = "expired_refresh_token"
|
||||
mock_refresh_token.side_effect = Exception("Refresh token expired")
|
||||
mock_extract_token.return_value = "refresh_token_for_missing_account"
|
||||
mock_refresh_token.side_effect = RefreshTokenAccountNotFoundError("Invalid account")
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/refresh-token", method="POST"):
|
||||
@ -144,7 +146,71 @@ class TestRefreshTokenApi:
|
||||
# Assert
|
||||
assert status_code == 401
|
||||
assert response["result"] == "fail"
|
||||
assert "expired" in response["message"].lower()
|
||||
assert response["message"] == "Invalid account"
|
||||
|
||||
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
|
||||
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)
|
||||
def test_refresh_returns_unauthorized_for_banned_account(self, mock_refresh_token, mock_extract_token, app: Flask):
|
||||
"""
|
||||
Test token refresh maps banned accounts to unauthorized responses.
|
||||
|
||||
Verifies that:
|
||||
- Authorization failures raised during account loading return 401
|
||||
- The failure response preserves the authorization message
|
||||
"""
|
||||
# Arrange
|
||||
mock_extract_token.return_value = "refresh_token_for_banned_account"
|
||||
mock_refresh_token.side_effect = Unauthorized("Account is banned.")
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/refresh-token", method="POST"):
|
||||
refresh_api = RefreshTokenApi()
|
||||
response, status_code = refresh_api.post()
|
||||
|
||||
# Assert
|
||||
assert status_code == 401
|
||||
assert response["result"] == "fail"
|
||||
assert response["message"] == "Account is banned."
|
||||
|
||||
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
|
||||
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)
|
||||
def test_refresh_propagates_non_whitelisted_value_error(self, mock_refresh_token, mock_extract_token, app: Flask):
|
||||
"""
|
||||
Test token refresh preserves non-whitelisted ValueError failures.
|
||||
|
||||
Verifies that:
|
||||
- Only known refresh-token validation errors are mapped to 401
|
||||
- Unexpected ValueError instances continue to propagate
|
||||
"""
|
||||
# Arrange
|
||||
mock_extract_token.return_value = "valid_refresh_token"
|
||||
mock_refresh_token.side_effect = ValueError("unexpected parse failure")
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/refresh-token", method="POST"):
|
||||
refresh_api = RefreshTokenApi()
|
||||
with pytest.raises(ValueError, match="unexpected parse failure"):
|
||||
refresh_api.post()
|
||||
|
||||
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
|
||||
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)
|
||||
def test_refresh_propagates_unexpected_service_errors(self, mock_refresh_token, mock_extract_token, app: Flask):
|
||||
"""
|
||||
Test token refresh preserves unexpected service failures.
|
||||
|
||||
Verifies that:
|
||||
- Operational errors are not misreported as authentication failures
|
||||
- The original exception is preserved for higher-level error handling
|
||||
"""
|
||||
# Arrange
|
||||
mock_extract_token.return_value = "valid_refresh_token"
|
||||
mock_refresh_token.side_effect = RuntimeError("redis unavailable")
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/refresh-token", method="POST"):
|
||||
refresh_api = RefreshTokenApi()
|
||||
with pytest.raises(RuntimeError, match="redis unavailable"):
|
||||
refresh_api.post()
|
||||
|
||||
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
|
||||
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)
|
||||
|
||||
@ -97,12 +97,35 @@ class TestResolveAgent:
|
||||
assert config_version_kind == "snapshot"
|
||||
assert soul.model is not None
|
||||
|
||||
def test_unpublished_agent_raises_before_model_resolution(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_unpublished_draft_still_resolves_active_snapshot(self, monkeypatch: pytest.MonkeyPatch):
|
||||
bound_agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id="snap-1",
|
||||
active_config_is_published=False,
|
||||
)
|
||||
inner_agent = SimpleNamespace(id="agent-1")
|
||||
snapshot = _snapshot()
|
||||
_patch_session(monkeypatch, [bound_agent, inner_agent, snapshot])
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
|
||||
|
||||
agent, config_id, config_version_kind, soul = AgentAppGenerator()._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
draft_type=None,
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
assert agent is bound_agent
|
||||
assert config_id == snapshot.id
|
||||
assert config_version_kind == "snapshot"
|
||||
assert soul.prompt.system_prompt == "You are Iris."
|
||||
|
||||
def test_agent_without_active_snapshot_raises_before_model_resolution(self, monkeypatch: pytest.MonkeyPatch):
|
||||
bound_agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id=None,
|
||||
active_config_is_published=False,
|
||||
)
|
||||
_patch_session(monkeypatch, [bound_agent])
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import json
|
||||
from base64 import b64decode
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@ -44,7 +43,7 @@ def test_serialize_inputs_encodes_payload() -> None:
|
||||
def test_transform_response_parses_json_result_and_converts_scientific_notation() -> None:
|
||||
response = '<<RESULT>>{"value": "1e+3", "nested": {"x": "2E-2"}, "arr": ["3e+1"]}<<RESULT>>'
|
||||
|
||||
result: Mapping[str, Any] = _DummyTransformer.transform_response(response)
|
||||
result: dict[str, Any] = _DummyTransformer.transform_response(response)
|
||||
|
||||
assert result == {"value": 1000.0, "nested": {"x": 0.02}, "arr": [30.0]}
|
||||
|
||||
|
||||
@ -46,6 +46,18 @@ class TestUploadDSL:
|
||||
with pytest.raises(ValueError, match="claim_code"):
|
||||
upload_dsl(b"app: demo")
|
||||
|
||||
@patch("core.helper.creators.httpx.post")
|
||||
def test_raises_on_non_string_claim_code(self, mock_post):
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.json.return_value = {"data": {"claim_code": 123}}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
from core.helper.creators import upload_dsl
|
||||
|
||||
with pytest.raises(ValueError, match="claim_code"):
|
||||
upload_dsl(b"app: demo")
|
||||
|
||||
@patch("core.helper.creators.httpx.post")
|
||||
def test_raises_on_http_error(self, mock_post):
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from core.helper.marketplace import (
|
||||
@ -53,6 +54,16 @@ def test_batch_fetch_plugin_by_ids_returns_plugins_from_response(mocker: MockerF
|
||||
response.raise_for_status.assert_called_once()
|
||||
|
||||
|
||||
def test_batch_fetch_plugin_by_ids_rejects_invalid_plugins_response(mocker: MockerFixture) -> None:
|
||||
response = MagicMock()
|
||||
response.json.return_value = {"data": {"plugins": ["p1"]}}
|
||||
response.raise_for_status.return_value = None
|
||||
mocker.patch("core.helper.marketplace.httpx.post", return_value=response)
|
||||
|
||||
with pytest.raises(ValueError, match="plugins list"):
|
||||
batch_fetch_plugin_by_ids(["p1"])
|
||||
|
||||
|
||||
def test_batch_fetch_plugin_manifests_returns_empty_for_empty_input(mocker: MockerFixture) -> None:
|
||||
post_mock = mocker.patch("core.helper.marketplace.httpx.post")
|
||||
|
||||
|
||||
@ -10,11 +10,13 @@ from core.mcp.server.streamable_http import (
|
||||
build_parameter_schema,
|
||||
convert_input_form_to_parameters,
|
||||
extract_answer_from_response,
|
||||
extract_structured_output,
|
||||
handle_call_tool,
|
||||
handle_initialize,
|
||||
handle_list_tools,
|
||||
handle_mcp_request,
|
||||
handle_ping,
|
||||
negotiate_protocol_version,
|
||||
prepare_tool_arguments,
|
||||
process_mapping_response,
|
||||
)
|
||||
@ -64,6 +66,8 @@ class TestHandleMCPRequest:
|
||||
# Setup initialize request
|
||||
self.mock_request.root = Mock(spec=types.InitializeRequest)
|
||||
self.mock_request.root.id = 123
|
||||
self.mock_request.root.params = Mock()
|
||||
self.mock_request.root.params.protocolVersion = "2025-06-18"
|
||||
request_type = Mock(return_value=types.InitializeRequest)
|
||||
|
||||
with patch("core.mcp.server.streamable_http.type", request_type):
|
||||
@ -91,6 +95,33 @@ class TestHandleMCPRequest:
|
||||
assert result.jsonrpc == "2.0"
|
||||
assert result.id == 123
|
||||
|
||||
def test_handle_list_tools_request_threads_protocol_version(self):
|
||||
"""The negotiated version reaches handle_list_tools through the dispatcher."""
|
||||
self.mock_request.root = Mock(spec=types.ListToolsRequest)
|
||||
self.mock_request.root.id = 123
|
||||
|
||||
result = handle_mcp_request(
|
||||
Mock(), self.app, self.mock_request, self.user_input_form, self.mcp_server, self.end_user, 123, "2025-06-18"
|
||||
)
|
||||
|
||||
assert isinstance(result, types.JSONRPCResponse)
|
||||
tool = result.result["tools"][0]
|
||||
assert tool["outputSchema"] == {"type": "object"}
|
||||
assert tool["title"] == "test_app"
|
||||
|
||||
def test_handle_list_tools_request_legacy_serialization_unchanged(self):
|
||||
"""A 2024-11-05 tools/list response serializes without any 2025-06-18 fields."""
|
||||
self.mock_request.root = Mock(spec=types.ListToolsRequest)
|
||||
self.mock_request.root.id = 123
|
||||
|
||||
result = handle_mcp_request(
|
||||
Mock(), self.app, self.mock_request, self.user_input_form, self.mcp_server, self.end_user, 123, "2024-11-05"
|
||||
)
|
||||
|
||||
assert isinstance(result, types.JSONRPCResponse)
|
||||
tool = result.result["tools"][0]
|
||||
assert set(tool) == {"name", "description", "inputSchema"}
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_handle_call_tool_request(self, mock_app_generate):
|
||||
"""Test handling call tool request"""
|
||||
@ -119,6 +150,43 @@ class TestHandleMCPRequest:
|
||||
# Verify AppGenerateService was called
|
||||
mock_app_generate.generate.assert_called_once()
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_handle_call_tool_request_threads_protocol_version(self, mock_app_generate):
|
||||
"""The negotiated version reaches handle_call_tool through the dispatcher."""
|
||||
mock_call_request = Mock(spec=types.CallToolRequest)
|
||||
mock_call_request.params = Mock()
|
||||
mock_call_request.params.arguments = {"query": "test question"}
|
||||
mock_call_request.id = 123
|
||||
self.mock_request.root = mock_call_request
|
||||
|
||||
mock_app_generate.generate.return_value = {"answer": "test answer"}
|
||||
|
||||
result = handle_mcp_request(
|
||||
Mock(), self.app, self.mock_request, self.user_input_form, self.mcp_server, self.end_user, 123, "2025-06-18"
|
||||
)
|
||||
|
||||
assert isinstance(result, types.JSONRPCResponse)
|
||||
assert result.result["structuredContent"] == {"answer": "test answer"}
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_handle_call_tool_request_legacy_serialization_unchanged(self, mock_app_generate):
|
||||
"""A 2024-11-05 tools/call response serializes without structuredContent."""
|
||||
mock_call_request = Mock(spec=types.CallToolRequest)
|
||||
mock_call_request.params = Mock()
|
||||
mock_call_request.params.arguments = {"query": "test question"}
|
||||
mock_call_request.id = 123
|
||||
self.mock_request.root = mock_call_request
|
||||
|
||||
mock_app_generate.generate.return_value = {"answer": "test answer"}
|
||||
|
||||
result = handle_mcp_request(
|
||||
Mock(), self.app, self.mock_request, self.user_input_form, self.mcp_server, self.end_user, 123, "2024-11-05"
|
||||
)
|
||||
|
||||
assert isinstance(result, types.JSONRPCResponse)
|
||||
assert "structuredContent" not in result.result
|
||||
assert result.result["content"][0]["text"] == "test answer"
|
||||
|
||||
def test_handle_unknown_request_type(self):
|
||||
"""Test handling unknown request type"""
|
||||
|
||||
@ -183,18 +251,49 @@ class TestIndividualHandlers:
|
||||
result = handle_ping()
|
||||
assert isinstance(result, types.EmptyResult)
|
||||
|
||||
def test_handle_initialize(self):
|
||||
"""Test initialize handler"""
|
||||
description = "Test server"
|
||||
|
||||
def test_handle_initialize_echoes_supported_version(self):
|
||||
"""A supported requested version is echoed back unchanged."""
|
||||
with patch("core.mcp.server.streamable_http.dify_config") as mock_config:
|
||||
mock_config.project.version = "1.0.0"
|
||||
result = handle_initialize(description)
|
||||
result = handle_initialize("Test server", "2024-11-05")
|
||||
|
||||
assert isinstance(result, types.InitializeResult)
|
||||
assert result.protocolVersion == types.SERVER_LATEST_PROTOCOL_VERSION
|
||||
assert result.protocolVersion == "2024-11-05"
|
||||
assert result.instructions == "Test server"
|
||||
|
||||
def test_handle_initialize_echoes_intermediate_version(self):
|
||||
"""The intermediate supported version (2025-03-26) is echoed back."""
|
||||
with patch("core.mcp.server.streamable_http.dify_config") as mock_config:
|
||||
mock_config.project.version = "1.0.0"
|
||||
result = handle_initialize("Test server", "2025-03-26")
|
||||
|
||||
assert result.protocolVersion == "2025-03-26"
|
||||
|
||||
def test_handle_initialize_negotiates_latest_for_modern_client(self):
|
||||
"""A 2025-06-18 client gets 2025-06-18 back."""
|
||||
with patch("core.mcp.server.streamable_http.dify_config") as mock_config:
|
||||
mock_config.project.version = "1.0.0"
|
||||
result = handle_initialize("Test server", "2025-06-18")
|
||||
|
||||
assert result.protocolVersion == "2025-06-18"
|
||||
|
||||
def test_handle_initialize_falls_back_for_unknown_version(self):
|
||||
"""An unsupported requested version falls back to the server latest."""
|
||||
with patch("core.mcp.server.streamable_http.dify_config") as mock_config:
|
||||
mock_config.project.version = "1.0.0"
|
||||
result = handle_initialize("Test server", "1999-01-01")
|
||||
|
||||
assert result.protocolVersion == types.SERVER_LATEST_PROTOCOL_VERSION
|
||||
assert result.protocolVersion == "2025-06-18"
|
||||
|
||||
def test_handle_initialize_non_string_version_falls_back(self):
|
||||
"""A malformed (non-string) requested version falls back to the server latest."""
|
||||
with patch("core.mcp.server.streamable_http.dify_config") as mock_config:
|
||||
mock_config.project.version = "1.0.0"
|
||||
result = handle_initialize("Test server", 20250618)
|
||||
|
||||
assert result.protocolVersion == types.SERVER_LATEST_PROTOCOL_VERSION
|
||||
|
||||
def test_handle_list_tools(self):
|
||||
"""Test list tools handler"""
|
||||
app_name = "test_app"
|
||||
@ -210,6 +309,30 @@ class TestIndividualHandlers:
|
||||
assert result.tools[0].name == "test_app"
|
||||
assert result.tools[0].description == "Test server"
|
||||
|
||||
def test_handle_list_tools_adds_structured_output_for_modern_client(self):
|
||||
"""Tool advertises outputSchema and title when negotiated >= 2025-06-18."""
|
||||
result = handle_list_tools("test_app", AppMode.CHAT, [], "Test server", {}, "2025-06-18")
|
||||
|
||||
tool = result.tools[0]
|
||||
assert tool.outputSchema == {"type": "object"}
|
||||
assert tool.title == "test_app"
|
||||
|
||||
def test_handle_list_tools_omits_structured_output_for_legacy_client(self):
|
||||
"""Tool stays unchanged (no outputSchema/title) for 2024-11-05 clients."""
|
||||
result = handle_list_tools("test_app", AppMode.CHAT, [], "Test server", {}, "2024-11-05")
|
||||
|
||||
tool = result.tools[0]
|
||||
assert tool.outputSchema is None
|
||||
assert tool.title is None
|
||||
|
||||
def test_handle_list_tools_omits_structured_output_for_intermediate_client(self):
|
||||
"""The 2025-03-26 negotiated version is below the structured-output threshold."""
|
||||
result = handle_list_tools("test_app", AppMode.CHAT, [], "Test server", {}, "2025-03-26")
|
||||
|
||||
tool = result.tools[0]
|
||||
assert tool.outputSchema is None
|
||||
assert tool.title is None
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_handle_call_tool(self, mock_app_generate):
|
||||
"""Test call tool handler"""
|
||||
@ -239,6 +362,44 @@ class TestIndividualHandlers:
|
||||
assert hasattr(text_content, "text")
|
||||
assert text_content.text == "test answer"
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_handle_call_tool_structured_output_modern_client(self, mock_app_generate):
|
||||
"""structuredContent is attached alongside TextContent for >= 2025-06-18."""
|
||||
app = Mock(spec=App)
|
||||
app.mode = AppMode.CHAT
|
||||
|
||||
mock_request = Mock()
|
||||
mock_call_request = Mock(spec=types.CallToolRequest)
|
||||
mock_call_request.params = Mock()
|
||||
mock_call_request.params.arguments = {"query": "test question"}
|
||||
mock_request.root = mock_call_request
|
||||
|
||||
mock_app_generate.generate.return_value = {"answer": "test answer"}
|
||||
|
||||
result = handle_call_tool(Mock(), app, mock_request, [], Mock(spec=EndUser), "2025-06-18")
|
||||
|
||||
assert result.structuredContent == {"answer": "test answer"}
|
||||
assert result.content[0].text == "test answer"
|
||||
|
||||
@patch("core.mcp.server.streamable_http.AppGenerateService")
|
||||
def test_handle_call_tool_no_structured_output_legacy_client(self, mock_app_generate):
|
||||
"""structuredContent is omitted for 2024-11-05 clients."""
|
||||
app = Mock(spec=App)
|
||||
app.mode = AppMode.CHAT
|
||||
|
||||
mock_request = Mock()
|
||||
mock_call_request = Mock(spec=types.CallToolRequest)
|
||||
mock_call_request.params = Mock()
|
||||
mock_call_request.params.arguments = {"query": "test question"}
|
||||
mock_request.root = mock_call_request
|
||||
|
||||
mock_app_generate.generate.return_value = {"answer": "test answer"}
|
||||
|
||||
result = handle_call_tool(Mock(), app, mock_request, [], Mock(spec=EndUser), "2024-11-05")
|
||||
|
||||
assert result.structuredContent is None
|
||||
assert result.content[0].text == "test answer"
|
||||
|
||||
def test_handle_call_tool_no_end_user(self):
|
||||
"""Test call tool handler without end user"""
|
||||
app = Mock(spec=App)
|
||||
@ -375,6 +536,65 @@ class TestUtilityFunctions:
|
||||
|
||||
assert result == "thinking...more thinking"
|
||||
|
||||
def test_extract_structured_output_workflow(self):
|
||||
"""Workflow mode exposes the raw outputs mapping as structured content."""
|
||||
app = Mock(spec=App)
|
||||
app.mode = AppMode.WORKFLOW
|
||||
|
||||
response = {"data": {"outputs": {"result": "test result"}}}
|
||||
|
||||
assert extract_structured_output(app, response, "ignored") == {"result": "test result"}
|
||||
|
||||
def test_extract_structured_output_chat(self):
|
||||
"""Chat mode wraps the answer string under an 'answer' key."""
|
||||
app = Mock(spec=App)
|
||||
app.mode = AppMode.CHAT
|
||||
|
||||
assert extract_structured_output(app, {"answer": "hi"}, "hi") == {"answer": "hi"}
|
||||
|
||||
def test_extract_structured_output_workflow_missing_outputs(self):
|
||||
"""Missing or malformed outputs fall back to None."""
|
||||
app = Mock(spec=App)
|
||||
app.mode = AppMode.WORKFLOW
|
||||
|
||||
assert extract_structured_output(app, {"data": {}}, "ignored") is None
|
||||
|
||||
def test_extract_structured_output_workflow_non_mapping_response(self):
|
||||
"""A non-mapping workflow response yields no structured output."""
|
||||
app = Mock(spec=App)
|
||||
app.mode = AppMode.WORKFLOW
|
||||
|
||||
assert extract_structured_output(app, None, "ignored") is None
|
||||
|
||||
def test_extract_structured_output_workflow_non_mapping_data(self):
|
||||
"""A non-mapping 'data' entry yields no structured output."""
|
||||
app = Mock(spec=App)
|
||||
app.mode = AppMode.WORKFLOW
|
||||
|
||||
assert extract_structured_output(app, {"data": "not a mapping"}, "ignored") is None
|
||||
|
||||
def test_extract_structured_output_workflow_non_mapping_outputs(self):
|
||||
"""A non-mapping 'outputs' entry yields no structured output."""
|
||||
app = Mock(spec=App)
|
||||
app.mode = AppMode.WORKFLOW
|
||||
|
||||
assert extract_structured_output(app, {"data": {"outputs": ["not", "a", "mapping"]}}, "ignored") is None
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.ADVANCED_CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION])
|
||||
def test_extract_structured_output_other_answer_modes(self, mode):
|
||||
"""Every chat-style mode wraps the answer string under an 'answer' key."""
|
||||
app = Mock(spec=App)
|
||||
app.mode = mode
|
||||
|
||||
assert extract_structured_output(app, {"answer": "hi"}, "hi") == {"answer": "hi"}
|
||||
|
||||
def test_extract_structured_output_unknown_mode(self):
|
||||
"""Modes outside the MCP surface produce no structured output."""
|
||||
app = Mock(spec=App)
|
||||
app.mode = AppMode.CHANNEL
|
||||
|
||||
assert extract_structured_output(app, {"answer": "hi"}, "hi") is None
|
||||
|
||||
def test_process_mapping_response_invalid_mode(self):
|
||||
"""Test processing mapping response with invalid app mode"""
|
||||
app = Mock(spec=App)
|
||||
@ -578,3 +798,29 @@ class TestUtilityFunctions:
|
||||
# Or validation should also raise SchemaError
|
||||
with pytest.raises(jsonschema.exceptions.SchemaError):
|
||||
jsonschema.validate(instance={"count": 1.23}, schema=bad_schema)
|
||||
|
||||
|
||||
class TestNegotiateProtocolVersion:
|
||||
"""Test the MCP-Protocol-Version header resolver."""
|
||||
|
||||
def test_initialize_ignores_header(self):
|
||||
"""Initialize negotiates via the request body, so its header is ignored."""
|
||||
assert negotiate_protocol_version("anything", True) == types.DEFAULT_NEGOTIATED_VERSION
|
||||
|
||||
def test_absent_header_defaults(self):
|
||||
"""An absent header defaults to 2025-03-26 per the spec back-compat rule."""
|
||||
assert negotiate_protocol_version(None, False) == types.DEFAULT_NEGOTIATED_VERSION
|
||||
|
||||
def test_empty_header_treated_as_absent(self):
|
||||
"""An empty header value is treated as absent and defaults to 2025-03-26."""
|
||||
assert negotiate_protocol_version("", False) == types.DEFAULT_NEGOTIATED_VERSION
|
||||
|
||||
def test_supported_header_passes_through(self):
|
||||
"""All supported header values are used as the negotiated version."""
|
||||
assert negotiate_protocol_version("2025-06-18", False) == "2025-06-18"
|
||||
assert negotiate_protocol_version("2025-03-26", False) == "2025-03-26"
|
||||
assert negotiate_protocol_version("2024-11-05", False) == "2024-11-05"
|
||||
|
||||
def test_unsupported_header_returns_none(self):
|
||||
"""An explicit but unsupported header signals an error (None)."""
|
||||
assert negotiate_protocol_version("1999-01-01", False) is None
|
||||
|
||||
@ -4,6 +4,7 @@ import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from core.mcp.types import (
|
||||
DEFAULT_NEGOTIATED_VERSION,
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
INVALID_REQUEST,
|
||||
@ -11,6 +12,7 @@ from core.mcp.types import (
|
||||
METHOD_NOT_FOUND,
|
||||
PARSE_ERROR,
|
||||
SERVER_LATEST_PROTOCOL_VERSION,
|
||||
SERVER_SUPPORTED_PROTOCOL_VERSIONS,
|
||||
Annotations,
|
||||
CallToolRequest,
|
||||
CallToolRequestParams,
|
||||
@ -59,7 +61,9 @@ class TestConstants:
|
||||
def test_protocol_versions(self):
|
||||
"""Test protocol version constants."""
|
||||
assert LATEST_PROTOCOL_VERSION == "2025-06-18"
|
||||
assert SERVER_LATEST_PROTOCOL_VERSION == "2024-11-05"
|
||||
assert SERVER_LATEST_PROTOCOL_VERSION == "2025-06-18"
|
||||
assert DEFAULT_NEGOTIATED_VERSION == "2025-03-26"
|
||||
assert sorted(SERVER_SUPPORTED_PROTOCOL_VERSIONS) == ["2024-11-05", "2025-03-26", "2025-06-18"]
|
||||
|
||||
def test_error_codes(self):
|
||||
"""Test JSON-RPC error code constants."""
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@ -8,17 +9,17 @@ from services.plugin.plugin_migration import PluginMigration
|
||||
MIGRATION_MODULE = "services.plugin.plugin_migration"
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_returns_none_when_disabled(mocker: MockerFixture) -> None:
|
||||
def test_fetch_latest_package_identifier_returns_none_when_disabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", False)
|
||||
batch_fetch = mocker.patch("services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests")
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
result = PluginMigration._fetch_latest_package_identifier("langgenius/openai")
|
||||
|
||||
assert result is None
|
||||
batch_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_calls_marketplace_when_enabled(mocker: MockerFixture) -> None:
|
||||
def test_fetch_latest_package_identifier_calls_marketplace_when_enabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", True)
|
||||
manifest = mocker.MagicMock()
|
||||
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
||||
@ -27,7 +28,7 @@ def test_fetch_plugin_unique_identifier_calls_marketplace_when_enabled(mocker: M
|
||||
return_value=[manifest],
|
||||
)
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
result = PluginMigration._fetch_latest_package_identifier("langgenius/openai")
|
||||
|
||||
assert result == "langgenius/openai:1.0.0@abc"
|
||||
|
||||
@ -75,7 +76,27 @@ class TestHandlePluginInstanceInstall:
|
||||
|
||||
mock_marketplace.download_plugin_pkg.assert_called_once()
|
||||
invalidate_cache.assert_called_once_with("tenant1")
|
||||
assert "success" in result or "failed" in result
|
||||
assert result["success"] == ["langgenius/openai"]
|
||||
assert result["failed"] == []
|
||||
|
||||
def test_reports_failed_plugin_ids_when_install_batch_raises(self) -> None:
|
||||
with (
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.marketplace") as mock_marketplace,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_marketplace.download_plugin_pkg.return_value = b"pkg_data"
|
||||
mock_installer = MagicMock()
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
mock_installer.install_from_identifiers.side_effect = RuntimeError("install failed")
|
||||
|
||||
result = PluginMigration.handle_plugin_instance_install(
|
||||
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
|
||||
)
|
||||
|
||||
assert result["success"] == []
|
||||
assert result["failed"] == ["langgenius/openai"]
|
||||
|
||||
def test_install_plugins_invalidates_cache_after_direct_tenant_install(self, tmp_path) -> None:
|
||||
extracted_plugins = tmp_path / "plugins.jsonl"
|
||||
@ -102,3 +123,30 @@ class TestHandlePluginInstanceInstall:
|
||||
|
||||
mock_installer.install_from_identifiers.assert_called_once()
|
||||
invalidate_cache.assert_called_once_with("tenant1")
|
||||
|
||||
def test_install_plugins_reports_missing_plugin_ids(self, tmp_path) -> None:
|
||||
extracted_plugins = tmp_path / "plugins.jsonl"
|
||||
output_file = tmp_path / "output.json"
|
||||
extracted_plugins.write_text('{"tenant_id":"tenant1","plugins":["langgenius/openai","langgenius/missing"]}\n')
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{MIGRATION_MODULE}.PluginMigration.extract_unique_plugins",
|
||||
return_value={
|
||||
"plugins": {"langgenius/openai": "langgenius/openai:1.0.0@abc"},
|
||||
"plugin_not_exist": ["langgenius/missing"],
|
||||
},
|
||||
),
|
||||
patch(f"{MIGRATION_MODULE}.PluginMigration.handle_plugin_instance_install", return_value={}),
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
patch(f"{MIGRATION_MODULE}.PluginService.invalidate_plugin_model_providers_cache"),
|
||||
):
|
||||
mock_installer = MagicMock()
|
||||
mock_installer.list_plugins.return_value = []
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
|
||||
PluginMigration.install_plugins(str(extracted_plugins), str(output_file), workers=1)
|
||||
|
||||
assert json.loads(output_file.read_text())["not_installed"] == [
|
||||
{"tenant_id": "tenant1", "plugin_not_exist": ["langgenius/missing"]}
|
||||
]
|
||||
|
||||
@ -643,6 +643,19 @@ def test_import_rag_pipeline_yaml_content_requires_mapping() -> None:
|
||||
assert "content must be a mapping" in result.error
|
||||
|
||||
|
||||
def test_import_rag_pipeline_rejects_oversized_yaml_content_by_bytes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.rag_pipeline.rag_pipeline_dsl_service.DSL_MAX_SIZE", 1)
|
||||
service = RagPipelineDslService(session=Mock())
|
||||
account = Mock(current_tenant_id="t1")
|
||||
|
||||
result = service.import_rag_pipeline(account=account, import_mode="yaml-content", yaml_content="é")
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert "10MB" in result.error
|
||||
|
||||
|
||||
def test_confirm_import_returns_failed_when_pending_data_is_invalid_type(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.redis_client.get", return_value=object())
|
||||
service = RagPipelineDslService(session=Mock())
|
||||
@ -901,6 +914,46 @@ def test_import_rag_pipeline_url_size_exceeds_limit(mocker: MockerFixture) -> No
|
||||
assert "10MB" in result.error
|
||||
|
||||
|
||||
def test_import_rag_pipeline_rejects_oversized_yaml_url_bytes_before_decode(
|
||||
mocker: MockerFixture,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.rag_pipeline.rag_pipeline_dsl_service.DSL_MAX_SIZE", 1)
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.content = b"\xff\xff"
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.remote_fetcher.make_request", return_value=response)
|
||||
service = RagPipelineDslService(session=Mock())
|
||||
account = Mock(current_tenant_id="t1")
|
||||
|
||||
result = service.import_rag_pipeline(
|
||||
account=account,
|
||||
import_mode="yaml-url",
|
||||
yaml_url="https://example.com/pipeline.yaml",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert "10MB" in result.error
|
||||
|
||||
|
||||
def test_import_rag_pipeline_returns_decode_error_for_invalid_yaml_url_bytes(mocker: MockerFixture) -> None:
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.content = b"\xff"
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.remote_fetcher.make_request", return_value=response)
|
||||
service = RagPipelineDslService(session=Mock())
|
||||
account = Mock(current_tenant_id="t1")
|
||||
|
||||
result = service.import_rag_pipeline(
|
||||
account=account,
|
||||
import_mode="yaml-url",
|
||||
yaml_url="https://example.com/pipeline.yaml",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert "utf-8" in result.error
|
||||
|
||||
|
||||
def test_import_rag_pipeline_fails_when_rag_pipeline_data_missing() -> None:
|
||||
service = RagPipelineDslService(session=Mock())
|
||||
account = Mock(current_tenant_id="t1")
|
||||
|
||||
@ -89,7 +89,7 @@ def test_deal_dependencies_installs_missing_marketplace_plugins(mocker: MockerFi
|
||||
installer_cls.return_value.list_plugins.return_value = [SimpleNamespace(plugin_id="installed-plugin")]
|
||||
|
||||
migration_cls = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration")
|
||||
migration_cls.return_value._fetch_plugin_unique_identifier.return_value = "missing-plugin:1.0.0"
|
||||
migration_cls.return_value._fetch_latest_package_identifier.return_value = "missing-plugin:1.0.0"
|
||||
|
||||
install_mock = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
@ -518,7 +518,7 @@ def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture) -> None:
|
||||
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
|
||||
installer.list_plugins.return_value = []
|
||||
migration = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration").return_value
|
||||
migration._fetch_plugin_unique_identifier.return_value = "langgenius/openai:1.0.0@abc"
|
||||
migration._fetch_latest_package_identifier.return_value = "langgenius/openai:1.0.0@abc"
|
||||
install_call = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
)
|
||||
|
||||
@ -1,16 +1,13 @@
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from models.account import Account, AccountStatus, TenantAccountRole, TenantStatus
|
||||
from models.base import TypeBase
|
||||
from services.account_service import AccountService, RegisterService, TenantService
|
||||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
@ -22,16 +19,6 @@ from services.errors.account import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session(request: pytest.FixtureRequest) -> Iterator[Session]:
|
||||
models: tuple[type[TypeBase], ...] = request.param
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
tables = [model.metadata.tables[model.__tablename__] for model in models]
|
||||
Account.metadata.create_all(engine, tables=tables)
|
||||
with Session(engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
|
||||
class TestAccountAssociatedDataFactory:
|
||||
"""Factory class for creating test data and mock objects for account service tests."""
|
||||
|
||||
|
||||
53
api/tests/unit_tests/services/test_app_dsl_service.py
Normal file
53
api/tests/unit_tests/services/test_app_dsl_service.py
Normal file
@ -0,0 +1,53 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from services.app_dsl_service import AppDslService, ImportStatus
|
||||
|
||||
|
||||
def test_import_app_rejects_oversized_yaml_content_by_bytes(monkeypatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 1)
|
||||
service = AppDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_app(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode="yaml-content",
|
||||
yaml_content="é",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert "10MB" in result.error
|
||||
|
||||
|
||||
def test_import_app_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 1)
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.content = b"\xff\xff"
|
||||
monkeypatch.setattr("services.app_dsl_service.remote_fetcher.make_request", Mock(return_value=response))
|
||||
service = AppDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_app(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode="yaml-url",
|
||||
yaml_url="https://example.com/app.yaml",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert "10MB" in result.error
|
||||
|
||||
|
||||
def test_import_app_returns_decode_error_for_invalid_yaml_url_bytes(monkeypatch) -> None:
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.content = b"\xff"
|
||||
monkeypatch.setattr("services.app_dsl_service.remote_fetcher.make_request", Mock(return_value=response))
|
||||
service = AppDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_app(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode="yaml-url",
|
||||
yaml_url="https://example.com/app.yaml",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert "utf-8" in result.error
|
||||
@ -95,7 +95,7 @@ def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch: pytest.M
|
||||
monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3)
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.ssrf_proxy.get",
|
||||
Mock(return_value=SimpleNamespace(status_code=200, text="too large")),
|
||||
Mock(return_value=SimpleNamespace(status_code=200, content=b"too large")),
|
||||
)
|
||||
|
||||
result = service.import_snippet(
|
||||
@ -108,6 +108,43 @@ def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch: pytest.M
|
||||
assert "YAML content size exceeds maximum limit" in result.error
|
||||
|
||||
|
||||
def test_import_snippet_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 1)
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.ssrf_proxy.get",
|
||||
Mock(return_value=SimpleNamespace(status_code=200, content=b"\xff\xff")),
|
||||
)
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_URL.value,
|
||||
yaml_url="https://example.com/snippet.yaml",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert "YAML content size exceeds maximum limit" in result.error
|
||||
|
||||
|
||||
def test_import_snippet_returns_decode_error_for_invalid_yaml_url_bytes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.ssrf_proxy.get",
|
||||
Mock(return_value=SimpleNamespace(status_code=200, content=b"\xff")),
|
||||
)
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_URL.value,
|
||||
yaml_url="https://example.com/snippet.yaml",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert "utf-8" in result.error
|
||||
|
||||
|
||||
def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
@ -127,12 +164,12 @@ def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch: p
|
||||
|
||||
def test_import_snippet_rejects_oversized_yaml_content(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3)
|
||||
monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 1)
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_CONTENT.value,
|
||||
yaml_content="too large",
|
||||
yaml_content="é",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
|
||||
@ -153,7 +153,7 @@ ENABLE_WEBSITE_WATERCRAWL=true
|
||||
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
# Enable preview features still in development (currently the /create and
|
||||
# /refine slash commands in the "Go to Anything" command palette).
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=false
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
|
||||
ENABLE_AGENT_V2=false
|
||||
EXPERIMENTAL_ENABLE_VINEXT=false
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ ENABLE_WEBSITE_JINAREADER=true
|
||||
ENABLE_WEBSITE_FIRECRAWL=true
|
||||
ENABLE_WEBSITE_WATERCRAWL=true
|
||||
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=false
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
|
||||
ENABLE_AGENT_V2=false
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=
|
||||
NEXT_PUBLIC_BATCH_CONCURRENCY=5
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
@ -12,6 +13,9 @@ import {
|
||||
|
||||
const WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS = 180_000
|
||||
|
||||
const getWebAppMessageInput = (webAppPage: Page) =>
|
||||
webAppPage.getByPlaceholder(/^Talk to /).last()
|
||||
|
||||
Then('I should see the Agent v2 Web app access URL', async function (this: DifyWorld) {
|
||||
const webAppCard = getWebAppCard(this)
|
||||
|
||||
@ -71,7 +75,7 @@ When('I send an E2E message in the Agent v2 Web app', async function (this: Dify
|
||||
if (!webAppPage)
|
||||
throw new Error('No Agent v2 Web app page was opened.')
|
||||
|
||||
const messageInput = webAppPage.getByRole('textbox').last()
|
||||
const messageInput = getWebAppMessageInput(webAppPage)
|
||||
await expect(messageInput).toBeEditable({ timeout: 30_000 })
|
||||
await messageInput.fill('Please reply with the test success marker.')
|
||||
await messageInput.press('Enter')
|
||||
@ -84,7 +88,7 @@ Then('the Agent v2 Web app should open in a new tab', async function (this: Dify
|
||||
throw new Error('No Agent v2 Web app page was opened.')
|
||||
|
||||
await expect(webAppPage).toHaveURL(webAppURL)
|
||||
await expect(webAppPage.getByRole('textbox').last()).toBeEditable({ timeout: 30_000 })
|
||||
await expect(getWebAppMessageInput(webAppPage)).toBeEditable({ timeout: 30_000 })
|
||||
await webAppPage.close()
|
||||
this.agentBuilder.accessPoint.webAppPage = undefined
|
||||
this.agentBuilder.accessPoint.webAppURL = undefined
|
||||
|
||||
@ -254,9 +254,8 @@ Then('I should see the Agent v2 Build mode confirmation state', async function (
|
||||
const page = this.getPage()
|
||||
|
||||
await expect(page.getByText('Build mode', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('You\'re in build mode. Shape this setup through the chat on the right, then Apply.'),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Configure can only be updated by the agent in this mode.')).toBeVisible()
|
||||
await expect(page.getByText('Shape this setup through the chat on the right, then Apply.')).toBeVisible()
|
||||
})
|
||||
|
||||
Then('Agent v2 Build chat should be blocked until a model is configured', async function (this: DifyWorld) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Browser } from '@playwright/test'
|
||||
import type { Browser, Page } from '@playwright/test'
|
||||
import type { Buffer } from 'node:buffer'
|
||||
import type { DifyWorld } from './world'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
@ -34,18 +34,50 @@ const sanitizeForPath = (value: string) =>
|
||||
|
||||
const writeArtifact = async (
|
||||
scenarioName: string,
|
||||
label: string,
|
||||
extension: 'html' | 'png',
|
||||
contents: Buffer | string,
|
||||
) => {
|
||||
const artifactPath = path.join(
|
||||
artifactsDir,
|
||||
`${Date.now()}-${sanitizeForPath(scenarioName || 'scenario')}.${extension}`,
|
||||
`${Date.now()}-${sanitizeForPath(scenarioName || 'scenario')}-${sanitizeForPath(label)}.${extension}`,
|
||||
)
|
||||
await writeFile(artifactPath, contents)
|
||||
|
||||
return artifactPath
|
||||
}
|
||||
|
||||
const uniqueDiagnosticPages = (pages: { label: string, page: Page | undefined }[]) => {
|
||||
const seen = new Set<Page>()
|
||||
|
||||
return pages.filter(({ page }) => {
|
||||
if (!page || page.isClosed() || seen.has(page))
|
||||
return false
|
||||
|
||||
seen.add(page)
|
||||
return true
|
||||
}) as { label: string, page: Page }[]
|
||||
}
|
||||
|
||||
const captureDiagnosticPage = async (
|
||||
world: DifyWorld,
|
||||
scenarioName: string,
|
||||
label: string,
|
||||
page: Page,
|
||||
) => {
|
||||
const screenshot = await page.screenshot({
|
||||
fullPage: true,
|
||||
})
|
||||
const screenshotPath = await writeArtifact(scenarioName, label, 'png', screenshot)
|
||||
world.attach(screenshot, 'image/png')
|
||||
|
||||
const html = await page.content()
|
||||
const htmlPath = await writeArtifact(scenarioName, label, 'html', html)
|
||||
world.attach(html, 'text/html')
|
||||
|
||||
return [screenshotPath, htmlPath]
|
||||
}
|
||||
|
||||
const recordCleanup = async (
|
||||
errors: string[],
|
||||
label: string,
|
||||
@ -91,16 +123,26 @@ After(async function (this: DifyWorld, { pickle, result }) {
|
||||
const elapsedMs = this.scenarioStartedAt ? Date.now() - this.scenarioStartedAt : undefined
|
||||
const status = result?.status || Status.UNKNOWN
|
||||
|
||||
if (diagnosticArtifactStatuses.has(status) && this.page) {
|
||||
const screenshot = await this.page.screenshot({
|
||||
fullPage: true,
|
||||
})
|
||||
const screenshotPath = await writeArtifact(pickle.name, 'png', screenshot)
|
||||
this.attach(screenshot, 'image/png')
|
||||
if (diagnosticArtifactStatuses.has(status)) {
|
||||
const artifactPaths: string[] = []
|
||||
const artifactErrors: string[] = []
|
||||
const diagnosticPages = uniqueDiagnosticPages([
|
||||
{ label: 'main-page', page: this.page },
|
||||
{ label: 'agent-v2-web-app', page: this.agentBuilder.accessPoint.webAppPage },
|
||||
{ label: 'agent-v2-api-reference', page: this.agentBuilder.accessPoint.apiReferencePage },
|
||||
{ label: 'agent-v2-workflow-reference', page: this.agentBuilder.accessPoint.workflowReferencePage },
|
||||
{ label: 'agent-v2-concurrent-configure', page: this.agentBuilder.configure.concurrentPage },
|
||||
{ label: 'agent-v2-workflow-console', page: this.agentBuilder.workflow.agentConsolePage },
|
||||
])
|
||||
|
||||
const html = await this.page.content()
|
||||
const htmlPath = await writeArtifact(pickle.name, 'html', html)
|
||||
this.attach(html, 'text/html')
|
||||
for (const { label, page } of diagnosticPages) {
|
||||
try {
|
||||
artifactPaths.push(...await captureDiagnosticPage(this, pickle.name, label, page))
|
||||
}
|
||||
catch (error) {
|
||||
artifactErrors.push(`${label}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.consoleErrors.length > 0)
|
||||
this.attach(`Console Errors:\n${this.consoleErrors.join('\n')}`, 'text/plain')
|
||||
@ -108,7 +150,11 @@ After(async function (this: DifyWorld, { pickle, result }) {
|
||||
if (this.pageErrors.length > 0)
|
||||
this.attach(`Page Errors:\n${this.pageErrors.join('\n')}`, 'text/plain')
|
||||
|
||||
this.attach(`Artifacts:\n${[screenshotPath, htmlPath].join('\n')}`, 'text/plain')
|
||||
if (artifactErrors.length > 0)
|
||||
this.attach(`Artifact Errors:\n${artifactErrors.join('\n')}`, 'text/plain')
|
||||
|
||||
if (artifactPaths.length > 0)
|
||||
this.attach(`Artifacts:\n${artifactPaths.join('\n')}`, 'text/plain')
|
||||
}
|
||||
|
||||
console.warn(
|
||||
|
||||
@ -7031,11 +7031,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control/__tests__/use-permission-keys.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -7056,11 +7051,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control/use-permission-keys.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control/use-workspace-access-rules.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -7228,17 +7218,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-common.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-empty-object-type": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-datasource.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -88,7 +88,7 @@ NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
|
||||
# Enable preview features still in development (currently the /create and
|
||||
# /refine slash commands in the "Go to Anything" command palette)
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=false
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
|
||||
|
||||
# Enable Agent v2 frontend entry points.
|
||||
NEXT_PUBLIC_ENABLE_AGENT_V2=false
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
|
||||
import Zendesk from '@/app/components/base/zendesk'
|
||||
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
|
||||
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
|
||||
import MainNavLayout from '@/app/components/main-nav/layout'
|
||||
import { NextRouteStateBridge } from '@/app/components/next-route-state'
|
||||
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
|
||||
@ -17,32 +18,35 @@ export default async function Layout({
|
||||
children,
|
||||
detailSidebar,
|
||||
}: {
|
||||
children: ReactNode
|
||||
detailSidebar: ReactNode
|
||||
children: React.ReactNode
|
||||
detailSidebar: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<React.Fragment>
|
||||
<GoogleAnalyticsScripts />
|
||||
<AmplitudeProvider />
|
||||
<OAuthRegistrationAnalytics />
|
||||
<EducationVerifyActionRecorder />
|
||||
<CommonLayoutHydrationBoundary>
|
||||
<NextRouteStateBridge>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalContextProvider>
|
||||
<MainNavLayout detailSidebar={detailSidebar}>
|
||||
{children}
|
||||
</MainNavLayout>
|
||||
<CommonLayoutGlobalMounts />
|
||||
</ModalContextProvider>
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<MaintenanceNotice />
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalContextProvider>
|
||||
<MainNavLayout detailSidebar={detailSidebar}>
|
||||
{children}
|
||||
</MainNavLayout>
|
||||
<CommonLayoutGlobalMounts />
|
||||
</ModalContextProvider>
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
</div>
|
||||
</NextRouteStateBridge>
|
||||
</CommonLayoutHydrationBoundary>
|
||||
<Zendesk />
|
||||
</>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { CommonLayoutHydrationBoundary } from '@/app/(commonLayout)/hydration-boundary'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
|
||||
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
|
||||
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
@ -12,30 +12,32 @@ import { ModalContextProvider } from '@/context/modal-context-provider'
|
||||
import { ProviderContextProvider } from '@/context/provider-context-provider'
|
||||
import Header from './header'
|
||||
|
||||
const Layout = async ({ children }: { children: ReactNode }) => {
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<React.Fragment>
|
||||
<GoogleAnalyticsScripts />
|
||||
<AmplitudeProvider />
|
||||
<OAuthRegistrationAnalytics />
|
||||
<EducationVerifyActionRecorder />
|
||||
<CommonLayoutHydrationBoundary>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalContextProvider>
|
||||
<HeaderWrapper>
|
||||
<Header />
|
||||
</HeaderWrapper>
|
||||
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg">
|
||||
{children}
|
||||
</div>
|
||||
</ModalContextProvider>
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background-body">
|
||||
<MaintenanceNotice />
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalContextProvider>
|
||||
<HeaderWrapper>
|
||||
<Header />
|
||||
</HeaderWrapper>
|
||||
<div className="relative flex h-0 min-h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg">
|
||||
{children}
|
||||
</div>
|
||||
</ModalContextProvider>
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
</div>
|
||||
</CommonLayoutHydrationBoundary>
|
||||
</>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
export default Layout
|
||||
|
||||
@ -4,14 +4,6 @@ import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import DatasetDetailTop from '../dataset-detail-top'
|
||||
|
||||
const mockBack = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
back: mockBack,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../toggle-button', () => ({
|
||||
default: ({ expand, handleToggle, icon }: { expand: boolean, handleToggle: () => void, icon?: ReactNode }) => (
|
||||
<button type="button" data-testid="toggle-button" data-expand={expand} data-has-icon={Boolean(icon)} onClick={handleToggle}>
|
||||
@ -41,14 +33,15 @@ describe('DatasetDetailTop', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('links the home icon to home and labels the breadcrumb as datasets', () => {
|
||||
it('links the combined home control to home and labels the breadcrumb as datasets', () => {
|
||||
renderWithGotoAnythingStore(<DatasetDetailTop />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
|
||||
expect(screen.getByRole('link', { name: 'common.menus.datasets' })).toHaveAttribute('href', '/datasets')
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the back button and quick search actions', () => {
|
||||
it('keeps the quick search action', () => {
|
||||
renderWithGotoAnythingStore(
|
||||
<>
|
||||
<DatasetDetailTop />
|
||||
@ -57,10 +50,8 @@ describe('DatasetDetailTop', () => {
|
||||
)
|
||||
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('false')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.back' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' }))
|
||||
|
||||
expect(mockBack).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import ToggleButton from './toggle-button'
|
||||
|
||||
type DatasetDetailTopProps = {
|
||||
@ -22,7 +21,6 @@ const DatasetDetailTop = ({
|
||||
onToggle,
|
||||
}: DatasetDetailTopProps) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const setGotoAnythingOpen = useSetGotoAnythingOpen()
|
||||
|
||||
if (!expand) {
|
||||
@ -43,23 +41,14 @@ const DatasetDetailTop = ({
|
||||
return (
|
||||
<div className="flex items-center py-2 pr-2 pl-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-px">
|
||||
<div className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 transition-colors hover:bg-background-default-hover">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.back', { ns: 'common' })}
|
||||
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={t('mainNav.home', { ns: 'common' })}
|
||||
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={t('mainNav.home', { ns: 'common' })}
|
||||
className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 text-text-tertiary transition-colors hover:bg-background-default-hover hover:text-text-secondary"
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
|
||||
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
|
||||
</Link>
|
||||
{expand && (
|
||||
<>
|
||||
<span className="shrink-0 system-md-regular text-text-quaternary">
|
||||
|
||||
@ -4,10 +4,18 @@ import { useLanguage } from '@/app/components/header/account-setting/model-provi
|
||||
import { NOTICE_I18N } from '@/i18n-config/language'
|
||||
import MaintenanceNotice from '../maintenance-notice'
|
||||
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
NEXT_PUBLIC_MAINTENANCE_NOTICE: 'true',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
|
||||
X: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/env', () => ({
|
||||
env: mockEnv,
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/components/header/account-setting/model-provider-page/hooks',
|
||||
() => ({
|
||||
@ -44,6 +52,7 @@ describe('MaintenanceNotice', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockEnv.NEXT_PUBLIC_MAINTENANCE_NOTICE = 'true'
|
||||
vi.mocked(useLanguage).mockReturnValue('en_US')
|
||||
setNoticeHref('#')
|
||||
})
|
||||
@ -71,6 +80,14 @@ describe('MaintenanceNotice', () => {
|
||||
const { container } = render(<MaintenanceNotice />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should not render when the notice env flag is disabled', () => {
|
||||
mockEnv.NEXT_PUBLIC_MAINTENANCE_NOTICE = ''
|
||||
|
||||
const { container } = render(<MaintenanceNotice />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
|
||||
@ -1,11 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { X } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { env } from '@/env'
|
||||
import { NOTICE_I18N } from '@/i18n-config/language'
|
||||
import { useHideMaintenanceNotice } from './storage'
|
||||
|
||||
const MaintenanceNotice = () => {
|
||||
function MaintenanceNotice() {
|
||||
if (!env.NEXT_PUBLIC_MAINTENANCE_NOTICE)
|
||||
return null
|
||||
|
||||
return <MaintenanceNoticeContent />
|
||||
}
|
||||
|
||||
function MaintenanceNoticeContent() {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLanguage()
|
||||
|
||||
|
||||
@ -1,8 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useAppContext, useSelector } from '../app-context'
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { queryClientAtom } from 'jotai-tanstack-query'
|
||||
import { useHydrateAtoms } from 'jotai/react/utils'
|
||||
import { Suspense } from 'react'
|
||||
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
|
||||
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
|
||||
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
|
||||
import { ZENDESK_FIELD_IDS } from '@/config'
|
||||
import { initialWorkspaceInfo, useAppContext, useSelector } from '../app-context'
|
||||
import { AppContextProvider } from '../app-context-provider'
|
||||
|
||||
const mockInvalidateQueries = vi.hoisted(() => vi.fn())
|
||||
const mockGetRequest = vi.hoisted(() => vi.fn())
|
||||
const mockPermissionKeysState = vi.hoisted(() => ({
|
||||
isPending: false,
|
||||
permissionKeys: ['app.create_and_management'],
|
||||
@ -19,55 +29,84 @@ const mockCurrentWorkspaceResponse = vi.hoisted(() => ({
|
||||
next_credit_reset_date: 1706745600,
|
||||
custom_config: {},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
}),
|
||||
useSuspenseQuery: (options: { queryKey?: readonly unknown[] }) => {
|
||||
if (options.queryKey?.[0] === 'system-features') {
|
||||
return {
|
||||
data: {
|
||||
branding: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
const mockCurrentWorkspaceQueryState = vi.hoisted(() => ({
|
||||
data: mockCurrentWorkspaceResponse as typeof mockCurrentWorkspaceResponse | undefined,
|
||||
isPending: false,
|
||||
}))
|
||||
const mockUserProfileResponseState = vi.hoisted(() => ({
|
||||
data: {
|
||||
profile: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
is_password_set: true,
|
||||
},
|
||||
meta: {
|
||||
currentVersion: '1.0.0',
|
||||
currentEnv: 'cloud',
|
||||
},
|
||||
} as {
|
||||
profile?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
avatar_url: string
|
||||
is_password_set: boolean
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
profile: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
is_password_set: true,
|
||||
},
|
||||
meta: {
|
||||
currentVersion: '1.0.0',
|
||||
currentEnv: 'cloud',
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
currentVersion: string | null
|
||||
currentEnv: string | null
|
||||
}
|
||||
},
|
||||
useQuery: (options: { select?: (workspace: typeof mockCurrentWorkspaceResponse) => unknown }) => ({
|
||||
data: options.select ? options.select(mockCurrentWorkspaceResponse) : mockCurrentWorkspaceResponse,
|
||||
isFetching: false,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
const mockSystemFeaturesState = vi.hoisted(() => ({
|
||||
data: {
|
||||
branding: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}))
|
||||
const mockLangGeniusVersionState = vi.hoisted(() => ({
|
||||
data: {
|
||||
version: '1.0.1',
|
||||
release_date: '',
|
||||
release_notes: '',
|
||||
can_auto_update: false,
|
||||
} as {
|
||||
version: string
|
||||
release_date: string
|
||||
release_notes: string
|
||||
can_auto_update: boolean
|
||||
} | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
ZENDESK_FIELD_IDS: {
|
||||
ENVIRONMENT: 'environment-field',
|
||||
VERSION: 'version-field',
|
||||
EMAIL: 'email-field',
|
||||
WORKSPACE_ID: 'workspace-id-field',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/system-features/client', () => ({
|
||||
systemFeaturesQueryOptions: () => ({
|
||||
queryKey: ['system-features'],
|
||||
queryFn: async () => mockSystemFeaturesState.data,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/account-profile/client', () => ({
|
||||
userProfileQueryOptions: () => ({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: async () => mockUserProfileResponseState.data,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -77,8 +116,16 @@ vi.mock('@/service/client', () => ({
|
||||
current: {
|
||||
post: {
|
||||
key: () => ['current-workspace'],
|
||||
queryOptions: (options: Record<string, unknown>) => ({
|
||||
queryOptions: (options: {
|
||||
select?: (workspace?: typeof mockCurrentWorkspaceResponse) => unknown
|
||||
}) => ({
|
||||
queryKey: ['current-workspace'],
|
||||
queryFn: async () => {
|
||||
if (mockCurrentWorkspaceQueryState.isPending)
|
||||
return new Promise(() => {})
|
||||
|
||||
return mockCurrentWorkspaceQueryState.data
|
||||
},
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
@ -87,34 +134,9 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-permission-keys', () => ({
|
||||
useWorkspacePermissionKeys: () => ({
|
||||
data: {
|
||||
workspace: {
|
||||
permission_keys: mockPermissionKeysState.permissionKeys,
|
||||
},
|
||||
app: {
|
||||
default_permission_keys: [],
|
||||
overrides: [],
|
||||
},
|
||||
dataset: {
|
||||
default_permission_keys: [],
|
||||
overrides: [],
|
||||
},
|
||||
},
|
||||
isPending: mockPermissionKeysState.isPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useLangGeniusVersion: () => ({
|
||||
data: {
|
||||
version: '1.0.1',
|
||||
release_date: '',
|
||||
release_notes: '',
|
||||
can_auto_update: false,
|
||||
},
|
||||
}),
|
||||
vi.mock('@/service/base', () => ({
|
||||
get: mockGetRequest,
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
@ -145,35 +167,303 @@ function AppContextProbe() {
|
||||
{selectedWorkspacePermissionKeys.join(',')}
|
||||
</span>
|
||||
<span>
|
||||
loading:
|
||||
permission loading:
|
||||
{String(context.isLoadingWorkspacePermissionKeys)}
|
||||
</span>
|
||||
<span>
|
||||
workspace loading:
|
||||
{String(context.isLoadingCurrentWorkspace)}
|
||||
</span>
|
||||
<span>
|
||||
workspace validating:
|
||||
{String(context.isValidatingCurrentWorkspace)}
|
||||
</span>
|
||||
<span>
|
||||
user:
|
||||
{context.userProfile.email}
|
||||
</span>
|
||||
<span>
|
||||
workspace:
|
||||
{context.currentWorkspace.name}
|
||||
</span>
|
||||
<span>
|
||||
role:
|
||||
{context.currentWorkspace.role}
|
||||
</span>
|
||||
<span>
|
||||
manager:
|
||||
{String(context.isCurrentWorkspaceManager)}
|
||||
</span>
|
||||
<span>
|
||||
owner:
|
||||
{String(context.isCurrentWorkspaceOwner)}
|
||||
</span>
|
||||
<span>
|
||||
editor:
|
||||
{String(context.isCurrentWorkspaceEditor)}
|
||||
</span>
|
||||
<span>
|
||||
dataset operator:
|
||||
{String(context.isCurrentWorkspaceDatasetOperator)}
|
||||
</span>
|
||||
<span>
|
||||
version:
|
||||
{context.langGeniusVersionInfo.current_version}
|
||||
/
|
||||
{context.langGeniusVersionInfo.latest_version}
|
||||
/
|
||||
{context.langGeniusVersionInfo.current_env}
|
||||
</span>
|
||||
<button type="button" onClick={context.mutateUserProfile}>refresh user</button>
|
||||
<button type="button" onClick={context.mutateCurrentWorkspace}>refresh workspace</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TestQueryClientHydrator({
|
||||
children,
|
||||
queryClient,
|
||||
}: {
|
||||
children: ReactNode
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
useHydrateAtoms(new Map([[queryClientAtom, queryClient]]))
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderProvider() {
|
||||
const queryClient = createTestQueryClient()
|
||||
const view = render(
|
||||
<JotaiProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TestQueryClientHydrator queryClient={queryClient}>
|
||||
<Suspense fallback={<span>loading</span>}>
|
||||
<AppContextProvider>
|
||||
<AppContextProbe />
|
||||
</AppContextProvider>
|
||||
</Suspense>
|
||||
</TestQueryClientHydrator>
|
||||
</QueryClientProvider>
|
||||
</JotaiProvider>,
|
||||
)
|
||||
|
||||
return {
|
||||
...view,
|
||||
queryClient,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppContextProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPermissionKeysState.isPending = false
|
||||
mockPermissionKeysState.permissionKeys = ['app.create_and_management']
|
||||
mockCurrentWorkspaceQueryState.data = mockCurrentWorkspaceResponse
|
||||
mockCurrentWorkspaceQueryState.isPending = false
|
||||
mockUserProfileResponseState.data = {
|
||||
profile: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
is_password_set: true,
|
||||
},
|
||||
meta: {
|
||||
currentVersion: '1.0.0',
|
||||
currentEnv: 'cloud',
|
||||
},
|
||||
}
|
||||
mockSystemFeaturesState.data = {
|
||||
branding: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
mockLangGeniusVersionState.data = {
|
||||
version: '1.0.1',
|
||||
release_date: '',
|
||||
release_notes: '',
|
||||
can_auto_update: false,
|
||||
}
|
||||
mockGetRequest.mockImplementation((url: string) => {
|
||||
if (url === '/workspaces/current/rbac/my-permissions') {
|
||||
if (mockPermissionKeysState.isPending)
|
||||
return new Promise(() => {})
|
||||
|
||||
return Promise.resolve({
|
||||
workspace: {
|
||||
permission_keys: mockPermissionKeysState.permissionKeys,
|
||||
},
|
||||
app: {
|
||||
default_permission_keys: [],
|
||||
overrides: [],
|
||||
},
|
||||
dataset: {
|
||||
default_permission_keys: [],
|
||||
overrides: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (url === '/version')
|
||||
return Promise.resolve(mockLangGeniusVersionState.data)
|
||||
|
||||
return Promise.reject(new Error(`Unexpected GET ${url}`))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace Permission Keys', () => {
|
||||
it('should provide current workspace permission keys from my-permissions', () => {
|
||||
render(
|
||||
<AppContextProvider>
|
||||
<AppContextProbe />
|
||||
</AppContextProvider>,
|
||||
)
|
||||
describe('Context compatibility values', () => {
|
||||
it('should provide profile, workspace, permissions, loading state, and version metadata', async () => {
|
||||
renderProvider()
|
||||
|
||||
expect(screen.getByText('keys:app.create_and_management')).toBeInTheDocument()
|
||||
expect(screen.getByText('loading:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('role:editor')).toBeInTheDocument()
|
||||
expect(await screen.findByText('user:user@example.com')).toBeInTheDocument()
|
||||
expect(await screen.findByText('workspace:Workspace')).toBeInTheDocument()
|
||||
expect(await screen.findByText('keys:app.create_and_management')).toBeInTheDocument()
|
||||
expect(screen.getByText('permission loading:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('workspace loading:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('workspace validating:false')).toBeInTheDocument()
|
||||
expect(await screen.findByText('version:1.0.0/1.0.1/cloud')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to placeholder values when profile, workspace, permission, or version data is missing', async () => {
|
||||
mockUserProfileResponseState.data = {
|
||||
meta: {
|
||||
currentVersion: null,
|
||||
currentEnv: null,
|
||||
},
|
||||
}
|
||||
mockCurrentWorkspaceQueryState.data = undefined
|
||||
mockPermissionKeysState.permissionKeys = []
|
||||
mockLangGeniusVersionState.data = undefined
|
||||
|
||||
renderProvider()
|
||||
|
||||
expect(await screen.findByText('user:')).toBeInTheDocument()
|
||||
expect(screen.getByText(`workspace:${initialWorkspaceInfo.name}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
|
||||
expect(screen.getByText('keys:')).toBeInTheDocument()
|
||||
expect(screen.getByText('version://')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should normalize invalid workspace roles to the initial workspace role', async () => {
|
||||
mockCurrentWorkspaceQueryState.data = {
|
||||
...mockCurrentWorkspaceResponse,
|
||||
role: 'unsupported-role',
|
||||
}
|
||||
|
||||
renderProvider()
|
||||
|
||||
expect(await screen.findByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should derive role flags from the current workspace role', async () => {
|
||||
mockCurrentWorkspaceQueryState.data = {
|
||||
...mockCurrentWorkspaceResponse,
|
||||
role: 'owner',
|
||||
}
|
||||
|
||||
renderProvider()
|
||||
|
||||
expect(await screen.findByText('manager:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('owner:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('editor:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset operator:false')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose query loading and validating state', async () => {
|
||||
mockPermissionKeysState.isPending = true
|
||||
mockCurrentWorkspaceQueryState.isPending = true
|
||||
|
||||
renderProvider()
|
||||
|
||||
expect(await screen.findByText('workspace loading:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('workspace validating:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('permission loading:true')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Refresh actions', () => {
|
||||
it('should invalidate the source queries when refresh actions are called', async () => {
|
||||
const { queryClient } = renderProvider()
|
||||
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /refresh user/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /refresh workspace/i }))
|
||||
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['user-profile'] })
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['current-workspace'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('External side effects', () => {
|
||||
it('should sync Zendesk fields and Amplitude identity when bootstrap data is available', async () => {
|
||||
renderProvider()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
|
||||
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
|
||||
value: 'cloud',
|
||||
}])
|
||||
})
|
||||
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
|
||||
id: ZENDESK_FIELD_IDS.VERSION,
|
||||
value: '1.0.1',
|
||||
}])
|
||||
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
|
||||
id: ZENDESK_FIELD_IDS.EMAIL,
|
||||
value: 'user@example.com',
|
||||
}])
|
||||
await waitFor(() => {
|
||||
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
|
||||
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
|
||||
value: 'workspace-1',
|
||||
}])
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(setUserId).toHaveBeenCalledWith('user@example.com')
|
||||
expect(setUserProperties).toHaveBeenCalledWith(expect.objectContaining({
|
||||
email: 'user@example.com',
|
||||
workspace_id: 'workspace-1',
|
||||
workspace_role: 'editor',
|
||||
}))
|
||||
expect(flushRegistrationSuccess).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not sync Amplitude identity when user id is missing', async () => {
|
||||
mockUserProfileResponseState.data = {
|
||||
profile: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
is_password_set: false,
|
||||
},
|
||||
meta: {
|
||||
currentVersion: '1.0.0',
|
||||
currentEnv: 'cloud',
|
||||
},
|
||||
}
|
||||
|
||||
renderProvider()
|
||||
|
||||
await screen.findByText('user:')
|
||||
expect(setUserId).not.toHaveBeenCalled()
|
||||
expect(setUserProperties).not.toHaveBeenCalled()
|
||||
expect(flushRegistrationSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
82
web/context/app-context-effects.ts
Normal file
82
web/context/app-context-effects.ts
Normal file
@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect } from 'react'
|
||||
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
|
||||
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
|
||||
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
|
||||
import { ZENDESK_FIELD_IDS } from '@/config'
|
||||
import {
|
||||
currentWorkspaceAtom,
|
||||
langGeniusVersionInfoAtom,
|
||||
userProfileAtom,
|
||||
} from './app-context-state'
|
||||
|
||||
export function useSyncZendeskFields() {
|
||||
const userProfile = useAtomValue(userProfileAtom)
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
|
||||
|
||||
useEffect(() => {
|
||||
if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) {
|
||||
setZendeskConversationFields([{
|
||||
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
|
||||
value: langGeniusVersionInfo.current_env.toLowerCase(),
|
||||
}])
|
||||
}
|
||||
}, [langGeniusVersionInfo?.current_env])
|
||||
|
||||
useEffect(() => {
|
||||
if (ZENDESK_FIELD_IDS.VERSION && langGeniusVersionInfo?.version) {
|
||||
setZendeskConversationFields([{
|
||||
id: ZENDESK_FIELD_IDS.VERSION,
|
||||
value: langGeniusVersionInfo.version,
|
||||
}])
|
||||
}
|
||||
}, [langGeniusVersionInfo?.version])
|
||||
|
||||
useEffect(() => {
|
||||
if (ZENDESK_FIELD_IDS.EMAIL && userProfile?.email) {
|
||||
setZendeskConversationFields([{
|
||||
id: ZENDESK_FIELD_IDS.EMAIL,
|
||||
value: userProfile.email,
|
||||
}])
|
||||
}
|
||||
}, [userProfile?.email])
|
||||
|
||||
useEffect(() => {
|
||||
if (ZENDESK_FIELD_IDS.WORKSPACE_ID && currentWorkspace?.id) {
|
||||
setZendeskConversationFields([{
|
||||
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
|
||||
value: currentWorkspace.id,
|
||||
}])
|
||||
}
|
||||
}, [currentWorkspace?.id])
|
||||
}
|
||||
|
||||
export function useSyncAmplitudeIdentity() {
|
||||
const userProfile = useAtomValue(userProfileAtom)
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile?.id) {
|
||||
setUserId(userProfile.email)
|
||||
const properties: Record<string, string | number | boolean> = {
|
||||
email: userProfile.email,
|
||||
name: userProfile.name,
|
||||
has_password: userProfile.is_password_set,
|
||||
}
|
||||
|
||||
if (currentWorkspace?.id) {
|
||||
properties.workspace_id = currentWorkspace.id
|
||||
properties.workspace_name = currentWorkspace.name
|
||||
properties.workspace_plan = currentWorkspace.plan
|
||||
properties.workspace_status = currentWorkspace.status
|
||||
properties.workspace_role = currentWorkspace.role
|
||||
}
|
||||
|
||||
setUserProperties(properties)
|
||||
flushRegistrationSuccess()
|
||||
}
|
||||
}, [userProfile, currentWorkspace])
|
||||
}
|
||||
78
web/context/app-context-normalizers.ts
Normal file
78
web/context/app-context-normalizers.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common'
|
||||
import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from './app-context'
|
||||
|
||||
const workspaceRoles = new Set<ICurrentWorkspace['role']>(['owner', 'admin', 'editor', 'dataset_operator', 'normal'])
|
||||
|
||||
export const emptyWorkspacePermissionKeys: string[] = []
|
||||
|
||||
export type WorkspaceRoleFlags = {
|
||||
isCurrentWorkspaceManager: boolean
|
||||
isCurrentWorkspaceOwner: boolean
|
||||
isCurrentWorkspaceEditor: boolean
|
||||
isCurrentWorkspaceDatasetOperator: boolean
|
||||
}
|
||||
|
||||
export type ProfileMeta = {
|
||||
currentVersion: string | null
|
||||
currentEnv: string | null
|
||||
}
|
||||
|
||||
function resolveWorkspaceRole(role: PostWorkspacesCurrentResponse['role']): ICurrentWorkspace['role'] {
|
||||
if (role && workspaceRoles.has(role as ICurrentWorkspace['role']))
|
||||
return role as ICurrentWorkspace['role']
|
||||
|
||||
return initialWorkspaceInfo.role
|
||||
}
|
||||
|
||||
export function normalizeCurrentWorkspace(workspace?: PostWorkspacesCurrentResponse): ICurrentWorkspace {
|
||||
if (!workspace)
|
||||
return initialWorkspaceInfo
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name ?? initialWorkspaceInfo.name,
|
||||
plan: workspace.plan ?? initialWorkspaceInfo.plan,
|
||||
status: workspace.status ?? initialWorkspaceInfo.status,
|
||||
created_at: workspace.created_at ?? initialWorkspaceInfo.created_at,
|
||||
role: resolveWorkspaceRole(workspace.role),
|
||||
providers: initialWorkspaceInfo.providers,
|
||||
trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits,
|
||||
trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used,
|
||||
next_credit_reset_date: workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date,
|
||||
trial_end_reason: workspace.trial_end_reason ?? undefined,
|
||||
custom_config: workspace.custom_config
|
||||
? {
|
||||
remove_webapp_brand: workspace.custom_config.remove_webapp_brand ?? undefined,
|
||||
replace_webapp_logo: workspace.custom_config.replace_webapp_logo ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceRoleFlags(currentWorkspace: ICurrentWorkspace): WorkspaceRoleFlags {
|
||||
return {
|
||||
isCurrentWorkspaceManager: ['owner', 'admin'].includes(currentWorkspace.role),
|
||||
isCurrentWorkspaceOwner: currentWorkspace.role === 'owner',
|
||||
isCurrentWorkspaceEditor: ['owner', 'admin', 'editor'].includes(currentWorkspace.role),
|
||||
isCurrentWorkspaceDatasetOperator: currentWorkspace.role === 'dataset_operator',
|
||||
}
|
||||
}
|
||||
|
||||
export function getLangGeniusVersionInfo({
|
||||
meta,
|
||||
versionData,
|
||||
}: {
|
||||
meta: ProfileMeta
|
||||
versionData?: Omit<LangGeniusVersionResponse, 'current_version' | 'latest_version' | 'current_env'>
|
||||
}): LangGeniusVersionResponse {
|
||||
if (!meta.currentVersion || !versionData)
|
||||
return initialLangGeniusVersionInfo
|
||||
|
||||
return {
|
||||
...versionData,
|
||||
current_version: meta.currentVersion,
|
||||
latest_version: versionData.version,
|
||||
current_env: meta.currentEnv || '',
|
||||
}
|
||||
}
|
||||
@ -1,203 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
|
||||
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common'
|
||||
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
|
||||
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
|
||||
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
|
||||
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
|
||||
import { ZENDESK_FIELD_IDS } from '@/config'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import {
|
||||
AppContext,
|
||||
initialLangGeniusVersionInfo,
|
||||
initialWorkspaceInfo,
|
||||
userProfilePlaceholder,
|
||||
useSelector,
|
||||
} from '@/context/app-context'
|
||||
import { env } from '@/env'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useWorkspacePermissionKeys } from '@/service/access-control/use-permission-keys'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
useLangGeniusVersion,
|
||||
} from '@/service/use-common'
|
||||
currentWorkspaceAtom,
|
||||
currentWorkspaceLoadingAtom,
|
||||
currentWorkspaceValidatingAtom,
|
||||
langGeniusVersionInfoAtom,
|
||||
refreshCurrentWorkspaceAtom,
|
||||
refreshUserProfileAtom,
|
||||
userProfileAtom,
|
||||
workspacePermissionKeysAtom,
|
||||
workspacePermissionKeysLoadingAtom,
|
||||
workspaceRoleFlagsAtom,
|
||||
} from '@/context/app-context-state'
|
||||
import {
|
||||
useSyncAmplitudeIdentity,
|
||||
useSyncZendeskFields,
|
||||
} from './app-context-effects'
|
||||
|
||||
type AppContextProviderProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const workspaceRoles = new Set<ICurrentWorkspace['role']>(['owner', 'admin', 'editor', 'dataset_operator', 'normal'])
|
||||
const emptyWorkspacePermissionKeys: string[] = []
|
||||
export function AppContextProvider({ children }: AppContextProviderProps) {
|
||||
const userProfile = useAtomValue(userProfileAtom)
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
const roleFlags = useAtomValue(workspaceRoleFlagsAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
|
||||
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
|
||||
const isValidatingCurrentWorkspace = useAtomValue(currentWorkspaceValidatingAtom)
|
||||
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
|
||||
|
||||
const resolveWorkspaceRole = (role: PostWorkspacesCurrentResponse['role']): ICurrentWorkspace['role'] => {
|
||||
if (role && workspaceRoles.has(role as ICurrentWorkspace['role']))
|
||||
return role as ICurrentWorkspace['role']
|
||||
const refreshUserProfile = useSetAtom(refreshUserProfileAtom)
|
||||
const refreshCurrentWorkspace = useSetAtom(refreshCurrentWorkspaceAtom)
|
||||
|
||||
return initialWorkspaceInfo.role
|
||||
}
|
||||
|
||||
const normalizeCurrentWorkspace = (workspace?: PostWorkspacesCurrentResponse): ICurrentWorkspace => {
|
||||
if (!workspace)
|
||||
return initialWorkspaceInfo
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name ?? initialWorkspaceInfo.name,
|
||||
plan: workspace.plan ?? initialWorkspaceInfo.plan,
|
||||
status: workspace.status ?? initialWorkspaceInfo.status,
|
||||
created_at: workspace.created_at ?? initialWorkspaceInfo.created_at,
|
||||
role: resolveWorkspaceRole(workspace.role),
|
||||
providers: initialWorkspaceInfo.providers,
|
||||
trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits,
|
||||
trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used,
|
||||
next_credit_reset_date: workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date,
|
||||
trial_end_reason: workspace.trial_end_reason ?? undefined,
|
||||
custom_config: workspace.custom_config
|
||||
? {
|
||||
remove_webapp_brand: workspace.custom_config.remove_webapp_brand ?? undefined,
|
||||
replace_webapp_logo: workspace.custom_config.replace_webapp_logo ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
|
||||
const currentWorkspaceQuery = useQuery(consoleQuery.workspaces.current.post.queryOptions({
|
||||
select: normalizeCurrentWorkspace,
|
||||
}))
|
||||
const workspacePermissionKeysQuery = useWorkspacePermissionKeys()
|
||||
const langGeniusVersionQuery = useLangGeniusVersion(
|
||||
userProfileResp?.meta.currentVersion,
|
||||
!systemFeatures.branding.enabled,
|
||||
)
|
||||
|
||||
const userProfile = useMemo<GetAccountProfileResponse>(() => userProfileResp?.profile || userProfilePlaceholder, [userProfileResp?.profile])
|
||||
const currentWorkspace = currentWorkspaceQuery.data ?? initialWorkspaceInfo
|
||||
const langGeniusVersionInfo = useMemo<LangGeniusVersionResponse>(() => {
|
||||
if (!userProfileResp?.meta?.currentVersion || !langGeniusVersionQuery.data)
|
||||
return initialLangGeniusVersionInfo
|
||||
|
||||
const current_version = userProfileResp.meta.currentVersion
|
||||
const current_env = userProfileResp.meta.currentEnv || ''
|
||||
const versionData = langGeniusVersionQuery.data
|
||||
return {
|
||||
...versionData,
|
||||
current_version,
|
||||
latest_version: versionData.version,
|
||||
current_env,
|
||||
}
|
||||
}, [langGeniusVersionQuery.data, userProfileResp?.meta])
|
||||
|
||||
const isCurrentWorkspaceManager = useMemo(() => ['owner', 'admin'].includes(currentWorkspace.role), [currentWorkspace.role])
|
||||
const isCurrentWorkspaceOwner = useMemo(() => currentWorkspace.role === 'owner', [currentWorkspace.role])
|
||||
const isCurrentWorkspaceEditor = useMemo(() => ['owner', 'admin', 'editor'].includes(currentWorkspace.role), [currentWorkspace.role])
|
||||
const isCurrentWorkspaceDatasetOperator = useMemo(() => currentWorkspace.role === 'dataset_operator', [currentWorkspace.role])
|
||||
|
||||
const mutateUserProfile = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
|
||||
}, [queryClient])
|
||||
|
||||
const mutateCurrentWorkspace = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() })
|
||||
}, [queryClient])
|
||||
|
||||
// #region Zendesk conversation fields
|
||||
useEffect(() => {
|
||||
if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) {
|
||||
setZendeskConversationFields([{
|
||||
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
|
||||
value: langGeniusVersionInfo.current_env.toLowerCase(),
|
||||
}])
|
||||
}
|
||||
}, [langGeniusVersionInfo?.current_env])
|
||||
|
||||
useEffect(() => {
|
||||
if (ZENDESK_FIELD_IDS.VERSION && langGeniusVersionInfo?.version) {
|
||||
setZendeskConversationFields([{
|
||||
id: ZENDESK_FIELD_IDS.VERSION,
|
||||
value: langGeniusVersionInfo.version,
|
||||
}])
|
||||
}
|
||||
}, [langGeniusVersionInfo?.version])
|
||||
|
||||
useEffect(() => {
|
||||
if (ZENDESK_FIELD_IDS.EMAIL && userProfile?.email) {
|
||||
setZendeskConversationFields([{
|
||||
id: ZENDESK_FIELD_IDS.EMAIL,
|
||||
value: userProfile.email,
|
||||
}])
|
||||
}
|
||||
}, [userProfile?.email])
|
||||
|
||||
useEffect(() => {
|
||||
if (ZENDESK_FIELD_IDS.WORKSPACE_ID && currentWorkspace?.id) {
|
||||
setZendeskConversationFields([{
|
||||
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
|
||||
value: currentWorkspace.id,
|
||||
}])
|
||||
}
|
||||
}, [currentWorkspace?.id])
|
||||
// #endregion Zendesk conversation fields
|
||||
|
||||
useEffect(() => {
|
||||
// Report user and workspace info to Amplitude when loaded
|
||||
if (userProfile?.id) {
|
||||
setUserId(userProfile.email)
|
||||
const properties: Record<string, string | number | boolean> = {
|
||||
email: userProfile.email,
|
||||
name: userProfile.name,
|
||||
has_password: userProfile.is_password_set,
|
||||
}
|
||||
|
||||
if (currentWorkspace?.id) {
|
||||
properties.workspace_id = currentWorkspace.id
|
||||
properties.workspace_name = currentWorkspace.name
|
||||
properties.workspace_plan = currentWorkspace.plan
|
||||
properties.workspace_status = currentWorkspace.status
|
||||
properties.workspace_role = currentWorkspace.role
|
||||
}
|
||||
|
||||
setUserProperties(properties)
|
||||
|
||||
// The user ID is now attached, so replay any registration success event captured
|
||||
// at signup time. This makes it land on the identified Amplitude profile instead
|
||||
// of an anonymous one (no-op when nothing was deferred).
|
||||
flushRegistrationSuccess()
|
||||
}
|
||||
}, [userProfile, currentWorkspace])
|
||||
useSyncZendeskFields()
|
||||
useSyncAmplitudeIdentity()
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{
|
||||
userProfile,
|
||||
mutateUserProfile,
|
||||
mutateUserProfile: () => {
|
||||
refreshUserProfile()
|
||||
},
|
||||
langGeniusVersionInfo,
|
||||
useSelector,
|
||||
currentWorkspace,
|
||||
isCurrentWorkspaceManager,
|
||||
isCurrentWorkspaceOwner,
|
||||
isCurrentWorkspaceEditor,
|
||||
isCurrentWorkspaceDatasetOperator,
|
||||
mutateCurrentWorkspace,
|
||||
isLoadingCurrentWorkspace: currentWorkspaceQuery.isPending,
|
||||
isLoadingWorkspacePermissionKeys: workspacePermissionKeysQuery.isPending,
|
||||
isValidatingCurrentWorkspace: currentWorkspaceQuery.isFetching,
|
||||
workspacePermissionKeys: workspacePermissionKeysQuery.data?.workspace.permission_keys ?? emptyWorkspacePermissionKeys,
|
||||
...roleFlags,
|
||||
mutateCurrentWorkspace: () => {
|
||||
refreshCurrentWorkspace()
|
||||
},
|
||||
isLoadingCurrentWorkspace,
|
||||
isLoadingWorkspacePermissionKeys,
|
||||
isValidatingCurrentWorkspace,
|
||||
workspacePermissionKeys,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{env.NEXT_PUBLIC_MAINTENANCE_NOTICE && <MaintenanceNotice />}
|
||||
<div className="relative flex h-0 min-h-0 grow flex-col overflow-hidden bg-background-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
116
web/context/app-context-state.ts
Normal file
116
web/context/app-context-state.ts
Normal file
@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
|
||||
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { DefinedQueryObserverResult } from '@tanstack/react-query'
|
||||
import type { UserProfileWithMeta } from '@/features/account-profile/client'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithQuery, atomWithSuspenseQuery, queryClientAtom } from 'jotai-tanstack-query'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { workspacePermissionKeysQueryOptions } from '@/service/access-control/use-permission-keys'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { langGeniusVersionQueryOptions } from '@/service/lang-genius-version'
|
||||
import {
|
||||
initialLangGeniusVersionInfo,
|
||||
initialWorkspaceInfo,
|
||||
userProfilePlaceholder,
|
||||
} from './app-context'
|
||||
import {
|
||||
emptyWorkspacePermissionKeys,
|
||||
getLangGeniusVersionInfo,
|
||||
getWorkspaceRoleFlags,
|
||||
normalizeCurrentWorkspace,
|
||||
} from './app-context-normalizers'
|
||||
|
||||
type SuspenseQueryResult<T> = Omit<DefinedQueryObserverResult<T>, 'isPlaceholderData'>
|
||||
|
||||
const accountProfileQueryAtom = atomWithSuspenseQuery(() => userProfileQueryOptions())
|
||||
|
||||
const systemFeaturesQueryAtom = atomWithSuspenseQuery(() => systemFeaturesQueryOptions())
|
||||
|
||||
export const userProfileAtom = atom((get): GetAccountProfileResponse => {
|
||||
const accountProfileQuery = get(accountProfileQueryAtom) as SuspenseQueryResult<UserProfileWithMeta>
|
||||
|
||||
return accountProfileQuery.data?.profile || userProfilePlaceholder
|
||||
})
|
||||
|
||||
const profileMetaAtom = atom((get) => {
|
||||
const accountProfileQuery = get(accountProfileQueryAtom) as SuspenseQueryResult<UserProfileWithMeta>
|
||||
|
||||
return accountProfileQuery.data?.meta ?? {
|
||||
currentVersion: null,
|
||||
currentEnv: null,
|
||||
}
|
||||
})
|
||||
|
||||
const currentWorkspaceQueryAtom = atomWithQuery(() => {
|
||||
return consoleQuery.workspaces.current.post.queryOptions({
|
||||
select: normalizeCurrentWorkspace,
|
||||
})
|
||||
})
|
||||
|
||||
const normalizedCurrentWorkspaceAtom = atom((get) => {
|
||||
return get(currentWorkspaceQueryAtom).data ?? initialWorkspaceInfo
|
||||
})
|
||||
|
||||
export const currentWorkspaceAtom = atom((get) => {
|
||||
return get(normalizedCurrentWorkspaceAtom)
|
||||
})
|
||||
|
||||
export const workspaceRoleFlagsAtom = atom((get) => {
|
||||
return getWorkspaceRoleFlags(get(currentWorkspaceAtom))
|
||||
})
|
||||
|
||||
const workspacePermissionKeysQueryAtom = atomWithQuery((get) => {
|
||||
const workspaceId = get(currentWorkspaceAtom).id
|
||||
|
||||
return workspacePermissionKeysQueryOptions(workspaceId)
|
||||
})
|
||||
|
||||
export const workspacePermissionKeysAtom = atom((get) => {
|
||||
return get(workspacePermissionKeysQueryAtom).data?.workspace.permission_keys ?? emptyWorkspacePermissionKeys
|
||||
})
|
||||
|
||||
export const workspacePermissionKeysLoadingAtom = atom((get) => {
|
||||
return get(workspacePermissionKeysQueryAtom).isPending
|
||||
})
|
||||
|
||||
export const currentWorkspaceLoadingAtom = atom((get) => {
|
||||
return get(currentWorkspaceQueryAtom).isPending
|
||||
})
|
||||
|
||||
export const currentWorkspaceValidatingAtom = atom((get) => {
|
||||
return get(currentWorkspaceQueryAtom).isFetching
|
||||
})
|
||||
|
||||
const versionQueryAtom = atomWithQuery((get) => {
|
||||
const meta = get(profileMetaAtom)
|
||||
const systemFeaturesQuery = get(systemFeaturesQueryAtom) as SuspenseQueryResult<GetSystemFeaturesResponse>
|
||||
const enabled = Boolean(meta.currentVersion && !systemFeaturesQuery.data?.branding.enabled)
|
||||
|
||||
return langGeniusVersionQueryOptions(meta.currentVersion, enabled)
|
||||
})
|
||||
|
||||
export const langGeniusVersionInfoAtom = atom((get) => {
|
||||
const meta = get(profileMetaAtom)
|
||||
const versionData = get(versionQueryAtom).data
|
||||
|
||||
if (!versionData)
|
||||
return initialLangGeniusVersionInfo
|
||||
|
||||
return getLangGeniusVersionInfo({
|
||||
meta,
|
||||
versionData,
|
||||
})
|
||||
})
|
||||
|
||||
export const refreshUserProfileAtom = atom(null, (get) => {
|
||||
const queryClient = get(queryClientAtom)
|
||||
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
|
||||
})
|
||||
|
||||
export const refreshCurrentWorkspaceAtom = atom(null, (get) => {
|
||||
const queryClient = get(queryClientAtom)
|
||||
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() })
|
||||
})
|
||||
@ -57,7 +57,7 @@ export NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER=${ENABLE_WEBSITE_JINAREADER:-true}
|
||||
export NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL=${ENABLE_WEBSITE_FIRECRAWL:-true}
|
||||
export NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL=${ENABLE_WEBSITE_WATERCRAWL:-true}
|
||||
export NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=${NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX:-false}
|
||||
export NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=${NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW:-false}
|
||||
export NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=${NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW:-true}
|
||||
export NEXT_PUBLIC_ENABLE_AGENT_V2=${NEXT_PUBLIC_ENABLE_AGENT_V2:-${ENABLE_AGENT_V2:-false}}
|
||||
export NEXT_PUBLIC_LOOP_NODE_MAX_COUNT=${LOOP_NODE_MAX_COUNT}
|
||||
export NEXT_PUBLIC_MAX_PARALLEL_LIMIT=${MAX_PARALLEL_LIMIT}
|
||||
|
||||
@ -69,7 +69,7 @@ const clientSchema = {
|
||||
* Currently gates the `/create` and `/refine` slash commands in the
|
||||
* "Go to Anything" command palette (Cmd/Ctrl+K).
|
||||
*/
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: coercedBoolean.default(false),
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: coercedBoolean.default(true),
|
||||
|
||||
/**
|
||||
* Cloud-only system-features defaults.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { AgentDetailSection } from '../navigation'
|
||||
import { AgentDetailSection, AgentDetailTop } from '../navigation'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pathname: '/roster/agent/agent-1/configure',
|
||||
@ -71,3 +71,17 @@ describe('AgentDetailSection', () => {
|
||||
expect(agentName.parentElement?.parentElement).toHaveClass('h-13', 'py-1.5', 'pl-1.5', 'pr-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentDetailTop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('links the combined home control to home', () => {
|
||||
render(<AgentDetailTop />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
|
||||
expect(screen.getByRole('link', { name: 'common.menus.roster' })).toHaveAttribute('href', '/roster')
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
@ -120,41 +120,6 @@ describe('AgentEnvEditor', () => {
|
||||
expect(screen.getByDisplayValue('SECOND_KEY')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve a newly added variable key when key and value change in the same batch', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<AgentComposerProvider
|
||||
initialDraft={{
|
||||
...defaultAgentSoulConfigFormState,
|
||||
envVariables: [{
|
||||
id: 'env-1',
|
||||
key: 'API_KEY',
|
||||
value: 'secret-value',
|
||||
scope: 'plain',
|
||||
}],
|
||||
}}
|
||||
>
|
||||
<AgentEnvEditor />
|
||||
</AgentComposerProvider>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.add' }))
|
||||
|
||||
const keyInputs = screen.getAllByPlaceholderText('agentV2.agentDetail.configure.advancedSettings.envEditor.keyPlaceholder')
|
||||
const valueInputs = screen.getAllByPlaceholderText('agentV2.agentDetail.configure.advancedSettings.envEditor.valuePlaceholder')
|
||||
const newKeyInput = keyInputs[1]!
|
||||
const newValueInput = valueInputs[1]!
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(newKeyInput, { target: { value: 'SECOND_KEY' } })
|
||||
fireEvent.change(newValueInput, { target: { value: 'plain' } })
|
||||
})
|
||||
|
||||
expect(newKeyInput).toHaveValue('SECOND_KEY')
|
||||
expect(newValueInput).toHaveValue('plain')
|
||||
})
|
||||
|
||||
it('should import dotenv variables into the env table when a file is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderAgentEnvEditor()
|
||||
|
||||
@ -436,23 +436,6 @@ export function AgentEnvEditor() {
|
||||
const envEditorTableId = 'agent-configure-env-editor-table'
|
||||
const visibleEnvVariables = envVariables.length > 0 ? envVariables : [starterVariable]
|
||||
|
||||
const updateVariable = (id: string, updater: (variable: EnvVariable) => EnvVariable) => {
|
||||
setEnvVariables((currentEnvVariables) => {
|
||||
const existingVariable = currentEnvVariables.find(variable => variable.id === id)
|
||||
|
||||
if (existingVariable) {
|
||||
return currentEnvVariables.map(variable => (
|
||||
variable.id === id ? updater(variable) : variable
|
||||
))
|
||||
}
|
||||
|
||||
if (id === starterVariable.id)
|
||||
return [updater(starterVariable)]
|
||||
|
||||
return currentEnvVariables
|
||||
})
|
||||
}
|
||||
|
||||
const addVariable = ({
|
||||
focusField = 'key',
|
||||
scope,
|
||||
@ -465,8 +448,8 @@ export function AgentEnvEditor() {
|
||||
...(scope ? { scope } : {}),
|
||||
}
|
||||
|
||||
setEnvVariables(currentEnvVariables => [
|
||||
...(currentEnvVariables.length > 0 ? currentEnvVariables : [starterVariable]),
|
||||
addEnvVariable({
|
||||
starterVariable,
|
||||
variable,
|
||||
})
|
||||
setFocusedVariable({ id: variable.id, field: focusField })
|
||||
@ -487,7 +470,7 @@ export function AgentEnvEditor() {
|
||||
if (importedVariables.length === 0)
|
||||
return
|
||||
|
||||
setEnvVariables(currentEnvVariables => [...currentEnvVariables, ...importedVariables])
|
||||
importEnvVariables(importedVariables)
|
||||
}
|
||||
const updateVariableKey = (id: string, key: string) => {
|
||||
setEnvVariableKey({ id, key, starterVariable })
|
||||
@ -499,7 +482,7 @@ export function AgentEnvEditor() {
|
||||
setEnvVariableValue({ id, starterVariable, value })
|
||||
}
|
||||
const deleteVariable = (id: string) => {
|
||||
setEnvVariables(currentEnvVariables => currentEnvVariables.filter(variable => variable.id !== id))
|
||||
removeEnvVariable(id)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -17,7 +17,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { getAgentDetailPath, getAgentIdFromPathname } from './routes'
|
||||
|
||||
@ -88,7 +88,6 @@ export function AgentDetailTop({
|
||||
}: AgentDetailTopProps) {
|
||||
const { t: tApp } = useTranslation('app')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const router = useRouter()
|
||||
const setGotoAnythingOpen = useSetGotoAnythingOpen()
|
||||
|
||||
if (!expand) {
|
||||
@ -109,23 +108,14 @@ export function AgentDetailTop({
|
||||
return (
|
||||
<div className="flex items-center py-2 pr-2 pl-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-px">
|
||||
<div className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 transition-colors hover:bg-background-default-hover">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tCommon('operation.back')}
|
||||
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={tCommon('mainNav.home')}
|
||||
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={tCommon('mainNav.home')}
|
||||
className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 text-text-tertiary transition-colors hover:bg-background-default-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
|
||||
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
|
||||
</Link>
|
||||
<span className="shrink-0 system-md-regular text-text-quaternary">
|
||||
/
|
||||
</span>
|
||||
|
||||
@ -4,6 +4,7 @@ import { Provider as JotaiProvider } from 'jotai'
|
||||
import { NextRouteStateBridge } from '@/app/components/next-route-state'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import { InstanceDetail } from '..'
|
||||
import { DeploymentDetailTop } from '../deployment-sidebar'
|
||||
|
||||
vi.mock('@/next/navigation', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/next/navigation')>()
|
||||
@ -60,3 +61,21 @@ describe('InstanceDetail', () => {
|
||||
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeploymentDetailTop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('links the combined home control to home', () => {
|
||||
render(
|
||||
<JotaiProvider>
|
||||
<DeploymentDetailTop />
|
||||
</JotaiProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
|
||||
expect(screen.getByRole('link', { name: 'common.menus.deployments' })).toHaveAttribute('href', '/deployments')
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -16,7 +16,7 @@ import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/Sidebar
|
||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { DeploymentActionsMenu } from '../deployment-actions'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
|
||||
import { TitleTooltip } from '../shared/components/title-tooltip'
|
||||
@ -187,7 +187,6 @@ export function DeploymentDetailTop({
|
||||
onToggle?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const setGotoAnythingOpen = useSetGotoAnythingOpen()
|
||||
|
||||
if (!expand) {
|
||||
@ -208,23 +207,14 @@ export function DeploymentDetailTop({
|
||||
return (
|
||||
<div className="flex items-center py-2 pr-2 pl-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-px">
|
||||
<div className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 transition-colors hover:bg-background-default-hover">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.back', { ns: 'common' })}
|
||||
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={t('mainNav.home', { ns: 'common' })}
|
||||
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={t('mainNav.home', { ns: 'common' })}
|
||||
className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 text-text-tertiary transition-colors hover:bg-background-default-hover hover:text-text-secondary"
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
|
||||
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
|
||||
</Link>
|
||||
<span className="shrink-0 system-md-regular text-text-quaternary">
|
||||
/
|
||||
</span>
|
||||
|
||||
@ -1,26 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { get } from '@/service/base'
|
||||
import { useWorkspacePermissionKeys } from '../use-permission-keys'
|
||||
import { workspacePermissionKeysQueryOptions } from '../use-permission-keys'
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useWorkspacePermissionKeys', () => {
|
||||
describe('workspacePermissionKeysQueryOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(get).mockResolvedValue({
|
||||
@ -33,11 +20,15 @@ describe('useWorkspacePermissionKeys', () => {
|
||||
// Current-user permissions come from the my-permissions RBAC endpoint.
|
||||
describe('Queries', () => {
|
||||
it('should fetch workspace permission keys', async () => {
|
||||
renderHook(() => useWorkspacePermissionKeys(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/my-permissions')
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
await queryClient.fetchQuery(workspacePermissionKeysQueryOptions())
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/my-permissions')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import type { PermissionKeysResponse } from '@/models/access-control'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { get } from '../base'
|
||||
|
||||
const NAME_SPACE = 'workspace-permission-keys'
|
||||
|
||||
export const useWorkspacePermissionKeys = () => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE],
|
||||
const workspacePermissionKeysQueryKey = (workspaceId?: string) => {
|
||||
return workspaceId ? [NAME_SPACE, workspaceId] as const : [NAME_SPACE] as const
|
||||
}
|
||||
|
||||
export const workspacePermissionKeysQueryOptions = (workspaceId?: string) => {
|
||||
return queryOptions<PermissionKeysResponse>({
|
||||
queryKey: workspacePermissionKeysQueryKey(workspaceId),
|
||||
queryFn: () => get<PermissionKeysResponse>('/workspaces/current/rbac/my-permissions'),
|
||||
enabled: workspaceId === undefined || Boolean(workspaceId),
|
||||
})
|
||||
}
|
||||
|
||||
13
web/service/lang-genius-version.ts
Normal file
13
web/service/lang-genius-version.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { LangGeniusVersionResponse } from '@/models/common'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { get } from './base'
|
||||
import { commonQueryKeys } from './use-common'
|
||||
|
||||
export const langGeniusVersionQueryOptions = (currentVersion?: string | null, enabled?: boolean) => {
|
||||
return queryOptions<LangGeniusVersionResponse>({
|
||||
queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined),
|
||||
queryFn: () => get<LangGeniusVersionResponse>('/version', { params: { current_version: currentVersion } }),
|
||||
enabled: !!currentVersion && (enabled ?? true),
|
||||
})
|
||||
}
|
||||
@ -10,13 +10,13 @@ import type {
|
||||
CodeBasedExtension,
|
||||
CommonResponse,
|
||||
FileUploadConfigResponse,
|
||||
LangGeniusVersionResponse,
|
||||
Member,
|
||||
StructuredOutputRulesRequestBody,
|
||||
StructuredOutputRulesResponse,
|
||||
} from '@/models/common'
|
||||
import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { get, post } from './base'
|
||||
|
||||
const NAME_SPACE = 'common'
|
||||
@ -54,14 +54,6 @@ export const useFileUploadConfig = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useLangGeniusVersion = (currentVersion?: string | null, enabled?: boolean) => {
|
||||
return useQuery<LangGeniusVersionResponse>({
|
||||
queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined),
|
||||
queryFn: () => get<LangGeniusVersionResponse>('/version', { params: { current_version: currentVersion } }),
|
||||
enabled: !!currentVersion && (enabled ?? true),
|
||||
})
|
||||
}
|
||||
|
||||
export const useGenerateStructuredOutputRules = () => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'generate-structured-output-rules'],
|
||||
@ -95,7 +87,7 @@ export const useMailValidity = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export type MailRegisterResponse = { result: string, data: {} }
|
||||
export type MailRegisterResponse = { result: string, data: Record<string, never> }
|
||||
|
||||
export const useMailRegister = () => {
|
||||
return useMutation({
|
||||
@ -149,7 +141,7 @@ export const useFilePreview = (fileID: string) => {
|
||||
export type SchemaTypeDefinition = {
|
||||
name: string
|
||||
schema: {
|
||||
properties: Record<string, any>
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user