Merge branch 'feat/creator-profile-home' into deploy/dev

# Conflicts:
#	api/controllers/console/agent/composer.py
#	api/controllers/console/agent/roster.py
This commit is contained in:
CodingOnStar 2026-09-02 16:39:05 +08:00
commit 39a289ffa7
90 changed files with 1507 additions and 883 deletions

1
.github/CODEOWNERS vendored
View File

@ -28,6 +28,7 @@
# E2E
/e2e/ @lyzno1
/.github/workflows/web-e2e.yml @lyzno1
# Backend (default owner, more specific rules below will override)
/api/ @QuantumGhost

View File

@ -1,70 +0,0 @@
name: Marketplace Performance E2E
# Opt-in diagnostic: single-sample timing budgets are too noisy to gate every
# PR, so this lane is only run on demand instead of from the main CI pipeline.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
test:
name: Marketplace Performance E2E
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 60
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup web dependencies
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
python-version: '3.12'
cache-dependency-glob: |
api/uv.lock
- name: Install API dependencies
run: uv sync --project api --dev
- name: Install Chromium for marketplace performance E2E
timeout-minutes: 15
working-directory: ./e2e
run: vp run e2e:install:ci:chromium
- name: Run marketplace performance benchmark
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_FORCE_WEB_BUILD: '1'
E2E_INIT_PASSWORD: E2eInit12345
run: vp run e2e:marketplace-performance
- name: Upload Cucumber report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cucumber-report-marketplace-performance
path: e2e/cucumber-report
retention-days: 7
- name: Upload E2E logs
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-logs-marketplace-performance
path: e2e/.logs/*.log
include-hidden-files: true
retention-days: 7

View File

@ -12,13 +12,196 @@ permissions:
contents: read
jobs:
test:
core-build:
name: Prepare Core E2E Web Build
if: ${{ !inputs.run-external-runtime }}
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup web dependencies
uses: ./.github/actions/setup-web
- name: Run E2E support unit tests
working-directory: ./e2e
run: vp run test:unit
- name: Build production Web app
working-directory: ./e2e
env:
E2E_FORCE_WEB_BUILD: '1'
run: vp run e2e:web:build
- name: Package Web build
run: >-
tar -cf e2e-web-build.tar -C web
.next/BUILD_ID
.next/e2e-web-build.sha256
.next/standalone
.next/static
- name: Upload Web build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: core-e2e-web-build
path: e2e-web-build.tar
if-no-files-found: error
overwrite: true
retention-days: 7
chromium-full:
name: Chromium Full (${{ matrix.shard }}/3)
if: ${{ !inputs.run-external-runtime }}
needs: core-build
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_INIT_PASSWORD: E2eInit12345
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup web dependencies
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: '3.12'
cache-dependency-glob: api/uv.lock
- name: Install API dependencies
run: uv sync --project api --dev
- name: Download Web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: core-e2e-web-build
- name: Extract Web build
run: tar -xf e2e-web-build.tar -C web
- name: Install Chromium
timeout-minutes: 15
working-directory: ./e2e
run: vp run e2e:install:ci:chromium
- name: Run Chromium full shard
working-directory: ./e2e
run: vp run e2e:full -- --shard ${{ matrix.shard }}/3
- name: Upload Cucumber report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cucumber-report-chromium-${{ matrix.shard }}
path: e2e/cucumber-report
if-no-files-found: ignore
overwrite: true
retention-days: 7
- name: Upload E2E logs
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-logs-chromium-${{ matrix.shard }}
path: e2e/.logs/*.log
if-no-files-found: ignore
include-hidden-files: true
overwrite: true
retention-days: 7
webkit-smoke:
name: WebKit Browser Smoke
if: ${{ !inputs.run-external-runtime }}
needs: core-build
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_BROWSER: webkit
E2E_INIT_PASSWORD: E2eInit12345
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup web dependencies
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: '3.12'
cache-dependency-glob: api/uv.lock
- name: Install API dependencies
run: uv sync --project api --dev
- name: Download Web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: core-e2e-web-build
- name: Extract Web build
run: tar -xf e2e-web-build.tar -C web
- name: Install WebKit
timeout-minutes: 15
working-directory: ./e2e
run: vp run e2e:install:ci:webkit
- name: Run WebKit browser smoke
working-directory: ./e2e
run: vp run e2e:full -- --tags '@browser-smoke'
- name: Upload Cucumber report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cucumber-report-webkit
path: e2e/cucumber-report
if-no-files-found: ignore
overwrite: true
retention-days: 7
- name: Upload E2E logs
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-logs-webkit
path: e2e/.logs/*.log
if-no-files-found: ignore
include-hidden-files: true
overwrite: true
retention-days: 7
external-runtime:
name: Web Full-Stack E2E
if: ${{ inputs.run-external-runtime }}
runs-on: ubuntu-24.04
timeout-minutes: 120
defaults:
run:
shell: bash
steps:
- name: Checkout code
@ -41,82 +224,12 @@ jobs:
- name: Install API dependencies
run: uv sync --project api --dev
- name: Run E2E support unit tests
if: ${{ !inputs.run-external-runtime }}
working-directory: ./e2e
run: vp run test:unit
- name: Install Playwright browsers for core E2E
if: ${{ !inputs.run-external-runtime }}
timeout-minutes: 15
working-directory: ./e2e
run: vp run e2e:install:ci
- name: Install Chromium for external runtime E2E
if: ${{ inputs.run-external-runtime }}
- name: Install Chromium
timeout-minutes: 15
working-directory: ./e2e
run: vp run e2e:install:ci:chromium
- name: Run isolated source-api and built-web Cucumber E2E tests
if: ${{ !inputs.run-external-runtime }}
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_FORCE_WEB_BUILD: '1'
E2E_INIT_PASSWORD: E2eInit12345
run: vp run e2e:full
- name: Preserve Chromium E2E report and logs
if: ${{ !cancelled() && !inputs.run-external-runtime }}
run: |
if [[ -d e2e/cucumber-report ]]; then
mv e2e/cucumber-report e2e/cucumber-report-non-external
fi
if [[ -d e2e/.logs ]]; then
mv e2e/.logs e2e/.logs-non-external
fi
- name: Run WebKit keyboard and browser smoke tests
if: ${{ !inputs.run-external-runtime }}
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_BROWSER: webkit
E2E_INIT_PASSWORD: E2eInit12345
run: |
teardown_webkit_smoke() {
local run_status=$?
trap - EXIT
if ! vp run e2e:middleware:down; then
echo "::error title=E2E teardown failed::WebKit smoke middleware did not shut down cleanly."
if [[ "$run_status" -eq 0 ]]; then
run_status=1
fi
fi
exit "$run_status"
}
trap teardown_webkit_smoke EXIT
vp run e2e:middleware:up
vp run e2e -- --tags '@browser-smoke'
- name: Preserve WebKit E2E report and logs
if: ${{ !cancelled() && !inputs.run-external-runtime }}
run: |
if [[ -d e2e/cucumber-report ]]; then
mv e2e/cucumber-report e2e/cucumber-report-webkit
fi
if [[ -d e2e/.logs ]]; then
mv e2e/.logs e2e/.logs-webkit
fi
- name: Run prepared and external runtime E2E tests
if: ${{ inputs.run-external-runtime }}
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
@ -149,10 +262,8 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cucumber-report
path: |
e2e/cucumber-report
e2e/cucumber-report-non-external
e2e/cucumber-report-webkit
path: e2e/cucumber-report
overwrite: true
retention-days: 7
- name: Upload E2E logs
@ -160,18 +271,17 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-logs
path: |
e2e/.logs/*.log
e2e/.logs-non-external/*.log
e2e/.logs-webkit/*.log
path: e2e/.logs/*.log
include-hidden-files: true
overwrite: true
retention-days: 7
- name: Upload E2E seed report
if: ${{ !cancelled() && inputs.run-external-runtime }}
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-seed-report
path: e2e/seed-report
if-no-files-found: ignore
overwrite: true
retention-days: 7

View File

@ -23,7 +23,7 @@ from controllers.common.schema import (
)
from controllers.console import console_ns
from controllers.console.app.error import AppNotFoundError
from controllers.console.app.wraps import get_app_model
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
@ -153,7 +153,7 @@ class AgentAppSandboxInfoResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
@ -183,7 +183,7 @@ class AgentAppSandboxListResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
@ -214,7 +214,7 @@ class AgentAppSandboxReadResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
@ -245,7 +245,7 @@ class AgentAppSandboxDownloadResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
@model_validate(AgentSandboxDownloadPayload)

View File

@ -1173,8 +1173,7 @@ class AppSiteStatus(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@agent_manage_required_for_agent_app
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION)
@with_session
@get_app_model(mode=None)
@model_validate(AppSiteStatusPayload)

View File

@ -24,7 +24,7 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.app.wraps import get_app_model, with_session
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model, with_session
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
@ -263,7 +263,7 @@ class AgentChatMessageApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_TEST_AND_RUN)
@with_current_user
@with_current_tenant_id
@with_session
@ -292,7 +292,7 @@ class AgentBuildChatFinalizeApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_TEST_AND_RUN)
@with_current_user
@with_current_tenant_id
@with_session

View File

@ -21,7 +21,7 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.app.wraps import get_app_model
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model
from controllers.console.explore.error import AppSuggestedQuestionsAfterAnswerDisabledError
from controllers.console.wraps import (
RBACPermission,
@ -174,7 +174,7 @@ class AgentChatMessageListApi(Resource):
@account_initialization_required
@setup_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
@with_current_user
@with_current_tenant_id
@with_session(write=False)

View File

@ -8,7 +8,6 @@ from constants.languages import supported_language
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import AppNotFoundError
from controllers.console.app.wraps import agent_manage_required_for_agent_app
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
@ -108,8 +107,8 @@ class AppSite(Resource):
allowed_roles=_APP_SITE_EDIT_ROLES,
rbac_resource_scope=RBACResourceScope.APP,
rbac_permission=RBACPermission.APP_RELEASE_AND_VERSION,
agent_manage_fallback=True,
)
@agent_manage_required_for_agent_app
@model_validate(AppSiteUpdatePayload)
def post(
self,
@ -139,8 +138,8 @@ class AppSiteAccessTokenReset(Resource):
allowed_roles=_APP_SITE_TOKEN_RESET_ROLES,
rbac_resource_scope=RBACResourceScope.APP,
rbac_permission=RBACPermission.APP_RELEASE_AND_VERSION,
agent_manage_fallback=True,
)
@agent_manage_required_for_agent_app
def post(self, request_context: RequestContext, app_id: UUID):
try:
site = application_services().app_sites.reset_access_token(request_context, str(app_id))

View File

@ -12,20 +12,22 @@ from typing import cast, overload
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.session import with_session
from controllers.common.wraps import RBACPermission, RBACResourceScope, enforce_rbac_access
from controllers.common.wraps import RBACPermission, RBACResourceScope, _extract_resource_id, enforce_rbac_access
from controllers.console.app.error import AppNotFoundError
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models import App, AppMode
from models.agent import AgentScope
from models.agent import Agent, AgentScope
from services.app_service import AppService
__all__ = [
"agent_manage_required_for_agent_app",
"enforce_agent_manage_or_app_scene",
"get_app_model",
"get_previewable_app_model",
"with_session",
@ -57,43 +59,116 @@ def _load_previewable_app_model(session: Session, app_id: str) -> App | None:
return AppService.get_normal_app_by_id(app_id, session)
def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Gate generic app management routes that target an Agent App.
def _agent_app_binding(app_id: str) -> Agent | None:
app_model = _load_app_model_from_scoped_session(app_id)
if app_model is None:
return None
return app_model.agent_app_binding_with_session(session=db.session(), include_archived=True)
A hidden workflow-only backing App only reuses the App runtime and is not
part of the general app management plane, so generic routes reject it
outright. Managing a roster Agent App mutates the roster Agent behind it
(rename/icon sync, archive, API enablement), so it additionally requires
workspace ``agent.manage`` on top of the route's existing App permission
checks when RBAC is enabled. A no-op for non-agent Apps. Must be placed
above ``get_app_model`` so the ``app_id`` path parameter is still present.
"""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
raw_app_id = kwargs.get("app_id") or kwargs.get("resource_id")
if raw_app_id is not None:
app_model = _load_app_model_from_scoped_session(str(raw_app_id))
binding = (
app_model.agent_app_binding_with_session(session=db.session(), include_archived=True)
if app_model is not None
else None
def _reject_hidden_agent_backing_app(path_args: dict[str, object]) -> None:
raw_app_id = path_args.get("app_id") or path_args.get("resource_id")
if raw_app_id is None:
return
binding = _agent_app_binding(str(raw_app_id))
if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY:
raise AppNotFoundError()
def enforce_agent_manage_or_app_scene(
*,
tenant_id: str,
account_id: str,
scene: RBACPermission,
path_args: dict[str, object],
) -> None:
# Must run before the RBAC_ENABLED check below: a hidden workflow-only
# backing App has to stay unreachable regardless of RBAC_ENABLED.
_reject_hidden_agent_backing_app(path_args)
if not dify_config.RBAC_ENABLED:
return
binding = _agent_app_binding(_extract_resource_id(RBACResourceScope.APP, tenant_id, path_args))
if binding is not None:
if binding.scope == AgentScope.WORKFLOW_ONLY:
raise AppNotFoundError()
try:
enforce_rbac_access(
tenant_id=tenant_id,
account_id=account_id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.AGENT_MANAGE,
resource_required=False,
)
if binding is not None:
if binding.scope == AgentScope.WORKFLOW_ONLY:
raise AppNotFoundError()
if dify_config.RBAC_ENABLED:
current_user, current_tenant_id = current_account_with_tenant()
enforce_rbac_access(
tenant_id=current_tenant_id,
account_id=current_user.id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.AGENT_MANAGE,
resource_required=False,
)
return view(*args, **kwargs)
return
except Forbidden:
pass # not an agent.manage holder — fall through to the normal scene check
return decorated
enforce_rbac_access(
tenant_id=tenant_id,
account_id=account_id,
resource_type=RBACResourceScope.APP,
scene=scene,
path_args=path_args,
)
@overload
def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]: ...
@overload
def agent_manage_required_for_agent_app[**P, R](
view: None = None, *, scene: RBACPermission | None = None
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
def agent_manage_required_for_agent_app[**P, R](
view: Callable[P, R] | None = None, *, scene: RBACPermission | None = None
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
# Must sit above get_app_model in the decorator stack — get_app_model
# deletes app_id from kwargs, and this decorator needs it.
# TODO: this is a workaround, remove this after ACL for agent app is available
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
if scene is not None:
if not dify_config.RBAC_ENABLED:
_reject_hidden_agent_backing_app(kwargs)
return view_func(*args, **kwargs)
current_user, current_tenant_id = current_account_with_tenant()
enforce_agent_manage_or_app_scene(
tenant_id=current_tenant_id,
account_id=current_user.id,
scene=scene,
path_args=kwargs,
)
return view_func(*args, **kwargs)
raw_app_id = kwargs.get("app_id") or kwargs.get("resource_id")
if raw_app_id is not None:
binding = _agent_app_binding(str(raw_app_id))
if binding is not None:
if binding.scope == AgentScope.WORKFLOW_ONLY:
raise AppNotFoundError()
if dify_config.RBAC_ENABLED:
current_user, current_tenant_id = current_account_with_tenant()
enforce_rbac_access(
tenant_id=current_tenant_id,
account_id=current_user.id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.AGENT_MANAGE,
resource_required=False,
)
return view_func(*args, **kwargs)
return decorated
if view is None:
return decorator
return decorator(view)
def _get_injected_session(args: tuple[object, ...]) -> Session | None:

View File

@ -9,6 +9,7 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.wraps import enforce_rbac_access
from controllers.console.app.wraps import enforce_agent_manage_or_app_scene
from controllers.console.wraps import (
account_initialization_required,
enable_change_email,
@ -52,6 +53,7 @@ def console_account_admission[T, **P, R](
rbac_resource_scope: RBACResourceScope | None = None,
rbac_permission: RBACPermission | None = None,
rbac_resource_required: bool = True,
agent_manage_fallback: bool = False,
) -> Callable[
[Callable[Concatenate[T, RequestContext, P], R]],
Callable[Concatenate[T, P], R | Response],
@ -66,6 +68,10 @@ def console_account_admission[T, **P, R](
if (rbac_resource_scope is None) != (rbac_permission is None):
raise AdmissionConfigurationError("RBAC resource scope and permission must be configured together")
if agent_manage_fallback and rbac_resource_scope != RBACResourceScope.APP:
raise AdmissionConfigurationError("agent_manage_fallback requires rbac_resource_scope=RBACResourceScope.APP")
if agent_manage_fallback and not rbac_resource_required:
raise AdmissionConfigurationError("agent_manage_fallback requires rbac_resource_required=True")
def decorator(
view: Callable[Concatenate[T, RequestContext, P], R],
@ -78,14 +84,22 @@ def console_account_admission[T, **P, R](
if allowed_roles is not None and not dify_config.RBAC_ENABLED and account.role not in allowed_roles:
raise Forbidden()
if rbac_resource_scope is not None and rbac_permission is not None:
enforce_rbac_access(
tenant_id=tenant_id,
account_id=account.id,
resource_type=rbac_resource_scope,
scene=rbac_permission,
resource_required=rbac_resource_required,
path_args=kwargs,
)
if agent_manage_fallback:
enforce_agent_manage_or_app_scene(
tenant_id=tenant_id,
account_id=account.id,
scene=rbac_permission,
path_args=kwargs,
)
else:
enforce_rbac_access(
tenant_id=tenant_id,
account_id=account.id,
resource_type=rbac_resource_scope,
scene=rbac_permission,
resource_required=rbac_resource_required,
path_args=kwargs,
)
request_context = RequestContext(
account_id=account.id,
active_workspace_id=tenant_id,

View File

@ -217,7 +217,7 @@ storage = [
############################################################
# [ Tools ] dependency group
############################################################
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.0,<4.0.0"]
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.3,<4.0.0"]
############################################################
# [ VDB ] workspace plugins — hollow packages under providers/vdb/*

View File

@ -149,10 +149,6 @@ def _account() -> Account:
@pytest.mark.parametrize(
"method",
[
module.AgentAppSandboxInfoResource.get,
module.AgentAppSandboxListResource.get,
module.AgentAppSandboxReadResource.get,
module.AgentAppSandboxDownloadResource.post,
module.WorkflowAgentSandboxListResource.get,
module.WorkflowAgentSandboxReadResource.get,
module.WorkflowAgentSandboxDownloadResource.post,

View File

@ -262,13 +262,6 @@ def test_api_key_lists_require_matching_rbac_permission(config_overrides: Callab
lambda: AppApiKeyListResource().get(resource_id=api_id),
[(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION, True)],
),
(
lambda: AgentApiKeyListApi().get(agent_id=api_id),
[
(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, False),
(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION, True),
],
),
(
lambda: DatasetApiKeyApi().get(),
[(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, False)],

14
api/uv.lock generated
View File

@ -1763,7 +1763,7 @@ storage = [
]
tools = [
{ name = "cloudscraper", specifier = ">=1.2.71,<2.0.0" },
{ name = "nltk", specifier = ">=3.10.0,<4.0.0" },
{ name = "nltk", specifier = ">=3.10.3,<4.0.0" },
]
trace-aliyun = [{ name = "dify-trace-aliyun", editable = "providers/trace/trace-aliyun" }]
trace-all = [
@ -4134,7 +4134,7 @@ wheels = [
[[package]]
name = "nltk"
version = "3.10.0"
version = "3.10.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@ -4143,9 +4143,9 @@ dependencies = [
{ name = "regex" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/e6/fe51d2bb1a3b446f59c5c8165999a9fee208bc346af90a7cbf7657bc0d75/nltk-3.10.3.tar.gz", hash = "sha256:bb9327a461c3811c2fa4900e03840401f2126adfb30c0072827c433bd2444ea4", size = 5137152, upload-time = "2026-08-12T23:46:37.258Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6d/ebd2af4640b12168fdf0cb74b6118df2f32a2f62ec7e0c06fbfd80706639/nltk-3.10.3-py3-none-any.whl", hash = "sha256:ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c", size = 1798643, upload-time = "2026-08-12T23:44:13.478Z" },
]
[[package]]
@ -5423,11 +5423,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.15.0"
version = "6.16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/17/17/ee75a92718ec7212de831e71454d702225aa5e474a805cce169806044453/pypdf-6.15.0.tar.gz", hash = "sha256:d39c4d955a76409284a905e2d65b40076d77ab76129e0faaeeb6612403ecfc79", size = 6993794, upload-time = "2026-08-06T13:06:49.929Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/5a/df92d1c1ef8806ca28f20f978ee059894868d93de797a7e2edebe7fe1a43/pypdf-6.16.1.tar.gz", hash = "sha256:c4d1b43ddae921387321cf63936cd16a7743b91d2da92f165c149a195c972ba9", size = 7003737, upload-time = "2026-08-14T12:24:04.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl", hash = "sha256:14e001d6504822cb1ca9c7ed9a69bccb320f59b320730f55af804361abe4d5ee", size = 378123, upload-time = "2026-08-06T13:06:47.709Z" },
{ url = "https://files.pythonhosted.org/packages/33/a1/724b18d6757ab7253a8fecd3a430eb8d980ed26872ba16651e7b5ddfc63f/pypdf-6.16.1-py3-none-any.whl", hash = "sha256:63fec31c4092ae50b6729beedcb469055b60d20c834bde1c402df241f371f644", size = 382924, upload-time = "2026-08-14T12:24:02.854Z" },
]
[[package]]

View File

@ -17,6 +17,7 @@ Run commands from the repository root. Install dependencies and browsers once wi
- Prepare and run external runtime scenarios: `E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:external`
- Seed against existing middleware without running Cucumber: `pnpm -C e2e seed -- --profile <prepared|external-runtime|post-merge>`
- Reset persisted E2E state: `pnpm -C e2e e2e:reset`
- Build the production Web artifact without starting services: `pnpm -C e2e e2e:web:build`
- Middleware lifecycle: `pnpm -C e2e e2e:middleware:up` and `pnpm -C e2e e2e:middleware:down`
- Scoped static checks: `vp check e2e`

View File

@ -3,7 +3,7 @@ import './scripts/env-register'
const hasCliTags = process.argv.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
const defaultNonExternalTags =
'not @axe and not @prepared and not @external-model and not @external-tool and not @marketplace-performance'
'not @axe and not @prepared and not @external-model and not @external-tool'
const selectedTags =
process.env.E2E_CUCUMBER_TAGS || (hasCliTags ? undefined : defaultNonExternalTags)
const tags = selectedTags ? `(${selectedTags}) and not @skip` : 'not @skip'

View File

@ -1,5 +0,0 @@
@marketplace-performance
Feature: Embedded Marketplace performance budget
Scenario: The first Marketplace collection stays within the initial rendering budget
When I measure the embedded Marketplace under Fast 4G and 4x CPU throttling
Then the embedded Marketplace should meet its initial rendering budgets

View File

@ -1,104 +0,0 @@
import type { DifyWorld, MarketplacePerformanceMetrics } from '../support/world'
import { Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import { e2eBrowser } from '../../test-env'
// Baseline against the frozen marketplace fixture stub: the first card lands
// around 2.3-2.6s under Fast 4G + 4x CPU throttling (dominated by the ~630KB
// server-rendered HTML), so 4s guards regressions with headroom for slower CI
// runners. The stub serves a frozen recommend banner, so the measured first
// screen also includes the trending carousel and its background image.
const FIRST_CARD_BUDGET_MS = 4_000
const DOCUMENT_ELEMENT_BUDGET = 2_000
// Hydrating the server-rendered list peaks around ~220ms on shared CI runners
// under 4x CPU throttling; 300ms still flags pathological main-thread work.
const LONG_TASK_BUDGET_MS = 300
const FAST_4G_DOWNLOAD_BYTES_PER_SECOND = 4_000_000 / 8
const FAST_4G_UPLOAD_BYTES_PER_SECOND = 3_000_000 / 8
type PerformanceWindow = Window & {
__marketplaceLongTaskDurations?: number[]
}
When(
'I measure the embedded Marketplace under Fast 4G and 4x CPU throttling',
async function (this: DifyWorld) {
if (e2eBrowser !== 'chromium')
throw new Error('The Marketplace performance benchmark requires E2E_BROWSER=chromium.')
if (!this.context)
throw new Error('Playwright context has not been initialized for this scenario.')
const page = this.getPage()
const cdpSession = await this.context.newCDPSession(page)
try {
await page.addInitScript(() => {
const performanceWindow = window as PerformanceWindow
performanceWindow.__marketplaceLongTaskDurations = []
if (!PerformanceObserver.supportedEntryTypes.includes('longtask')) return
const observer = new PerformanceObserver((entries) => {
performanceWindow.__marketplaceLongTaskDurations!.push(
...entries.getEntries().map((entry) => entry.duration),
)
})
observer.observe({ type: 'longtask', buffered: true })
})
await cdpSession.send('Network.enable')
await cdpSession.send('Network.emulateNetworkConditions', {
connectionType: 'cellular4g',
downloadThroughput: FAST_4G_DOWNLOAD_BYTES_PER_SECOND,
latency: 60,
offline: false,
uploadThroughput: FAST_4G_UPLOAD_BYTES_PER_SECOND,
})
await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 4 })
await page.goto('/marketplace', { waitUntil: 'domcontentloaded' })
await page.locator('[data-marketplace-card]').first().waitFor({
state: 'visible',
timeout: 30_000,
})
const firstCardVisibleMs = await page.evaluate(() => performance.now())
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
}),
)
this.marketplacePerformanceMetrics = await page.evaluate(
(visibleMs): MarketplacePerformanceMetrics => {
const performanceWindow = window as PerformanceWindow
const longTaskDurations = performanceWindow.__marketplaceLongTaskDurations ?? []
return {
firstCardVisibleMs: visibleMs,
documentElementCount: document.querySelectorAll('*').length,
longestTaskMs: Math.max(0, ...longTaskDurations),
}
},
firstCardVisibleMs,
)
} finally {
await cdpSession.detach()
}
},
)
Then(
'the embedded Marketplace should meet its initial rendering budgets',
async function (this: DifyWorld) {
const metrics = this.marketplacePerformanceMetrics
if (!metrics) throw new Error('Marketplace performance metrics were not captured.')
this.attach(JSON.stringify(metrics, null, 2), 'application/json')
expect(metrics.firstCardVisibleMs).toBeLessThanOrEqual(FIRST_CARD_BUDGET_MS)
expect(metrics.documentElementCount).toBeLessThanOrEqual(DOCUMENT_ELEMENT_BUDGET)
expect(metrics.longestTaskMs).toBeLessThanOrEqual(LONG_TASK_BUDGET_MS)
},
)

View File

@ -73,12 +73,6 @@ export const createAgentBuilderWorldState = () => ({
export type AgentBuilderWorldState = ReturnType<typeof createAgentBuilderWorldState>
export type MarketplacePerformanceMetrics = {
firstCardVisibleMs: number
documentElementCount: number
longestTaskMs: number
}
export class DifyWorld extends World {
context: BrowserContext | undefined
consoleRequestContext: APIRequestContext | undefined
@ -103,7 +97,6 @@ export class DifyWorld extends World {
capturedDownloads: Download[] = []
shareURL: string | undefined
sharedAppPage: Page | undefined
marketplacePerformanceMetrics: MarketplacePerformanceMetrics | undefined
constructor(options: IWorldOptions) {
super(options)
@ -128,7 +121,6 @@ export class DifyWorld extends World {
this.capturedDownloads = []
this.shareURL = undefined
this.sharedAppPage = undefined
this.marketplacePerformanceMetrics = undefined
}
async startSession(browser: Browser, authenticated: boolean) {

View File

@ -15,7 +15,7 @@
"e2e:install": "playwright install --with-deps chromium webkit",
"e2e:install:ci": "playwright install --with-deps --only-shell chromium webkit",
"e2e:install:ci:chromium": "playwright install --with-deps --only-shell chromium",
"e2e:marketplace-performance": "tsx ./scripts/run-cucumber.ts --full -- --tags @marketplace-performance",
"e2e:install:ci:webkit": "playwright install --with-deps --only-shell webkit",
"e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down",
"e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up",
"e2e:post-merge": "tsx ./scripts/run-post-merge.ts",
@ -23,6 +23,7 @@
"e2e:prepared": "tsx ./scripts/run-prepared.ts",
"e2e:prepared:prepare": "tsx ./scripts/run-cucumber.ts --seed-only --profile prepared",
"e2e:reset": "tsx ./scripts/setup.ts reset",
"e2e:web:build": "tsx ./scripts/setup.ts web-build",
"seed": "tsx ./scripts/run-cucumber.ts --seed-only",
"test:unit": "vp test run",
"type-check": "tsc"

View File

@ -3,7 +3,6 @@ import { mkdir, readFile, rm } from 'node:fs/promises'
import path from 'node:path'
import { runCleanupTasks } from '../support/cleanup'
import { assertCucumberScenariosStarted } from '../support/cucumber-messages'
import { startMarketplaceStub, stopMarketplaceStub } from '../support/marketplace-stub'
import { startLoggedProcess, stopManagedProcess, waitForUrl } from '../support/process'
import { startWebServer, stopWebServer } from '../support/web-server'
import { apiURL, baseURL, reuseExistingWebServer } from '../test-env'
@ -16,27 +15,8 @@ import './env-register'
const hasCustomTags = (forwardArgs: string[]) =>
forwardArgs.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
const collectTagExpressions = (forwardArgs: string[]) => {
const expressions: string[] = []
for (let index = 0; index < forwardArgs.length; index += 1) {
const arg = forwardArgs[index]!
if (arg === '--tags' && forwardArgs[index + 1]) expressions.push(forwardArgs[index + 1]!)
else if (arg.startsWith('--tags=')) expressions.push(arg.slice('--tags='.length))
}
if (process.env.E2E_CUCUMBER_TAGS) expressions.push(process.env.E2E_CUCUMBER_TAGS)
return expressions
}
const selectsMarketplacePerformance = (forwardArgs: string[]) =>
collectTagExpressions(forwardArgs).some((expression) =>
/(?<!not\s)@marketplace-performance/.test(expression),
)
const fullNonExternalTags =
'not @axe and not @prepared and not @external-model and not @external-tool and not @marketplace-performance'
'not @axe and not @prepared and not @external-model and not @external-tool'
const seedCeleryQueues = 'dataset,priority_dataset,workflow_based_app_execution'
const readLogTail = async (logFilePath: string) => {
@ -110,7 +90,6 @@ const main = async () => {
cleanupPromise = (async () => {
const cleanupErrors = await runCleanupTasks([
{ label: 'Stop web server', run: stopWebServer },
{ label: 'Stop marketplace API stub', run: stopMarketplaceStub },
{ label: 'Stop celery worker', run: () => stopManagedProcess(celeryProcess) },
{ label: 'Stop API server', run: () => stopManagedProcess(apiProcess) },
{ label: 'Stop agent backend', run: () => stopManagedProcess(difyAgentProcess) },
@ -208,18 +187,6 @@ const main = async () => {
logFilePath: path.join(logDir, 'cucumber-celery.log'),
})
// The performance benchmark must not depend on live marketplace.dify.ai
// content, so serve frozen fixtures from a local stub unless the caller
// explicitly points the web app at another marketplace API.
if (
selectsMarketplacePerformance(forwardArgs) &&
!process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX
) {
const { apiPrefix } = await startMarketplaceStub()
process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX = apiPrefix
console.log(`Marketplace API stub is serving frozen fixtures at ${apiPrefix}.`)
}
await startWebServer({
baseURL,
command: 'npx',

View File

@ -569,7 +569,7 @@ export const startMiddleware = async () => {
const printUsage = () => {
console.log(
'Usage: tsx ./scripts/setup.ts <reset|middleware-up|middleware-down|shellctl-sandbox|agent-backend|api|celery [--queues queues]|web>',
'Usage: tsx ./scripts/setup.ts <reset|middleware-up|middleware-down|shellctl-sandbox|agent-backend|api|celery [--queues queues]|web|web-build>',
)
}
@ -603,6 +603,9 @@ const main = async () => {
case 'web':
await startWeb()
return
case 'web-build':
await ensureWebBuild()
return
default:
printUsage()
process.exitCode = 1

View File

@ -1,216 +0,0 @@
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { createServer } from 'node:http'
/**
* Local Marketplace API stub for the performance benchmark.
*
* The embedded Marketplace page talks directly to NEXT_PUBLIC_MARKETPLACE_API_PREFIX
* from both the Next.js server and the browser. Serving frozen fixtures from this
* stub keeps the measured first-screen content identical on every run, so the
* rendering budgets do not depend on live marketplace.dify.ai content.
*/
const stubHost = '127.0.0.1'
const apiPrefixPath = '/api/v1'
const pluginIconSvg = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
'<rect width="40" height="40" rx="8" fill="#E5E7EB"/>',
'<circle cx="20" cy="20" r="10" fill="#6B7280"/>',
'</svg>',
].join('')
const makeFrozenPlugin = (name: string, label: string, installCount: number) => ({
type: 'plugin',
org: 'e2e-fixtures',
name,
plugin_id: `e2e-fixtures/${name}`,
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: `e2e-fixtures/${name}:1.0.0`,
icon: 'icon.svg',
verified: true,
label: { en_US: label, zh_Hans: label },
brief: {
en_US: `${label} is a frozen fixture plugin for the performance benchmark.`,
zh_Hans: `${label} is a frozen fixture plugin for the performance benchmark.`,
},
introduction: '',
repository: '',
category: 'tool',
install_count: installCount,
endpoint: { settings: [] },
tags: [{ name: 'search' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
})
const makeFrozenCollection = (name: string, label: string) => ({
name,
label: { en_US: label, zh_Hans: label },
description: {
en_US: `${label} frozen fixture collection.`,
zh_Hans: `${label} frozen fixture collection.`,
},
rule: '',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
searchable: false,
})
const frozenCollections = [
makeFrozenCollection('e2e-frozen-featured', 'Frozen Featured'),
makeFrozenCollection('e2e-frozen-popular', 'Frozen Popular'),
]
// A frozen recommend banner keeps the trending carousel (and its decorative
// background image) inside the measured first screen, so the benchmark covers
// the same rendering paths as production instead of an empty banner state.
const frozenBanners = [
{
id: 'e2e-frozen-banner-trending',
title: 'Trending',
sort: 1,
language: 'en-US',
style_type: 'recommend',
content: {
theme_type: 'hottest',
heading: 'Frozen Trending Plugins',
description: 'Frozen fixture banner for the performance benchmark.',
cards: Array.from({ length: 4 }, (_, index) => ({
item_type: 'plugin',
item_id: `e2e-fixtures/featured-plugin-${index + 1}`,
display_name: `Featured Plugin ${index + 1}`,
icon_url: `/api/v1/plugins/e2e-fixtures/featured-plugin-${index + 1}/icon`,
creator: 'e2e-fixtures',
link: '',
card_position: index + 1,
})),
},
},
]
const frozenCollectionPlugins: Record<string, unknown[]> = {
'e2e-frozen-featured': Array.from({ length: 8 }, (_, index) =>
makeFrozenPlugin(
`featured-plugin-${index + 1}`,
`Featured Plugin ${index + 1}`,
12_000 - index * 100,
),
),
'e2e-frozen-popular': Array.from({ length: 8 }, (_, index) =>
makeFrozenPlugin(
`popular-plugin-${index + 1}`,
`Popular Plugin ${index + 1}`,
8_000 - index * 100,
),
),
}
type StubResponse = {
body: string
contentType: string
}
const jsonResponse = (data: unknown): StubResponse => ({
body: JSON.stringify({ code: 0, msg: 'success', data }),
contentType: 'application/json',
})
const resolveStubResponse = (method: string, pathname: string): StubResponse | undefined => {
if (method === 'GET' && pathname === '/banners') return jsonResponse({ banners: frozenBanners })
if (method === 'GET' && pathname === '/collections')
return jsonResponse({ collections: frozenCollections })
const collectionPluginsMatch = pathname.match(/^\/collections\/([^/]+)\/plugins$/)
if (method === 'POST' && collectionPluginsMatch) {
return jsonResponse({
plugins: frozenCollectionPlugins[collectionPluginsMatch[1]!] ?? [],
})
}
if (method === 'POST' && /^\/(?:plugins|bundles)\/search\/advanced$/.test(pathname))
return jsonResponse({ plugins: [], bundles: [], total: 0 })
if (method === 'GET' && pathname === '/template-collections')
return jsonResponse({ collections: [], total: 0 })
if (method === 'POST' && /^\/template-collections\/[^/]+\/templates$/.test(pathname))
return jsonResponse({ templates: [], total: 0 })
if (method === 'POST' && pathname === '/templates/search/advanced')
return jsonResponse({ templates: [], total: 0 })
if (method === 'GET' && /^\/(?:plugins|bundles)\/[^/]+\/[^/]+\/icon$/.test(pathname))
return { body: pluginIconSvg, contentType: 'image/svg+xml' }
return undefined
}
const handleRequest = (request: IncomingMessage, response: ServerResponse) => {
request.resume()
const method = request.method ?? 'GET'
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? stubHost}`)
const requestedHeaders = request.headers['access-control-request-headers']
response.setHeader('Access-Control-Allow-Origin', '*')
response.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
response.setHeader(
'Access-Control-Allow-Headers',
Array.isArray(requestedHeaders) ? requestedHeaders.join(',') : (requestedHeaders ?? '*'),
)
response.setHeader('Cache-Control', 'no-store')
if (method === 'OPTIONS') {
response.writeHead(204)
response.end()
return
}
const pathname = url.pathname.startsWith(apiPrefixPath)
? url.pathname.slice(apiPrefixPath.length) || '/'
: undefined
const stubResponse = pathname === undefined ? undefined : resolveStubResponse(method, pathname)
if (!stubResponse) {
console.warn(`Marketplace stub has no fixture for ${method} ${url.pathname}; returning 404.`)
response.writeHead(404, { 'Content-Type': 'application/json' })
response.end(
JSON.stringify({ code: 404, msg: 'Marketplace stub fixture not found', data: null }),
)
return
}
response.writeHead(200, { 'Content-Type': stubResponse.contentType })
response.end(stubResponse.body)
}
let activeServer: Server | undefined
export const startMarketplaceStub = async (): Promise<{ apiPrefix: string }> => {
if (activeServer) throw new Error('The Marketplace API stub is already running.')
const port = Number(process.env.E2E_MARKETPLACE_STUB_PORT || 3620)
const server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => reject(error)
server.once('error', onError)
server.listen(port, stubHost, () => {
server.off('error', onError)
resolve()
})
})
activeServer = server
return { apiPrefix: `http://${stubHost}:${port}${apiPrefixPath}` }
}
export const stopMarketplaceStub = async () => {
const server = activeServer
activeServer = undefined
if (!server) return
server.closeAllConnections()
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
})
}

