From 00bff6a281fd973797acb206ce9043249e3eea70 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 1 Sep 2026 21:08:58 +0800 Subject: [PATCH 01/13] fix(web): ignore AbortError when canceling banner autoplay --- .../home/__tests__/home-trending.spec.tsx | 46 +++++++++++++++++++ .../home/home-trending-navigation.tsx | 2 + 2 files changed, 48 insertions(+) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx index 318482700f1..32ed4e68c06 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -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((_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( + , + ) + + 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, diff --git a/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx b/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx index 4b065bfc89b..5ef8d53041d 100644 --- a/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx +++ b/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx @@ -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 From 3f982e3dc89d943a2d8d09fed2d35c2b1e874156 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:19:08 +0000 Subject: [PATCH 02/13] ci(e2e): shard pull request browser tests (#41596) --- .github/CODEOWNERS | 1 + .github/workflows/web-e2e.yml | 278 ++++++++++++++++++++++++---------- e2e/AGENTS.md | 1 + e2e/package.json | 2 + e2e/scripts/setup.ts | 5 +- 5 files changed, 202 insertions(+), 85 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 070880ffc24..fd64ccdec9a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -28,6 +28,7 @@ # E2E /e2e/ @lyzno1 +/.github/workflows/web-e2e.yml @lyzno1 # Backend (default owner, more specific rules below will override) /api/ @QuantumGhost diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index e6bc08d44af..8700df8c1cc 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -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 diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 240d77c1be8..73e70c710bb 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -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 ` - 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` diff --git a/e2e/package.json b/e2e/package.json index 871766c9714..9c37769c1ce 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -15,6 +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: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", @@ -22,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" diff --git a/e2e/scripts/setup.ts b/e2e/scripts/setup.ts index 5aa99884560..6caf27435ac 100644 --- a/e2e/scripts/setup.ts +++ b/e2e/scripts/setup.ts @@ -569,7 +569,7 @@ export const startMiddleware = async () => { const printUsage = () => { console.log( - 'Usage: tsx ./scripts/setup.ts ', + 'Usage: tsx ./scripts/setup.ts ', ) } @@ -603,6 +603,9 @@ const main = async () => { case 'web': await startWeb() return + case 'web-build': + await ensureWebBuild() + return default: printUsage() process.exitCode = 1 From a3dbf9293ab75697b63e65c88ef1cc7af0390f7d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:20:41 +0000 Subject: [PATCH 03/13] chore(deps-dev): bump nltk from 3.10.0 to 3.10.3 in /api (#41602) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 2 +- api/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index dd67187413a..de89fdd5676 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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/* diff --git a/api/uv.lock b/api/uv.lock index 9a89afe4c82..7519f99e601 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -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]] From baf629dd36db22032146549b0e0008a227b763da Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:44:51 +0000 Subject: [PATCH 04/13] refactor(web): migrate external API selection input (#41610) --- oxlint-suppressions.json | 5 +---- .../create/ExternalApiSelection.tsx | 15 ++++++++++----- .../__tests__/ExternalApiSelection.spec.tsx | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index eb9226e0607..b49151c8f84 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -2222,9 +2222,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": { @@ -5377,4 +5374,4 @@ "count": 2 } } -} \ No newline at end of file +} diff --git a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx index 3813778233a..c14ebf3fcdb 100644 --- a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx @@ -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 = ({ 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 = ({
-
- 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' }) ?? ''} /> diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx index 5b3240501dd..caabcdf7f57 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx @@ -112,7 +112,7 @@ describe('ExternalApiSelection', () => { } render() - 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' }), From 18ac946e2b9fe79164b08fbb235dbfa4f58ab648 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:44:51 +0000 Subject: [PATCH 05/13] refactor(web): migrate dataset settings name input (#41611) --- oxlint-suppressions.json | 5 +---- .../settings-modal/__tests__/index.spec.tsx | 8 ++++---- .../dataset-config/settings-modal/index.tsx | 12 +++++++----- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index b49151c8f84..486baa63b5d 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -392,9 +392,6 @@ "jsx-a11y/no-static-element-interactions": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "typescript/no-explicit-any": { "count": 1 } @@ -5374,4 +5371,4 @@ "count": 2 } } -} +} \ No newline at end of file diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx index 6b3363366eb..acd85b398ce 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx @@ -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' })) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index be646be30fd..a36903a222a 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -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 = ({ 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 = ({
-
+
+
handleValueChange('name', e.target.value)} + onValueChange={(value) => handleValueChange('name', value)} className="block h-9" placeholder={t(($) => $['form.namePlaceholder'], { ns: 'datasetSettings' }) || ''} /> From ea98d38c3d9c65cc4dfca4ed5f52db3b2030c505 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:44:52 +0000 Subject: [PATCH 06/13] refactor(web): migrate webhook table input (#41612) --- oxlint-suppressions.json | 5 ----- .../components/__tests__/generic-table.spec.tsx | 6 ++++-- .../nodes/trigger-webhook/components/generic-table.tsx | 6 +++--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 486baa63b5d..13bfe2fc84c 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4335,11 +4335,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 diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx index 9c2b1874db2..a74672c430b 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/__tests__/generic-table.spec.tsx @@ -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) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx index 4aece5b7352..51b7421c4a9 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx @@ -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 ( { 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', )} From 93dae7d8a05e7948a52e3f93ada6f4c40eb06562 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:45:42 +0000 Subject: [PATCH 07/13] fix(rbac): let agent.manage operate every agent page (#41583) Co-authored-by: yunlu.wen --- api/controllers/console/agent/composer.py | 5 +- api/controllers/console/agent/roster.py | 13 +- .../console/app/agent_app_sandbox.py | 10 +- api/controllers/console/app/app.py | 3 +- api/controllers/console/app/completion.py | 6 +- api/controllers/console/app/message.py | 4 +- api/controllers/console/app/site.py | 5 +- api/controllers/console/app/wraps.py | 145 +++++++++++++----- api/controllers/console/flask_admission.py | 30 +++- .../console/app/test_agent_app_sandbox.py | 4 - .../controllers/console/test_apikey.py | 7 - 11 files changed, 152 insertions(+), 80 deletions(-) diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 8ae2db85ff0..e5ed3ed31e9 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -7,7 +7,7 @@ from werkzeug.exceptions import NotFound from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.common.session import with_session from controllers.console import console_ns -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, @@ -527,8 +527,7 @@ class AgentComposerApi(Resource): @login_required @account_initialization_required @edit_permission_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_EDIT) @with_current_user_id @with_current_tenant_id @with_session diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 739a4ac5399..6c2798a2017 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -32,6 +32,7 @@ from controllers.console.app.app import ( from controllers.console.app.app import ( UpdateAppPayload as GenericUpdateAppPayload, ) +from controllers.console.app.wraps import agent_manage_required_for_agent_app from controllers.console.wraps import ( RBACPermission, RBACResourceScope, @@ -983,8 +984,7 @@ class AgentApiStatusApi(Resource): @login_required @is_admin_or_owner_required @account_initialization_required - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION) @with_current_tenant_id @with_session @model_validate(AgentApiStatusPayload) @@ -1002,10 +1002,9 @@ class AgentApiKeyListApi(BaseApiKeyListResource): token_prefix = "app-" @console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__]) - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION) @with_current_tenant_id @edit_permission_required - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) @with_session(write=False) def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]: app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id) @@ -1015,8 +1014,7 @@ class AgentApiKeyListApi(BaseApiKeyListResource): @console_ns.response(400, "Maximum keys exceeded") @with_current_tenant_id @edit_permission_required - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION) @with_session def post(self, session: Session, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]: app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id) @@ -1035,8 +1033,7 @@ class AgentApiKeyApi(BaseApiKeyResource): @console_ns.response(204, "Agent service API key deleted") @with_current_user @with_current_tenant_id - @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) - @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION) @with_session def delete( self, diff --git a/api/controllers/console/app/agent_app_sandbox.py b/api/controllers/console/app/agent_app_sandbox.py index e58d25d0059..b306d570e9e 100644 --- a/api/controllers/console/app/agent_app_sandbox.py +++ b/api/controllers/console/app/agent_app_sandbox.py @@ -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) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 37abb584198..f14fe1eca95 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -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) diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 5f3982a65dc..53509d00f26 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -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 diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 8f6c2f54464..1c5a74e5172 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -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) diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index f7a20be978b..7d1d7efc616 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -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)) diff --git a/api/controllers/console/app/wraps.py b/api/controllers/console/app/wraps.py index e844348de85..8e18c6af01e 100644 --- a/api/controllers/console/app/wraps.py +++ b/api/controllers/console/app/wraps.py @@ -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: diff --git a/api/controllers/console/flask_admission.py b/api/controllers/console/flask_admission.py index 7181de23839..b8e3cc1ce16 100644 --- a/api/controllers/console/flask_admission.py +++ b/api/controllers/console/flask_admission.py @@ -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, diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py index 5d1040bc149..ae0f807a715 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py @@ -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, diff --git a/api/tests/unit_tests/controllers/console/test_apikey.py b/api/tests/unit_tests/controllers/console/test_apikey.py index 5013ea58ffd..8955ddf6bf7 100644 --- a/api/tests/unit_tests/controllers/console/test_apikey.py +++ b/api/tests/unit_tests/controllers/console/test_apikey.py @@ -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)], From 334a3043bc3af981d74fa9ca0de111bd1a733949 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:56:41 +0000 Subject: [PATCH 08/13] chore(deps): bump pypdf from 6.15.0 to 6.16.1 in /api (#41607) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index 7519f99e601..d7539edbf7a 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -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]] From 17135522af4a521dc1b446a0284ab0307055667a Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 2 Sep 2026 14:25:14 +0800 Subject: [PATCH 09/13] fix(web): address marketplace review comments Gate iframe install on plugin.install, keep the 15s deadline off package downloads, abort cancelled collection fetches, bound /templates prefetch, and stop caching partial template-collection failures. Revert unrelated Main Nav order and icon styling, remove the unused performance E2E bundle, and expose a standalone Marketplace entry instead of Knip path aliases. --- .../workflows/marketplace-performance-e2e.yml | 70 ------ e2e/cucumber.config.ts | 2 +- e2e/features/marketplace-performance.feature | 5 - .../marketplace-performance.steps.ts | 104 --------- e2e/features/support/world.ts | 8 - e2e/package.json | 1 - e2e/scripts/run-cucumber.ts | 35 +-- e2e/support/marketplace-stub.ts | 216 ------------------ knip.config.ts | 11 +- web/app/__tests__/layout.spec.tsx | 15 -- .../main-nav/__tests__/index.spec.tsx | 13 ++ .../main-nav/components/nav-link.tsx | 9 +- web/app/components/main-nav/routes.ts | 18 +- .../marketplace/__tests__/utils.spec.ts | 26 +++ .../detail-dialog/__tests__/index.spec.tsx | 119 +++++++++- .../marketplace/detail-dialog/index.tsx | 28 ++- .../home-trending-layout.browser.spec.tsx | 31 +-- .../home/__tests__/home-trending.spec.tsx | 21 +- .../marketplace/home/home-trending.tsx | 6 + .../plugins/marketplace/server-budget.ts | 2 +- .../standalone/__tests__/exports.spec.ts | 19 ++ .../plugins/marketplace/standalone/client.ts | 17 ++ .../plugins/marketplace/standalone/server.ts | 34 +++ .../components/plugins/marketplace/utils.ts | 6 +- web/global.d.ts | 4 + web/service/client.ts | 14 +- .../marketplace-template-discovery.spec.ts | 48 +++- web/service/marketplace-template-discovery.ts | 94 +++++--- web/utils/marketplace-site-track.ts | 10 + 29 files changed, 434 insertions(+), 552 deletions(-) delete mode 100644 .github/workflows/marketplace-performance-e2e.yml delete mode 100644 e2e/features/marketplace-performance.feature delete mode 100644 e2e/features/step-definitions/marketplace-performance.steps.ts delete mode 100644 e2e/support/marketplace-stub.ts create mode 100644 web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts create mode 100644 web/app/components/plugins/marketplace/standalone/client.ts create mode 100644 web/app/components/plugins/marketplace/standalone/server.ts diff --git a/.github/workflows/marketplace-performance-e2e.yml b/.github/workflows/marketplace-performance-e2e.yml deleted file mode 100644 index 94de24a534a..00000000000 --- a/.github/workflows/marketplace-performance-e2e.yml +++ /dev/null @@ -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 diff --git a/e2e/cucumber.config.ts b/e2e/cucumber.config.ts index 3f443ba9faa..b7768c36d7b 100644 --- a/e2e/cucumber.config.ts +++ b/e2e/cucumber.config.ts @@ -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' diff --git a/e2e/features/marketplace-performance.feature b/e2e/features/marketplace-performance.feature deleted file mode 100644 index 36c8ddca28f..00000000000 --- a/e2e/features/marketplace-performance.feature +++ /dev/null @@ -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 diff --git a/e2e/features/step-definitions/marketplace-performance.steps.ts b/e2e/features/step-definitions/marketplace-performance.steps.ts deleted file mode 100644 index fbf5af25e22..00000000000 --- a/e2e/features/step-definitions/marketplace-performance.steps.ts +++ /dev/null @@ -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((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) - }, -) diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 794c439a187..04d73fa8dde 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -73,12 +73,6 @@ export const createAgentBuilderWorldState = () => ({ export type AgentBuilderWorldState = ReturnType -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) { diff --git a/e2e/package.json b/e2e/package.json index df0b4e36dba..9c37769c1ce 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -16,7 +16,6 @@ "e2e:install:ci": "playwright install --with-deps --only-shell chromium webkit", "e2e:install:ci:chromium": "playwright install --with-deps --only-shell chromium", "e2e:install:ci:webkit": "playwright install --with-deps --only-shell webkit", - "e2e:marketplace-performance": "tsx ./scripts/run-cucumber.ts --full -- --tags @marketplace-performance", "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", diff --git a/e2e/scripts/run-cucumber.ts b/e2e/scripts/run-cucumber.ts index c59c7fba2f7..74f93b6d2b3 100644 --- a/e2e/scripts/run-cucumber.ts +++ b/e2e/scripts/run-cucumber.ts @@ -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) => - /(? { @@ -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', diff --git a/e2e/support/marketplace-stub.ts b/e2e/support/marketplace-stub.ts deleted file mode 100644 index 8effe368e35..00000000000 --- a/e2e/support/marketplace-stub.ts +++ /dev/null @@ -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 = [ - '', - '', - '', - '', -].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 = { - '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((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((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())) - }) -} diff --git a/knip.config.ts b/knip.config.ts index ddd5dd6e02c..7030baff3bd 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -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}!', diff --git a/web/app/__tests__/layout.spec.tsx b/web/app/__tests__/layout.spec.tsx index e2dc1b9d4ef..ea56e750817 100644 --- a/web/app/__tests__/layout.spec.tsx +++ b/web/app/__tests__/layout.spec.tsx @@ -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') - }) }) diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 10fce19ce42..e5c22d4edbb 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -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', () => { diff --git a/web/app/components/main-nav/components/nav-link.tsx b/web/app/components/main-nav/components/nav-link.tsx index 4b96205c8a2..328437933db 100644 --- a/web/app/components/main-nav/components/nav-link.tsx +++ b/web/app/components/main-nav/components/nav-link.tsx @@ -6,9 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn' import Link from '@/next/link' const NavIcon = ({ icon, className }: { icon: string; className?: string }) => ( - - - + ) type MainNavLinkProps = { @@ -32,10 +30,7 @@ const MainNavLink = ({ item, pathname, children }: MainNavLinkProps) => { )} > - + {item.label} diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index d8ec10883b4..c54c9af346d 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -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'), diff --git a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts index 546d16e7c3f..dc148df534b 100644 --- a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts +++ b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts @@ -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: [] } }) diff --git a/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx index de3b9935e23..47f2727ac08 100644 --- a/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx @@ -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( + + + + + , + ) + + 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( + + + , + ) + + 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() + } + }) }) diff --git a/web/app/components/plugins/marketplace/detail-dialog/index.tsx b/web/app/components/plugins/marketplace/detail-dialog/index.tsx index 2bf01d8e6e9..b491f5660cd 100644 --- a/web/app/components/plugins/marketplace/detail-dialog/index.tsx +++ b/web/app/components/plugins/marketplace/detail-dialog/index.tsx @@ -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) => { + 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 ( { 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( , ) await screen.getByRole('button', { name: 'Third banner' }).click() - await new Promise((resolve) => setTimeout(resolve, 450)) - - const track = document.querySelector('[data-carousel-track]')! - const progress = document.querySelector('[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() }) }) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx index 32ed4e68c06..56fefe8eaca 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -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' @@ -738,6 +738,25 @@ describe('HomeTrending', () => { ) }) + it('clamps the active slide when a refetch shrinks the banner list', async () => { + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Duck Duck Go' })) + expect(screen.getByRole('button', { name: 'Duck Duck Go' })).toHaveAttribute( + 'aria-current', + 'true', + ) + + rerender() + + 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() diff --git a/web/app/components/plugins/marketplace/home/home-trending.tsx b/web/app/components/plugins/marketplace/home/home-trending.tsx index b67c76f6a3d..5ea89ce5aaa 100644 --- a/web/app/components/plugins/marketplace/home/home-trending.tsx +++ b/web/app/components/plugins/marketplace/home/home-trending.tsx @@ -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 diff --git a/web/app/components/plugins/marketplace/server-budget.ts b/web/app/components/plugins/marketplace/server-budget.ts index 39249a64441..290fecc3506 100644 --- a/web/app/components/plugins/marketplace/server-budget.ts +++ b/web/app/components/plugins/marketplace/server-budget.ts @@ -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): Promise { let cancelBudget = () => {} diff --git a/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts b/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts new file mode 100644 index 00000000000..a94847cf151 --- /dev/null +++ b/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts @@ -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') + }) +}) diff --git a/web/app/components/plugins/marketplace/standalone/client.ts b/web/app/components/plugins/marketplace/standalone/client.ts new file mode 100644 index 00000000000..16cca794a4f --- /dev/null +++ b/web/app/components/plugins/marketplace/standalone/client.ts @@ -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, +} diff --git a/web/app/components/plugins/marketplace/standalone/server.ts b/web/app/components/plugins/marketplace/standalone/server.ts new file mode 100644 index 00000000000..32237f491ae --- /dev/null +++ b/web/app/components/plugins/marketplace/standalone/server.ts @@ -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, +} diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 7f51d4368ef..c375654c8d4 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -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] = [] } } diff --git a/web/global.d.ts b/web/global.d.ts index 5d1e51bce0a..5e25133dd25 100644 --- a/web/global.d.ts +++ b/web/global.d.ts @@ -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) => void rememberReferrer: (itemId: string, section: 'banner' | 'search' | 'list' | 'direct') => void diff --git a/web/service/client.ts b/web/service/client.ts index 14ad3c465f7..362f633a005 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -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: [ diff --git a/web/service/marketplace-template-discovery.spec.ts b/web/service/marketplace-template-discovery.spec.ts index 7a29322e601..156cda0125d 100644 --- a/web/service/marketplace-template-discovery.spec.ts +++ b/web/service/marketplace-template-discovery.spec.ts @@ -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({ diff --git a/web/service/marketplace-template-discovery.ts b/web/service/marketplace-template-discovery.ts index 9cbb4f5fcf7..d5aa48fe31f 100644 --- a/web/service/marketplace-template-discovery.ts +++ b/web/service/marketplace-template-discovery.ts @@ -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 | null = null async function fetchCollectionsAndTemplates(): Promise { - 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 { - 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) diff --git a/web/utils/marketplace-site-track.ts b/web/utils/marketplace-site-track.ts index f5bbdfe8e0a..7043f5365a5 100644 --- a/web/utils/marketplace-site-track.ts +++ b/web/utils/marketplace-site-track.ts @@ -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 `` 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 = { From ea6c67b231004542a9cd2f13cc470dec2f7bdf76 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 2 Sep 2026 15:22:17 +0800 Subject: [PATCH 10/13] fix(web): paginate creator inventory with load more SSR only the first publisher page instead of five sequential fetches, and let the profile load remaining plugins and templates on demand. Harden social_links and deps_plugins so dirty API payloads cannot crash the creator page. --- .../__tests__/creator-content.spec.tsx | 69 +++++++++++ .../__tests__/data.server.spec.ts | 53 +++++--- .../__tests__/dify-profile.spec.tsx | 7 ++ .../creator-profile/__tests__/model.spec.ts | 29 +++++ .../creator-profile/creator-content.tsx | 113 +++++++++++++++++- .../creator-profile/data.server.ts | 111 ++++------------- .../creator-profile/dify-profile.tsx | 26 +++- .../marketplace/creator-profile/model.ts | 78 +++++++++--- .../marketplace/creator-profile/publisher.ts | 102 ++++++++++++++++ .../marketplace/creator-profile/view.tsx | 26 +++- web/i18n/ar-TN/plugin.json | 2 + web/i18n/de-DE/plugin.json | 2 + web/i18n/en-US/plugin.json | 2 + web/i18n/es-ES/plugin.json | 2 + web/i18n/fa-IR/plugin.json | 2 + web/i18n/fr-FR/plugin.json | 2 + web/i18n/hi-IN/plugin.json | 2 + web/i18n/id-ID/plugin.json | 2 + web/i18n/it-IT/plugin.json | 2 + web/i18n/ja-JP/plugin.json | 2 + web/i18n/ko-KR/plugin.json | 2 + web/i18n/lo-LA/plugin.json | 2 + web/i18n/nl-NL/plugin.json | 2 + web/i18n/pl-PL/plugin.json | 2 + web/i18n/pt-BR/plugin.json | 2 + web/i18n/ro-RO/plugin.json | 2 + web/i18n/ru-RU/plugin.json | 2 + web/i18n/sl-SI/plugin.json | 2 + web/i18n/th-TH/plugin.json | 2 + web/i18n/tr-TR/plugin.json | 2 + web/i18n/uk-UA/plugin.json | 2 + web/i18n/vi-VN/plugin.json | 2 + web/i18n/zh-Hans/plugin.json | 2 + web/i18n/zh-Hant/plugin.json | 2 + 34 files changed, 530 insertions(+), 132 deletions(-) create mode 100644 web/app/components/plugins/marketplace/creator-profile/publisher.ts diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx index 39306668d88..b34d152ec5b 100644 --- a/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx @@ -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() + return { + ...actual, + fetchPublisherPluginPage: publisherMocks.fetchPublisherPluginPage, + fetchPublisherTemplatePage: publisherMocks.fetchPublisherTemplatePage, + } +}) + vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') const translations: Record = { @@ -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( + ({ 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() + }) }) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts b/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts index c35b8ccc774..8aac42bfb92 100644 --- a/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts @@ -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 () => { diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx index 9760ad9591a..1eb2c318b94 100644 --- a/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx @@ -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 () => { diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts b/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts index ff8a507998c..99696799f8c 100644 --- a/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts @@ -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']) + }) }) diff --git a/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx b/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx index 8665049314f..bc3a890fad2 100644 --- a/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx +++ b/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx @@ -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 + templatesByCreationId: Record + }) => 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 (
$['marketplace.creatorProfile.empty'], { ns: 'plugin' })}
)} + + {hasMore && ( +
+ + {loadMoreFailed && ( +

+ {t(($) => $['marketplace.creatorProfile.loadMoreFailed'], { ns: 'plugin' })} +

+ )} +
+ )} ) } diff --git a/web/app/components/plugins/marketplace/creator-profile/data.server.ts b/web/app/components/plugins/marketplace/creator-profile/data.server.ts index 6de6c8a4df2..38910290da6 100644 --- a/web/app/components/plugins/marketplace/creator-profile/data.server.ts +++ b/web/app/components/plugins/marketplace/creator-profile/data.server.ts @@ -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 ( - 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 => { 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, + }, } }, ) diff --git a/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx b/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx index 74f80bb3f27..8ae4997db63 100644 --- a/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx +++ b/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx @@ -34,7 +34,18 @@ const normalizePlugin = (plugin: Plugin): Plugin => ({ export default function DifyCreatorProfile({ loadedProfile, locale }: DifyCreatorProfileProps) { const router = useRouter() const [selected, setSelected] = useState(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), diff --git a/web/app/components/plugins/marketplace/creator-profile/model.ts b/web/app/components/plugins/marketplace/creator-profile/model.ts index d76b770a8f1..9814dcec192 100644 --- a/web/app/components/plugins/marketplace/creator-profile/model.ts +++ b/web/app/components/plugins/marketplace/creator-profile/model.ts @@ -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 templatesByCreationId: Record + 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)[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> | 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, + }), } } diff --git a/web/app/components/plugins/marketplace/creator-profile/publisher.ts b/web/app/components/plugins/marketplace/creator-profile/publisher.ts new file mode 100644 index 00000000000..ef0b6fdcbc9 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/publisher.ts @@ -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' + +export const CREATOR_PAGE_SIZE = 40 + +export type PublisherPage = { + items: T[] + total?: number + hasMore: boolean +} + +export 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> { + 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> { + 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 + templatesByCreationId: Record +} => ({ + 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]), + ), +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/view.tsx b/web/app/components/plugins/marketplace/creator-profile/view.tsx index ebae19c187e..fec3ac7e8d8 100644 --- a/web/app/components/plugins/marketplace/creator-profile/view.tsx +++ b/web/app/components/plugins/marketplace/creator-profile/view.tsx @@ -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 + templatesByCreationId: Record + }) => 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({ )} > - +
diff --git a/web/i18n/ar-TN/plugin.json b/web/i18n/ar-TN/plugin.json index a7c37b5f10d..321f336910b 100644 --- a/web/i18n/ar-TN/plugin.json +++ b/web/i18n/ar-TN/plugin.json @@ -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": "ابحث عن الإضافات والقوالب", diff --git a/web/i18n/de-DE/plugin.json b/web/i18n/de-DE/plugin.json index 4c3d321beff..b7afef2bda9 100644 --- a/web/i18n/de-DE/plugin.json +++ b/web/i18n/de-DE/plugin.json @@ -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", diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json index 18abf4dd904..b59877a0fdb 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -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", diff --git a/web/i18n/es-ES/plugin.json b/web/i18n/es-ES/plugin.json index e57b989c6a1..e4d07d3b7cf 100644 --- a/web/i18n/es-ES/plugin.json +++ b/web/i18n/es-ES/plugin.json @@ -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", diff --git a/web/i18n/fa-IR/plugin.json b/web/i18n/fa-IR/plugin.json index d1adac64969..4934bd863f0 100644 --- a/web/i18n/fa-IR/plugin.json +++ b/web/i18n/fa-IR/plugin.json @@ -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": "جستجوی افزونه و قالب", diff --git a/web/i18n/fr-FR/plugin.json b/web/i18n/fr-FR/plugin.json index 8cc5485fdff..f12125037a0 100644 --- a/web/i18n/fr-FR/plugin.json +++ b/web/i18n/fr-FR/plugin.json @@ -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", diff --git a/web/i18n/hi-IN/plugin.json b/web/i18n/hi-IN/plugin.json index 4ec874c1712..8eecf5ef583 100644 --- a/web/i18n/hi-IN/plugin.json +++ b/web/i18n/hi-IN/plugin.json @@ -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": "प्लगिन और टेम्पलेट खोजें", diff --git a/web/i18n/id-ID/plugin.json b/web/i18n/id-ID/plugin.json index 9c917d11807..17284d58c09 100644 --- a/web/i18n/id-ID/plugin.json +++ b/web/i18n/id-ID/plugin.json @@ -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", diff --git a/web/i18n/it-IT/plugin.json b/web/i18n/it-IT/plugin.json index f3f2ff975a1..391b6627560 100644 --- a/web/i18n/it-IT/plugin.json +++ b/web/i18n/it-IT/plugin.json @@ -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", diff --git a/web/i18n/ja-JP/plugin.json b/web/i18n/ja-JP/plugin.json index 2b32e6b9708..00ce4ee68a1 100644 --- a/web/i18n/ja-JP/plugin.json +++ b/web/i18n/ja-JP/plugin.json @@ -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": "プラグインとテンプレートを検索", diff --git a/web/i18n/ko-KR/plugin.json b/web/i18n/ko-KR/plugin.json index 3ce92d3cf39..8325c713d86 100644 --- a/web/i18n/ko-KR/plugin.json +++ b/web/i18n/ko-KR/plugin.json @@ -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": "플러그인 및 템플릿 검색", diff --git a/web/i18n/lo-LA/plugin.json b/web/i18n/lo-LA/plugin.json index 4e03771d312..6be5c041a7e 100644 --- a/web/i18n/lo-LA/plugin.json +++ b/web/i18n/lo-LA/plugin.json @@ -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": "ຄົ້ນຫາປລັກອິນ ແລະ ແມ່ແບບ", diff --git a/web/i18n/nl-NL/plugin.json b/web/i18n/nl-NL/plugin.json index ac5a406588a..ac1b6ce6966 100644 --- a/web/i18n/nl-NL/plugin.json +++ b/web/i18n/nl-NL/plugin.json @@ -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", diff --git a/web/i18n/pl-PL/plugin.json b/web/i18n/pl-PL/plugin.json index 31f7d70b715..1d1fa7eba20 100644 --- a/web/i18n/pl-PL/plugin.json +++ b/web/i18n/pl-PL/plugin.json @@ -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", diff --git a/web/i18n/pt-BR/plugin.json b/web/i18n/pt-BR/plugin.json index da9710427db..78819acbfbf 100644 --- a/web/i18n/pt-BR/plugin.json +++ b/web/i18n/pt-BR/plugin.json @@ -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", diff --git a/web/i18n/ro-RO/plugin.json b/web/i18n/ro-RO/plugin.json index a02da38423e..5f760875155 100644 --- a/web/i18n/ro-RO/plugin.json +++ b/web/i18n/ro-RO/plugin.json @@ -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", diff --git a/web/i18n/ru-RU/plugin.json b/web/i18n/ru-RU/plugin.json index 3b886c0ff4b..923af28c8b5 100644 --- a/web/i18n/ru-RU/plugin.json +++ b/web/i18n/ru-RU/plugin.json @@ -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": "Поиск плагинов и шаблонов", diff --git a/web/i18n/sl-SI/plugin.json b/web/i18n/sl-SI/plugin.json index 959425d379f..00044dcba9a 100644 --- a/web/i18n/sl-SI/plugin.json +++ b/web/i18n/sl-SI/plugin.json @@ -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", diff --git a/web/i18n/th-TH/plugin.json b/web/i18n/th-TH/plugin.json index 67b64c4ded7..537b24e3170 100644 --- a/web/i18n/th-TH/plugin.json +++ b/web/i18n/th-TH/plugin.json @@ -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": "ค้นหาปลั๊กอินและเทมเพลต", diff --git a/web/i18n/tr-TR/plugin.json b/web/i18n/tr-TR/plugin.json index 2718093eb6e..01b69d79f78 100644 --- a/web/i18n/tr-TR/plugin.json +++ b/web/i18n/tr-TR/plugin.json @@ -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", diff --git a/web/i18n/uk-UA/plugin.json b/web/i18n/uk-UA/plugin.json index 80dff6cdd78..74c0276ad99 100644 --- a/web/i18n/uk-UA/plugin.json +++ b/web/i18n/uk-UA/plugin.json @@ -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": "Пошук плагінів і шаблонів", diff --git a/web/i18n/vi-VN/plugin.json b/web/i18n/vi-VN/plugin.json index 1967395535d..e203765e754 100644 --- a/web/i18n/vi-VN/plugin.json +++ b/web/i18n/vi-VN/plugin.json @@ -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", diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json index efae41eb2a8..306a946e2de 100644 --- a/web/i18n/zh-Hans/plugin.json +++ b/web/i18n/zh-Hans/plugin.json @@ -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": "搜索插件和模板", diff --git a/web/i18n/zh-Hant/plugin.json b/web/i18n/zh-Hant/plugin.json index e22a867d9e8..4d1eabd3e18 100644 --- a/web/i18n/zh-Hant/plugin.json +++ b/web/i18n/zh-Hant/plugin.json @@ -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": "搜尋外掛和模板", From 7b0068fc2771d6e0d817ae4af48b0ac086cdeb56 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 2 Sep 2026 16:15:33 +0800 Subject: [PATCH 11/13] fix(web): submit marketplace home search on Enter Enter without a keyboard-highlighted suggestion submits the typed query, matching View more, instead of opening a hovered result. --- .../embedded-marketplace-search.spec.tsx | 30 ++++++++++ .../marketplace-search-autocomplete.spec.tsx | 60 +++++++++++++++++++ .../home/marketplace-search-autocomplete.tsx | 21 +++++-- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx index 2b27d97da2c..5e400ed0e40 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx @@ -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() + }) }) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx index 0af166f4c17..a608d98aade 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx @@ -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( + , + { 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: { diff --git a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx index 850b79fdf77..12def226326 100644 --- a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx +++ b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx @@ -197,6 +197,12 @@ export function MarketplaceSearchAutocomplete({ const [isOpen, setIsOpen] = useState(false) const searchRootRef = useRef(null) const resultsPanelRef = useRef(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' + }} > { + if (event.key !== 'Enter' || !inputName) return + if (keyboardHighlightedRef.current) return + event.preventDefault() + event.stopPropagation() + if (value.trim()) submitSearchForm() + }} /> {!!value && ( { - const form = searchRootRef.current?.closest('form') - if (form instanceof HTMLFormElement) form.requestSubmit() - }} + onClick={submitSearchForm} > {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })} From 00ec45088f69ba1aee140bcff5e773f8d0900095 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 2 Sep 2026 16:29:01 +0800 Subject: [PATCH 12/13] fix(dify-ui): wait for Form Dialog initial focus The Name field is focused after the open animation, so asserting immediately left the play function racing the trigger button. --- packages/dify-ui/src/dialog/index.stories.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/dify-ui/src/dialog/index.stories.tsx b/packages/dify-ui/src/dialog/index.stories.tsx index f985644c633..bd454010a1b 100644 --- a/packages/dify-ui/src/dialog/index.stories.tsx +++ b/packages/dify-ui/src/dialog/index.stories.tsx @@ -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() + }) }, } From f05875d3900284ef9234cc75745341d19bd0f0aa Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 2 Sep 2026 16:29:19 +0800 Subject: [PATCH 13/13] fix(web): drop unused marketplace knip exports Keep CREATOR_PAGE_SIZE, publisherPageHasMore, and MarketplaceSearchScope file-local; they are only used in the same modules. --- .../plugins/marketplace/creator-profile/publisher.ts | 4 ++-- .../marketplace/home/marketplace-search-autocomplete.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/app/components/plugins/marketplace/creator-profile/publisher.ts b/web/app/components/plugins/marketplace/creator-profile/publisher.ts index ef0b6fdcbc9..624a30c2234 100644 --- a/web/app/components/plugins/marketplace/creator-profile/publisher.ts +++ b/web/app/components/plugins/marketplace/creator-profile/publisher.ts @@ -6,7 +6,7 @@ import { marketplaceClient } from '@/service/client' import { getFormattedPlugin, getPluginIconInMarketplace } from '../utils' import { adaptCreations, toPublisherSortQuery } from './model' -export const CREATOR_PAGE_SIZE = 40 +const CREATOR_PAGE_SIZE = 40 export type PublisherPage = { items: T[] @@ -14,7 +14,7 @@ export type PublisherPage = { hasMore: boolean } -export const publisherPageHasMore = (page: number, itemCount: number, total?: number) => +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) => diff --git a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx index 12def226326..f7375f1717f 100644 --- a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx +++ b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx @@ -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 }