mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
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:
commit
39a289ffa7
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -28,6 +28,7 @@
|
||||
|
||||
# E2E
|
||||
/e2e/ @lyzno1
|
||||
/.github/workflows/web-e2e.yml @lyzno1
|
||||
|
||||
# Backend (default owner, more specific rules below will override)
|
||||
/api/ @QuantumGhost
|
||||
|
||||
@ -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
|
||||
278
.github/workflows/web-e2e.yml
vendored
278
.github/workflows/web-e2e.yml
vendored
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/*
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
14
api/uv.lock
generated
@ -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]]
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
},
|
||||
)
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()))
|
||||
})
|
||||
}
|
||||
@ -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}!',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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' }) || ''}
|
||||
/>
|
||||
|
||||
@ -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' }) ?? ''}
|
||||
/>
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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: [] } })
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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]),
|
||||
),
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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" />)
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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 = () => {}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
17
web/app/components/plugins/marketplace/standalone/client.ts
Normal file
17
web/app/components/plugins/marketplace/standalone/client.ts
Normal 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,
|
||||
}
|
||||
34
web/app/components/plugins/marketplace/standalone/server.ts
Normal file
34
web/app/components/plugins/marketplace/standalone/server.ts
Normal 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,
|
||||
}
|
||||
@ -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] = []
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
4
web/global.d.ts
vendored
@ -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
|
||||
|
||||
@ -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": "ابحث عن الإضافات والقوالب",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "جستجوی افزونه و قالب",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "प्लगिन और टेम्पलेट खोजें",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "プラグインとテンプレートを検索",
|
||||
|
||||
@ -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": "플러그인 및 템플릿 검색",
|
||||
|
||||
@ -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": "ຄົ້ນຫາປລັກອິນ ແລະ ແມ່ແບບ",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Поиск плагинов и шаблонов",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "ค้นหาปลั๊กอินและเทมเพลต",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Пошук плагінів і шаблонів",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "搜索插件和模板",
|
||||
|
||||
@ -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": "搜尋外掛和模板",
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user