View File

@ -15,14 +15,9 @@ const config: KnipConfig = {
'tsslint.config.ts',
'dev-proxy.config.ts',
'plugins/eslint/index.js',
// Consumed by the dify-marketplace repository, which mounts this
// repo as a submodule and imports these modules via path aliases.
'app/components/plugins/marketplace/index.tsx',
'app/components/plugins/marketplace/hydration-server.tsx',
'app/components/plugins/marketplace/server-budget.ts',
'app/components/plugins/marketplace/creator-profile/model.ts',
'app/components/plugins/marketplace/home/marketplace-live-search.tsx',
'app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx',
// Public surface consumed by the standalone Marketplace host.
'app/components/plugins/marketplace/standalone/server.ts',
'app/components/plugins/marketplace/standalone/client.ts',
],
project: [
'**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,css,mdx}!',

View File

@ -392,9 +392,6 @@
"jsx-a11y/no-static-element-interactions": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"typescript/no-explicit-any": {
"count": 1
}
@ -2222,9 +2219,6 @@
"web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx": {
"eslint-react/set-state-in-effect": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx": {
@ -4333,11 +4327,6 @@
"count": 4
}
},
"web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx": {
"typescript/no-non-null-asserted-optional-chain": {
"count": 1

View File

@ -363,7 +363,9 @@ export const FormDialog: Story = {
await userEvent.click(canvas.getByRole('button', { name: 'Configure API extension' }))
await expect(body.getByRole('textbox', { name: 'Name' })).toHaveFocus()
await waitFor(async () => {
await expect(body.getByRole('textbox', { name: 'Name' })).toHaveFocus()
})
},
}

View File

@ -1,6 +1,3 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { QueryClient } from '@tanstack/react-query'
let queryClient: QueryClient
@ -133,16 +130,4 @@ describe('Root layout System Features bootstrap', () => {
expect(queryClient.getQueryData(['console', 'system-features'])).toBeUndefined()
})
it('does not inject marketplace PWA chrome or a global ResizeObserver filter', () => {
const source = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), '../layout.tsx'),
'utf8',
)
expect(source).not.toContain('manifest.json')
expect(source).not.toContain('apple-touch-icon')
expect(source).not.toContain('browserconfig.xml')
expect(source).not.toContain('ResizeObserver')
})
})

View File

@ -284,7 +284,7 @@ describe('SettingsModal', () => {
await renderSettingsModal(dataset)
// Assert
expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue(
expect(screen.getByRole('textbox', { name: 'datasetSettings.form.name' })).toHaveValue(
'Test Dataset',
)
expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue(
@ -333,7 +333,7 @@ describe('SettingsModal', () => {
const user = userEvent.setup()
await renderSettingsModal(createDataset())
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
const nameInput = screen.getByRole('textbox', { name: 'datasetSettings.form.name' })
// Act
await user.clear(nameInput)
@ -417,7 +417,7 @@ describe('SettingsModal', () => {
const user = userEvent.setup()
await renderSettingsModal(createDataset())
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
const nameInput = screen.getByRole('textbox', { name: 'datasetSettings.form.name' })
// Act
await user.clear(nameInput)
@ -483,7 +483,7 @@ describe('SettingsModal', () => {
// Act
await renderSettingsModal(dataset)
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
const nameInput = screen.getByRole('textbox', { name: 'datasetSettings.form.name' })
await user.clear(nameInput)
await user.type(nameInput, 'Updated Internal Dataset')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))

View File

@ -5,14 +5,14 @@ import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from 'es-toolkit/predicate'
import { useQueryState } from 'nuqs'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/app/configuration/toast'
import Input from '@/app/components/base/input'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import IndexMethod from '@/app/components/datasets/settings/index-method'
@ -58,6 +58,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
const translateRetrieval: RetrievalTranslate = (selector, options) => t(selector, options)
const docLink = useDocLink()
const ref = useRef(null)
const nameInputId = useId()
const isExternal = currentDataset.provider === 'external'
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const [loading, setLoading] = useState(false)
@ -232,13 +233,14 @@ const SettingsModal: FC<SettingsModalProps> = ({
<div className="overflow-y-auto border-b border-divider-regular p-6 pt-5 pb-17">
<div className={cn(rowClass, 'items-center')}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">
<label htmlFor={nameInputId} className="system-sm-semibold text-text-secondary">
{t(($) => $['form.name'], { ns: 'datasetSettings' })}
</div>
</label>
</div>
<Input
id={nameInputId}
value={localeCurrentDataset.name}
onChange={(e) => handleValueChange('name', e.target.value)}
onValueChange={(value) => handleValueChange('name', value)}
className="block h-9"
placeholder={t(($) => $['form.namePlaceholder'], { ns: 'datasetSettings' }) || ''}
/>

View File

@ -1,12 +1,12 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Input } from '@langgenius/dify-ui/input'
import { RiAddLine } from '@remixicon/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useEffect, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { useModalContext } from '@/context/modal-context'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
@ -30,6 +30,7 @@ const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({
consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({ input: {} })
const { data } = useQuery(externalKnowledgeApiQueryOptions)
const externalKnowledgeApiList = data?.data ?? []
const externalKnowledgeIdInputId = useId()
const [selectedApiId, setSelectedApiId] = useState(external_knowledge_api_id)
const { setShowExternalKnowledgeAPIModal } = useModalContext()
@ -94,14 +95,18 @@ const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({
</div>
<div className="flex flex-col gap-1 self-stretch">
<div className="flex flex-col self-stretch">
<label className="system-sm-semibold text-text-secondary">
<label
htmlFor={externalKnowledgeIdInputId}
className="system-sm-semibold text-text-secondary"
>
{t(($) => $.externalKnowledgeId, { ns: 'dataset' })}
</label>
</div>
<Input
id={externalKnowledgeIdInputId}
value={external_knowledge_id}
onChange={(e) =>
onChange({ external_knowledge_id: e.target.value, external_knowledge_api_id })
onValueChange={(value) =>
onChange({ external_knowledge_id: value, external_knowledge_api_id })
}
placeholder={t(($) => $.externalKnowledgeIdPlaceholder, { ns: 'dataset' }) ?? ''}
/>

View File

@ -112,7 +112,7 @@ describe('ExternalApiSelection', () => {
}
render(<Harness />)
await user.type(screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder'), 'kb-123')
await user.type(screen.getByRole('textbox', { name: 'dataset.externalKnowledgeId' }), 'kb-123')
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ external_knowledge_id: 'kb-123' }),

View File

@ -723,6 +723,19 @@ describe('MainNav', () => {
expect(
marketplaceLink.querySelector('.i-custom-vender-main-nav-marketplace-v2'),
).toBeInTheDocument()
expect(
within(screen.getByRole('navigation'))
.getAllByRole('link')
.map((link) => link.getAttribute('href')),
).toEqual([
'/',
'/apps',
'/agents',
'/datasets',
'/skills',
'/integrations/model-provider',
'/marketplace',
])
})
it('hides the roster entry when Agent v2 is disabled', () => {

View File

@ -6,9 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import Link from '@/next/link'
const NavIcon = ({ icon, className }: { icon: string; className?: string }) => (
<span aria-hidden className={cn('flex size-5 shrink-0 items-center justify-center', className)}>
<span className={cn(icon, 'size-[18px]')} />
</span>
<span aria-hidden className={cn(icon, 'h-5 w-5 shrink-0', className)} />
)
type MainNavLinkProps = {
@ -32,10 +30,7 @@ const MainNavLink = ({ item, pathname, children }: MainNavLinkProps) => {
)}
>
<NavIcon icon={item.icon} className="group-aria-[current=page]:hidden" />
<NavIcon
icon={item.activeIcon}
className="hidden drop-shadow-[0_0_4px_rgba(49,70,255,0.18)] group-aria-[current=page]:flex"
/>
<NavIcon icon={item.activeIcon} className="hidden group-aria-[current=page]:block" />
<span className="min-w-0 truncate group-aria-[current=page]:text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]">
{item.label}
</span>

View File

@ -70,15 +70,6 @@ export const MAIN_NAV_ROUTES = [
visibility: CAN_MANAGE_AGENTS,
feature: 'agentV2',
},
{
key: 'skills',
href: '/skills',
labelKey: 'mainNav.skills',
active: (path: string) => isPathUnderRoute(path, '/skills'),
icon: 'i-custom-vender-main-nav-skill',
activeIcon: 'i-custom-vender-main-nav-skill-active',
visibility: SKILL_ENABLED_FOR_WORKSPACE,
},
{
key: 'datasets',
href: '/datasets',
@ -88,6 +79,15 @@ export const MAIN_NAV_ROUTES = [
activeIcon: 'i-custom-vender-main-nav-knowledge-v2-active',
visibility: VISIBLE_TO_ALL,
},
{
key: 'skills',
href: '/skills',
labelKey: 'mainNav.skills',
active: (path: string) => isPathUnderRoute(path, '/skills'),
icon: 'i-custom-vender-main-nav-skill',
activeIcon: 'i-custom-vender-main-nav-skill-active',
visibility: SKILL_ENABLED_FOR_WORKSPACE,
},
{
key: 'integrations',
href: buildIntegrationPath('provider'),

View File

@ -331,6 +331,32 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
expect(result.marketplaceCollectionPluginsMap.broken).toEqual([])
})
it('propagates cancellation instead of resolving empty carousels', async () => {
const controller = new AbortController()
mockCollections.mockResolvedValueOnce({
data: {
collections: [
{ name: 'ok', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
{ name: 'slow', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
],
},
})
mockCollectionPlugins
.mockResolvedValueOnce({ data: { plugins: [{ type: 'plugin', org: 'a', name: 'b' }] } })
.mockImplementationOnce(async () => {
controller.abort()
const error = new Error('Aborted')
error.name = 'AbortError'
throw error
})
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
await expect(
getMarketplaceCollectionsAndPlugins({}, { signal: controller.signal }),
).rejects.toMatchObject({ name: 'AbortError' })
})
it('should append condition and type to URL when provided', async () => {
mockCollections.mockResolvedValueOnce({ data: { collections: [] } })

View File

@ -5,6 +5,20 @@ import { describe, expect, it, vi } from 'vitest'
import { renderWithNuqs } from '@/test/nuqs-testing'
import CreatorContent from '../creator-content'
const publisherMocks = vi.hoisted(() => ({
fetchPublisherPluginPage: vi.fn(),
fetchPublisherTemplatePage: vi.fn(),
}))
vi.mock('../publisher', async (importOriginal) => {
const actual = await importOriginal<typeof import('../publisher')>()
return {
...actual,
fetchPublisherPluginPage: publisherMocks.fetchPublisherPluginPage,
fetchPublisherTemplatePage: publisherMocks.fetchPublisherTemplatePage,
}
})
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
const translations: Record<string, string> = {
@ -17,6 +31,8 @@ vi.mock('#i18n', async () => {
'marketplace.creatorProfile.sort.desc': 'Sort descending',
'marketplace.creatorProfile.type.plugin': 'Plugin',
'marketplace.creatorProfile.type.template': 'Template',
'marketplace.creatorProfile.loadMore': 'Load more',
'marketplace.creatorProfile.loadMoreFailed': "Couldn't load more creations.",
}
return {
@ -59,6 +75,10 @@ const creations = [
const cardNames = () => screen.getAllByRole('link').map((link) => link.getAttribute('aria-label'))
describe('CreatorContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('writes sort into the URL and reorders the current cards', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderWithNuqs(
@ -91,4 +111,53 @@ describe('CreatorContent', () => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_by')).toBe('popularity')
})
})
it('loads the next publisher pages when more creations exist', async () => {
const user = userEvent.setup()
publisherMocks.fetchPublisherPluginPage.mockResolvedValue({
items: [
{
type: 'plugin',
org: 'dify',
name: 'delta',
labels: { 'en-US': 'Delta' },
brief: { 'en-US': 'Delta plugin' },
install_count: 4,
created_at: '2026-01-04T00:00:00Z',
updated_at: '2026-02-04T00:00:00Z',
},
],
hasMore: false,
})
publisherMocks.fetchPublisherTemplatePage.mockResolvedValue({ items: [], hasMore: false })
renderWithNuqs(
<CreatorContent
creations={creations}
locale="en-US"
inventory={{
uniqueHandle: 'scarlettmao',
pluginHasMore: true,
templateHasMore: false,
pluginNextPage: 2,
templateNextPage: 2,
}}
getCreationAction={(creation) => ({ type: 'link', href: `/creation/${creation.id}` })}
/>,
)
await user.click(screen.getByRole('button', { name: 'Load more' }))
await waitFor(() => {
expect(cardNames()).toContain('Delta')
})
expect(publisherMocks.fetchPublisherPluginPage).toHaveBeenCalledWith({
uniqueHandle: 'scarlettmao',
page: 2,
sortField: 'updatedAt',
sortOrder: 'desc',
})
expect(publisherMocks.fetchPublisherTemplatePage).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument()
})
})

View File

@ -164,35 +164,50 @@ describe('loadCreatorProfile', () => {
expect(loaded?.viewModel.creations.map(({ kind }) => kind)).toEqual(['template', 'plugin'])
})
it('fetches remaining publisher pages until the reported total is loaded', async () => {
const extraPlugin = {
...plugin,
name: 'extra',
plugin_id: 'dify/extra',
} as MarketplacePlugin
mocks.publisherPlugins
.mockResolvedValueOnce({
data: { plugins: [plugin], total: 2 },
})
.mockResolvedValueOnce({
data: { plugins: [extraPlugin], total: 2 },
})
it('loads only the first publisher page and reports remaining inventory', async () => {
mocks.publisherPlugins.mockResolvedValue({
data: {
plugins: Array.from({ length: 40 }, (_, index) => ({ ...plugin, name: `p-${index}` })),
total: 90,
},
})
mocks.publisherTemplates.mockResolvedValue({
data: { templates: [template], total: 1 },
})
const loaded = await loadCreatorProfile({
uniqueHandle: 'paged-creator',
locale: 'en-US',
})
expect(mocks.publisherPlugins).toHaveBeenNthCalledWith(1, {
expect(mocks.publisherPlugins).toHaveBeenCalledOnce()
expect(mocks.publisherPlugins).toHaveBeenCalledWith({
params: { uniqueHandle: 'paged-creator' },
query: { page: 1, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' },
})
expect(mocks.publisherPlugins).toHaveBeenNthCalledWith(2, {
params: { uniqueHandle: 'paged-creator' },
query: { page: 2, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' },
expect(loaded?.inventory).toMatchObject({
uniqueHandle: 'paged-creator',
pluginHasMore: true,
templateHasMore: false,
pluginNextPage: 2,
})
expect(loaded?.pluginsByCreationId['plugin:dify/search']).toBeDefined()
expect(loaded?.pluginsByCreationId['plugin:dify/extra']).toBeDefined()
expect(loaded?.viewModel.creations).toHaveLength(41)
})
it('does not treat a full first page as the complete inventory when total is missing', async () => {
mocks.publisherPlugins.mockResolvedValue({
data: {
plugins: Array.from({ length: 40 }, (_, index) => ({ ...plugin, name: `p-${index}` })),
},
})
const loaded = await loadCreatorProfile({
uniqueHandle: 'uncounted-creator',
locale: 'en-US',
})
expect(mocks.publisherPlugins).toHaveBeenCalledOnce()
expect(loaded?.inventory.pluginHasMore).toBe(true)
})
it('keeps successful creations when one publisher request fails', async () => {

View File

@ -99,6 +99,13 @@ const loadedProfile: LoadedCreatorProfile = {
templatesByCreationId: {
'template:template-one': template,
},
inventory: {
uniqueHandle: 'creator',
pluginHasMore: false,
templateHasMore: false,
pluginNextPage: 2,
templateNextPage: 2,
},
}
vi.mock('#i18n', async () => {

View File

@ -192,4 +192,33 @@ describe('creator profile model', () => {
expect(normalizeCreatorSocialLink('data:text/html,bad')).toBeNull()
expect(normalizeCreatorSocialLink('mailto:test@example.com')).toBeNull()
})
it('ignores non-string social links and template dependencies instead of throwing', () => {
expect(normalizeCreatorSocialLink({ href: 'https://x.com/x' })).toBeNull()
expect(normalizeCreatorSocialLink(null)).toBeNull()
const viewModel = adaptCreatorProfile({
creator: {
...creator,
social_links: [{ href: 'https://x.com/x' }, 'github.com/evanz'] as unknown as string[],
},
kind: 'individual',
locale: 'en-US',
avatarUrl: '/avatar',
backgroundUrl: '/background',
plugins: [],
templates: [
{
...template,
deps_plugins: [null, 'dify/search', ''] as unknown as string[],
},
],
resolvePluginIcon: () => '/plugin-icon',
resolveTemplateIcon: () => '',
resolveDependencyIcon: (id) => `/dependency/${id}`,
})
expect(viewModel.profile.socialLinks).toEqual([expect.objectContaining({ platform: 'github' })])
expect(viewModel.creations[0]?.dependencyIcons).toEqual(['/dependency/dify/search'])
})
})

View File

@ -1,11 +1,14 @@
'use client'
import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace'
import type {
CreatorCreation,
CreatorCreationAction,
CreatorInventory,
CreatorSortField,
CreatorSortOrder,
} from './model'
import type { Plugin } from '@/app/components/plugins/types'
import {
DropdownMenu,
DropdownMenuContent,
@ -15,7 +18,7 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { parseAsStringEnum, useQueryStates } from 'nuqs'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from '#i18n'
import CreationCard from './creation-card'
import {
@ -24,10 +27,17 @@ import {
DEFAULT_CREATOR_SORT_ORDER,
sortCreatorCreations,
} from './model'
import { fetchPublisherPluginPage, fetchPublisherTemplatePage, toCreatorRecords } from './publisher'
type CreatorContentProps = {
creations: CreatorCreation[]
getCreationAction: (creation: CreatorCreation) => CreatorCreationAction
inventory?: CreatorInventory
locale?: string
onRecordsLoaded?: (records: {
pluginsByCreationId: Record<string, Plugin>
templatesByCreationId: Record<string, MarketplaceTemplate>
}) => void
}
const sortSearchOptions = { history: 'replace' as const, shallow: false, scroll: false }
@ -40,11 +50,34 @@ const creatorSortSearchParsers = {
),
}
export default function CreatorContent({ creations, getCreationAction }: CreatorContentProps) {
export default function CreatorContent({
creations,
getCreationAction,
inventory,
locale = 'en-US',
onRecordsLoaded,
}: CreatorContentProps) {
const { t } = useTranslation()
const [sort, setSort] = useQueryStates(creatorSortSearchParsers, sortSearchOptions)
const sortField = sort.sort_by
const sortOrder = sort.sort_order
const [sourceCreations, setSourceCreations] = useState(creations)
const [loadedCreations, setLoadedCreations] = useState(creations)
const [pluginHasMore, setPluginHasMore] = useState(inventory?.pluginHasMore ?? false)
const [templateHasMore, setTemplateHasMore] = useState(inventory?.templateHasMore ?? false)
const [pluginNextPage, setPluginNextPage] = useState(inventory?.pluginNextPage ?? 2)
const [templateNextPage, setTemplateNextPage] = useState(inventory?.templateNextPage ?? 2)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [loadMoreFailed, setLoadMoreFailed] = useState(false)
if (creations !== sourceCreations) {
setSourceCreations(creations)
setLoadedCreations(creations)
setPluginHasMore(inventory?.pluginHasMore ?? false)
setTemplateHasMore(inventory?.templateHasMore ?? false)
setPluginNextPage(inventory?.pluginNextPage ?? 2)
setTemplateNextPage(inventory?.templateNextPage ?? 2)
setLoadMoreFailed(false)
}
const sortOptions: Array<{ value: CreatorSortField; label: string }> = [
{
value: 'updatedAt',
@ -61,10 +94,61 @@ export default function CreatorContent({ creations, getCreationAction }: Creator
]
const selectedSort = sortOptions.find((option) => option.value === sortField) ?? sortOptions[0]!
const sortedCreations = useMemo(
() => sortCreatorCreations(creations, sortField, sortOrder),
[creations, sortField, sortOrder],
() => sortCreatorCreations(loadedCreations, sortField, sortOrder),
[loadedCreations, sortField, sortOrder],
)
const nextSortOrder = sortOrder === 'desc' ? 'asc' : 'desc'
const hasMore = pluginHasMore || templateHasMore
const uniqueHandle = inventory?.uniqueHandle
const loadMore = async () => {
if (!uniqueHandle || isLoadingMore || !hasMore) return
setIsLoadingMore(true)
setLoadMoreFailed(false)
try {
const [pluginPage, templatePage] = await Promise.all([
pluginHasMore
? fetchPublisherPluginPage({
uniqueHandle,
page: pluginNextPage,
sortField,
sortOrder,
})
: Promise.resolve({ items: [] as MarketplacePlugin[], hasMore: false }),
templateHasMore
? fetchPublisherTemplatePage({
uniqueHandle,
page: templateNextPage,
sortField,
sortOrder,
})
: Promise.resolve({ items: [] as MarketplaceTemplate[], hasMore: false }),
])
const records = toCreatorRecords({
locale,
plugins: pluginPage.items,
templates: templatePage.items,
})
setLoadedCreations((current) => {
const seen = new Set(current.map((creation) => creation.id))
return [...current, ...records.creations.filter((creation) => !seen.has(creation.id))]
})
if (pluginHasMore) {
setPluginHasMore(pluginPage.hasMore)
setPluginNextPage((page) => page + 1)
}
if (templateHasMore) {
setTemplateHasMore(templatePage.hasMore)
setTemplateNextPage((page) => page + 1)
}
onRecordsLoaded?.(records)
} catch {
setLoadMoreFailed(true)
} finally {
setIsLoadingMore(false)
}
}
return (
<section
@ -151,6 +235,27 @@ export default function CreatorContent({ creations, getCreationAction }: Creator
{t(($) => $['marketplace.creatorProfile.empty'], { ns: 'plugin' })}
</div>
)}
{hasMore && (
<div className="flex w-full flex-col items-center gap-2 pt-6">
<button
type="button"
aria-busy={isLoadingMore || undefined}
disabled={isLoadingMore}
className="flex h-8 items-center rounded-lg px-3 system-sm-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:opacity-50"
onClick={() => {
void loadMore()
}}
>
{t(($) => $['marketplace.creatorProfile.loadMore'], { ns: 'plugin' })}
</button>
{loadMoreFailed && (
<p className="system-xs-regular text-text-destructive">
{t(($) => $['marketplace.creatorProfile.loadMoreFailed'], { ns: 'plugin' })}
</p>
)}
</div>
)}
</section>
)
}

View File

@ -1,42 +1,24 @@
import type {
MarketplaceCreator,
MarketplaceOrganization,
MarketplacePlugin,
MarketplaceTemplate,
} from '@dify/contracts/marketplace'
import type { MarketplaceCreator, MarketplaceOrganization } from '@dify/contracts/marketplace'
import type { CreatorSortField, CreatorSortOrder, LoadedCreatorProfile } from './model'
import { cache } from 'react'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { marketplaceClient } from '@/service/client'
import { getFormattedPlugin, getPluginIconInMarketplace } from '../utils'
import { getPluginIconInMarketplace } from '../utils'
import {
adaptCreatorProfile,
parseCreatorSortField,
parseCreatorSortOrder,
sortCreatorCreations,
toPublisherSortQuery,
} from './model'
import {
fetchPublisherPluginPage,
fetchPublisherTemplatePage,
getDependencyIcon,
getTemplateIcon,
toCreatorRecords,
} from './publisher'
import 'server-only'
const PAGE_SIZE = 40
const MAX_PAGES = 5
const fetchAllPublisherPages = async <T>(
fetchPage: (page: number) => Promise<{ items: T[]; total?: number }>,
) => {
const first = await fetchPage(1)
const items = [...first.items]
const total = first.total ?? items.length
for (let page = 2; page <= MAX_PAGES && items.length < total; page++) {
const next = await fetchPage(page)
if (next.items.length === 0) break
items.push(...next.items)
}
return items
}
const mapOrganizationToCreator = (
organization: MarketplaceOrganization,
uniqueHandle: string,
@ -73,50 +55,6 @@ const getPublisher = async (uniqueHandle: string, publisherType?: string) => {
return response.data?.creator
}
const getPublisherPlugins = async (
uniqueHandle: string,
sortField: CreatorSortField,
sortOrder: CreatorSortOrder,
) => {
const { plugins } = toPublisherSortQuery(sortField, sortOrder)
return fetchAllPublisherPages(async (page) => {
const response = await marketplaceClient.publisherPlugins({
params: { uniqueHandle },
query: { page, page_size: PAGE_SIZE, ...plugins },
})
return {
items: response.data?.plugins ?? [],
total: response.data?.total,
}
})
}
const getPublisherTemplates = async (
uniqueHandle: string,
sortField: CreatorSortField,
sortOrder: CreatorSortOrder,
) => {
const { templates } = toPublisherSortQuery(sortField, sortOrder)
return fetchAllPublisherPages(async (page) => {
const response = await marketplaceClient.publisherTemplates({
params: { uniqueHandle },
query: { page, page_size: PAGE_SIZE, ...templates },
})
return {
items: response.data?.templates ?? [],
total: response.data?.total,
}
})
}
const getTemplateIcon = (template: MarketplaceTemplate) =>
template.icon_file_key
? `${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(template.id)}/icon`
: ''
const getDependencyIcon = (pluginId: string) =>
`${MARKETPLACE_API_PREFIX}/plugins/${pluginId.split('/').map(encodeURIComponent).join('/')}/icon`
const loadCreatorProfileCached = cache(
async (
uniqueHandle: string,
@ -127,18 +65,18 @@ const loadCreatorProfileCached = cache(
): Promise<LoadedCreatorProfile | null> => {
const [creatorResult, pluginsResult, templatesResult] = await Promise.allSettled([
getPublisher(uniqueHandle, publisherType),
getPublisherPlugins(uniqueHandle, sortField, sortOrder),
getPublisherTemplates(uniqueHandle, sortField, sortOrder),
fetchPublisherPluginPage({ uniqueHandle, page: 1, sortField, sortOrder }),
fetchPublisherTemplatePage({ uniqueHandle, page: 1, sortField, sortOrder }),
])
if (creatorResult.status === 'rejected') throw creatorResult.reason
const creator = creatorResult.value
if (!creator) return null
const plugins: MarketplacePlugin[] =
pluginsResult.status === 'fulfilled' ? pluginsResult.value : []
const templates: MarketplaceTemplate[] =
templatesResult.status === 'fulfilled' ? templatesResult.value : []
const plugins = pluginsResult.status === 'fulfilled' ? pluginsResult.value.items : []
const templates = templatesResult.status === 'fulfilled' ? templatesResult.value.items : []
const pluginPage = pluginsResult.status === 'fulfilled' ? pluginsResult.value : undefined
const templatePage = templatesResult.status === 'fulfilled' ? templatesResult.value : undefined
const kind = publisherType === 'organization' ? 'organization' : 'individual'
const resource = kind === 'organization' ? 'organizations' : 'creators'
const encodedHandle = encodeURIComponent(uniqueHandle)
@ -160,21 +98,22 @@ const loadCreatorProfileCached = cache(
resolveTemplateIcon: getTemplateIcon,
resolveDependencyIcon: getDependencyIcon,
})
const records = toCreatorRecords({ locale, plugins, templates })
return {
viewModel: {
...viewModel,
creations: sortCreatorCreations(viewModel.creations, sortField, sortOrder),
},
pluginsByCreationId: Object.fromEntries(
plugins.map((plugin) => [
`${plugin.type}:${plugin.org}/${plugin.name}`,
getFormattedPlugin(plugin),
]),
),
templatesByCreationId: Object.fromEntries(
templates.map((template) => [`template:${template.id}`, template]),
),
pluginsByCreationId: records.pluginsByCreationId,
templatesByCreationId: records.templatesByCreationId,
inventory: {
uniqueHandle,
pluginHasMore: pluginPage?.hasMore ?? false,
templateHasMore: templatePage?.hasMore ?? false,
pluginNextPage: 2,
templateNextPage: 2,
},
}
},
)

View File

@ -34,7 +34,18 @@ const normalizePlugin = (plugin: Plugin): Plugin => ({
export default function DifyCreatorProfile({ loadedProfile, locale }: DifyCreatorProfileProps) {
const router = useRouter()
const [selected, setSelected] = useState<SelectedCreation | null>(null)
const profilePlugins = Object.values(loadedProfile.pluginsByCreationId)
const [sourceProfile, setSourceProfile] = useState(loadedProfile)
const [pluginsByCreationId, setPluginsByCreationId] = useState(loadedProfile.pluginsByCreationId)
const [templatesByCreationId, setTemplatesByCreationId] = useState(
loadedProfile.templatesByCreationId,
)
if (loadedProfile !== sourceProfile) {
setSourceProfile(loadedProfile)
setPluginsByCreationId(loadedProfile.pluginsByCreationId)
setTemplatesByCreationId(loadedProfile.templatesByCreationId)
}
const profilePlugins = Object.values(pluginsByCreationId)
const pluginIds = useMemo(
() =>
Array.from(
@ -52,12 +63,12 @@ export default function DifyCreatorProfile({ loadedProfile, locale }: DifyCreato
const selectCreation = (creation: CreatorCreation) => {
if (creation.kind === 'plugin') {
const plugin = loadedProfile.pluginsByCreationId[creation.id]
const plugin = pluginsByCreationId[creation.id]
if (plugin) setSelected({ kind: 'plugin', plugin: normalizePlugin(plugin) })
return
}
const template = loadedProfile.templatesByCreationId[creation.id]
const template = templatesByCreationId[creation.id]
if (template) setSelected({ kind: 'template', template })
}
@ -82,6 +93,15 @@ export default function DifyCreatorProfile({ loadedProfile, locale }: DifyCreato
profile={loadedProfile.viewModel}
homeHref="/marketplace"
isMarketplacePlatform
inventory={loadedProfile.inventory}
locale={locale}
onRecordsLoaded={(records) => {
setPluginsByCreationId((current) => ({ ...current, ...records.pluginsByCreationId }))
setTemplatesByCreationId((current) => ({
...current,
...records.templatesByCreationId,
}))
}}
getCreationAction={(creation) => ({
type: 'select',
onSelect: () => selectCreation(creation),

View File

@ -98,10 +98,19 @@ export type CreatorProfileViewModel = {
creations: CreatorCreation[]
}
export type CreatorInventory = {
uniqueHandle: string
pluginHasMore: boolean
templateHasMore: boolean
pluginNextPage: number
templateNextPage: number
}
export type LoadedCreatorProfile = {
viewModel: CreatorProfileViewModel
pluginsByCreationId: Record<string, Plugin>
templatesByCreationId: Record<string, MarketplaceTemplate>
inventory: CreatorInventory
}
export type CreatorCreationAction =
@ -136,22 +145,26 @@ const toTimestamp = (value?: MarketplaceTimestamp | null) => {
return Number.isNaN(timestamp) ? 0 : timestamp
}
const firstLocalizedString = (value: object, keys: string[]) => {
for (const key of keys) {
const entry = (value as Record<string, unknown>)[key]
if (typeof entry === 'string' && entry) return entry
}
return (
Object.values(value).find((entry): entry is string => typeof entry === 'string' && !!entry) ??
''
)
}
const getCreatorLocalizedText = (
value: Partial<Record<string, string>> | string | undefined,
locale: string,
) => {
if (typeof value === 'string') return value
if (!value) return ''
if (!value || typeof value !== 'object') return ''
const normalizedLocale = locale.replace('-', '_')
return (
value[locale] ||
value[normalizedLocale] ||
value['en-US'] ||
value.en_US ||
Object.values(value).find(Boolean) ||
''
)
return firstLocalizedString(value, [locale, normalizedLocale, 'en-US', 'en_US'])
}
const getSocialPlatform = (hostname: string): CreatorSocialPlatform => {
@ -170,7 +183,8 @@ const getSocialPlatform = (hostname: string): CreatorSocialPlatform => {
return 'website'
}
export const normalizeCreatorSocialLink = (value: string): CreatorSocialLink | null => {
export const normalizeCreatorSocialLink = (value: unknown): CreatorSocialLink | null => {
if (typeof value !== 'string') return null
const trimmedValue = value.trim()
if (!trimmedValue) return null
@ -201,18 +215,22 @@ const getCreatorBadges = (creator: MarketplaceCreator) => {
return Array.from(badges)
}
export const adaptCreatorProfile = ({
creator,
kind,
export const adaptCreations = ({
locale,
avatarUrl,
backgroundUrl,
plugins,
templates,
resolvePluginIcon,
resolveTemplateIcon,
resolveDependencyIcon,
}: CreatorProfileAdapterInput): CreatorProfileViewModel => {
}: Pick<
CreatorProfileAdapterInput,
| 'locale'
| 'plugins'
| 'templates'
| 'resolvePluginIcon'
| 'resolveTemplateIcon'
| 'resolveDependencyIcon'
>): CreatorCreation[] => {
const pluginCreations = plugins.map((plugin): CreatorCreation => ({
id: `${plugin.type}:${plugin.org}/${plugin.name}`,
kind: 'plugin',
@ -240,7 +258,9 @@ export const adaptCreatorProfile = ({
const templateCreations = templates.map((template): CreatorCreation => {
const templateIcon = resolveTemplateIcon(template)
const dependencyIds = template.deps_plugins ?? []
const dependencyIds = (template.deps_plugins ?? []).filter(
(id): id is string => typeof id === 'string' && id.length > 0,
)
const publisher =
template.publisher_handle ||
template.publisher_unique_handle ||
@ -269,6 +289,21 @@ export const adaptCreatorProfile = ({
}
})
return [...pluginCreations, ...templateCreations]
}
export const adaptCreatorProfile = ({
creator,
kind,
locale,
avatarUrl,
backgroundUrl,
plugins,
templates,
resolvePluginIcon,
resolveTemplateIcon,
resolveDependencyIcon,
}: CreatorProfileAdapterInput): CreatorProfileViewModel => {
return {
profile: {
kind,
@ -283,7 +318,14 @@ export const adaptCreatorProfile = ({
.map(normalizeCreatorSocialLink)
.filter((link): link is CreatorSocialLink => link !== null),
},
creations: [...pluginCreations, ...templateCreations],
creations: adaptCreations({
locale,
plugins,
templates,
resolvePluginIcon,
resolveTemplateIcon,
resolveDependencyIcon,
}),
}
}

View File

@ -0,0 +1,102 @@
import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace'
import type { CreatorCreation, CreatorSortField, CreatorSortOrder } from './model'
import type { Plugin } from '@/app/components/plugins/types'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { marketplaceClient } from '@/service/client'
import { getFormattedPlugin, getPluginIconInMarketplace } from '../utils'
import { adaptCreations, toPublisherSortQuery } from './model'
const CREATOR_PAGE_SIZE = 40
export type PublisherPage<T> = {
items: T[]
total?: number
hasMore: boolean
}
const publisherPageHasMore = (page: number, itemCount: number, total?: number) =>
typeof total === 'number' ? page * CREATOR_PAGE_SIZE < total : itemCount === CREATOR_PAGE_SIZE
export const getTemplateIcon = (template: MarketplaceTemplate) =>
template.icon_file_key
? `${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(template.id)}/icon`
: ''
export const getDependencyIcon = (pluginId: string) => {
if (!pluginId.includes('/')) return ''
return `${MARKETPLACE_API_PREFIX}/plugins/${pluginId.split('/').map(encodeURIComponent).join('/')}/icon`
}
export async function fetchPublisherPluginPage({
uniqueHandle,
page,
sortField,
sortOrder,
}: {
uniqueHandle: string
page: number
sortField: CreatorSortField
sortOrder: CreatorSortOrder
}): Promise<PublisherPage<MarketplacePlugin>> {
const { plugins } = toPublisherSortQuery(sortField, sortOrder)
const response = await marketplaceClient.publisherPlugins({
params: { uniqueHandle },
query: { page, page_size: CREATOR_PAGE_SIZE, ...plugins },
})
const items = response.data?.plugins ?? []
const total = response.data?.total
return { items, total, hasMore: publisherPageHasMore(page, items.length, total) }
}
export async function fetchPublisherTemplatePage({
uniqueHandle,
page,
sortField,
sortOrder,
}: {
uniqueHandle: string
page: number
sortField: CreatorSortField
sortOrder: CreatorSortOrder
}): Promise<PublisherPage<MarketplaceTemplate>> {
const { templates } = toPublisherSortQuery(sortField, sortOrder)
const response = await marketplaceClient.publisherTemplates({
params: { uniqueHandle },
query: { page, page_size: CREATOR_PAGE_SIZE, ...templates },
})
const items = response.data?.templates ?? []
const total = response.data?.total
return { items, total, hasMore: publisherPageHasMore(page, items.length, total) }
}
export const toCreatorRecords = ({
locale,
plugins,
templates,
}: {
locale: string
plugins: MarketplacePlugin[]
templates: MarketplaceTemplate[]
}): {
creations: CreatorCreation[]
pluginsByCreationId: Record<string, Plugin>
templatesByCreationId: Record<string, MarketplaceTemplate>
} => ({
creations: adaptCreations({
locale,
plugins,
templates,
resolvePluginIcon: getPluginIconInMarketplace,
resolveTemplateIcon: getTemplateIcon,
resolveDependencyIcon: getDependencyIcon,
}),
pluginsByCreationId: Object.fromEntries(
plugins.map((plugin) => [
`${plugin.type}:${plugin.org}/${plugin.name}`,
getFormattedPlugin(plugin),
]),
),
templatesByCreationId: Object.fromEntries(
templates.map((template) => [`template:${template.id}`, template]),
),
})

View File

@ -1,7 +1,14 @@
'use client'
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
import type { ReactNode } from 'react'
import type { CreatorCreation, CreatorCreationAction, CreatorProfileViewModel } from './model'
import type {
CreatorCreation,
CreatorCreationAction,
CreatorInventory,
CreatorProfileViewModel,
} from './model'
import type { Plugin } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from '#i18n'
import Link from '@/next/link'
@ -15,6 +22,12 @@ export type CreatorProfileViewProps = {
header?: ReactNode
homeHref: string
isMarketplacePlatform: boolean
inventory?: CreatorInventory
locale?: string
onRecordsLoaded?: (records: {
pluginsByCreationId: Record<string, Plugin>
templatesByCreationId: Record<string, MarketplaceTemplate>
}) => void
}
export default function CreatorProfileView({
@ -23,6 +36,9 @@ export default function CreatorProfileView({
header,
homeHref,
isMarketplacePlatform,
inventory,
locale,
onRecordsLoaded,
}: CreatorProfileViewProps) {
const { t } = useTranslation()
@ -79,7 +95,13 @@ export default function CreatorProfileView({
)}
>
<CreatorSidebar profile={profile.profile} />
<CreatorContent creations={profile.creations} getCreationAction={getCreationAction} />
<CreatorContent
creations={profile.creations}
getCreationAction={getCreationAction}
inventory={inventory}
locale={locale}
onRecordsLoaded={onRecordsLoaded}
/>
</div>
</div>
</main>

View File

@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from 'next-themes'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import MarketplaceDetailDialog from '../index'
@ -13,9 +14,16 @@ const mocks = vi.hoisted(() => ({
vi.mock('../../utils', () => ({
getPluginLinkInMarketplace: (
plugin: Plugin,
params: { installed: string; language: string; source?: string; theme?: string; view: string },
params: {
canInstall?: string
installed: string
language: string
source?: string
theme?: string
view: string
},
) =>
`about:blank?plugin=${plugin.org}/${plugin.name}&installed=${params.installed}&language=${params.language}&source=${params.source}&theme=${params.theme}&view=${params.view}`,
`about:blank?plugin=${plugin.org}/${plugin.name}&installed=${params.installed}&language=${params.language}&source=${params.source}&theme=${params.theme}&view=${params.view}&canInstall=${params.canInstall}`,
}))
vi.mock('../use-silent-install', () => ({
@ -67,7 +75,7 @@ describe('MarketplaceDetailDialog', () => {
'src',
// resolvedTheme maps the "system" preference to the concrete value, so
// the embedded detail page receives light/dark rather than "system".
'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=light&view=modal',
'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=light&view=modal&canInstall=true',
)
expect(document.querySelector('.bg-linear-to-t')).not.toBeInTheDocument()
@ -135,4 +143,109 @@ describe('MarketplaceDetailDialog', () => {
)
})
})
it('does not install when the workspace lacks plugin.install', async () => {
render(
<PluginInstallPermissionProvider canInstallPlugin={false}>
<ThemeProvider forcedTheme="dark">
<MarketplaceDetailDialog
open
isInstalled={false}
plugin={plugin}
onOpenChange={vi.fn()}
/>
</ThemeProvider>
</PluginInstallPermissionProvider>,
)
const frame = screen.getByTitle(
'Plugin A · plugin.detailPanel.operation.detail',
) as HTMLIFrameElement
expect(frame).toHaveAttribute(
'src',
'about:blank?plugin=dify/plugin-a&installed=false&language=en-US&source=http://localhost:3000&theme=light&view=modal&canInstall=false',
)
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
fireEvent(
window,
new MessageEvent('message', {
data: {
type: 'dify-marketplace:install-plugin',
pluginUniqueIdentifier: plugin.latest_package_identifier,
},
origin: 'null',
source: frame.contentWindow,
}),
)
expect(mocks.install).not.toHaveBeenCalled()
await waitFor(() => {
expect(postMessage).toHaveBeenCalledWith(
{
type: 'dify-marketplace:install-plugin-status',
pluginUniqueIdentifier: plugin.latest_package_identifier,
status: 'failed',
},
'null',
)
})
})
it('ignores a late install result after the timeout has already settled', async () => {
vi.useFakeTimers()
let finishInstall: ((result: { status: 'success' }) => void) | undefined
mocks.install.mockImplementation(
() =>
new Promise((resolve) => {
finishInstall = resolve
}),
)
try {
render(
<ThemeProvider forcedTheme="dark">
<MarketplaceDetailDialog
open
isInstalled={false}
plugin={plugin}
onOpenChange={vi.fn()}
/>
</ThemeProvider>,
)
const frame = screen.getByTitle(
'Plugin A · plugin.detailPanel.operation.detail',
) as HTMLIFrameElement
const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage')
fireEvent(
window,
new MessageEvent('message', {
data: {
type: 'dify-marketplace:install-plugin',
pluginUniqueIdentifier: plugin.latest_package_identifier,
},
origin: 'null',
source: frame.contentWindow,
}),
)
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
expect(postMessage).toHaveBeenCalledWith(
{
type: 'dify-marketplace:install-plugin-status',
pluginUniqueIdentifier: plugin.latest_package_identifier,
status: 'timeout',
},
'null',
)
finishInstall?.({ status: 'success' })
await Promise.resolve()
await vi.runAllTimersAsync()
expect(postMessage).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})

View File

@ -4,6 +4,7 @@ import type { Plugin } from '@/app/components/plugins/types'
import { useTheme } from 'next-themes'
import { useCallback, useEffect, useRef } from 'react'
import { useLocale, useTranslation } from '#i18n'
import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
import { getPluginLinkInMarketplace } from '../utils'
import MarketplaceDetailDialogFrame from './frame'
import { useSilentMarketplaceInstall } from './use-silent-install'
@ -31,11 +32,13 @@ const isInstallRequest = (data: unknown, pluginUniqueIdentifier: string) => {
}
function OpenMarketplaceDetailDialog({
canInstallPlugin,
onOpenChange,
plugin,
src,
title,
}: {
canInstallPlugin: boolean
onOpenChange: (open: boolean) => void
plugin: Plugin
src: string
@ -57,9 +60,25 @@ function OpenMarketplaceDetailDialog({
if (!isInstallRequest(data, plugin.latest_package_identifier)) return
const uniqueIdentifier = plugin.latest_package_identifier
if (!canInstallPlugin) {
reply({
type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE,
pluginUniqueIdentifier: uniqueIdentifier,
status: 'failed',
})
return
}
let settled = false
const settle = (payload: Record<string, unknown>) => {
if (settled) return
settled = true
reply(payload)
}
const timeoutId = window.setTimeout(() => {
timeoutIdsRef.current.delete(timeoutId)
reply({
settle({
type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE,
pluginUniqueIdentifier: uniqueIdentifier,
status: 'timeout',
@ -70,14 +89,14 @@ function OpenMarketplaceDetailDialog({
void install(plugin).then((result) => {
window.clearTimeout(timeoutId)
timeoutIdsRef.current.delete(timeoutId)
reply({
settle({
type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE,
pluginUniqueIdentifier: uniqueIdentifier,
...result,
})
})
},
[install, plugin],
[canInstallPlugin, install, plugin],
)
return (
@ -99,6 +118,7 @@ function MarketplaceDetailDialog({
}: MarketplaceDetailDialogProps) {
const { t } = useTranslation()
const locale = useLocale()
const { canInstallPlugin } = useOptionalPluginInstallPermission()
// resolvedTheme maps the "system" preference to the concrete light/dark
// value the marketplace page expects.
const { resolvedTheme } = useTheme()
@ -107,6 +127,7 @@ function MarketplaceDetailDialog({
const installedForSrcRef = useRef(isInstalled)
if (!open) installedForSrcRef.current = isInstalled
const detailURL = getPluginLinkInMarketplace(plugin, {
canInstall: String(canInstallPlugin),
installed: String(installedForSrcRef.current),
language: locale,
source: globalThis.location?.origin,
@ -128,6 +149,7 @@ function MarketplaceDetailDialog({
return (
<OpenMarketplaceDetailDialog
canInstallPlugin={canInstallPlugin}
plugin={plugin}
src={detailURL}
title={title}

View File

@ -237,4 +237,34 @@ describe('EmbeddedMarketplaceSearch', () => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google')
})
})
it('filters the current catalog when Enter is pressed instead of opening a result', async () => {
mockPluginSearch.mockResolvedValue({
data: {
plugins: [
{
type: 'plugin',
org: 'langgenius',
name: 'google-search',
plugin_id: 'langgenius/google-search',
label: { en_US: 'Google Search' },
brief: { en_US: 'Search the web from your workflow.' },
category: 'tool',
},
],
total: 1,
},
})
const user = userEvent.setup()
const { onUrlUpdate } = renderSearch()
await user.type(screen.getByRole('combobox'), 'google')
await user.hover(await screen.findByRole('option', { name: /Google Search/ }))
await user.keyboard('{Enter}')
await waitFor(() => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google')
})
expect(screen.queryByRole('dialog', { name: 'plugin-detail' })).not.toBeInTheDocument()
})
})

View File

@ -239,37 +239,30 @@ describe('Marketplace home trending layout', () => {
expect(getComputedStyle(artwork!).borderBottomLeftRadius).toBe('16px')
})
it('moves forwards into the first slide clone before resetting the loop', async () => {
it('wraps from the last banner back to the first visible slide', async () => {
const screen = await render(
<HomeTrending banners={carouselBanners} isMarketplacePlatform page="plugins" />,
)
await screen.getByRole('button', { name: 'Third banner' }).click()
await new Promise((resolve) => setTimeout(resolve, 450))
const track = document.querySelector<HTMLElement>('[data-carousel-track]')!
const progress = document.querySelector<HTMLElement>('[data-carousel-progress]')!
const progressAnimation = progress.getAnimations()[0]
expect(progressAnimation).toBeDefined()
progressAnimation!.finish()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(screen.getByRole('button', { name: 'Third banner' }).element()).toHaveAttribute(
'aria-current',
'true',
)
expect(track.style.transform).toContain('-300%')
expect(track.querySelector('[data-carousel-loop-clone]')).toBeInTheDocument()
await expect
.poll(() => track.getAttribute('data-carousel-loop-phase'), { timeout: 1000 })
.toBe('idle')
.poll(
() =>
screen
.getByRole('button', { name: 'First banner' })
.element()
.getAttribute('aria-current'),
{ timeout: 8000 },
)
.toBe('true')
expect(screen.getByRole('button', { name: 'First banner' }).element()).toHaveAttribute(
'aria-current',
'true',
expect(screen.getByRole('group', { name: 'First banner' }).element()).not.toHaveAttribute(
'inert',
)
expect(track.style.transform).toBe('translate3d(0%, 0px, 0px)')
expect(track.querySelector('[data-carousel-loop-clone]')).not.toBeInTheDocument()
})
})

View File

@ -1,5 +1,5 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { trackEvent } from '@/app/components/base/amplitude'
@ -259,6 +259,7 @@ describe('HomeTrending', () => {
value: vi.fn(() => {
const animation = {
cancel: vi.fn(),
finished: Promise.resolve(),
onfinish: null,
pause: vi.fn(),
play: vi.fn(),
@ -298,6 +299,47 @@ describe('HomeTrending', () => {
}
})
it('does not reject when the autoplay animation is canceled on unmount', async () => {
let rejectFinished: (reason: unknown) => void = () => {}
const finished = new Promise<Animation>((_resolve, reject) => {
rejectFinished = reject
})
const progressAnimation = {
cancel: vi.fn(() => {
rejectFinished(
Object.assign(new Error('The animation was canceled.'), { name: 'AbortError' }),
)
}),
onfinish: null,
pause: vi.fn(),
play: vi.fn(),
finished,
} as unknown as Animation
const originalAnimate = Element.prototype.animate
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
value: vi.fn(() => progressAnimation),
})
try {
const { unmount } = render(
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />,
)
unmount()
await act(async () => {
await Promise.resolve()
})
expect(progressAnimation.cancel).toHaveBeenCalled()
} finally {
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
value: originalAnimate,
})
}
})
it('toggles the carousel between paused and playing states', async () => {
const user = userEvent.setup()
@ -360,6 +402,7 @@ describe('HomeTrending', () => {
const cancel = vi.fn()
const progressAnimation = {
cancel,
finished: Promise.resolve(),
onfinish: null,
pause,
play,
@ -517,6 +560,7 @@ describe('HomeTrending', () => {
const play = vi.fn()
const progressAnimation = {
cancel: vi.fn(),
finished: Promise.resolve(),
onfinish: null,
pause,
play,
@ -557,6 +601,7 @@ describe('HomeTrending', () => {
const play = vi.fn()
const progressAnimation = {
cancel: vi.fn(),
finished: Promise.resolve(),
onfinish: null,
pause,
play,
@ -592,6 +637,7 @@ describe('HomeTrending', () => {
const play = vi.fn()
const progressAnimation = {
cancel: vi.fn(),
finished: Promise.resolve(),
onfinish: null,
pause,
play,
@ -692,6 +738,25 @@ describe('HomeTrending', () => {
)
})
it('clamps the active slide when a refetch shrinks the banner list', async () => {
const { rerender } = render(
<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />,
)
fireEvent.click(screen.getByRole('button', { name: 'Duck Duck Go' }))
expect(screen.getByRole('button', { name: 'Duck Duck Go' })).toHaveAttribute(
'aria-current',
'true',
)
rerender(<HomeTrending banners={[banners[0]!]} isMarketplacePlatform page="plugins" />)
await waitFor(() => {
expect(screen.getByRole('group', { name: 'Trending' })).not.toHaveAttribute('inert')
})
expect(screen.queryByRole('button', { name: 'Duck Duck Go' })).not.toBeInTheDocument()
})
it('renders no carousel when the API returns no banners', () => {
render(<HomeTrending banners={[]} isMarketplacePlatform page="plugins" />)

View File

@ -253,6 +253,66 @@ describe('MarketplaceSearchAutocomplete', () => {
expect(handleSubmit).toHaveBeenCalledOnce()
})
it('submits the typed query on Enter without selecting a hovered suggestion', async () => {
mockTemplateSearch.mockResolvedValue({
data: {
templates: [
{
id: 'template-1',
template_name: 'Legal Research Agent',
overview: 'Research legal questions with cited sources.',
publisher_handle: 'dify',
usage_count: 120,
categories: ['knowledge'],
icon: '📄',
icon_background: '#FFFFFF',
icon_file_key: '',
},
],
total: 1,
},
})
mockPluginSearch.mockResolvedValue({
data: {
plugins: [
{
type: 'plugin',
org: 'langgenius',
name: 'google-search',
label: { en_US: 'Google Search' },
brief: { en_US: 'Search the web from your workflow.' },
category: 'tool',
},
],
total: 1,
},
})
const user = userEvent.setup()
const handleSubmit = vi.fn((event: Event) => {
event.preventDefault()
})
const { container } = render(
<MarketplaceSearchForm
action="/search/all"
locale="en-US"
placeholder="Search plugins or templates"
query=""
scope="all"
/>,
{ wrapper: Wrapper },
)
container.querySelector('form')?.addEventListener('submit', handleSubmit)
await user.type(screen.getByRole('combobox'), 'search')
await user.hover(await screen.findByRole('option', { name: /Legal Research Agent/ }))
await user.keyboard('{Enter}')
expect(handleSubmit).toHaveBeenCalledOnce()
expect(screen.getByRole('combobox')).toHaveValue('search')
})
it('submits the route search form when a suggestion is chosen', async () => {
mockPluginSearch.mockResolvedValue({
data: {

View File

@ -95,6 +95,8 @@ function TrendingNavigation({
if (pauseReasonsRef.current.size > 0) progressAnimation.pause()
progressAnimation.onfinish = onNext
// cancel() rejects `finished` with AbortError; keep that from becoming unhandled.
void progressAnimation.finished.catch(() => {})
return () => {
progressAnimation.onfinish = null

View File

@ -109,6 +109,12 @@ function HomeTrending({
setTrackIndex(index)
setSelectedIndex(index)
}, [])
const lastIndex = Math.max(0, banners.length - 1)
if (selectedIndex > lastIndex) {
setLoopPhase('idle')
setSelectedIndex(lastIndex)
setTrackIndex(lastIndex)
}
const selectNextSlide = useCallback(() => {
if (selectedIndex < banners.length - 1) {
const nextIndex = selectedIndex + 1

View File

@ -30,7 +30,7 @@ import { marketplaceQuery } from '@/service/client'
import { markMarketplaceSiteSearch } from '@/utils/marketplace-site-track'
import { getPluginIconInMarketplace } from '../utils'
export type MarketplaceSearchScope = 'all' | 'plugins' | 'templates'
type MarketplaceSearchScope = 'all' | 'plugins' | 'templates'
export type MarketplaceSearchSelection =
| { kind: 'plugin'; plugin: MarketplacePlugin }
@ -197,6 +197,12 @@ export function MarketplaceSearchAutocomplete({
const [isOpen, setIsOpen] = useState(false)
const searchRootRef = useRef<HTMLDivElement>(null)
const resultsPanelRef = useRef<HTMLDivElement>(null)
const keyboardHighlightedRef = useRef(false)
const submitSearchForm = () => {
const form = searchRootRef.current?.closest('form')
if (form instanceof HTMLFormElement) form.requestSubmit()
}
const debouncedSearch = useDebounce(value.trim(), { wait: 300 })
const hasQuery = Boolean(debouncedSearch)
const searchesPlugins = scope === 'all' || scope === 'plugins'
@ -323,6 +329,9 @@ export function MarketplaceSearchAutocomplete({
openOnInputClick
submitOnItemClick={Boolean(inputName) && !onSuggestionSelect}
value={value}
onItemHighlighted={(item, details) => {
keyboardHighlightedRef.current = Boolean(item) && details.reason === 'keyboard'
}}
>
<AutocompleteInputGroup size="large">
<span
@ -335,6 +344,13 @@ export function MarketplaceSearchAutocomplete({
placeholder={placeholder}
size="large"
type="text"
onKeyDownCapture={(event) => {
if (event.key !== 'Enter' || !inputName) return
if (keyboardHighlightedRef.current) return
event.preventDefault()
event.stopPropagation()
if (value.trim()) submitSearchForm()
}}
/>
{!!value && (
<AutocompleteClear
@ -366,10 +382,7 @@ export function MarketplaceSearchAutocomplete({
<button
type="button"
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left outline-hidden hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
onClick={() => {
const form = searchRootRef.current?.closest('form')
if (form instanceof HTMLFormElement) form.requestSubmit()
}}
onClick={submitSearchForm}
>
<span className="system-sm-medium text-text-accent">
{t(($) => $['marketplace.viewMore'], { ns: 'plugin' })}

View File

@ -21,7 +21,7 @@
* instead of letting `HydrateQueryClient` own it, which is a wider change than
* bounding the waits.
*/
const SERVER_PREFETCH_BUDGET_MS = 2_500
export const SERVER_PREFETCH_BUDGET_MS = 2_500
export async function withinServerBudget(work: Promise<unknown>): Promise<void> {
let cancelBudget = () => {}

View File

@ -0,0 +1,19 @@
import { describe, expect, it, vi } from 'vite-plus/test'
import { standaloneMarketplaceClient } from '../client'
import { standaloneMarketplaceServer } from '../server'
vi.mock('../../index', () => ({ default: () => null }))
vi.mock('../../hydration-server', () => ({ HydrateQueryClient: () => null }))
describe('standalone Marketplace host entry', () => {
it('exports the client search surface', () => {
expect(standaloneMarketplaceClient.MarketplaceLiveSearch).toEqual(expect.any(Function))
expect(standaloneMarketplaceClient.MarketplaceSearchAutocomplete).toEqual(expect.any(Function))
})
it('exports the server prefetch helpers and creator model', () => {
expect(standaloneMarketplaceServer.withinServerBudget).toEqual(expect.any(Function))
expect(standaloneMarketplaceServer.SERVER_PREFETCH_BUDGET_MS).toBeGreaterThan(0)
expect(standaloneMarketplaceServer.parseCreatorSortField('popularity')).toBe('popularity')
})
})

View File

@ -0,0 +1,17 @@
'use client'
/**
* Public client surface for the standalone Marketplace host (dify-marketplace).
* Import this module instead of treating private Marketplace paths as Knip entries.
*/
import MarketplaceLiveSearch from '../home/marketplace-live-search'
import {
MarketplaceSearchAutocomplete,
MarketplaceSearchForm,
} from '../home/marketplace-search-autocomplete'
export const standaloneMarketplaceClient = {
MarketplaceLiveSearch,
MarketplaceSearchAutocomplete,
MarketplaceSearchForm,
}

View File

@ -0,0 +1,34 @@
/**
* Public server surface for the standalone Marketplace host (dify-marketplace).
* Import this module instead of treating private Marketplace paths as Knip entries.
*/
import {
adaptCreatorProfile,
CREATOR_SORT_FIELDS,
DEFAULT_CREATOR_SORT_FIELD,
DEFAULT_CREATOR_SORT_ORDER,
getStandaloneCreationHref,
parseCreatorSortField,
parseCreatorSortOrder,
sortCreatorCreations,
toPublisherSortQuery,
} from '../creator-profile/model'
import { HydrateQueryClient } from '../hydration-server'
import Marketplace from '../index'
import { SERVER_PREFETCH_BUDGET_MS, withinServerBudget } from '../server-budget'
export const standaloneMarketplaceServer = {
Marketplace,
HydrateQueryClient,
SERVER_PREFETCH_BUDGET_MS,
withinServerBudget,
adaptCreatorProfile,
CREATOR_SORT_FIELDS,
DEFAULT_CREATOR_SORT_FIELD,
DEFAULT_CREATOR_SORT_ORDER,
getStandaloneCreationHref,
parseCreatorSortField,
parseCreatorSortOrder,
sortCreatorCreations,
toPublisherSortQuery,
}

View File

@ -148,9 +148,11 @@ export const getMarketplaceCollectionsAndPlugins = async (
try {
marketplaceCollectionPluginsMap[collection.name] =
await getMarketplacePluginsByCollectionId(collection.name, query, options)
} catch {
} catch (error) {
if (options?.signal?.aborted) throw error
// One empty carousel beats a blank catalog: the collection list itself
// loaded, so render what did arrive.
// loaded, so render what did arrive. Cancellation must not take this
// path — react-query would cache the empty carousels as a success.
marketplaceCollectionPluginsMap[collection.name] = []
}
}

View File

@ -82,7 +82,9 @@ describe('GenericTable', () => {
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'my key' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), {
target: { value: 'my key' },
})
expect(onChange).toHaveBeenLastCalledWith([{ name: 'my_key', enabled: false }])
})
@ -102,7 +104,7 @@ describe('GenericTable', () => {
/>,
)
const inputs = screen.getAllByRole('textbox')
const inputs = screen.getAllByRole('textbox', { name: 'Name' })
expect(inputs).toHaveLength(3)
expect(screen.getAllByRole('button', { name: 'Delete row' })).toHaveLength(2)

View File

@ -2,6 +2,7 @@
import type { FC, ReactNode } from 'react'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import {
Select,
SelectItem,
@ -16,7 +17,6 @@ import {
import { RiDeleteBinLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import Input from '@/app/components/base/input'
import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
// Tiny utility to judge whether a cell value is effectively present
@ -110,6 +110,7 @@ const renderInputCell = (
) => {
return (
<Input
aria-label={column.title}
value={(value as string) || ''}
onChange={(e) => {
if (column.key === 'key' || column.key === 'name')
@ -124,9 +125,8 @@ const renderInputCell = (
}}
placeholder={column.placeholder}
disabled={readonly}
wrapperClassName="w-full min-w-0"
className={cn(
'h-6 rounded-none border-0 bg-transparent p-0 shadow-none',
'h-6 min-w-0 rounded-none border-0 bg-transparent p-0 shadow-none',
'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent',
'system-sm-regular text-text-secondary placeholder:text-text-quaternary',
)}

4
web/global.d.ts vendored
View File

@ -19,6 +19,10 @@ declare global {
interface Window {
gtag?: Gtag
dataLayer?: unknown[]
/**
* Optional analytics bridge injected by the standalone Marketplace host.
* Absent in Dify console builds; see `utils/marketplace-site-track.ts`.
*/
__marketplaceTracking__?: {
track: (eventName: string, properties?: Record<string, unknown>) => void
rememberReferrer: (itemId: string, section: 'banner' | 'search' | 'list' | 'direct') => void

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "الأعمال",
"marketplace.creatorProfile.empty": "لا توجد أعمال بعد.",
"marketplace.creatorProfile.home": "الصفحة الرئيسية للسوق",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "على الويب",
"marketplace.creatorProfile.organization": "منظمة",
"marketplace.creatorProfile.searchPlaceholder": "ابحث عن الإضافات والقوالب",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Kreationen",
"marketplace.creatorProfile.empty": "Noch keine Kreationen.",
"marketplace.creatorProfile.home": "Marketplace-Startseite",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Im Web",
"marketplace.creatorProfile.organization": "Organisation",
"marketplace.creatorProfile.searchPlaceholder": "Plugins und Vorlagen suchen",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Creations",
"marketplace.creatorProfile.empty": "No creations yet.",
"marketplace.creatorProfile.home": "Marketplace home",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "On the web",
"marketplace.creatorProfile.organization": "Organization",
"marketplace.creatorProfile.searchPlaceholder": "Search plugins and templates",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Creaciones",
"marketplace.creatorProfile.empty": "Aún no hay creaciones.",
"marketplace.creatorProfile.home": "Inicio del Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "En la web",
"marketplace.creatorProfile.organization": "Organización",
"marketplace.creatorProfile.searchPlaceholder": "Buscar plugins y plantillas",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "آثار",
"marketplace.creatorProfile.empty": "هنوز اثری وجود ندارد.",
"marketplace.creatorProfile.home": "خانه Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "در وب",
"marketplace.creatorProfile.organization": "سازمان",
"marketplace.creatorProfile.searchPlaceholder": "جستجوی افزونه و قالب",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Créations",
"marketplace.creatorProfile.empty": "Aucune création pour le moment.",
"marketplace.creatorProfile.home": "Accueil du Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Sur le web",
"marketplace.creatorProfile.organization": "Organisation",
"marketplace.creatorProfile.searchPlaceholder": "Rechercher des plugins et des modèles",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "रचनाएँ",
"marketplace.creatorProfile.empty": "अभी कोई रचना नहीं।",
"marketplace.creatorProfile.home": "Marketplace होम",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "वेब पर",
"marketplace.creatorProfile.organization": "संगठन",
"marketplace.creatorProfile.searchPlaceholder": "प्लगिन और टेम्पलेट खोजें",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Karya",
"marketplace.creatorProfile.empty": "Belum ada karya.",
"marketplace.creatorProfile.home": "Beranda Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Di web",
"marketplace.creatorProfile.organization": "Organisasi",
"marketplace.creatorProfile.searchPlaceholder": "Cari plugin dan template",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Creazioni",
"marketplace.creatorProfile.empty": "Nessuna creazione al momento.",
"marketplace.creatorProfile.home": "Home del Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Sul web",
"marketplace.creatorProfile.organization": "Organizzazione",
"marketplace.creatorProfile.searchPlaceholder": "Cerca plugin e modelli",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "作品",
"marketplace.creatorProfile.empty": "作品はまだありません。",
"marketplace.creatorProfile.home": "Marketplace ホーム",
"marketplace.creatorProfile.loadMore": "さらに読み込む",
"marketplace.creatorProfile.loadMoreFailed": "作品を追加で読み込めませんでした。",
"marketplace.creatorProfile.onTheWeb": "ウェブサイト",
"marketplace.creatorProfile.organization": "組織",
"marketplace.creatorProfile.searchPlaceholder": "プラグインとテンプレートを検索",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "작품",
"marketplace.creatorProfile.empty": "아직 작품이 없습니다.",
"marketplace.creatorProfile.home": "Marketplace 홈",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "웹에서",
"marketplace.creatorProfile.organization": "조직",
"marketplace.creatorProfile.searchPlaceholder": "플러그인 및 템플릿 검색",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "ຜົນງານ",
"marketplace.creatorProfile.empty": "ຍັງບໍ່ມີຜົນງານ.",
"marketplace.creatorProfile.home": "ໜ້າຫຼັກ Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "ເທິງເວັບ",
"marketplace.creatorProfile.organization": "ອົງກອນ",
"marketplace.creatorProfile.searchPlaceholder": "ຄົ້ນຫາປລັກອິນ ແລະ ແມ່ແບບ",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Creaties",
"marketplace.creatorProfile.empty": "Nog geen creaties.",
"marketplace.creatorProfile.home": "Marketplace-startpagina",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Op het web",
"marketplace.creatorProfile.organization": "Organisatie",
"marketplace.creatorProfile.searchPlaceholder": "Zoek plugins en sjablonen",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Twórczość",
"marketplace.creatorProfile.empty": "Brak prac.",
"marketplace.creatorProfile.home": "Strona główna Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "W sieci",
"marketplace.creatorProfile.organization": "Organizacja",
"marketplace.creatorProfile.searchPlaceholder": "Szukaj wtyczek i szablonów",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Criações",
"marketplace.creatorProfile.empty": "Nenhuma criação ainda.",
"marketplace.creatorProfile.home": "Página inicial do Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Na web",
"marketplace.creatorProfile.organization": "Organização",
"marketplace.creatorProfile.searchPlaceholder": "Pesquisar plugins e modelos",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Creații",
"marketplace.creatorProfile.empty": "Nicio creație încă.",
"marketplace.creatorProfile.home": "Pagina principală Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Pe web",
"marketplace.creatorProfile.organization": "Organizație",
"marketplace.creatorProfile.searchPlaceholder": "Caută pluginuri și șabloane",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Работы",
"marketplace.creatorProfile.empty": "Пока нет работ.",
"marketplace.creatorProfile.home": "Главная Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "В интернете",
"marketplace.creatorProfile.organization": "Организация",
"marketplace.creatorProfile.searchPlaceholder": "Поиск плагинов и шаблонов",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Stvaritve",
"marketplace.creatorProfile.empty": "Še ni stvaritev.",
"marketplace.creatorProfile.home": "Domov Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Na spletu",
"marketplace.creatorProfile.organization": "Organizacija",
"marketplace.creatorProfile.searchPlaceholder": "Iskanje vtičnikov in predlog",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "ผลงาน",
"marketplace.creatorProfile.empty": "ยังไม่มีผลงาน",
"marketplace.creatorProfile.home": "หน้าแรก Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "บนเว็บ",
"marketplace.creatorProfile.organization": "องค์กร",
"marketplace.creatorProfile.searchPlaceholder": "ค้นหาปลั๊กอินและเทมเพลต",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Çalışmalar",
"marketplace.creatorProfile.empty": "Henüz çalışma yok.",
"marketplace.creatorProfile.home": "Marketplace ana sayfası",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Web'de",
"marketplace.creatorProfile.organization": "Organizasyon",
"marketplace.creatorProfile.searchPlaceholder": "Eklenti ve şablon ara",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Роботи",
"marketplace.creatorProfile.empty": "Поки немає робіт.",
"marketplace.creatorProfile.home": "Головна Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "В інтернеті",
"marketplace.creatorProfile.organization": "Організація",
"marketplace.creatorProfile.searchPlaceholder": "Пошук плагінів і шаблонів",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Tác phẩm",
"marketplace.creatorProfile.empty": "Chưa có tác phẩm nào.",
"marketplace.creatorProfile.home": "Trang chủ Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Trên web",
"marketplace.creatorProfile.organization": "Tổ chức",
"marketplace.creatorProfile.searchPlaceholder": "Tìm plugin và mẫu",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "作品",
"marketplace.creatorProfile.empty": "暂无作品。",
"marketplace.creatorProfile.home": "Marketplace 首页",
"marketplace.creatorProfile.loadMore": "加载更多",
"marketplace.creatorProfile.loadMoreFailed": "无法加载更多作品。",
"marketplace.creatorProfile.onTheWeb": "社交主页",
"marketplace.creatorProfile.organization": "组织",
"marketplace.creatorProfile.searchPlaceholder": "搜索插件和模板",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "作品",
"marketplace.creatorProfile.empty": "尚無作品。",
"marketplace.creatorProfile.home": "Marketplace 首頁",
"marketplace.creatorProfile.loadMore": "載入更多",
"marketplace.creatorProfile.loadMoreFailed": "無法載入更多作品。",
"marketplace.creatorProfile.onTheWeb": "社交主頁",
"marketplace.creatorProfile.organization": "組織",
"marketplace.creatorProfile.searchPlaceholder": "搜尋外掛和模板",

View File

@ -53,6 +53,15 @@ function withRequestDeadline(callerSignal: AbortSignal | null | undefined): Abor
return controller.signal
}
function isMarketplacePackageDownload(input: Request | URL | string): boolean {
const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
try {
return new URL(href).pathname.endsWith('/download')
} catch {
return false
}
}
function isURL(path: string) {
try {
// oxlint-disable-next-line no-new
@ -117,10 +126,13 @@ const marketplaceLink = new OpenAPILink(marketplaceRouterContract, {
headers: () => getMarketplaceHeaders(),
fetch: (request, init) => {
const requestInit = init as RequestInit | undefined
const callerSignal = requestInit?.signal ?? request.signal
return globalThis.fetch(request, {
...requestInit,
cache: 'no-store',
signal: withRequestDeadline(requestInit?.signal ?? request.signal),
signal: isMarketplacePackageDownload(request)
? callerSignal
: withRequestDeadline(callerSignal),
})
},
interceptors: [

View File

@ -42,19 +42,53 @@ describe('marketplace template discovery', () => {
const result = await getMarketplaceTemplateCollectionsAndTemplates()
expect(mocks.templateCollections).toHaveBeenCalledWith({
query: { page: 1, page_size: 100 },
})
expect(mocks.templateCollectionTemplates).toHaveBeenNthCalledWith(1, {
params: { collectionName: 'featured' },
body: { limit: 24 },
})
expect(mocks.templateCollections).toHaveBeenCalledWith(
{
query: { page: 1, page_size: 100 },
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
expect(mocks.templateCollectionTemplates).toHaveBeenNthCalledWith(
1,
{
params: { collectionName: 'featured' },
body: { limit: 24 },
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
expect(result.ok).toBe(false)
expect(result.templatesByCollection).toEqual({
featured: [{ id: 'template-1' }],
partners: [],
})
})
it('does not cache a partial collection failure', async () => {
const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery()
mocks.templateCollections.mockResolvedValue({
data: {
collections: [
{ name: 'featured', label: {}, description: {}, priority: 1 },
{ name: 'partners', label: {}, description: {}, priority: 2 },
],
},
})
mocks.templateCollectionTemplates
.mockRejectedValueOnce(new Error('Unavailable'))
.mockResolvedValueOnce({ data: { templates: [{ id: 'template-1' }] } })
.mockResolvedValue({ data: { templates: [{ id: 'template-2' }] } })
const failed = await getMarketplaceTemplateCollectionsAndTemplates()
expect(failed.ok).toBe(false)
const recovered = await getMarketplaceTemplateCollectionsAndTemplates()
expect(recovered.ok).toBe(true)
expect(recovered.templatesByCollection).toEqual({
featured: [{ id: 'template-2' }],
partners: [{ id: 'template-2' }],
})
})
it('serves collections from the cache instead of refetching every render', async () => {
const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery()
mocks.templateCollections.mockResolvedValue({

View File

@ -2,6 +2,7 @@ import type {
MarketplaceTemplate,
MarketplaceTemplateCollection,
} from '@dify/contracts/marketplace'
import { SERVER_PREFETCH_BUDGET_MS } from '@/app/components/plugins/marketplace/server-budget'
import { marketplaceClient } from './client'
export type MarketplaceTemplateCollectionsResult = {
@ -42,45 +43,64 @@ let collectionsCache: {
let collectionsInFlight: Promise<MarketplaceTemplateCollectionsResult> | null = null
async function fetchCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> {
const response = await marketplaceClient.templateCollections({
query: {
page: 1,
page_size: 100,
},
})
const collections = response.data?.collections ?? []
const entries: (readonly [string, MarketplaceTemplate[]])[] = []
const budget = AbortSignal.timeout(SERVER_PREFETCH_BUDGET_MS)
// Bounded fan-out: fetch collection previews in small batches instead of
// firing one uncached request per collection all at once.
for (
let batchStart = 0;
batchStart < collections.length;
batchStart += COLLECTION_FETCH_BATCH_SIZE
) {
const batch = collections.slice(batchStart, batchStart + COLLECTION_FETCH_BATCH_SIZE)
entries.push(
...(await Promise.all(
batch.map(async (collection) => {
try {
const collectionResponse = await marketplaceClient.templateCollectionTemplates({
params: { collectionName: collection.name },
body: { limit: COLLECTION_PREVIEW_TEMPLATE_LIMIT },
})
return [collection.name, collectionResponse.data?.templates ?? []] as const
} catch {
return [collection.name, [] as MarketplaceTemplate[]] as const
}
}),
)),
try {
const response = await marketplaceClient.templateCollections(
{
query: {
page: 1,
page_size: 100,
},
},
{ signal: budget },
)
}
const collections = response.data?.collections ?? []
const entries: (readonly [string, MarketplaceTemplate[]])[] = []
let hadCollectionFailure = false
return {
collections,
templatesByCollection: Object.fromEntries(entries),
ok: true,
// Bounded fan-out: fetch collection previews in small batches instead of
// firing one uncached request per collection all at once. The route budget
// aborts leftover work so `/templates` cannot wait N batches × 15s.
for (
let batchStart = 0;
batchStart < collections.length;
batchStart += COLLECTION_FETCH_BATCH_SIZE
) {
if (budget.aborted) return FAILED_COLLECTIONS_RESULT
const batch = collections.slice(batchStart, batchStart + COLLECTION_FETCH_BATCH_SIZE)
entries.push(
...(await Promise.all(
batch.map(async (collection) => {
try {
const collectionResponse = await marketplaceClient.templateCollectionTemplates(
{
params: { collectionName: collection.name },
body: { limit: COLLECTION_PREVIEW_TEMPLATE_LIMIT },
},
{ signal: budget },
)
return [collection.name, collectionResponse.data?.templates ?? []] as const
} catch (error) {
if (budget.aborted) throw error
hadCollectionFailure = true
return [collection.name, [] as MarketplaceTemplate[]] as const
}
}),
)),
)
}
return {
collections,
templatesByCollection: Object.fromEntries(entries),
ok: !hadCollectionFailure,
}
} catch (error) {
if (budget.aborted) return FAILED_COLLECTIONS_RESULT
throw error
}
}
@ -97,7 +117,7 @@ export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<M
collectionsInFlight = fetchCollectionsAndTemplates()
.then((result) => {
collectionsCache = { expiresAt: Date.now() + COLLECTIONS_CACHE_TTL_MS, result }
if (result.ok) collectionsCache = { expiresAt: Date.now() + COLLECTIONS_CACHE_TTL_MS, result }
return result
})
.catch(() => FAILED_COLLECTIONS_RESULT)

View File

@ -1,3 +1,13 @@
/**
* Standalone Marketplace host contract.
*
* The Dify console never stamps `data-is-marketplace` or assigns
* `window.__marketplaceTracking__`, so every helper here is a no-op in a Dify
* build. The standalone marketplace (dify-marketplace) owns the producer: it
* sets `data-is-marketplace` on `<body>` and injects `__marketplaceTracking__`
* from its analytics runtime. Shared Marketplace UI calls these helpers; the
* host implements the bridge.
*/
type MarketplaceSiteReferrerSection = 'banner' | 'search' | 'list' | 'direct'
type MarketplaceSiteFilter = {