diff --git a/.agents/skills/component-refactoring/SKILL.md b/.agents/skills/component-refactoring/SKILL.md index 0ed18d71d1..98a94592ab 100644 --- a/.agents/skills/component-refactoring/SKILL.md +++ b/.agents/skills/component-refactoring/SKILL.md @@ -367,7 +367,7 @@ For each extraction: ┌────────────────────────────────────────┐ │ 1. Extract code │ │ 2. Run: pnpm lint:fix │ - │ 3. Run: pnpm type-check:tsgo │ + │ 3. Run: pnpm type-check │ │ 4. Run: pnpm test │ │ 5. Test functionality manually │ │ 6. PASS? → Next extraction │ diff --git a/.agents/skills/frontend-testing/references/checklist.md b/.agents/skills/frontend-testing/references/checklist.md index 99258498dd..519c3f166f 100644 --- a/.agents/skills/frontend-testing/references/checklist.md +++ b/.agents/skills/frontend-testing/references/checklist.md @@ -127,7 +127,7 @@ For the current file being tested: - [ ] Run full directory test: `pnpm test path/to/directory/` - [ ] Check coverage report: `pnpm test:coverage` - [ ] Run `pnpm lint:fix` on all test files -- [ ] Run `pnpm type-check:tsgo` +- [ ] Run `pnpm type-check` ## Common Issues to Watch diff --git a/.github/workflows/anti-slop.yml b/.github/workflows/anti-slop.yml deleted file mode 100644 index b0f0a36bc9..0000000000 --- a/.github/workflows/anti-slop.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Anti-Slop PR Check - -on: - pull_request_target: - types: [opened, edited, synchronize] - -permissions: - pull-requests: write - contents: read - -jobs: - anti-slop: - runs-on: ubuntu-latest - steps: - - uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - close-pr: false - failure-add-pr-labels: "needs-revision" diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index fd910531db..717413937f 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -35,7 +35,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -84,7 +84,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -105,7 +105,7 @@ jobs: run: sh .github/workflows/expose_service_ports.sh - name: Set up Sandbox - uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 + uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 with: compose-file: | docker/docker-compose.middleware.yaml @@ -156,7 +156,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: "3.12" diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 3946834e09..35683b112f 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -25,7 +25,7 @@ jobs: - name: Check Docker Compose inputs if: github.event_name != 'merge_group' id: docker-compose-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | docker/generate_docker_compose @@ -35,7 +35,7 @@ jobs: - name: Check web inputs if: github.event_name != 'merge_group' id: web-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | web/** @@ -48,7 +48,7 @@ jobs: - name: Check api inputs if: github.event_name != 'merge_group' id: api-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** @@ -58,7 +58,7 @@ jobs: python-version: "3.11" - if: github.event_name != 'merge_group' - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Generate Docker Compose if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true' @@ -123,4 +123,4 @@ jobs: vp exec eslint --concurrency=2 --prune-suppressions --quiet || true - if: github.event_name != 'merge_group' - uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3 + uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4 diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 5991abe3ba..17b867dd6d 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: "3.12" @@ -40,7 +40,7 @@ jobs: cp middleware.env.example middleware.env - name: Set up Middlewares - uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 + uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 with: compose-file: | docker/docker-compose.middleware.yaml @@ -69,7 +69,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: "3.12" @@ -94,7 +94,7 @@ jobs: sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env - name: Set up Middlewares - uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 + uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 with: compose-file: | docker/docker-compose.middleware.yaml diff --git a/.github/workflows/pyrefly-diff.yml b/.github/workflows/pyrefly-diff.yml index ac3732579c..eb15cd6f75 100644 --- a/.github/workflows/pyrefly-diff.yml +++ b/.github/workflows/pyrefly-diff.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Python & UV - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true diff --git a/.github/workflows/pyrefly-type-coverage-comment.yml b/.github/workflows/pyrefly-type-coverage-comment.yml index 974da99aad..3c6c96a664 100644 --- a/.github/workflows/pyrefly-type-coverage-comment.yml +++ b/.github/workflows/pyrefly-type-coverage-comment.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python & UV - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true diff --git a/.github/workflows/pyrefly-type-coverage.yml b/.github/workflows/pyrefly-type-coverage.yml index c795c32e31..0599c94eef 100644 --- a/.github/workflows/pyrefly-type-coverage.yml +++ b/.github/workflows/pyrefly-type-coverage.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Python & UV - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index c32fc9d0cb..fe11e7134f 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -25,7 +25,7 @@ jobs: - name: Check changed files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** @@ -33,7 +33,7 @@ jobs: - name: Setup UV and Python if: steps.changed-files.outputs.any_changed == 'true' - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: false python-version: "3.12" @@ -73,7 +73,7 @@ jobs: - name: Check changed files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | web/** @@ -93,7 +93,7 @@ jobs: - name: Restore ESLint cache if: steps.changed-files.outputs.any_changed == 'true' id: eslint-cache-restore - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: web/.eslintcache key: ${{ runner.os }}-web-eslint-${{ hashFiles('web/package.json', 'pnpm-lock.yaml', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }} @@ -122,7 +122,7 @@ jobs: - name: Save ESLint cache if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: web/.eslintcache key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }} @@ -140,7 +140,7 @@ jobs: - name: Check changed files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | **.sh diff --git a/.github/workflows/tool-test-sdks.yaml b/.github/workflows/tool-test-sdks.yaml index 467f31fccf..bf33207a14 100644 --- a/.github/workflows/tool-test-sdks.yaml +++ b/.github/workflows/tool-test-sdks.yaml @@ -30,7 +30,7 @@ jobs: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: '' diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 541200293d..eecbbb1a56 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -158,7 +158,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1.0.93 + uses: anthropics/claude-code-action@38ec876110f9fbf8b950c79f534430740c3ac009 # v1.0.101 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/vdb-tests-full.yml b/.github/workflows/vdb-tests-full.yml index f0def8fe7a..b79e8927d7 100644 --- a/.github/workflows/vdb-tests-full.yml +++ b/.github/workflows/vdb-tests-full.yml @@ -36,7 +36,7 @@ jobs: remove_tool_cache: true - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -65,7 +65,7 @@ jobs: # tiflash - name: Set up Full Vector Store Matrix - uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 + uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 with: compose-file: | docker/docker-compose.yaml diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml index f3966f15b9..bd13d662c3 100644 --- a/.github/workflows/vdb-tests.yml +++ b/.github/workflows/vdb-tests.yml @@ -33,7 +33,7 @@ jobs: remove_tool_cache: true - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -62,7 +62,7 @@ jobs: # tiflash - name: Set up Vector Stores for Smoke Coverage - uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 + uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 with: compose-file: | docker/docker-compose.yaml diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index 10dc31bde8..6bd4d4f406 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -28,7 +28,7 @@ jobs: uses: ./.github/actions/setup-web - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: "3.12" diff --git a/.gitignore b/.gitignore index 53dea88899..852fb4258e 100644 --- a/.gitignore +++ b/.gitignore @@ -236,6 +236,10 @@ scripts/stress-test/reports/ .playwright-mcp/ .serena/ +# vitest browser mode attachments (failure screenshots, traces, etc.) +.vitest-attachments/ +**/__screenshots__/ + # settings *.local.json *.local.md diff --git a/AGENTS.md b/AGENTS.md index d25d2eed96..8be2daef95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ The codebase is split into: ## Language Style - **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation. -- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check:tsgo`, and avoid `any` types. +- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check`, and avoid `any` types. ## General Practices diff --git a/api/.env.example b/api/.env.example index 6cfe0266c2..f6f65011ea 100644 --- a/api/.env.example +++ b/api/.env.example @@ -659,6 +659,11 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y MARKETPLACE_ENABLED=true MARKETPLACE_API_URL=https://marketplace.dify.ai +# Creators Platform configuration +CREATORS_PLATFORM_FEATURES_ENABLED=true +CREATORS_PLATFORM_API_URL=https://creators.dify.ai +CREATORS_PLATFORM_OAUTH_CLIENT_ID= + # Endpoint configuration ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id} diff --git a/api/README.md b/api/README.md index 00562f3f78..a075bc0fa9 100644 --- a/api/README.md +++ b/api/README.md @@ -101,3 +101,11 @@ The scripts resolve paths relative to their location, so you can run them from a uv run ruff format ./ # Format code uv run basedpyright . # Type checking ``` + +## Generate TS stub + +``` +uv run dev/generate_swagger_specs.py --output-dir openapi +``` + +use https://jsontotable.org/openapi-to-typescript to convert to typescript diff --git a/api/commands/plugin.py b/api/commands/plugin.py index c34391025a..8bd5392d7b 100644 --- a/api/commands/plugin.py +++ b/api/commands/plugin.py @@ -11,7 +11,7 @@ from configs import dify_config from core.helper import encrypter from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.plugin import PluginInstaller -from core.tools.utils.system_oauth_encryption import encrypt_system_oauth_params +from core.tools.utils.system_encryption import encrypt_system_params from extensions.ext_database import db from models import Tenant from models.oauth import DatasourceOauthParamConfig, DatasourceProvider @@ -44,7 +44,7 @@ def setup_system_tool_oauth_client(provider, client_params): click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow")) click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow")) - oauth_client_params = encrypt_system_oauth_params(client_params_dict) + oauth_client_params = encrypt_system_params(client_params_dict) click.echo(click.style("Client params encrypted successfully.", fg="green")) except Exception as e: click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red")) @@ -94,7 +94,7 @@ def setup_system_trigger_oauth_client(provider, client_params): click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow")) click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow")) - oauth_client_params = encrypt_system_oauth_params(client_params_dict) + oauth_client_params = encrypt_system_params(client_params_dict) click.echo(click.style("Client params encrypted successfully.", fg="green")) except Exception as e: click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red")) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 68fb031bb6..249272e308 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -287,6 +287,27 @@ class MarketplaceConfig(BaseSettings): ) +class CreatorsPlatformConfig(BaseSettings): + """ + Configuration for Creators Platform integration + """ + + CREATORS_PLATFORM_FEATURES_ENABLED: bool = Field( + description="Enable or disable Creators Platform features", + default=True, + ) + + CREATORS_PLATFORM_API_URL: HttpUrl = Field( + description="Creators Platform API URL", + default=HttpUrl("https://creators.dify.ai"), + ) + + CREATORS_PLATFORM_OAUTH_CLIENT_ID: str = Field( + description="OAuth client ID for Creators Platform integration", + default="", + ) + + class EndpointConfig(BaseSettings): """ Configuration for various application endpoints and URLs @@ -1405,6 +1426,7 @@ class FeatureConfig( AuthConfig, # Changed from OAuthConfig to AuthConfig BillingConfig, CodeExecutionSandboxConfig, + CreatorsPlatformConfig, TriggerConfig, AsyncWorkflowConfig, PluginConfig, diff --git a/api/controllers/common/human_input.py b/api/controllers/common/human_input.py new file mode 100644 index 0000000000..5d6f4efb95 --- /dev/null +++ b/api/controllers/common/human_input.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, JsonValue + + +class HumanInputFormSubmitPayload(BaseModel): + inputs: dict[str, JsonValue] + action: str diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index e5758ab050..551ffe255a 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -728,6 +728,32 @@ class AppExportApi(Resource): return payload.model_dump(mode="json") +@console_ns.route("/apps//publish-to-creators-platform") +class AppPublishToCreatorsPlatformApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=None) + @edit_permission_required + def post(self, app_model): + """Publish app to Creators Platform""" + from configs import dify_config + from core.helper.creators import get_redirect_url, upload_dsl + + if not dify_config.CREATORS_PLATFORM_FEATURES_ENABLED: + return {"error": "Creators Platform features are not enabled"}, 403 + + current_user, _ = current_account_with_tenant() + + dsl_content = AppDslService.export_dsl(app_model=app_model, include_secret=False) + dsl_bytes = dsl_content.encode("utf-8") + + claim_code = upload_dsl(dsl_bytes) + redirect_url = get_redirect_url(str(current_user.id), claim_code) + + return {"redirect_url": redirect_url} + + @console_ns.route("/apps//name") class AppNameApi(Resource): @console_ns.doc("check_app_name") diff --git a/api/controllers/console/human_input_form.py b/api/controllers/console/human_input_form.py index 845af37365..79b3e6cc9f 100644 --- a/api/controllers/console/human_input_form.py +++ b/api/controllers/console/human_input_form.py @@ -8,10 +8,10 @@ from collections.abc import Generator from flask import Response, jsonify, request from flask_restx import Resource -from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker +from controllers.common.human_input import HumanInputFormSubmitPayload from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, setup_required from controllers.web.error import InvalidArgumentError, NotFoundError @@ -20,11 +20,11 @@ from core.app.apps.base_app_generator import BaseAppGenerator from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter from core.app.apps.message_generator import MessageGenerator from core.app.apps.workflow.app_generator import WorkflowAppGenerator +from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface from extensions.ext_database import db from libs.login import current_account_with_tenant, login_required from models import App from models.enums import CreatorUserRole -from models.human_input import RecipientType from models.model import AppMode from models.workflow import WorkflowRun from repositories.factory import DifyAPIRepositoryFactory @@ -34,11 +34,6 @@ from services.workflow_event_snapshot_service import build_workflow_event_stream logger = logging.getLogger(__name__) -class HumanInputFormSubmitPayload(BaseModel): - inputs: dict - action: str - - def _jsonify_form_definition(form: Form) -> Response: payload = form.get_definition().model_dump() payload["expiration_time"] = int(form.expiration_time.timestamp()) @@ -56,6 +51,11 @@ class ConsoleHumanInputFormApi(Resource): if form.tenant_id != current_tenant_id: raise NotFoundError("App not found") + @staticmethod + def _ensure_console_recipient_type(form: Form) -> None: + if not is_recipient_type_allowed_for_surface(form.recipient_type, HumanInputSurface.CONSOLE): + raise NotFoundError("form not found") + @setup_required @login_required @account_initialization_required @@ -99,10 +99,8 @@ class ConsoleHumanInputFormApi(Resource): raise NotFoundError(f"form not found, token={form_token}") self._ensure_console_access(form) - + self._ensure_console_recipient_type(form) recipient_type = form.recipient_type - if recipient_type not in {RecipientType.CONSOLE, RecipientType.BACKSTAGE}: - raise NotFoundError(f"form not found, token={form_token}") # The type checker is not smart enought to validate the following invariant. # So we need to assert it manually. assert recipient_type is not None, "recipient_type cannot be None here." diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 44404005b2..c01286cc59 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -595,13 +595,25 @@ class ChangeEmailSendEmailApi(Resource): account = None user_email = None email_for_sending = args.email.lower() - if args.phase is not None and args.phase == "new_email": + # Default to the initial phase; any legacy/unexpected client input is + # coerced back to `old_email` so we never trust the caller to declare + # later phases without a verified predecessor token. + send_phase = AccountService.CHANGE_EMAIL_PHASE_OLD + if args.phase is not None and args.phase == AccountService.CHANGE_EMAIL_PHASE_NEW: + send_phase = AccountService.CHANGE_EMAIL_PHASE_NEW if args.token is None: raise InvalidTokenError() reset_data = AccountService.get_change_email_data(args.token) if reset_data is None: raise InvalidTokenError() + + # The token used to request a new-email code must come from the + # old-email verification step. This prevents the bypass described + # in GHSA-4q3w-q5mc-45rq where the phase-1 token was reused here. + token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY) + if token_phase != AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED: + raise InvalidTokenError() user_email = reset_data.get("email", "") if user_email.lower() != current_user.email.lower(): @@ -620,7 +632,7 @@ class ChangeEmailSendEmailApi(Resource): email=email_for_sending, old_email=user_email, language=language, - phase=args.phase, + phase=send_phase, ) return {"result": "success", "data": token} @@ -655,12 +667,31 @@ class ChangeEmailCheckApi(Resource): AccountService.add_change_email_error_rate_limit(user_email) raise EmailCodeError() + # Only advance tokens that were minted by the matching send-code step; + # refuse tokens that have already progressed or lack a phase marker so + # the chain `old_email -> old_email_verified -> new_email -> new_email_verified` + # is strictly enforced. + phase_transitions = { + AccountService.CHANGE_EMAIL_PHASE_OLD: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED, + AccountService.CHANGE_EMAIL_PHASE_NEW: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED, + } + token_phase = token_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY) + if not isinstance(token_phase, str): + raise InvalidTokenError() + refreshed_phase = phase_transitions.get(token_phase) + if refreshed_phase is None: + raise InvalidTokenError() + # Verified, revoke the first token AccountService.revoke_change_email_token(args.token) - # Refresh token data by generating a new token + # Refresh token data by generating a new token that carries the + # upgraded phase so later steps can check it. _, new_token = AccountService.generate_change_email_token( - user_email, code=args.code, old_email=token_data.get("old_email"), additional_data={} + user_email, + code=args.code, + old_email=token_data.get("old_email"), + additional_data={AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: refreshed_phase}, ) AccountService.reset_change_email_error_rate_limit(user_email) @@ -690,13 +721,29 @@ class ChangeEmailResetApi(Resource): if not reset_data: raise InvalidTokenError() - AccountService.revoke_change_email_token(args.token) + # Only tokens that completed both verification phases may be used to + # change the email. This closes GHSA-4q3w-q5mc-45rq where a token from + # the initial send-code step could be replayed directly here. + token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY) + if token_phase != AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED: + raise InvalidTokenError() + + # Bind the new email to the token that was mailed and verified, so a + # verified token cannot be reused with a different `new_email` value. + token_email = reset_data.get("email") + normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email + if normalized_token_email != normalized_new_email: + raise InvalidTokenError() old_email = reset_data.get("old_email", "") current_user, _ = current_account_with_tenant() if current_user.email.lower() != old_email.lower(): raise AccountNotFound() + # Revoke only after all checks pass so failed attempts don't burn a + # legitimately verified token. + AccountService.revoke_change_email_token(args.token) + updated_account = AccountService.update_account_email(current_user, email=normalized_new_email) AccountService.send_change_email_completed_notify_email( diff --git a/api/controllers/console/workspace/endpoint.py b/api/controllers/console/workspace/endpoint.py index b6b9deb1f9..142f583172 100644 --- a/api/controllers/console/workspace/endpoint.py +++ b/api/controllers/console/workspace/endpoint.py @@ -1,3 +1,11 @@ +"""Console workspace endpoint controllers. + +This module exposes workspace-scoped plugin endpoint management APIs. The +canonical write routes follow resource-oriented paths, while the historical +verb-based aliases stay available as deprecated resources so OpenAPI metadata +marks only the legacy paths as deprecated. +""" + from typing import Any from flask import request @@ -25,7 +33,12 @@ class EndpointIdPayload(BaseModel): endpoint_id: str -class EndpointUpdatePayload(EndpointIdPayload): +class EndpointUpdatePayload(BaseModel): + settings: dict[str, Any] + name: str = Field(min_length=1) + + +class LegacyEndpointUpdatePayload(EndpointIdPayload): settings: dict[str, Any] name: str = Field(min_length=1) @@ -76,6 +89,7 @@ register_schema_models( EndpointCreatePayload, EndpointIdPayload, EndpointUpdatePayload, + LegacyEndpointUpdatePayload, EndpointListQuery, EndpointListForPluginQuery, EndpointCreateResponse, @@ -88,8 +102,60 @@ register_schema_models( ) -@console_ns.route("/workspaces/current/endpoints/create") -class EndpointCreateApi(Resource): +def _create_endpoint() -> dict[str, bool]: + """Create a plugin endpoint for the current workspace.""" + user, tenant_id = current_account_with_tenant() + + args = EndpointCreatePayload.model_validate(console_ns.payload) + + try: + return { + "success": EndpointService.create_endpoint( + tenant_id=tenant_id, + user_id=user.id, + plugin_unique_identifier=args.plugin_unique_identifier, + name=args.name, + settings=args.settings, + ) + } + except PluginPermissionDeniedError as e: + raise ValueError(e.description) from e + + +def _update_endpoint(endpoint_id: str) -> dict[str, bool]: + """Update a plugin endpoint identified by the canonical path parameter.""" + user, tenant_id = current_account_with_tenant() + + args = EndpointUpdatePayload.model_validate(console_ns.payload) + + return { + "success": EndpointService.update_endpoint( + tenant_id=tenant_id, + user_id=user.id, + endpoint_id=endpoint_id, + name=args.name, + settings=args.settings, + ) + } + + +def _delete_endpoint(endpoint_id: str) -> dict[str, bool]: + """Delete a plugin endpoint identified by the canonical path parameter.""" + user, tenant_id = current_account_with_tenant() + + return { + "success": EndpointService.delete_endpoint( + tenant_id=tenant_id, + user_id=user.id, + endpoint_id=endpoint_id, + ) + } + + +@console_ns.route("/workspaces/current/endpoints") +class EndpointCollectionApi(Resource): + """Canonical collection resource for endpoint creation.""" + @console_ns.doc("create_endpoint") @console_ns.doc(description="Create a new plugin endpoint") @console_ns.expect(console_ns.models[EndpointCreatePayload.__name__]) @@ -104,22 +170,33 @@ class EndpointCreateApi(Resource): @is_admin_or_owner_required @account_initialization_required def post(self): - user, tenant_id = current_account_with_tenant() + return _create_endpoint() - args = EndpointCreatePayload.model_validate(console_ns.payload) - try: - return { - "success": EndpointService.create_endpoint( - tenant_id=tenant_id, - user_id=user.id, - plugin_unique_identifier=args.plugin_unique_identifier, - name=args.name, - settings=args.settings, - ) - } - except PluginPermissionDeniedError as e: - raise ValueError(e.description) from e +@console_ns.route("/workspaces/current/endpoints/create") +class DeprecatedEndpointCreateApi(Resource): + """Deprecated verb-based alias for endpoint creation.""" + + @console_ns.doc("create_endpoint_deprecated") + @console_ns.doc(deprecated=True) + @console_ns.doc( + description=( + "Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead." + ) + ) + @console_ns.expect(console_ns.models[EndpointCreatePayload.__name__]) + @console_ns.response( + 200, + "Endpoint created successfully", + console_ns.models[EndpointCreateResponse.__name__], + ) + @console_ns.response(403, "Admin privileges required") + @setup_required + @login_required + @is_admin_or_owner_required + @account_initialization_required + def post(self): + return _create_endpoint() @console_ns.route("/workspaces/current/endpoints/list") @@ -190,10 +267,56 @@ class EndpointListForSinglePluginApi(Resource): ) -@console_ns.route("/workspaces/current/endpoints/delete") -class EndpointDeleteApi(Resource): +@console_ns.route("/workspaces/current/endpoints/") +class EndpointItemApi(Resource): + """Canonical item resource for endpoint updates and deletion.""" + @console_ns.doc("delete_endpoint") @console_ns.doc(description="Delete a plugin endpoint") + @console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}}) + @console_ns.response( + 200, + "Endpoint deleted successfully", + console_ns.models[EndpointDeleteResponse.__name__], + ) + @console_ns.response(403, "Admin privileges required") + @setup_required + @login_required + @is_admin_or_owner_required + @account_initialization_required + def delete(self, id: str): + return _delete_endpoint(endpoint_id=id) + + @console_ns.doc("update_endpoint") + @console_ns.doc(description="Update a plugin endpoint") + @console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__]) + @console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}}) + @console_ns.response( + 200, + "Endpoint updated successfully", + console_ns.models[EndpointUpdateResponse.__name__], + ) + @console_ns.response(403, "Admin privileges required") + @setup_required + @login_required + @is_admin_or_owner_required + @account_initialization_required + def patch(self, id: str): + return _update_endpoint(endpoint_id=id) + + +@console_ns.route("/workspaces/current/endpoints/delete") +class DeprecatedEndpointDeleteApi(Resource): + """Deprecated verb-based alias for endpoint deletion.""" + + @console_ns.doc("delete_endpoint_deprecated") + @console_ns.doc(deprecated=True) + @console_ns.doc( + description=( + "Deprecated legacy alias for deleting a plugin endpoint. " + "Use DELETE /workspaces/current/endpoints/{id} instead." + ) + ) @console_ns.expect(console_ns.models[EndpointIdPayload.__name__]) @console_ns.response( 200, @@ -206,22 +329,23 @@ class EndpointDeleteApi(Resource): @is_admin_or_owner_required @account_initialization_required def post(self): - user, tenant_id = current_account_with_tenant() - args = EndpointIdPayload.model_validate(console_ns.payload) - - return { - "success": EndpointService.delete_endpoint( - tenant_id=tenant_id, user_id=user.id, endpoint_id=args.endpoint_id - ) - } + return _delete_endpoint(endpoint_id=args.endpoint_id) @console_ns.route("/workspaces/current/endpoints/update") -class EndpointUpdateApi(Resource): - @console_ns.doc("update_endpoint") - @console_ns.doc(description="Update a plugin endpoint") - @console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__]) +class DeprecatedEndpointUpdateApi(Resource): + """Deprecated verb-based alias for endpoint updates.""" + + @console_ns.doc("update_endpoint_deprecated") + @console_ns.doc(deprecated=True) + @console_ns.doc( + description=( + "Deprecated legacy alias for updating a plugin endpoint. " + "Use PATCH /workspaces/current/endpoints/{id} instead." + ) + ) + @console_ns.expect(console_ns.models[LegacyEndpointUpdatePayload.__name__]) @console_ns.response( 200, "Endpoint updated successfully", @@ -233,19 +357,8 @@ class EndpointUpdateApi(Resource): @is_admin_or_owner_required @account_initialization_required def post(self): - user, tenant_id = current_account_with_tenant() - - args = EndpointUpdatePayload.model_validate(console_ns.payload) - - return { - "success": EndpointService.update_endpoint( - tenant_id=tenant_id, - user_id=user.id, - endpoint_id=args.endpoint_id, - name=args.name, - settings=args.settings, - ) - } + args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload) + return _update_endpoint(endpoint_id=args.endpoint_id) @console_ns.route("/workspaces/current/endpoints/enable") diff --git a/api/controllers/service_api/__init__.py b/api/controllers/service_api/__init__.py index 4f7f7d9a98..182631e8f5 100644 --- a/api/controllers/service_api/__init__.py +++ b/api/controllers/service_api/__init__.py @@ -23,9 +23,11 @@ from .app import ( conversation, file, file_preview, + human_input_form, message, site, workflow, + workflow_events, ) from .dataset import ( dataset, @@ -50,6 +52,7 @@ __all__ = [ "file", "file_preview", "hit_testing", + "human_input_form", "index", "message", "metadata", @@ -58,6 +61,7 @@ __all__ = [ "segment", "site", "workflow", + "workflow_events", ] api.add_namespace(service_api_ns) diff --git a/api/controllers/service_api/app/human_input_form.py b/api/controllers/service_api/app/human_input_form.py new file mode 100644 index 0000000000..8e5003dbbf --- /dev/null +++ b/api/controllers/service_api/app/human_input_form.py @@ -0,0 +1,137 @@ +""" +Service API human input form endpoints. + +This module exposes app-token authenticated APIs for fetching and submitting +paused human input forms in workflow/chatflow runs. +""" + +import json +import logging +from datetime import datetime + +from flask import Response +from flask_restx import Resource +from werkzeug.exceptions import BadRequest, NotFound + +from controllers.common.human_input import HumanInputFormSubmitPayload +from controllers.common.schema import register_schema_models +from controllers.service_api import service_api_ns +from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token +from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface +from extensions.ext_database import db +from models.model import App, EndUser +from services.human_input_service import Form, FormNotFoundError, HumanInputService + +logger = logging.getLogger(__name__) + + +register_schema_models(service_api_ns, HumanInputFormSubmitPayload) + + +def _stringify_default_values(values: dict[str, object]) -> dict[str, str]: + result: dict[str, str] = {} + for key, value in values.items(): + if value is None: + result[key] = "" + elif isinstance(value, (dict, list)): + result[key] = json.dumps(value, ensure_ascii=False) + else: + result[key] = str(value) + return result + + +def _to_timestamp(value: datetime) -> int: + return int(value.timestamp()) + + +def _jsonify_form_definition(form: Form) -> Response: + definition_payload = form.get_definition().model_dump() + payload = { + "form_content": definition_payload["rendered_content"], + "inputs": definition_payload["inputs"], + "resolved_default_values": _stringify_default_values(definition_payload["default_values"]), + "user_actions": definition_payload["user_actions"], + "expiration_time": _to_timestamp(form.expiration_time), + } + return Response(json.dumps(payload, ensure_ascii=False), mimetype="application/json") + + +def _ensure_form_belongs_to_app(form: Form, app_model: App) -> None: + if form.app_id != app_model.id or form.tenant_id != app_model.tenant_id: + raise NotFound("Form not found") + + +def _ensure_form_is_allowed_for_service_api(form: Form) -> None: + # Keep app-token callers scoped to the public web-form surface; internal HITL + # routes must continue to flow through console-only authentication. + if not is_recipient_type_allowed_for_surface(form.recipient_type, HumanInputSurface.SERVICE_API): + raise NotFound("Form not found") + + +@service_api_ns.route("/form/human_input/") +class WorkflowHumanInputFormApi(Resource): + @service_api_ns.doc("get_human_input_form") + @service_api_ns.doc(description="Get a paused human input form by token") + @service_api_ns.doc(params={"form_token": "Human input form token"}) + @service_api_ns.doc( + responses={ + 200: "Form retrieved successfully", + 401: "Unauthorized - invalid API token", + 404: "Form not found", + 412: "Form already submitted or expired", + } + ) + @validate_app_token + def get(self, app_model: App, form_token: str): + service = HumanInputService(db.engine) + form = service.get_form_by_token(form_token) + if form is None: + raise NotFound("Form not found") + + _ensure_form_belongs_to_app(form, app_model) + _ensure_form_is_allowed_for_service_api(form) + service.ensure_form_active(form) + return _jsonify_form_definition(form) + + @service_api_ns.expect(service_api_ns.models[HumanInputFormSubmitPayload.__name__]) + @service_api_ns.doc("submit_human_input_form") + @service_api_ns.doc(description="Submit a paused human input form by token") + @service_api_ns.doc(params={"form_token": "Human input form token"}) + @service_api_ns.doc( + responses={ + 200: "Form submitted successfully", + 400: "Bad request - invalid submission data", + 401: "Unauthorized - invalid API token", + 404: "Form not found", + 412: "Form already submitted or expired", + } + ) + @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) + def post(self, app_model: App, end_user: EndUser, form_token: str): + payload = HumanInputFormSubmitPayload.model_validate(service_api_ns.payload or {}) + + service = HumanInputService(db.engine) + form = service.get_form_by_token(form_token) + if form is None: + raise NotFound("Form not found") + + _ensure_form_belongs_to_app(form, app_model) + _ensure_form_is_allowed_for_service_api(form) + + recipient_type = form.recipient_type + if recipient_type is None: + logger.warning("Recipient type is None for form, form_id=%s", form.id) + raise BadRequest("Form recipient type is invalid") + + try: + service.submit_form_by_token( + recipient_type=recipient_type, + form_token=form_token, + selected_action_id=payload.action, + form_data=payload.inputs, + submission_end_user_id=end_user.id, + ) + except FormNotFoundError: + raise NotFound("Form not found") + + return {}, 200 diff --git a/api/controllers/service_api/app/workflow_events.py b/api/controllers/service_api/app/workflow_events.py new file mode 100644 index 0000000000..b281b271c0 --- /dev/null +++ b/api/controllers/service_api/app/workflow_events.py @@ -0,0 +1,142 @@ +""" +Service API workflow resume event stream endpoints. +""" + +import json +from collections.abc import Generator + +from flask import Response, request +from flask_restx import Resource +from sqlalchemy.orm import sessionmaker +from werkzeug.exceptions import NotFound + +from controllers.service_api import service_api_ns +from controllers.service_api.app.error import NotWorkflowAppError +from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token +from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator +from core.app.apps.base_app_generator import BaseAppGenerator +from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter +from core.app.apps.message_generator import MessageGenerator +from core.app.apps.workflow.app_generator import WorkflowAppGenerator +from core.app.entities.task_entities import StreamEvent +from core.workflow.human_input_policy import HumanInputSurface +from extensions.ext_database import db +from models.enums import CreatorUserRole +from models.model import App, AppMode, EndUser +from repositories.factory import DifyAPIRepositoryFactory +from services.workflow_event_snapshot_service import build_workflow_event_stream + + +@service_api_ns.route("/workflow//events") +class WorkflowEventsApi(Resource): + """Service API for getting workflow execution events after resume.""" + + @service_api_ns.doc("get_workflow_events") + @service_api_ns.doc(description="Get workflow execution events stream after resume") + @service_api_ns.doc( + params={ + "task_id": "Workflow run ID", + "user": "End user identifier (query param)", + "include_state_snapshot": ( + "Whether to replay from persisted state snapshot, " + 'specify `"true"` to include a status snapshot of executed nodes' + ), + "continue_on_pause": ( + "Whether to keep the stream open across workflow_paused events," + 'specify `"true"` to keep the stream open for `workflow_paused` events.' + ), + } + ) + @service_api_ns.doc( + responses={ + 200: "SSE event stream", + 401: "Unauthorized - invalid API token", + 404: "Workflow run not found", + } + ) + @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True)) + def get(self, app_model: App, end_user: EndUser, task_id: str): + app_mode = AppMode.value_of(app_model.mode) + if app_mode not in {AppMode.WORKFLOW, AppMode.ADVANCED_CHAT}: + raise NotWorkflowAppError() + + session_maker = sessionmaker(db.engine) + repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + workflow_run = repo.get_workflow_run_by_id_and_tenant_id( + tenant_id=app_model.tenant_id, + run_id=task_id, + ) + + if workflow_run is None: + raise NotFound("Workflow run not found") + + if workflow_run.app_id != app_model.id: + raise NotFound("Workflow run not found") + + if workflow_run.created_by_role != CreatorUserRole.END_USER: + raise NotFound("Workflow run not found") + + if workflow_run.created_by != end_user.id: + raise NotFound("Workflow run not found") + + workflow_run_entity = workflow_run + + if workflow_run_entity.finished_at is not None: + response = WorkflowResponseConverter.workflow_run_result_to_finish_response( + task_id=workflow_run_entity.id, + workflow_run=workflow_run_entity, + creator_user=end_user, + ) + + payload = response.model_dump(mode="json") + payload["event"] = response.event.value + + def _generate_finished_events() -> Generator[str, None, None]: + yield f"data: {json.dumps(payload)}\n\n" + + event_generator = _generate_finished_events + else: + msg_generator = MessageGenerator() + generator: BaseAppGenerator + if app_mode == AppMode.ADVANCED_CHAT: + generator = AdvancedChatAppGenerator() + elif app_mode == AppMode.WORKFLOW: + generator = WorkflowAppGenerator() + else: + raise NotWorkflowAppError() + + include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true" + continue_on_pause = request.args.get("continue_on_pause", "false").lower() == "true" + terminal_events: list[StreamEvent] | None = [] if continue_on_pause else None + + def _generate_stream_events(): + if include_state_snapshot: + return generator.convert_to_event_stream( + build_workflow_event_stream( + app_mode=app_mode, + workflow_run=workflow_run_entity, + tenant_id=app_model.tenant_id, + app_id=app_model.id, + session_maker=session_maker, + human_input_surface=HumanInputSurface.SERVICE_API, + close_on_pause=not continue_on_pause, + ) + ) + return generator.convert_to_event_stream( + msg_generator.retrieve_events( + app_mode, + workflow_run_entity.id, + terminal_events=terminal_events, + ), + ) + + event_generator = _generate_stream_events + + return Response( + event_generator(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index 6db047567f..bc28ecb6b7 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -1,4 +1,12 @@ +"""Service API endpoints for dataset document management. + +The canonical Service API paths use hyphenated route segments. Legacy underscore +aliases remain registered for backward compatibility, but they must stay marked +deprecated in generated API docs so clients migrate toward the canonical paths. +""" + import json +from collections.abc import Mapping from contextlib import ExitStack from typing import Self from uuid import UUID @@ -117,12 +125,137 @@ register_schema_models( ) -@service_api_ns.route( - "/datasets//document/create_by_text", - "/datasets//document/create-by-text", -) +def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Mapping[str, object], int]: + """Create a document from text for both canonical and legacy routes.""" + payload = DocumentTextCreatePayload.model_validate(service_api_ns.payload or {}) + args = payload.model_dump(exclude_none=True) + + dataset_id_str = str(dataset_id) + tenant_id_str = str(tenant_id) + dataset = db.session.scalar( + select(Dataset).where(Dataset.tenant_id == tenant_id_str, Dataset.id == dataset_id_str).limit(1) + ) + + if not dataset: + raise ValueError("Dataset does not exist.") + + if not dataset.indexing_technique and not args["indexing_technique"]: + raise ValueError("indexing_technique is required.") + + embedding_model_provider = payload.embedding_model_provider + embedding_model = payload.embedding_model + if embedding_model_provider and embedding_model: + DatasetService.check_embedding_model_setting(tenant_id_str, embedding_model_provider, embedding_model) + + retrieval_model = payload.retrieval_model + if ( + retrieval_model + and retrieval_model.reranking_model + and retrieval_model.reranking_model.reranking_provider_name + and retrieval_model.reranking_model.reranking_model_name + ): + DatasetService.check_reranking_model_setting( + tenant_id_str, + retrieval_model.reranking_model.reranking_provider_name, + retrieval_model.reranking_model.reranking_model_name, + ) + + if not current_user: + raise ValueError("current_user is required") + + upload_file = FileService(db.engine).upload_text( + text=payload.text, text_name=payload.name, user_id=current_user.id, tenant_id=tenant_id_str + ) + data_source = { + "type": "upload_file", + "info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}}, + } + args["data_source"] = data_source + knowledge_config = KnowledgeConfig.model_validate(args) + DocumentService.document_create_args_validate(knowledge_config) + + if not current_user: + raise ValueError("current_user is required") + + try: + documents, batch = DocumentService.save_document_with_dataset_id( + dataset=dataset, + knowledge_config=knowledge_config, + account=current_user, + dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None, + created_from="api", + ) + except ProviderTokenNotInitError as ex: + raise ProviderNotInitializeError(ex.description) + document = documents[0] + + documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch} + return documents_and_batch_fields, 200 + + +def _update_document_by_text(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Mapping[str, object], int]: + """Update a document from text for both canonical and legacy routes.""" + payload = DocumentTextUpdate.model_validate(service_api_ns.payload or {}) + dataset = db.session.scalar( + select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == str(dataset_id)).limit(1) + ) + args = payload.model_dump(exclude_none=True) + if not dataset: + raise ValueError("Dataset does not exist.") + + retrieval_model = payload.retrieval_model + if ( + retrieval_model + and retrieval_model.reranking_model + and retrieval_model.reranking_model.reranking_provider_name + and retrieval_model.reranking_model.reranking_model_name + ): + DatasetService.check_reranking_model_setting( + tenant_id, + retrieval_model.reranking_model.reranking_provider_name, + retrieval_model.reranking_model.reranking_model_name, + ) + + # indexing_technique is already set in dataset since this is an update + args["indexing_technique"] = dataset.indexing_technique + + if args.get("text"): + text = args.get("text") + name = args.get("name") + if not current_user: + raise ValueError("current_user is required") + upload_file = FileService(db.engine).upload_text( + text=str(text), text_name=str(name), user_id=current_user.id, tenant_id=tenant_id + ) + data_source = { + "type": "upload_file", + "info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}}, + } + args["data_source"] = data_source + + args["original_document_id"] = str(document_id) + knowledge_config = KnowledgeConfig.model_validate(args) + DocumentService.document_create_args_validate(knowledge_config) + + try: + documents, batch = DocumentService.save_document_with_dataset_id( + dataset=dataset, + knowledge_config=knowledge_config, + account=current_user, + dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None, + created_from="api", + ) + except ProviderTokenNotInitError as ex: + raise ProviderNotInitializeError(ex.description) + document = documents[0] + + documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch} + return documents_and_batch_fields, 200 + + +@service_api_ns.route("/datasets//document/create-by-text") class DocumentAddByTextApi(DatasetApiResource): - """Resource for documents.""" + """Resource for the canonical text document creation route.""" @service_api_ns.expect(service_api_ns.models[DocumentTextCreatePayload.__name__]) @service_api_ns.doc("create_document_by_text") @@ -138,81 +271,43 @@ class DocumentAddByTextApi(DatasetApiResource): @cloud_edition_billing_resource_check("vector_space", "dataset") @cloud_edition_billing_resource_check("documents", "dataset") @cloud_edition_billing_rate_limit_check("knowledge", "dataset") - def post(self, tenant_id, dataset_id): + def post(self, tenant_id: str, dataset_id: UUID): """Create document by text.""" - payload = DocumentTextCreatePayload.model_validate(service_api_ns.payload or {}) - args = payload.model_dump(exclude_none=True) + return _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id) - dataset_id = str(dataset_id) - tenant_id = str(tenant_id) - dataset = db.session.scalar( - select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1) + +@service_api_ns.route("/datasets//document/create_by_text") +class DeprecatedDocumentAddByTextApi(DatasetApiResource): + """Deprecated resource alias for text document creation.""" + + @service_api_ns.expect(service_api_ns.models[DocumentTextCreatePayload.__name__]) + @service_api_ns.doc("create_document_by_text_deprecated") + @service_api_ns.doc(deprecated=True) + @service_api_ns.doc( + description=( + "Deprecated legacy alias for creating a new document by providing text content. " + "Use /datasets/{dataset_id}/document/create-by-text instead." ) - - if not dataset: - raise ValueError("Dataset does not exist.") - - if not dataset.indexing_technique and not args["indexing_technique"]: - raise ValueError("indexing_technique is required.") - - embedding_model_provider = payload.embedding_model_provider - embedding_model = payload.embedding_model - if embedding_model_provider and embedding_model: - DatasetService.check_embedding_model_setting(tenant_id, embedding_model_provider, embedding_model) - - retrieval_model = payload.retrieval_model - if ( - retrieval_model - and retrieval_model.reranking_model - and retrieval_model.reranking_model.reranking_provider_name - and retrieval_model.reranking_model.reranking_model_name - ): - DatasetService.check_reranking_model_setting( - tenant_id, - retrieval_model.reranking_model.reranking_provider_name, - retrieval_model.reranking_model.reranking_model_name, - ) - - if not current_user: - raise ValueError("current_user is required") - - upload_file = FileService(db.engine).upload_text( - text=payload.text, text_name=payload.name, user_id=current_user.id, tenant_id=tenant_id - ) - data_source = { - "type": "upload_file", - "info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}}, + ) + @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc( + responses={ + 200: "Document created successfully", + 401: "Unauthorized - invalid API token", + 400: "Bad request - invalid parameters", } - args["data_source"] = data_source - knowledge_config = KnowledgeConfig.model_validate(args) - # validate args - DocumentService.document_create_args_validate(knowledge_config) - - if not current_user: - raise ValueError("current_user is required") - - try: - documents, batch = DocumentService.save_document_with_dataset_id( - dataset=dataset, - knowledge_config=knowledge_config, - account=current_user, - dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None, - created_from="api", - ) - except ProviderTokenNotInitError as ex: - raise ProviderNotInitializeError(ex.description) - document = documents[0] - - documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch} - return documents_and_batch_fields, 200 + ) + @cloud_edition_billing_resource_check("vector_space", "dataset") + @cloud_edition_billing_resource_check("documents", "dataset") + @cloud_edition_billing_rate_limit_check("knowledge", "dataset") + def post(self, tenant_id: str, dataset_id: UUID): + """Create document by text through the deprecated underscore alias.""" + return _create_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id) -@service_api_ns.route( - "/datasets//documents//update_by_text", - "/datasets//documents//update-by-text", -) +@service_api_ns.route("/datasets//documents//update-by-text") class DocumentUpdateByTextApi(DatasetApiResource): - """Resource for update documents.""" + """Resource for the canonical text document update route.""" @service_api_ns.expect(service_api_ns.models[DocumentTextUpdate.__name__]) @service_api_ns.doc("update_document_by_text") @@ -229,62 +324,35 @@ class DocumentUpdateByTextApi(DatasetApiResource): @cloud_edition_billing_rate_limit_check("knowledge", "dataset") def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID): """Update document by text.""" - payload = DocumentTextUpdate.model_validate(service_api_ns.payload or {}) - dataset = db.session.scalar( - select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == str(dataset_id)).limit(1) + return _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id) + + +@service_api_ns.route("/datasets//documents//update_by_text") +class DeprecatedDocumentUpdateByTextApi(DatasetApiResource): + """Deprecated resource alias for text document updates.""" + + @service_api_ns.expect(service_api_ns.models[DocumentTextUpdate.__name__]) + @service_api_ns.doc("update_document_by_text_deprecated") + @service_api_ns.doc(deprecated=True) + @service_api_ns.doc( + description=( + "Deprecated legacy alias for updating an existing document by providing text content. " + "Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead." ) - args = payload.model_dump(exclude_none=True) - if not dataset: - raise ValueError("Dataset does not exist.") - - retrieval_model = payload.retrieval_model - if ( - retrieval_model - and retrieval_model.reranking_model - and retrieval_model.reranking_model.reranking_provider_name - and retrieval_model.reranking_model.reranking_model_name - ): - DatasetService.check_reranking_model_setting( - tenant_id, - retrieval_model.reranking_model.reranking_provider_name, - retrieval_model.reranking_model.reranking_model_name, - ) - - # indexing_technique is already set in dataset since this is an update - args["indexing_technique"] = dataset.indexing_technique - - if args.get("text"): - text = args.get("text") - name = args.get("name") - if not current_user: - raise ValueError("current_user is required") - upload_file = FileService(db.engine).upload_text( - text=str(text), text_name=str(name), user_id=current_user.id, tenant_id=tenant_id - ) - data_source = { - "type": "upload_file", - "info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}}, - } - args["data_source"] = data_source - # validate args - args["original_document_id"] = str(document_id) - knowledge_config = KnowledgeConfig.model_validate(args) - DocumentService.document_create_args_validate(knowledge_config) - - try: - documents, batch = DocumentService.save_document_with_dataset_id( - dataset=dataset, - knowledge_config=knowledge_config, - account=current_user, - dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None, - created_from="api", - ) - except ProviderTokenNotInitError as ex: - raise ProviderNotInitializeError(ex.description) - document = documents[0] - - documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": batch} - return documents_and_batch_fields, 200 + ) + @service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"}) + @service_api_ns.doc( + responses={ + 200: "Document updated successfully", + 401: "Unauthorized - invalid API token", + 404: "Document not found", + } + ) + @cloud_edition_billing_resource_check("vector_space", "dataset") + @cloud_edition_billing_rate_limit_check("knowledge", "dataset") + def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID): + """Update document by text through the deprecated underscore alias.""" + return _update_document_by_text(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id) @service_api_ns.route( diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py index 44876f8303..1ddf2e0717 100644 --- a/api/controllers/web/human_input_form.py +++ b/api/controllers/web/human_input_form.py @@ -9,11 +9,11 @@ from typing import Any, NotRequired, TypedDict from flask import Response, request from flask_restx import Resource -from pydantic import BaseModel from sqlalchemy import select from werkzeug.exceptions import Forbidden from configs import dify_config +from controllers.common.human_input import HumanInputFormSubmitPayload from controllers.web import web_ns from controllers.web.error import NotFoundError, WebFormRateLimitExceededError from controllers.web.site import serialize_app_site_payload @@ -26,11 +26,6 @@ from services.human_input_service import Form, FormNotFoundError, HumanInputServ logger = logging.getLogger(__name__) -class HumanInputFormSubmitPayload(BaseModel): - inputs: dict - action: str - - _FORM_SUBMIT_RATE_LIMITER = RateLimiter( prefix="web_form_submit_rate_limit", max_attempts=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS, diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index 985ded0f74..2ca108cd3a 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -39,7 +39,11 @@ from core.app.apps.exc import GenerateTaskStoppedError from core.app.apps.message_based_app_generator import MessageBasedAppGenerator from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom -from core.app.entities.task_entities import ChatbotAppBlockingResponse, ChatbotAppStreamResponse +from core.app.entities.task_entities import ( + AdvancedChatPausedBlockingResponse, + ChatbotAppBlockingResponse, + ChatbotAppStreamResponse, +) from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer from core.helper.trace_id_helper import extract_external_trace_id_from_args from core.ops.ops_trace_manager import TraceQueueManager @@ -656,7 +660,11 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): user: Account | EndUser, draft_var_saver_factory: DraftVariableSaverFactory, stream: bool = False, - ) -> ChatbotAppBlockingResponse | Generator[ChatbotAppStreamResponse, None, None]: + ) -> ( + ChatbotAppBlockingResponse + | AdvancedChatPausedBlockingResponse + | Generator[ChatbotAppStreamResponse, None, None] + ): """ Handle response. :param application_generate_entity: application generate entity diff --git a/api/core/app/apps/advanced_chat/generate_response_converter.py b/api/core/app/apps/advanced_chat/generate_response_converter.py index fe2702ed69..7cb0c9a8d3 100644 --- a/api/core/app/apps/advanced_chat/generate_response_converter.py +++ b/api/core/app/apps/advanced_chat/generate_response_converter.py @@ -3,7 +3,7 @@ from typing import Any, cast from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.task_entities import ( - AppBlockingResponse, + AdvancedChatPausedBlockingResponse, AppStreamResponse, ChatbotAppBlockingResponse, ChatbotAppStreamResponse, @@ -12,22 +12,40 @@ from core.app.entities.task_entities import ( NodeFinishStreamResponse, NodeStartStreamResponse, PingStreamResponse, + StreamEvent, ) -class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = ChatbotAppBlockingResponse - +class AdvancedChatAppGenerateResponseConverter( + AppGenerateResponseConverter[ChatbotAppBlockingResponse | AdvancedChatPausedBlockingResponse] +): @classmethod - def convert_blocking_full_response(cls, blocking_response: AppBlockingResponse) -> dict[str, Any]: + def convert_blocking_full_response( + cls, blocking_response: ChatbotAppBlockingResponse | AdvancedChatPausedBlockingResponse + ) -> dict[str, Any]: """ Convert blocking full response. :param blocking_response: blocking response :return: """ - blocking_response = cast(ChatbotAppBlockingResponse, blocking_response) + if isinstance(blocking_response, AdvancedChatPausedBlockingResponse): + paused_data = blocking_response.data.model_dump(mode="json") + return { + "event": StreamEvent.WORKFLOW_PAUSED.value, + "task_id": blocking_response.task_id, + "id": blocking_response.data.id, + "message_id": blocking_response.data.message_id, + "conversation_id": blocking_response.data.conversation_id, + "mode": blocking_response.data.mode, + "answer": blocking_response.data.answer, + "metadata": blocking_response.data.metadata, + "created_at": blocking_response.data.created_at, + "workflow_run_id": blocking_response.data.workflow_run_id, + "data": paused_data, + } + response = { - "event": "message", + "event": StreamEvent.MESSAGE.value, "task_id": blocking_response.task_id, "id": blocking_response.data.id, "message_id": blocking_response.data.message_id, @@ -41,7 +59,9 @@ class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter): return response @classmethod - def convert_blocking_simple_response(cls, blocking_response: AppBlockingResponse) -> dict[str, Any]: + def convert_blocking_simple_response( + cls, blocking_response: ChatbotAppBlockingResponse | AdvancedChatPausedBlockingResponse + ) -> dict[str, Any]: """ Convert blocking simple response. :param blocking_response: blocking response @@ -50,7 +70,8 @@ class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter): response = cls.convert_blocking_full_response(blocking_response) metadata = response.get("metadata", {}) - response["metadata"] = cls._get_simple_metadata(metadata) + if isinstance(metadata, dict): + response["metadata"] = cls._get_simple_metadata(metadata) return response diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 78b582bdf5..82dbf5381d 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -53,14 +53,18 @@ from core.app.entities.queue_entities import ( WorkflowQueueMessage, ) from core.app.entities.task_entities import ( + AdvancedChatPausedBlockingResponse, ChatbotAppBlockingResponse, ChatbotAppStreamResponse, ErrorStreamResponse, + HumanInputRequiredPauseReasonPayload, + HumanInputRequiredResponse, MessageAudioEndStreamResponse, MessageAudioStreamResponse, MessageEndStreamResponse, PingStreamResponse, StreamResponse, + WorkflowPauseStreamResponse, WorkflowTaskState, ) from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline @@ -210,7 +214,13 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): if message.status == MessageStatus.PAUSED and message.answer: self._task_state.answer = message.answer - def process(self) -> Union[ChatbotAppBlockingResponse, Generator[ChatbotAppStreamResponse, None, None]]: + def process( + self, + ) -> Union[ + ChatbotAppBlockingResponse, + AdvancedChatPausedBlockingResponse, + Generator[ChatbotAppStreamResponse, None, None], + ]: """ Process generate task pipeline. :return: @@ -226,14 +236,39 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): else: return self._to_blocking_response(generator) - def _to_blocking_response(self, generator: Generator[StreamResponse, None, None]) -> ChatbotAppBlockingResponse: + def _to_blocking_response( + self, generator: Generator[StreamResponse, None, None] + ) -> Union[ChatbotAppBlockingResponse, AdvancedChatPausedBlockingResponse]: """ Process blocking response. :return: """ + human_input_responses: list[HumanInputRequiredResponse] = [] for stream_response in generator: if isinstance(stream_response, ErrorStreamResponse): raise stream_response.err + elif isinstance(stream_response, HumanInputRequiredResponse): + human_input_responses.append(stream_response) + elif isinstance(stream_response, WorkflowPauseStreamResponse): + return AdvancedChatPausedBlockingResponse( + task_id=stream_response.task_id, + data=AdvancedChatPausedBlockingResponse.Data( + id=self._message_id, + mode=self._conversation_mode, + conversation_id=self._conversation_id, + message_id=self._message_id, + workflow_run_id=stream_response.data.workflow_run_id, + answer=self._task_state.answer, + metadata=self._message_end_to_stream_response().metadata, + created_at=self._message_created_at, + paused_nodes=stream_response.data.paused_nodes, + reasons=stream_response.data.reasons, + status=stream_response.data.status, + elapsed_time=stream_response.data.elapsed_time, + total_tokens=stream_response.data.total_tokens, + total_steps=stream_response.data.total_steps, + ), + ) elif isinstance(stream_response, MessageEndStreamResponse): extras = {} if stream_response.metadata: @@ -254,8 +289,41 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): else: continue + if human_input_responses: + return self._build_paused_blocking_response_from_human_input(human_input_responses) + raise ValueError("queue listening stopped unexpectedly.") + def _build_paused_blocking_response_from_human_input( + self, human_input_responses: list[HumanInputRequiredResponse] + ) -> AdvancedChatPausedBlockingResponse: + runtime_state = self._resolve_graph_runtime_state() + paused_nodes = list(dict.fromkeys(response.data.node_id for response in human_input_responses)) + reasons = [ + HumanInputRequiredPauseReasonPayload.from_response_data(response.data).model_dump(mode="json") + for response in human_input_responses + ] + + return AdvancedChatPausedBlockingResponse( + task_id=self._application_generate_entity.task_id, + data=AdvancedChatPausedBlockingResponse.Data( + id=self._message_id, + mode=self._conversation_mode, + conversation_id=self._conversation_id, + message_id=self._message_id, + workflow_run_id=human_input_responses[-1].workflow_run_id, + answer=self._task_state.answer, + metadata=self._message_end_to_stream_response().metadata, + created_at=self._message_created_at, + paused_nodes=paused_nodes, + reasons=reasons, + status=WorkflowExecutionStatus.PAUSED, + elapsed_time=time.perf_counter() - self._base_task_pipeline.start_at, + total_tokens=runtime_state.total_tokens, + total_steps=runtime_state.node_run_steps, + ), + ) + def _to_stream_response( self, generator: Generator[StreamResponse, None, None] ) -> Generator[ChatbotAppStreamResponse, Any, None]: diff --git a/api/core/app/apps/agent_chat/generate_response_converter.py b/api/core/app/apps/agent_chat/generate_response_converter.py index 731c6ee12e..03bc0a9108 100644 --- a/api/core/app/apps/agent_chat/generate_response_converter.py +++ b/api/core/app/apps/agent_chat/generate_response_converter.py @@ -1,6 +1,8 @@ from collections.abc import Generator from typing import Any, cast +from pydantic import JsonValue + from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.task_entities import ( AppStreamResponse, @@ -12,11 +14,9 @@ from core.app.entities.task_entities import ( ) -class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = ChatbotAppBlockingResponse - +class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter[ChatbotAppBlockingResponse]): @classmethod - def convert_blocking_full_response(cls, blocking_response: ChatbotAppBlockingResponse): # type: ignore[override] + def convert_blocking_full_response(cls, blocking_response: ChatbotAppBlockingResponse): """ Convert blocking full response. :param blocking_response: blocking response @@ -37,7 +37,7 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter): return response @classmethod - def convert_blocking_simple_response(cls, blocking_response: ChatbotAppBlockingResponse): # type: ignore[override] + def convert_blocking_simple_response(cls, blocking_response: ChatbotAppBlockingResponse): """ Convert blocking simple response. :param blocking_response: blocking response @@ -70,7 +70,7 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "conversation_id": chunk.conversation_id, "message_id": chunk.message_id, @@ -101,7 +101,7 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "conversation_id": chunk.conversation_id, "message_id": chunk.message_id, diff --git a/api/core/app/apps/base_app_generate_response_converter.py b/api/core/app/apps/base_app_generate_response_converter.py index 406d07927e..c5723927fc 100644 --- a/api/core/app/apps/base_app_generate_response_converter.py +++ b/api/core/app/apps/base_app_generate_response_converter.py @@ -1,7 +1,9 @@ import logging from abc import ABC, abstractmethod from collections.abc import Generator, Mapping -from typing import Any, Union +from typing import Any, Union, cast + +from pydantic import JsonValue from graphon.model_runtime.errors.invoke import InvokeError @@ -12,8 +14,10 @@ from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotIni logger = logging.getLogger(__name__) -class AppGenerateResponseConverter(ABC): - _blocking_response_type: type[AppBlockingResponse] +class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC): + @classmethod + def _cast_blocking_response(cls, response: AppBlockingResponse) -> TBlockingResponse: + return cast(TBlockingResponse, response) @classmethod def convert( @@ -21,7 +25,7 @@ class AppGenerateResponseConverter(ABC): ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], Any, None]: if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.SERVICE_API}: if isinstance(response, AppBlockingResponse): - return cls.convert_blocking_full_response(response) + return cls.convert_blocking_full_response(cls._cast_blocking_response(response)) else: def _generate_full_response() -> Generator[dict[str, Any] | str, Any, None]: @@ -30,7 +34,7 @@ class AppGenerateResponseConverter(ABC): return _generate_full_response() else: if isinstance(response, AppBlockingResponse): - return cls.convert_blocking_simple_response(response) + return cls.convert_blocking_simple_response(cls._cast_blocking_response(response)) else: def _generate_simple_response() -> Generator[dict[str, Any] | str, Any, None]: @@ -40,12 +44,12 @@ class AppGenerateResponseConverter(ABC): @classmethod @abstractmethod - def convert_blocking_full_response(cls, blocking_response: AppBlockingResponse) -> dict[str, Any]: + def convert_blocking_full_response(cls, blocking_response: TBlockingResponse) -> dict[str, Any]: raise NotImplementedError @classmethod @abstractmethod - def convert_blocking_simple_response(cls, blocking_response: AppBlockingResponse) -> dict[str, Any]: + def convert_blocking_simple_response(cls, blocking_response: TBlockingResponse) -> dict[str, Any]: raise NotImplementedError @classmethod @@ -107,13 +111,13 @@ class AppGenerateResponseConverter(ABC): return metadata @classmethod - def _error_to_stream_response(cls, e: Exception) -> dict[str, Any]: + def _error_to_stream_response(cls, e: Exception) -> dict[str, JsonValue]: """ Error to stream response. :param e: exception :return: """ - error_responses: dict[type[Exception], dict[str, Any]] = { + error_responses: dict[type[Exception], dict[str, JsonValue]] = { ValueError: {"code": "invalid_param", "status": 400}, ProviderTokenNotInitError: {"code": "provider_not_initialize", "status": 400}, QuotaExceededError: { @@ -127,7 +131,7 @@ class AppGenerateResponseConverter(ABC): } # Determine the response based on the type of exception - data: dict[str, Any] | None = None + data: dict[str, JsonValue] | None = None for k, v in error_responses.items(): if isinstance(e, k): data = v diff --git a/api/core/app/apps/chat/generate_response_converter.py b/api/core/app/apps/chat/generate_response_converter.py index 3d0375151d..26efcbfafd 100644 --- a/api/core/app/apps/chat/generate_response_converter.py +++ b/api/core/app/apps/chat/generate_response_converter.py @@ -1,6 +1,8 @@ from collections.abc import Generator from typing import Any, cast +from pydantic import JsonValue + from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.task_entities import ( AppStreamResponse, @@ -12,11 +14,9 @@ from core.app.entities.task_entities import ( ) -class ChatAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = ChatbotAppBlockingResponse - +class ChatAppGenerateResponseConverter(AppGenerateResponseConverter[ChatbotAppBlockingResponse]): @classmethod - def convert_blocking_full_response(cls, blocking_response: ChatbotAppBlockingResponse): # type: ignore[override] + def convert_blocking_full_response(cls, blocking_response: ChatbotAppBlockingResponse): """ Convert blocking full response. :param blocking_response: blocking response @@ -37,7 +37,7 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter): return response @classmethod - def convert_blocking_simple_response(cls, blocking_response: ChatbotAppBlockingResponse): # type: ignore[override] + def convert_blocking_simple_response(cls, blocking_response: ChatbotAppBlockingResponse): """ Convert blocking simple response. :param blocking_response: blocking response @@ -70,7 +70,7 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "conversation_id": chunk.conversation_id, "message_id": chunk.message_id, @@ -101,7 +101,7 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "conversation_id": chunk.conversation_id, "message_id": chunk.message_id, diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index a515531616..dd6a97ba6c 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -65,6 +65,7 @@ from core.tools.tool_manager import ToolManager from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE from core.trigger.trigger_manager import TriggerManager from core.workflow.human_input_forms import load_form_tokens_by_form_id +from core.workflow.human_input_policy import HumanInputSurface, enrich_human_input_pause_reasons from core.workflow.system_variables import SystemVariableKey, system_variables_to_mapping from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_database import db @@ -336,7 +337,26 @@ class WorkflowResponseConverter: except (TypeError, json.JSONDecodeError): definition_payload = {} display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui")) - form_token_by_form_id = load_form_tokens_by_form_id(human_input_form_ids, session=session) + form_token_by_form_id = load_form_tokens_by_form_id( + human_input_form_ids, + session=session, + surface=( + HumanInputSurface.SERVICE_API + if self._application_generate_entity.invoke_from == InvokeFrom.SERVICE_API + else None + ), + ) + + # Reconnect paths must preserve the same pause-reason contract as live streams; + # otherwise clients see schema drift after resume. + pause_reasons = enrich_human_input_pause_reasons( + pause_reasons, + form_tokens_by_form_id=form_token_by_form_id, + expiration_times_by_form_id={ + form_id: int(expiration_time.timestamp()) + for form_id, expiration_time in expiration_times_by_form_id.items() + }, + ) responses: list[StreamResponse] = [] diff --git a/api/core/app/apps/completion/generate_response_converter.py b/api/core/app/apps/completion/generate_response_converter.py index 71886b39ba..ad978f58e0 100644 --- a/api/core/app/apps/completion/generate_response_converter.py +++ b/api/core/app/apps/completion/generate_response_converter.py @@ -1,6 +1,8 @@ from collections.abc import Generator from typing import Any, cast +from pydantic import JsonValue + from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.task_entities import ( AppStreamResponse, @@ -12,17 +14,15 @@ from core.app.entities.task_entities import ( ) -class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = CompletionAppBlockingResponse - +class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter[CompletionAppBlockingResponse]): @classmethod - def convert_blocking_full_response(cls, blocking_response: CompletionAppBlockingResponse): # type: ignore[override] + def convert_blocking_full_response(cls, blocking_response: CompletionAppBlockingResponse): """ Convert blocking full response. :param blocking_response: blocking response :return: """ - response = { + response: dict[str, Any] = { "event": "message", "task_id": blocking_response.task_id, "id": blocking_response.data.id, @@ -36,7 +36,7 @@ class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter): return response @classmethod - def convert_blocking_simple_response(cls, blocking_response: CompletionAppBlockingResponse): # type: ignore[override] + def convert_blocking_simple_response(cls, blocking_response: CompletionAppBlockingResponse): """ Convert blocking simple response. :param blocking_response: blocking response @@ -69,7 +69,7 @@ class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "message_id": chunk.message_id, "created_at": chunk.created_at, @@ -99,7 +99,7 @@ class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "message_id": chunk.message_id, "created_at": chunk.created_at, diff --git a/api/core/app/apps/message_generator.py b/api/core/app/apps/message_generator.py index 68631bb230..c04f20c796 100644 --- a/api/core/app/apps/message_generator.py +++ b/api/core/app/apps/message_generator.py @@ -1,6 +1,7 @@ -from collections.abc import Callable, Generator, Mapping +from collections.abc import Callable, Generator, Iterable, Mapping from core.app.apps.streaming_utils import stream_topic_events +from core.app.entities.task_entities import StreamEvent from extensions.ext_redis import get_pubsub_broadcast_channel from libs.broadcast_channel.channel import Topic from models.model import AppMode @@ -26,6 +27,7 @@ class MessageGenerator: idle_timeout=300, ping_interval: float = 10.0, on_subscribe: Callable[[], None] | None = None, + terminal_events: Iterable[str | StreamEvent] | None = None, ) -> Generator[Mapping | str, None, None]: topic = cls.get_response_topic(app_mode, workflow_run_id) return stream_topic_events( @@ -33,4 +35,5 @@ class MessageGenerator: idle_timeout=idle_timeout, ping_interval=ping_interval, on_subscribe=on_subscribe, + terminal_events=terminal_events, ) diff --git a/api/core/app/apps/pipeline/generate_response_converter.py b/api/core/app/apps/pipeline/generate_response_converter.py index 02b3160b7c..3913657ae8 100644 --- a/api/core/app/apps/pipeline/generate_response_converter.py +++ b/api/core/app/apps/pipeline/generate_response_converter.py @@ -13,11 +13,9 @@ from core.app.entities.task_entities import ( ) -class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = WorkflowAppBlockingResponse - +class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter[WorkflowAppBlockingResponse]): @classmethod - def convert_blocking_full_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, Any]: # type: ignore[override] + def convert_blocking_full_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, object]: """ Convert blocking full response. :param blocking_response: blocking response @@ -26,7 +24,7 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): return dict(blocking_response.model_dump()) @classmethod - def convert_blocking_simple_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, Any]: # type: ignore[override] + def convert_blocking_simple_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, object]: """ Convert blocking simple response. :param blocking_response: blocking response diff --git a/api/core/app/apps/pipeline/pipeline_generator.py b/api/core/app/apps/pipeline/pipeline_generator.py index 83c74b86e5..aa653ca5da 100644 --- a/api/core/app/apps/pipeline/pipeline_generator.py +++ b/api/core/app/apps/pipeline/pipeline_generator.py @@ -29,7 +29,11 @@ from core.app.apps.workflow.generate_response_converter import WorkflowAppGenera from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline from core.app.entities.app_invoke_entities import InvokeFrom, RagPipelineGenerateEntity from core.app.entities.rag_pipeline_invoke_entities import RagPipelineInvokeEntity -from core.app.entities.task_entities import WorkflowAppBlockingResponse, WorkflowAppStreamResponse +from core.app.entities.task_entities import ( + WorkflowAppBlockingResponse, + WorkflowAppPausedBlockingResponse, + WorkflowAppStreamResponse, +) from core.datasource.entities.datasource_entities import ( DatasourceProviderType, OnlineDriveBrowseFilesRequest, @@ -627,7 +631,11 @@ class PipelineGenerator(BaseAppGenerator): user: Account | EndUser, draft_var_saver_factory: DraftVariableSaverFactory, stream: bool = False, - ) -> WorkflowAppBlockingResponse | Generator[WorkflowAppStreamResponse, None, None]: + ) -> ( + WorkflowAppBlockingResponse + | WorkflowAppPausedBlockingResponse + | Generator[WorkflowAppStreamResponse, None, None] + ): """ Handle response. :param application_generate_entity: application generate entity diff --git a/api/core/app/apps/streaming_utils.py b/api/core/app/apps/streaming_utils.py index af3441aca3..5743bad4b6 100644 --- a/api/core/app/apps/streaming_utils.py +++ b/api/core/app/apps/streaming_utils.py @@ -59,7 +59,7 @@ def stream_topic_events( def _normalize_terminal_events(terminal_events: Iterable[str | StreamEvent] | None) -> set[str]: - if not terminal_events: + if terminal_events is None: return {StreamEvent.WORKFLOW_FINISHED.value, StreamEvent.WORKFLOW_PAUSED.value} values: set[str] = set() for item in terminal_events: diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index 3421a13133..1b1cea93e6 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -29,7 +29,11 @@ from core.app.apps.workflow.app_runner import WorkflowAppRunner from core.app.apps.workflow.generate_response_converter import WorkflowAppGenerateResponseConverter from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity -from core.app.entities.task_entities import WorkflowAppBlockingResponse, WorkflowAppStreamResponse +from core.app.entities.task_entities import ( + WorkflowAppBlockingResponse, + WorkflowAppPausedBlockingResponse, + WorkflowAppStreamResponse, +) from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer from core.db.session_factory import session_factory from core.helper.trace_id_helper import extract_external_trace_id_from_args @@ -633,7 +637,11 @@ class WorkflowAppGenerator(BaseAppGenerator): user: Account | EndUser, draft_var_saver_factory: DraftVariableSaverFactory, stream: bool = False, - ) -> WorkflowAppBlockingResponse | Generator[WorkflowAppStreamResponse, None, None]: + ) -> ( + WorkflowAppBlockingResponse + | WorkflowAppPausedBlockingResponse + | Generator[WorkflowAppStreamResponse, None, None] + ): """ Handle response. :param application_generate_entity: application generate entity diff --git a/api/core/app/apps/workflow/generate_response_converter.py b/api/core/app/apps/workflow/generate_response_converter.py index c69826cbef..4037388798 100644 --- a/api/core/app/apps/workflow/generate_response_converter.py +++ b/api/core/app/apps/workflow/generate_response_converter.py @@ -9,24 +9,29 @@ from core.app.entities.task_entities import ( NodeStartStreamResponse, PingStreamResponse, WorkflowAppBlockingResponse, + WorkflowAppPausedBlockingResponse, WorkflowAppStreamResponse, ) -class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = WorkflowAppBlockingResponse - +class WorkflowAppGenerateResponseConverter( + AppGenerateResponseConverter[WorkflowAppBlockingResponse | WorkflowAppPausedBlockingResponse] +): @classmethod - def convert_blocking_full_response(cls, blocking_response: WorkflowAppBlockingResponse): # type: ignore[override] + def convert_blocking_full_response( + cls, blocking_response: WorkflowAppBlockingResponse | WorkflowAppPausedBlockingResponse + ) -> dict[str, Any]: """ Convert blocking full response. :param blocking_response: blocking response :return: """ - return blocking_response.model_dump() + return dict(blocking_response.model_dump()) @classmethod - def convert_blocking_simple_response(cls, blocking_response: WorkflowAppBlockingResponse): # type: ignore[override] + def convert_blocking_simple_response( + cls, blocking_response: WorkflowAppBlockingResponse | WorkflowAppPausedBlockingResponse + ) -> dict[str, Any]: """ Convert blocking simple response. :param blocking_response: blocking response diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py index 96387133b1..d198a34fc3 100644 --- a/api/core/app/apps/workflow/generate_task_pipeline.py +++ b/api/core/app/apps/workflow/generate_task_pipeline.py @@ -45,12 +45,15 @@ from core.app.entities.queue_entities import ( ) from core.app.entities.task_entities import ( ErrorStreamResponse, + HumanInputRequiredPauseReasonPayload, + HumanInputRequiredResponse, MessageAudioEndStreamResponse, MessageAudioStreamResponse, PingStreamResponse, StreamResponse, TextChunkStreamResponse, WorkflowAppBlockingResponse, + WorkflowAppPausedBlockingResponse, WorkflowAppStreamResponse, WorkflowFinishStreamResponse, WorkflowPauseStreamResponse, @@ -118,7 +121,11 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): ) self._graph_runtime_state: GraphRuntimeState | None = self._base_task_pipeline.queue_manager.graph_runtime_state - def process(self) -> Union[WorkflowAppBlockingResponse, Generator[WorkflowAppStreamResponse, None, None]]: + def process( + self, + ) -> Union[ + WorkflowAppBlockingResponse, WorkflowAppPausedBlockingResponse, Generator[WorkflowAppStreamResponse, None, None] + ]: """ Process generate task pipeline. :return: @@ -129,19 +136,24 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): else: return self._to_blocking_response(generator) - def _to_blocking_response(self, generator: Generator[StreamResponse, None, None]) -> WorkflowAppBlockingResponse: + def _to_blocking_response( + self, generator: Generator[StreamResponse, None, None] + ) -> Union[WorkflowAppBlockingResponse, WorkflowAppPausedBlockingResponse]: """ To blocking response. :return: """ + human_input_responses: list[HumanInputRequiredResponse] = [] for stream_response in generator: if isinstance(stream_response, ErrorStreamResponse): raise stream_response.err + elif isinstance(stream_response, HumanInputRequiredResponse): + human_input_responses.append(stream_response) elif isinstance(stream_response, WorkflowPauseStreamResponse): - response = WorkflowAppBlockingResponse( + return WorkflowAppPausedBlockingResponse( task_id=self._application_generate_entity.task_id, workflow_run_id=stream_response.data.workflow_run_id, - data=WorkflowAppBlockingResponse.Data( + data=WorkflowAppPausedBlockingResponse.Data( id=stream_response.data.workflow_run_id, workflow_id=self._workflow.id, status=stream_response.data.status, @@ -152,12 +164,13 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): total_steps=stream_response.data.total_steps, created_at=stream_response.data.created_at, finished_at=None, + paused_nodes=stream_response.data.paused_nodes, + reasons=stream_response.data.reasons, ), ) - return response elif isinstance(stream_response, WorkflowFinishStreamResponse): - response = WorkflowAppBlockingResponse( + return WorkflowAppBlockingResponse( task_id=self._application_generate_entity.task_id, workflow_run_id=stream_response.data.id, data=WorkflowAppBlockingResponse.Data( @@ -174,12 +187,44 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): ), ) - return response else: continue + if human_input_responses: + return self._build_paused_blocking_response_from_human_input(human_input_responses) + raise ValueError("queue listening stopped unexpectedly.") + def _build_paused_blocking_response_from_human_input( + self, human_input_responses: list[HumanInputRequiredResponse] + ) -> WorkflowAppPausedBlockingResponse: + runtime_state = self._resolve_graph_runtime_state() + paused_nodes = list(dict.fromkeys(response.data.node_id for response in human_input_responses)) + created_at = int(runtime_state.start_at) + reasons = [ + HumanInputRequiredPauseReasonPayload.from_response_data(response.data).model_dump(mode="json") + for response in human_input_responses + ] + + return WorkflowAppPausedBlockingResponse( + task_id=self._application_generate_entity.task_id, + workflow_run_id=human_input_responses[-1].workflow_run_id, + data=WorkflowAppPausedBlockingResponse.Data( + id=human_input_responses[-1].workflow_run_id, + workflow_id=self._workflow.id, + status=WorkflowExecutionStatus.PAUSED, + outputs={}, + error=None, + elapsed_time=time.perf_counter() - self._base_task_pipeline.start_at, + total_tokens=runtime_state.total_tokens, + total_steps=runtime_state.node_run_steps, + created_at=created_at, + finished_at=None, + paused_nodes=paused_nodes, + reasons=reasons, + ), + ) + def _to_stream_response( self, generator: Generator[StreamResponse, None, None] ) -> Generator[WorkflowAppStreamResponse, None, None]: diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index 88faf235d1..ad05566521 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -1,15 +1,16 @@ from collections.abc import Mapping, Sequence from enum import StrEnum -from typing import Any +from typing import Any, Literal -from graphon.entities import WorkflowStartReason -from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus -from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage -from graphon.nodes.human_input.entities import FormInput, UserAction -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, JsonValue from core.app.entities.agent_strategy import AgentStrategyInfo from core.rag.entities import RetrievalSourceMetadata +from graphon.entities import WorkflowStartReason +from graphon.entities.pause_reason import PauseReasonType +from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus +from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage +from graphon.nodes.human_input.entities import FormInput, UserAction class AnnotationReplyAccount(BaseModel): @@ -295,6 +296,40 @@ class HumanInputRequiredResponse(StreamResponse): data: Data +class HumanInputRequiredPauseReasonPayload(BaseModel): + """ + Public pause-reason payload used by blocking responses when only + ``human_input_required`` events are available. + """ + + TYPE: Literal[PauseReasonType.HUMAN_INPUT_REQUIRED] = PauseReasonType.HUMAN_INPUT_REQUIRED + form_id: str + node_id: str + node_title: str + form_content: str + inputs: Sequence[FormInput] = Field(default_factory=list) + actions: Sequence[UserAction] = Field(default_factory=list) + display_in_ui: bool = False + form_token: str | None = None + resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) + expiration_time: int + + @classmethod + def from_response_data(cls, data: HumanInputRequiredResponse.Data) -> "HumanInputRequiredPauseReasonPayload": + return cls( + form_id=data.form_id, + node_id=data.node_id, + node_title=data.node_title, + form_content=data.form_content, + inputs=data.inputs, + actions=data.actions, + display_in_ui=data.display_in_ui, + form_token=data.form_token, + resolved_default_values=data.resolved_default_values, + expiration_time=data.expiration_time, + ) + + class HumanInputFormFilledResponse(StreamResponse): class Data(BaseModel): """ @@ -355,7 +390,7 @@ class NodeStartStreamResponse(StreamResponse): workflow_run_id: str data: Data - def to_ignore_detail_dict(self): + def to_ignore_detail_dict(self) -> dict[str, JsonValue]: return { "event": self.event.value, "task_id": self.task_id, @@ -412,7 +447,7 @@ class NodeFinishStreamResponse(StreamResponse): workflow_run_id: str data: Data - def to_ignore_detail_dict(self): + def to_ignore_detail_dict(self) -> dict[str, JsonValue]: return { "event": self.event.value, "task_id": self.task_id, @@ -774,6 +809,34 @@ class ChatbotAppBlockingResponse(AppBlockingResponse): data: Data +class AdvancedChatPausedBlockingResponse(AppBlockingResponse): + """ + ChatbotAppPausedBlockingResponse entity + """ + + class Data(BaseModel): + """ + Data entity + """ + + id: str + mode: str + conversation_id: str + message_id: str + workflow_run_id: str + answer: str + metadata: Mapping[str, object] = Field(default_factory=dict) + created_at: int + paused_nodes: Sequence[str] = Field(default_factory=list) + reasons: Sequence[Mapping[str, Any]] = Field(default_factory=list[Mapping[str, Any]]) + status: WorkflowExecutionStatus + elapsed_time: float + total_tokens: int + total_steps: int + + data: Data + + class CompletionAppBlockingResponse(AppBlockingResponse): """ CompletionAppBlockingResponse entity @@ -819,6 +882,33 @@ class WorkflowAppBlockingResponse(AppBlockingResponse): data: Data +class WorkflowAppPausedBlockingResponse(AppBlockingResponse): + """ + WorkflowAppPausedBlockingResponse entity + """ + + class Data(BaseModel): + """ + Data entity + """ + + id: str + workflow_id: str + status: WorkflowExecutionStatus + outputs: Mapping[str, Any] | None = None + error: str | None = None + elapsed_time: float + total_tokens: int + total_steps: int + created_at: int + finished_at: int | None + paused_nodes: Sequence[str] = Field(default_factory=list) + reasons: Sequence[Mapping[str, Any]] = Field(default_factory=list) + + workflow_run_id: str + data: Data + + class AgentLogStreamResponse(StreamResponse): """ AgentLogStreamResponse entity diff --git a/api/core/app/file_access/scope.py b/api/core/app/file_access/scope.py index 80d504ef1c..a583301f9b 100644 --- a/api/core/app/file_access/scope.py +++ b/api/core/app/file_access/scope.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Generator # Changed from Iterator from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass @@ -32,7 +32,7 @@ def get_current_file_access_scope() -> FileAccessScope | None: @contextmanager -def bind_file_access_scope(scope: FileAccessScope) -> Iterator[None]: +def bind_file_access_scope(scope: FileAccessScope) -> Generator[None, None, None]: # Changed from Iterator[None] token = _current_file_access_scope.set(scope) try: yield diff --git a/api/core/app/llm/model_access.py b/api/core/app/llm/model_access.py index 278d0cb30b..3286d39c4d 100644 --- a/api/core/app/llm/model_access.py +++ b/api/core/app/llm/model_access.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy from typing import Any from graphon.model_runtime.entities.model_entities import ModelType @@ -15,8 +16,21 @@ from core.provider_manager import ProviderManager class DifyCredentialsProvider: + """Resolves and returns LLM credentials for a given provider and model. + + Fetched credentials are stored in :attr:`credentials_cache` and reused for + subsequent ``fetch`` calls for the same ``(provider_name, model_name)``. + Because of that cache, a single instance can return stale credentials after + the tenant or provider configuration changes (e.g. API key rotation). + + Do **not** keep one instance for the lifetime of a process or across + unrelated invocations. Create a new provider per request, workflow run, or + other bounded scope where up-to-date credentials matter. + """ + tenant_id: str provider_manager: ProviderManager + credentials_cache: dict[tuple[str, str], dict[str, Any]] def __init__( self, @@ -31,8 +45,12 @@ class DifyCredentialsProvider: user_id=run_context.user_id, ) self.provider_manager = provider_manager + self.credentials_cache = {} def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]: + if (provider_name, model_name) in self.credentials_cache: + return deepcopy(self.credentials_cache[(provider_name, model_name)]) + provider_configurations = self.provider_manager.get_configurations(self.tenant_id) provider_configuration = provider_configurations.get(provider_name) if not provider_configuration: @@ -47,6 +65,7 @@ class DifyCredentialsProvider: if credentials is None: raise ProviderTokenNotInitError(f"Model {model_name} credentials is not initialized.") + self.credentials_cache[(provider_name, model_name)] = deepcopy(credentials) return credentials @@ -66,7 +85,8 @@ class DifyModelFactory: provider_manager=create_plugin_provider_manager( tenant_id=run_context.tenant_id, user_id=run_context.user_id, - ) + ), + enable_credentials_cache=True, ) self.model_manager = model_manager @@ -85,7 +105,7 @@ def build_dify_model_access(run_context: DifyRunContext) -> tuple[CredentialsPro tenant_id=run_context.tenant_id, user_id=run_context.user_id, ) - model_manager = ModelManager(provider_manager=provider_manager) + model_manager = ModelManager(provider_manager=provider_manager, enable_credentials_cache=True) return ( DifyCredentialsProvider(run_context=run_context, provider_manager=provider_manager), diff --git a/api/core/helper/creators.py b/api/core/helper/creators.py new file mode 100644 index 0000000000..b01e16f18a --- /dev/null +++ b/api/core/helper/creators.py @@ -0,0 +1,41 @@ +""" +Helper module for Creators Platform integration. + +Provides functionality to upload DSL files to the Creators Platform +and generate redirect URLs with OAuth authorization codes. +""" + +import logging +from urllib.parse import urlencode + +import httpx +from yarl import URL + +from configs import dify_config + +logger = logging.getLogger(__name__) + +creators_platform_api_url = URL(str(dify_config.CREATORS_PLATFORM_API_URL)) + + +def upload_dsl(dsl_file_bytes: bytes, filename: str = "template.yaml") -> str: + url = str(creators_platform_api_url / "api/v1/templates/anonymous-upload") + response = httpx.post(url, files={"file": (filename, dsl_file_bytes)}, timeout=30) + response.raise_for_status() + data = response.json() + claim_code = data.get("data", {}).get("claim_code") + if not claim_code: + raise ValueError("Creators Platform did not return a valid claim_code") + return claim_code + + +def get_redirect_url(user_account_id: str, claim_code: str) -> str: + base_url = str(dify_config.CREATORS_PLATFORM_API_URL).rstrip("/") + params: dict[str, str] = {"dsl_claim_code": claim_code} + client_id = str(dify_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID or "") + if client_id: + from services.oauth_server import OAuthServerService + + oauth_code = OAuthServerService.sign_oauth_authorization_code(client_id, user_account_id) + params["oauth_code"] = oauth_code + return f"{base_url}?{urlencode(params)}" diff --git a/api/core/llm_generator/output_parser/suggested_questions_after_answer.py b/api/core/llm_generator/output_parser/suggested_questions_after_answer.py index c030802c79..7ac340926d 100644 --- a/api/core/llm_generator/output_parser/suggested_questions_after_answer.py +++ b/api/core/llm_generator/output_parser/suggested_questions_after_answer.py @@ -10,7 +10,14 @@ logger = logging.getLogger(__name__) class SuggestedQuestionsAfterAnswerOutputParser: def __init__(self, instruction_prompt: str | None = None) -> None: - self._instruction_prompt = instruction_prompt or DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT + self._instruction_prompt = self._build_instruction_prompt(instruction_prompt) + + @staticmethod + def _build_instruction_prompt(instruction_prompt: str | None) -> str: + if not instruction_prompt or not instruction_prompt.strip(): + return DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT + + return f'{instruction_prompt}\nYou must output a JSON array like ["question1", "question2", "question3"].' def get_format_instructions(self) -> str: return self._instruction_prompt diff --git a/api/core/llm_generator/prompts.py b/api/core/llm_generator/prompts.py index 855a00c9cd..adbd79d3d0 100644 --- a/api/core/llm_generator/prompts.py +++ b/api/core/llm_generator/prompts.py @@ -107,6 +107,7 @@ DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = ( DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS = 256 DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE = 0.0 + GENERATOR_QA_PROMPT = ( " The user will send a long text. Generate a Question and Answer pairs only using the knowledge" " in the long text. Please think step by step." diff --git a/api/core/model_manager.py b/api/core/model_manager.py index 86d0e3baaa..457c888e33 100644 --- a/api/core/model_manager.py +++ b/api/core/model_manager.py @@ -1,5 +1,6 @@ import logging from collections.abc import Callable, Generator, Iterable, Mapping, Sequence +from copy import deepcopy from typing import IO, Any, Literal, Optional, ParamSpec, TypeVar, Union, cast, overload from configs import dify_config @@ -36,11 +37,13 @@ class ModelInstance: Model instance class. """ - def __init__(self, provider_model_bundle: ProviderModelBundle, model: str): + def __init__(self, provider_model_bundle: ProviderModelBundle, model: str, credentials: dict | None = None) -> None: self.provider_model_bundle = provider_model_bundle self.model_name = model self.provider = provider_model_bundle.configuration.provider.provider - self.credentials = self._fetch_credentials_from_bundle(provider_model_bundle, model) + if credentials is None: + credentials = self._fetch_credentials_from_bundle(provider_model_bundle, model) + self.credentials = credentials # Runtime LLM invocation fields. self.parameters: Mapping[str, Any] = {} self.stop: Sequence[str] = () @@ -434,8 +437,30 @@ class ModelInstance: class ModelManager: - def __init__(self, provider_manager: ProviderManager): + """Resolves :class:`ModelInstance` objects for a tenant and provider. + + When ``enable_credentials_cache`` is ``True``, resolved credentials for each + ``(tenant_id, provider, model_type, model)`` are stored in + ``_credentials_cache`` and reused. That can return **stale** credentials after + API keys or provider settings change, so a manager constructed with + ``enable_credentials_cache=True`` should not be kept for the lifetime of a + process or shared across unrelated work. Prefer a new manager per request, + workflow run, or similar bounded scope. + + The default is ``enable_credentials_cache=False``; in that mode the internal + credential cache is not populated, and each ``get_model_instance`` call + loads credentials from the current provider configuration. + """ + + def __init__( + self, + provider_manager: ProviderManager, + *, + enable_credentials_cache: bool = False, + ) -> None: self._provider_manager = provider_manager + self._credentials_cache: dict[tuple[str, str, str, str], Any] = {} + self._enable_credentials_cache = enable_credentials_cache @classmethod def for_tenant(cls, tenant_id: str, user_id: str | None = None) -> "ModelManager": @@ -463,8 +488,19 @@ class ModelManager: tenant_id=tenant_id, provider=provider, model_type=model_type ) - model_instance = ModelInstance(provider_model_bundle, model) - return model_instance + cred_cache_key = (tenant_id, provider, model_type.value, model) + + if cred_cache_key in self._credentials_cache: + return ModelInstance( + provider_model_bundle, + model, + deepcopy(self._credentials_cache[cred_cache_key]), + ) + + ret = ModelInstance(provider_model_bundle, model) + if self._enable_credentials_cache: + self._credentials_cache[cred_cache_key] = deepcopy(ret.credentials) + return ret def get_default_provider_model_name(self, tenant_id: str, model_type: ModelType) -> tuple[str | None, str | None]: """ diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index 39ef31632e..0f9b851e87 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -70,12 +70,32 @@ class ProviderManager: Request-bound managers may carry caller identity in that runtime, and the resulting ``ProviderConfiguration`` objects must reuse it for downstream model-type and schema lookups. + + Configuration assembly is cached per manager instance so call chains that + share one request-scoped manager can reuse the same provider graph instead + of rebuilding it for every lookup. Call ``clear_configurations_cache()`` + when a long-lived manager needs to observe writes performed within the same + instance scope. """ + decoding_rsa_key: Any | None + decoding_cipher_rsa: Any | None + _model_runtime: ModelRuntime + _configurations_cache: dict[str, ProviderConfigurations] + def __init__(self, model_runtime: ModelRuntime): self.decoding_rsa_key = None self.decoding_cipher_rsa = None self._model_runtime = model_runtime + self._configurations_cache = {} + + def clear_configurations_cache(self, tenant_id: str | None = None) -> None: + """Drop assembled provider configurations cached on this manager instance.""" + if tenant_id is None: + self._configurations_cache.clear() + return + + self._configurations_cache.pop(tenant_id, None) def get_configurations(self, tenant_id: str) -> ProviderConfigurations: """ @@ -114,6 +134,10 @@ class ProviderManager: :param tenant_id: :return: """ + cached_configurations = self._configurations_cache.get(tenant_id) + if cached_configurations is not None: + return cached_configurations + # Get all provider records of the workspace provider_name_to_provider_records_dict = self._get_all_providers(tenant_id) @@ -273,6 +297,8 @@ class ProviderManager: provider_configurations[str(provider_id_entity)] = provider_configuration + self._configurations_cache[tenant_id] = provider_configurations + # Return the encapsulated object return provider_configurations diff --git a/api/core/rag/datasource/keyword/jieba/jieba.py b/api/core/rag/datasource/keyword/jieba/jieba.py index ed264878d3..392af351b6 100644 --- a/api/core/rag/datasource/keyword/jieba/jieba.py +++ b/api/core/rag/datasource/keyword/jieba/jieba.py @@ -139,8 +139,10 @@ class Jieba(BaseKeyword): "__data__": {"index_id": self.dataset.id, "summary": None, "table": keyword_table}, } dataset_keyword_table = self.dataset.dataset_keyword_table - keyword_data_source_type = dataset_keyword_table.data_source_type + keyword_data_source_type = dataset_keyword_table.data_source_type if dataset_keyword_table else "file" if keyword_data_source_type == "database": + if dataset_keyword_table is None: + return dataset_keyword_table.keyword_table = dumps_with_sets(keyword_table_dict) db.session.commit() else: @@ -154,7 +156,8 @@ class Jieba(BaseKeyword): if dataset_keyword_table: keyword_table_dict = dataset_keyword_table.keyword_table_dict if keyword_table_dict: - return dict(keyword_table_dict["__data__"]["table"]) + data: Any = keyword_table_dict["__data__"] + return dict(data["table"]) else: keyword_data_source_type = dify_config.KEYWORD_DATA_SOURCE_TYPE dataset_keyword_table = DatasetKeywordTable( diff --git a/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py b/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py index 84f35c25f8..2af8238cc4 100644 --- a/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py +++ b/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py @@ -1,4 +1,5 @@ import re +from collections.abc import Callable from operator import itemgetter from typing import cast @@ -80,12 +81,14 @@ class JiebaKeywordTableHandler: def extract_tags(self, sentence: str, top_k: int | None = 20, **kwargs): # Basic frequency-based keyword extraction as a fallback when TF-IDF is unavailable. - top_k = kwargs.pop("topK", top_k) + top_k = cast(int | None, kwargs.pop("topK", top_k)) + if top_k is None: + top_k = 20 cut = getattr(jieba, "cut", None) if self._lcut: tokens = self._lcut(sentence) elif callable(cut): - tokens = list(cut(sentence)) + tokens = list(cast(Callable[[str], list[str]], cut)(sentence)) else: tokens = re.findall(r"\w+", sentence) @@ -106,9 +109,9 @@ class JiebaKeywordTableHandler: """Extract keywords with JIEBA tfidf.""" keywords = self._tfidf.extract_tags( sentence=text, - topK=max_keywords_per_chunk, + topK=max_keywords_per_chunk or 10, ) - # jieba.analyse.extract_tags returns list[Any] when withFlag is False by default. + # jieba.analyse.extract_tags returns an untyped list when withFlag is False by default. keywords = cast(list[str], keywords) return set(self._expand_tokens_with_subtokens(set(keywords))) diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index f978e072f3..f17f7cd330 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -158,7 +158,7 @@ class RetrievalService: ) if futures: - for future in concurrent.futures.as_completed(futures, timeout=3600): + for _ in concurrent.futures.as_completed(futures, timeout=3600): if exceptions: for f in futures: f.cancel() diff --git a/api/core/rag/extractor/extract_processor.py b/api/core/rag/extractor/extract_processor.py index fbd2a6db93..b679edab36 100644 --- a/api/core/rag/extractor/extract_processor.py +++ b/api/core/rag/extractor/extract_processor.py @@ -94,6 +94,7 @@ class ExtractProcessor: cls, extract_setting: ExtractSetting, is_automatic: bool = False, file_path: str | None = None ) -> list[Document]: if extract_setting.datasource_type == DatasourceType.FILE: + upload_file = extract_setting.upload_file with tempfile.TemporaryDirectory() as temp_dir: upload_file = extract_setting.upload_file if not file_path: @@ -104,6 +105,7 @@ class ExtractProcessor: storage.download(upload_file.key, file_path) input_file = Path(file_path) file_extension = input_file.suffix.lower() + assert upload_file is not None, "upload_file is required" etl_type = dify_config.ETL_TYPE extractor: BaseExtractor | None = None if etl_type == "Unstructured": diff --git a/api/core/rag/retrieval/router/multi_dataset_function_call_router.py b/api/core/rag/retrieval/router/multi_dataset_function_call_router.py index dce7b6226c..66096a0a95 100644 --- a/api/core/rag/retrieval/router/multi_dataset_function_call_router.py +++ b/api/core/rag/retrieval/router/multi_dataset_function_call_router.py @@ -29,10 +29,10 @@ class FunctionCallMultiDatasetRouter: SystemPromptMessage(content="You are a helpful AI assistant."), UserPromptMessage(content=query), ] - result: LLMResult = model_instance.invoke_llm( + result: LLMResult = model_instance.invoke_llm( # pyright: ignore[reportCallIssue, reportArgumentType] prompt_messages=prompt_messages, tools=dataset_tools, - stream=False, + stream=False, # pyright: ignore[reportArgumentType] model_parameters={"temperature": 0.2, "top_p": 0.3, "max_tokens": 1500}, ) usage = result.usage or LLMUsage.empty_usage() diff --git a/api/core/rag/splitter/fixed_text_splitter.py b/api/core/rag/splitter/fixed_text_splitter.py index 66b375dad1..52c9a02f97 100644 --- a/api/core/rag/splitter/fixed_text_splitter.py +++ b/api/core/rag/splitter/fixed_text_splitter.py @@ -4,7 +4,7 @@ from __future__ import annotations import codecs import re -from collections.abc import Collection +from collections.abc import Set as AbstractSet from typing import Any, Literal from core.model_manager import ModelInstance @@ -21,8 +21,8 @@ class EnhanceRecursiveCharacterTextSplitter(RecursiveCharacterTextSplitter): def from_encoder[T: EnhanceRecursiveCharacterTextSplitter]( cls: type[T], embedding_model_instance: ModelInstance | None, - allowed_special: Literal["all"] | set[str] = set(), - disallowed_special: Literal["all"] | Collection[str] = "all", + allowed_special: Literal["all"] | AbstractSet[str] = frozenset(), + disallowed_special: Literal["all"] | AbstractSet[str] = "all", **kwargs: Any, ) -> T: def _token_encoder(texts: list[str]) -> list[int]: @@ -40,6 +40,7 @@ class EnhanceRecursiveCharacterTextSplitter(RecursiveCharacterTextSplitter): return [len(text) for text in texts] + _ = _token_encoder # kept for future token-length wiring return cls(length_function=_character_encoder, **kwargs) diff --git a/api/core/rag/splitter/text_splitter.py b/api/core/rag/splitter/text_splitter.py index 7f2117e2dd..a8d9013fbc 100644 --- a/api/core/rag/splitter/text_splitter.py +++ b/api/core/rag/splitter/text_splitter.py @@ -4,7 +4,8 @@ import copy import logging import re from abc import ABC, abstractmethod -from collections.abc import Callable, Collection, Iterable, Sequence, Set +from collections.abc import Callable, Iterable, Sequence +from collections.abc import Set as AbstractSet from dataclasses import dataclass from typing import Any, Literal @@ -187,8 +188,8 @@ class TokenTextSplitter(TextSplitter): self, encoding_name: str = "gpt2", model_name: str | None = None, - allowed_special: Literal["all"] | Set[str] = set(), - disallowed_special: Literal["all"] | Collection[str] = "all", + allowed_special: Literal["all"] | AbstractSet[str] = frozenset(), + disallowed_special: Literal["all"] | AbstractSet[str] = "all", **kwargs: Any, ): """Create a new TextSplitter.""" @@ -207,8 +208,8 @@ class TokenTextSplitter(TextSplitter): else: enc = tiktoken.get_encoding(encoding_name) self._tokenizer = enc - self._allowed_special = allowed_special - self._disallowed_special = disallowed_special + self._allowed_special: Literal["all"] | AbstractSet[str] = allowed_special + self._disallowed_special: Literal["all"] | AbstractSet[str] = disallowed_special def split_text(self, text: str) -> list[str]: def _encode(_text: str) -> list[int]: diff --git a/api/core/tools/utils/system_oauth_encryption.py b/api/core/tools/utils/system_encryption.py similarity index 57% rename from api/core/tools/utils/system_oauth_encryption.py rename to api/core/tools/utils/system_encryption.py index 6b7007842d..ca7e6a13fe 100644 --- a/api/core/tools/utils/system_oauth_encryption.py +++ b/api/core/tools/utils/system_encryption.py @@ -14,23 +14,23 @@ from configs import dify_config logger = logging.getLogger(__name__) -class OAuthEncryptionError(Exception): - """OAuth encryption/decryption specific error""" +class EncryptionError(Exception): + """Encryption/decryption specific error""" pass -class SystemOAuthEncrypter: +class SystemEncrypter: """ - A simple OAuth parameters encrypter using AES-CBC encryption. + A simple parameters encrypter using AES-CBC encryption. - This class provides methods to encrypt and decrypt OAuth parameters + This class provides methods to encrypt and decrypt parameters using AES-CBC mode with a key derived from the application's SECRET_KEY. """ def __init__(self, secret_key: str | None = None): """ - Initialize the OAuth encrypter. + Initialize the encrypter. Args: secret_key: Optional secret key. If not provided, uses dify_config.SECRET_KEY @@ -43,19 +43,19 @@ class SystemOAuthEncrypter: # Generate a fixed 256-bit key using SHA-256 self.key = hashlib.sha256(secret_key.encode()).digest() - def encrypt_oauth_params(self, oauth_params: Mapping[str, Any]) -> str: + def encrypt_params(self, params: Mapping[str, Any]) -> str: """ - Encrypt OAuth parameters. + Encrypt parameters. Args: - oauth_params: OAuth parameters dictionary, e.g., {"client_id": "xxx", "client_secret": "xxx"} + params: Parameters dictionary, e.g., {"client_id": "xxx", "client_secret": "xxx"} Returns: Base64-encoded encrypted string Raises: - OAuthEncryptionError: If encryption fails - ValueError: If oauth_params is invalid + EncryptionError: If encryption fails + ValueError: If params is invalid """ try: @@ -66,7 +66,7 @@ class SystemOAuthEncrypter: cipher = AES.new(self.key, AES.MODE_CBC, iv) # Encrypt data - padded_data = pad(TypeAdapter(dict).dump_json(dict(oauth_params)), AES.block_size) + padded_data = pad(TypeAdapter(dict).dump_json(dict(params)), AES.block_size) encrypted_data = cipher.encrypt(padded_data) # Combine IV and encrypted data @@ -76,20 +76,20 @@ class SystemOAuthEncrypter: return base64.b64encode(combined).decode() except Exception as e: - raise OAuthEncryptionError(f"Encryption failed: {str(e)}") from e + raise EncryptionError(f"Encryption failed: {str(e)}") from e - def decrypt_oauth_params(self, encrypted_data: str) -> Mapping[str, Any]: + def decrypt_params(self, encrypted_data: str) -> Mapping[str, Any]: """ - Decrypt OAuth parameters. + Decrypt parameters. Args: encrypted_data: Base64-encoded encrypted string Returns: - Decrypted OAuth parameters dictionary + Decrypted parameters dictionary Raises: - OAuthEncryptionError: If decryption fails + EncryptionError: If decryption fails ValueError: If encrypted_data is invalid """ if not isinstance(encrypted_data, str): @@ -118,70 +118,70 @@ class SystemOAuthEncrypter: unpadded_data = unpad(decrypted_data, AES.block_size) # Parse JSON - oauth_params: Mapping[str, Any] = TypeAdapter(Mapping[str, Any]).validate_json(unpadded_data) + params: Mapping[str, Any] = TypeAdapter(Mapping[str, Any]).validate_json(unpadded_data) - if not isinstance(oauth_params, dict): + if not isinstance(params, dict): raise ValueError("Decrypted data is not a valid dictionary") - return oauth_params + return params except Exception as e: - raise OAuthEncryptionError(f"Decryption failed: {str(e)}") from e + raise EncryptionError(f"Decryption failed: {str(e)}") from e # Factory function for creating encrypter instances -def create_system_oauth_encrypter(secret_key: str | None = None) -> SystemOAuthEncrypter: +def create_system_encrypter(secret_key: str | None = None) -> SystemEncrypter: """ - Create an OAuth encrypter instance. + Create an encrypter instance. Args: secret_key: Optional secret key. If not provided, uses dify_config.SECRET_KEY Returns: - SystemOAuthEncrypter instance + SystemEncrypter instance """ - return SystemOAuthEncrypter(secret_key=secret_key) + return SystemEncrypter(secret_key=secret_key) # Global encrypter instance (for backward compatibility) -_oauth_encrypter: SystemOAuthEncrypter | None = None +_encrypter: SystemEncrypter | None = None -def get_system_oauth_encrypter() -> SystemOAuthEncrypter: +def get_system_encrypter() -> SystemEncrypter: """ - Get the global OAuth encrypter instance. + Get the global encrypter instance. Returns: - SystemOAuthEncrypter instance + SystemEncrypter instance """ - global _oauth_encrypter - if _oauth_encrypter is None: - _oauth_encrypter = SystemOAuthEncrypter() - return _oauth_encrypter + global _encrypter + if _encrypter is None: + _encrypter = SystemEncrypter() + return _encrypter # Convenience functions for backward compatibility -def encrypt_system_oauth_params(oauth_params: Mapping[str, Any]) -> str: +def encrypt_system_params(params: Mapping[str, Any]) -> str: """ - Encrypt OAuth parameters using the global encrypter. + Encrypt parameters using the global encrypter. Args: - oauth_params: OAuth parameters dictionary + params: Parameters dictionary Returns: Base64-encoded encrypted string """ - return get_system_oauth_encrypter().encrypt_oauth_params(oauth_params) + return get_system_encrypter().encrypt_params(params) -def decrypt_system_oauth_params(encrypted_data: str) -> Mapping[str, Any]: +def decrypt_system_params(encrypted_data: str) -> Mapping[str, Any]: """ - Decrypt OAuth parameters using the global encrypter. + Decrypt parameters using the global encrypter. Args: encrypted_data: Base64-encoded encrypted string Returns: - Decrypted OAuth parameters dictionary + Decrypted parameters dictionary """ - return get_system_oauth_encrypter().decrypt_oauth_params(encrypted_data) + return get_system_encrypter().decrypt_params(encrypted_data) diff --git a/api/core/tools/utils/web_reader_tool.py b/api/core/tools/utils/web_reader_tool.py index ed3ed3e0de..94a2c0427b 100644 --- a/api/core/tools/utils/web_reader_tool.py +++ b/api/core/tools/utils/web_reader_tool.py @@ -105,7 +105,7 @@ class Article: def extract_using_readabilipy(html: str): - json_article: dict[str, Any] = simple_json_from_html_string(html, use_readability=True) + json_article: dict[str, Any] = simple_json_from_html_string(html, use_readability=False) article = Article( title=json_article.get("title") or "", author=json_article.get("byline") or "", diff --git a/api/core/workflow/human_input_forms.py b/api/core/workflow/human_input_forms.py index f124b321d4..b02f69ec33 100644 --- a/api/core/workflow/human_input_forms.py +++ b/api/core/workflow/human_input_forms.py @@ -12,20 +12,16 @@ from collections.abc import Sequence from sqlalchemy import select from sqlalchemy.orm import Session +from core.workflow.human_input_policy import HumanInputSurface, get_preferred_form_token from extensions.ext_database import db from models.human_input import HumanInputFormRecipient, RecipientType -_FORM_TOKEN_PRIORITY = { - RecipientType.BACKSTAGE: 0, - RecipientType.CONSOLE: 1, - RecipientType.STANDALONE_WEB_APP: 2, -} - def load_form_tokens_by_form_id( form_ids: Sequence[str], *, session: Session | None = None, + surface: HumanInputSurface | None = None, ) -> dict[str, str]: """Load the preferred access token for each human input form.""" unique_form_ids = list(dict.fromkeys(form_ids)) @@ -33,23 +29,43 @@ def load_form_tokens_by_form_id( return {} if session is not None: - return _load_form_tokens_by_form_id(session, unique_form_ids) + return _load_form_tokens_by_form_id(session, unique_form_ids, surface=surface) with Session(bind=db.engine, expire_on_commit=False) as new_session: - return _load_form_tokens_by_form_id(new_session, unique_form_ids) + return _load_form_tokens_by_form_id(new_session, unique_form_ids, surface=surface) -def _load_form_tokens_by_form_id(session: Session, form_ids: Sequence[str]) -> dict[str, str]: - tokens_by_form_id: dict[str, tuple[int, str]] = {} +def _load_form_tokens_by_form_id( + session: Session, + form_ids: Sequence[str], + *, + surface: HumanInputSurface | None = None, +) -> dict[str, str]: + recipients_by_form_id: dict[str, list[tuple[RecipientType, str]]] = {} stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids)) for recipient in session.scalars(stmt): - priority = _FORM_TOKEN_PRIORITY.get(recipient.recipient_type) - if priority is None or not recipient.access_token: + if not recipient.access_token: continue + recipients_by_form_id.setdefault(recipient.form_id, []).append( + (recipient.recipient_type, recipient.access_token) + ) - candidate = (priority, recipient.access_token) - current = tokens_by_form_id.get(recipient.form_id) - if current is None or candidate[0] < current[0]: - tokens_by_form_id[recipient.form_id] = candidate + tokens_by_form_id: dict[str, str] = {} + for form_id, recipients in recipients_by_form_id.items(): + token = _get_surface_form_token(recipients, surface=surface) + if token is not None: + tokens_by_form_id[form_id] = token + return tokens_by_form_id - return {form_id: token for form_id, (_, token) in tokens_by_form_id.items()} + +def _get_surface_form_token( + recipients: Sequence[tuple[RecipientType, str]], + *, + surface: HumanInputSurface | None, +) -> str | None: + if surface == HumanInputSurface.SERVICE_API: + for recipient_type, token in recipients: + if recipient_type == RecipientType.STANDALONE_WEB_APP and token: + return token + + return get_preferred_form_token(recipients) diff --git a/api/core/workflow/human_input_policy.py b/api/core/workflow/human_input_policy.py new file mode 100644 index 0000000000..798eb8723f --- /dev/null +++ b/api/core/workflow/human_input_policy.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from enum import StrEnum +from typing import Any + +from graphon.entities.pause_reason import PauseReasonType +from models.human_input import RecipientType + + +class HumanInputSurface(StrEnum): + SERVICE_API = "service_api" + CONSOLE = "console" + + +# Service API is intentionally narrower than other surfaces: app-token callers +# should only be able to act on end-user web forms, not internal console flows. +_ALLOWED_RECIPIENT_TYPES_BY_SURFACE: dict[HumanInputSurface, frozenset[RecipientType]] = { + HumanInputSurface.SERVICE_API: frozenset({RecipientType.STANDALONE_WEB_APP}), + HumanInputSurface.CONSOLE: frozenset({RecipientType.CONSOLE, RecipientType.BACKSTAGE}), +} + +# A single HITL form can have multiple recipient records; this shared priority +# keeps every API surface consistent about which resume token to expose. +_RECIPIENT_TOKEN_PRIORITY: dict[RecipientType, int] = { + RecipientType.BACKSTAGE: 0, + RecipientType.CONSOLE: 1, + RecipientType.STANDALONE_WEB_APP: 2, +} + + +def is_recipient_type_allowed_for_surface( + recipient_type: RecipientType | None, + surface: HumanInputSurface, +) -> bool: + if recipient_type is None: + return False + return recipient_type in _ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface] + + +def get_preferred_form_token( + recipients: Sequence[tuple[RecipientType, str]], +) -> str | None: + chosen_token: str | None = None + chosen_priority: int | None = None + for recipient_type, token in recipients: + priority = _RECIPIENT_TOKEN_PRIORITY.get(recipient_type) + if priority is None or not token: + continue + if chosen_priority is None or priority < chosen_priority: + chosen_priority = priority + chosen_token = token + return chosen_token + + +def enrich_human_input_pause_reasons( + reasons: Sequence[Mapping[str, Any]], + *, + form_tokens_by_form_id: Mapping[str, str], + expiration_times_by_form_id: Mapping[str, int], +) -> list[dict[str, Any]]: + enriched: list[dict[str, Any]] = [] + for reason in reasons: + updated = dict(reason) + if updated.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED: + form_id = updated.get("form_id") + if isinstance(form_id, str): + updated["form_token"] = form_tokens_by_form_id.get(form_id) + expiration_time = expiration_times_by_form_id.get(form_id) + if expiration_time is not None: + updated["expiration_time"] = expiration_time + enriched.append(updated) + return enriched diff --git a/api/dev/generate_swagger_specs.py b/api/dev/generate_swagger_specs.py new file mode 100644 index 0000000000..7e9688bfb4 --- /dev/null +++ b/api/dev/generate_swagger_specs.py @@ -0,0 +1,172 @@ +"""Generate Flask-RESTX Swagger 2.0 specs without booting the full backend. + +This helper intentionally avoids `app_factory.create_app()`. The normal backend +startup eagerly initializes database, Redis, Celery, and storage extensions, +which is unnecessary when the goal is only to serialize the Flask-RESTX +`/swagger.json` documents. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +from dataclasses import dataclass +from pathlib import Path + +from flask import Flask +from flask_restx.swagger import Swagger + +logger = logging.getLogger(__name__) + +API_ROOT = Path(__file__).resolve().parents[1] +if str(API_ROOT) not in sys.path: + sys.path.insert(0, str(API_ROOT)) + + +@dataclass(frozen=True) +class SpecTarget: + route: str + filename: str + + +SPEC_TARGETS: tuple[SpecTarget, ...] = ( + SpecTarget(route="/console/api/swagger.json", filename="console-swagger.json"), + SpecTarget(route="/api/swagger.json", filename="web-swagger.json"), + SpecTarget(route="/v1/swagger.json", filename="service-swagger.json"), +) + +_ORIGINAL_REGISTER_MODEL = Swagger.register_model +_ORIGINAL_REGISTER_FIELD = Swagger.register_field + + +def _apply_runtime_defaults() -> None: + """Force the small config surface required for Swagger generation.""" + + os.environ.setdefault("SECRET_KEY", "spec-export") + os.environ.setdefault("STORAGE_TYPE", "local") + os.environ.setdefault("STORAGE_LOCAL_PATH", "/tmp/dify-storage") + os.environ.setdefault("SWAGGER_UI_ENABLED", "true") + + from configs import dify_config + + dify_config.SECRET_KEY = os.environ["SECRET_KEY"] + dify_config.STORAGE_TYPE = "local" + dify_config.STORAGE_LOCAL_PATH = os.environ["STORAGE_LOCAL_PATH"] + dify_config.SWAGGER_UI_ENABLED = os.environ["SWAGGER_UI_ENABLED"].lower() == "true" + + +def _patch_swagger_for_inline_nested_dicts() -> None: + """Teach Flask-RESTX Swagger generation to tolerate inline nested field maps. + + Some existing controllers use `fields.Nested({...})` with a raw field mapping + instead of a named `api.model(...)`. Flask-RESTX crashes on those anonymous + dicts during schema registration, so this helper upgrades them into temporary + named models at export time. + """ + + if getattr(Swagger, "_dify_inline_nested_dict_patch", False): + return + + def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object: + anonymous_models = getattr(self, "_anonymous_inline_models", None) + if anonymous_models is None: + anonymous_models = {} + self._anonymous_inline_models = anonymous_models + + anonymous_name = anonymous_models.get(id(nested_fields)) + if anonymous_name is None: + anonymous_name = f"_AnonymousInlineModel{len(anonymous_models) + 1}" + anonymous_models[id(nested_fields)] = anonymous_name + self.api.model(anonymous_name, nested_fields) + + return self.api.models[anonymous_name] + + def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]: + if isinstance(model, dict): + model = get_or_create_inline_model(self, model) + + return _ORIGINAL_REGISTER_MODEL(self, model) + + def register_field_with_inline_dict_support(self: Swagger, field: object) -> None: + nested = getattr(field, "nested", None) + if isinstance(nested, dict): + field.model = get_or_create_inline_model(self, nested) # type: ignore + + _ORIGINAL_REGISTER_FIELD(self, field) + + Swagger.register_model = register_model_with_inline_dict_support + Swagger.register_field = register_field_with_inline_dict_support + Swagger._dify_inline_nested_dict_patch = True + + +def create_spec_app() -> Flask: + """Build a minimal Flask app that only mounts the Swagger-producing blueprints.""" + + _apply_runtime_defaults() + _patch_swagger_for_inline_nested_dicts() + + app = Flask(__name__) + + from controllers.console import bp as console_bp + from controllers.service_api import bp as service_api_bp + from controllers.web import bp as web_bp + + app.register_blueprint(console_bp) + app.register_blueprint(web_bp) + app.register_blueprint(service_api_bp) + + return app + + +def generate_specs(output_dir: Path) -> list[Path]: + """Write all Swagger specs to `output_dir` and return the written paths.""" + + output_dir.mkdir(parents=True, exist_ok=True) + + app = create_spec_app() + client = app.test_client() + + written_paths: list[Path] = [] + for target in SPEC_TARGETS: + response = client.get(target.route) + if response.status_code != 200: + raise RuntimeError(f"failed to fetch {target.route}: {response.status_code}") + + payload = response.get_json() + if not isinstance(payload, dict): + raise RuntimeError(f"unexpected response payload for {target.route}") + + output_path = output_dir / target.filename + output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + written_paths.append(output_path) + + return written_paths + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "-o", + "--output-dir", + type=Path, + default=Path("openapi"), + help="Directory where the Swagger JSON files will be written.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + written_paths = generate_specs(args.output_dir) + + for path in written_paths: + logger.debug(path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/api/libs/flask_utils.py b/api/libs/flask_utils.py index 52fc787c79..838af2bf32 100644 --- a/api/libs/flask_utils.py +++ b/api/libs/flask_utils.py @@ -1,5 +1,5 @@ import contextvars -from collections.abc import Iterator +from collections.abc import Generator # Changed from Iterator from contextlib import contextmanager from typing import TYPE_CHECKING @@ -13,7 +13,7 @@ if TYPE_CHECKING: def preserve_flask_contexts( flask_app: Flask, context_vars: contextvars.Context, -) -> Iterator[None]: +) -> Generator[None, None, None]: # Changed from Iterator[None] """ A context manager that handles: 1. flask-login's UserProxy copy diff --git a/api/models/comment.py b/api/models/comment.py index 4104861d10..277287132d 100644 --- a/api/models/comment.py +++ b/api/models/comment.py @@ -42,8 +42,7 @@ class WorkflowComment(Base): Index("workflow_comments_created_at_idx", "created_at"), ) - id: Mapped[str] = mapped_column( - StringUUID, server_default=sa.text("uuidv7()")) + id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) position_x: Mapped[float] = mapped_column(sa.Float) @@ -152,8 +151,7 @@ class WorkflowCommentReply(Base): Index("comment_replies_created_at_idx", "created_at"), ) - id: Mapped[str] = mapped_column( - StringUUID, server_default=sa.text("uuidv7()")) + id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) comment_id: Mapped[str] = mapped_column( StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False ) @@ -200,8 +198,7 @@ class WorkflowCommentMention(Base): Index("comment_mentions_user_idx", "mentioned_user_id"), ) - id: Mapped[str] = mapped_column( - StringUUID, server_default=sa.text("uuidv7()")) + id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) comment_id: Mapped[str] = mapped_column( StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False ) diff --git a/api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_sql.py b/api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_sql.py index b2908ebdae..11398efb58 100644 --- a/api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_sql.py +++ b/api/providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_sql.py @@ -1,6 +1,6 @@ import json import uuid -from collections.abc import Iterator +from collections.abc import Generator # Added Generator from contextlib import contextmanager from typing import Any @@ -75,7 +75,7 @@ class AnalyticdbVectorBySql: ) @contextmanager - def _get_cursor(self) -> Iterator[Any]: + def _get_cursor(self) -> Generator[Any, None, None]: # Changed from Iterator[Any] assert self.pool is not None, "Connection pool is not initialized" conn = self.pool.getconn() cur = conn.cursor() diff --git a/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py b/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py index 815ac30c0b..bab176e285 100644 --- a/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py +++ b/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py @@ -59,7 +59,7 @@ class CouchbaseVector(BaseVector): auth = PasswordAuthenticator(config.user, config.password) options = ClusterOptions(auth) - self._cluster = Cluster(config.connection_string, options) + self._cluster = Cluster(config.connection_string, options) # pyright: ignore[reportArgumentType] self._bucket = self._cluster.bucket(config.bucket_name) self._scope = self._bucket.scope(config.scope_name) self._bucket_name = config.bucket_name @@ -306,7 +306,7 @@ class CouchbaseVector(BaseVector): def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]: top_k = kwargs.get("top_k", 4) try: - CBrequest = search.SearchRequest.create(search.QueryStringQuery("text:" + query)) + CBrequest = search.SearchRequest.create(search.QueryStringQuery("text:" + query)) # pyright: ignore[reportCallIssue] search_iter = self._scope.search( self._collection_name + "_search", CBrequest, SearchOptions(limit=top_k, fields=["*"]) ) diff --git a/api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/milvus_vector.py b/api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/milvus_vector.py index 46f3224a95..823b877707 100644 --- a/api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/milvus_vector.py +++ b/api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/milvus_vector.py @@ -1,6 +1,6 @@ import json import logging -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from packaging import version from pydantic import BaseModel, model_validator @@ -92,7 +92,7 @@ class MilvusVector(BaseVector): def _load_collection_fields(self, fields: list[str] | None = None): if fields is None: # Load collection fields from remote server - collection_info = self._client.describe_collection(self._collection_name) + collection_info = cast(dict[str, Any], self._client.describe_collection(self._collection_name)) fields = [field["name"] for field in collection_info["fields"]] # Since primary field is auto-id, no need to track it self._fields = [f for f in fields if f != Field.PRIMARY_KEY] @@ -106,7 +106,8 @@ class MilvusVector(BaseVector): return False try: - milvus_version = self._client.get_server_version() + milvus_version_raw = self._client.get_server_version() + milvus_version = milvus_version_raw if isinstance(milvus_version_raw, str) else str(milvus_version_raw) # Check if it's Zilliz Cloud - it supports full-text search with Milvus 2.5 compatibility if "Zilliz Cloud" in milvus_version: return True diff --git a/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py b/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py index 70377c82c8..5d9ab38529 100644 --- a/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py +++ b/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py @@ -3,7 +3,7 @@ import json import logging import re import uuid -from typing import Any +from typing import Any, TypedDict import jieba.posseg as pseg # type: ignore import numpy @@ -25,6 +25,18 @@ logger = logging.getLogger(__name__) oracledb.defaults.fetch_lobs = False +class _OraclePoolParams(TypedDict, total=False): + user: str + password: str + dsn: str + min: int + max: int + increment: int + config_dir: str | None + wallet_location: str | None + wallet_password: str | None + + class OracleVectorConfig(BaseModel): user: str password: str @@ -127,22 +139,18 @@ class OracleVector(BaseVector): return connection def _create_connection_pool(self, config: OracleVectorConfig): - pool_params = { - "user": config.user, - "password": config.password, - "dsn": config.dsn, - "min": 1, - "max": 5, - "increment": 1, - } + pool_params = _OraclePoolParams( + user=config.user, + password=config.password, + dsn=config.dsn, + min=1, + max=5, + increment=1, + ) if config.is_autonomous: - pool_params.update( - { - "config_dir": config.config_dir, - "wallet_location": config.wallet_location, - "wallet_password": config.wallet_password, - } - ) + pool_params["config_dir"] = config.config_dir + pool_params["wallet_location"] = config.wallet_location + pool_params["wallet_password"] = config.wallet_password return oracledb.create_pool(**pool_params) def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs): diff --git a/api/pyproject.toml b/api/pyproject.toml index c349979a7c..aa41093826 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -6,7 +6,7 @@ requires-python = "~=3.12.0" dependencies = [ # Legacy: mature and widely deployed "bleach>=6.3.0", - "boto3>=1.42.88", + "boto3>=1.42.91", "celery>=5.6.3", "croniter>=6.2.2", "flask-cors>=6.0.2", @@ -30,7 +30,7 @@ dependencies = [ "flask-migrate>=4.1.0,<5.0.0", "flask-orjson>=2.0.0,<3.0.0", "flask-restx>=1.3.2,<2.0.0", - "google-cloud-aiplatform>=1.147.0,<2.0.0", + "google-cloud-aiplatform>=1.148.1,<2.0.0", "httpx[socks]>=0.28.1,<1.0.0", "opentelemetry-distro>=0.62b0,<1.0.0", "opentelemetry-instrumentation-celery>=0.62b0,<1.0.0", @@ -46,7 +46,7 @@ dependencies = [ "fastopenapi[flask]~=0.7.0", "graphon~=0.2.2", "httpx-sse~=0.4.0", - "json-repair~=0.59.2", + "json-repair~=0.59.4", ] # Before adding new dependency, consider place it in # alphabet order (a-z) and suitable group. @@ -114,10 +114,10 @@ override-dependencies = [ dev = [ "coverage>=7.13.4", "dotenv-linter>=0.7.0", - "faker>=20.1.0", + "faker>=40.15.0", "lxml-stubs>=0.5.1", - "basedpyright>=1.39.0", - "ruff>=0.15.10", + "basedpyright>=1.39.3", + "ruff>=0.15.11", "pytest>=9.0.3", "pytest-benchmark>=5.2.3", "pytest-cov>=7.1.0", @@ -157,14 +157,14 @@ dev = [ "types-tensorflow>=2.18.0.20260408", "types-tqdm>=4.67.3.20260408", "types-ujson>=5.10.0", - "boto3-stubs>=1.42.88", + "boto3-stubs>=1.42.92", "types-jmespath>=1.1.0.20260408", - "hypothesis>=6.151.12", + "hypothesis>=6.152.1", "types_pyOpenSSL>=24.1.0", "types_cffi>=2.0.0.20260408", "types_setuptools>=82.0.0.20260408", "pandas-stubs>=3.0.0", - "scipy-stubs>=1.15.3.0", + "scipy-stubs>=1.17.1.4", "types-python-http-client>=3.3.7.20260408", "import-linter>=2.3", "types-redis>=4.6.0.20241004", @@ -173,8 +173,8 @@ dev = [ # "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved. "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", - "pyrefly>=0.61.1", - "xinference-client>=2.4.0", + "pyrefly>=0.62.0", + "xinference-client>=2.5.0", ] ############################################################ @@ -183,13 +183,13 @@ dev = [ ############################################################ storage = [ "azure-storage-blob>=12.28.0", - "bce-python-sdk>=0.9.69", + "bce-python-sdk>=0.9.70", "cos-python-sdk-v5>=1.9.41", "esdk-obs-python>=3.22.2", "google-cloud-storage>=3.10.1", "opendal>=0.46.0", "oss2>=2.19.1", - "supabase>=2.18.1", + "supabase>=2.28.3", "tos>=2.9.0", ] @@ -272,7 +272,7 @@ vdb-vastbase = ["dify-vdb-vastbase"] vdb-vikingdb = ["dify-vdb-vikingdb"] vdb-weaviate = ["dify-vdb-weaviate"] # Optional client used by some tests / integrations (not a vector backend plugin) -vdb-xinference = ["xinference-client>=2.4.0"] +vdb-xinference = ["xinference-client>=2.5.0"] trace-all = [ "dify-trace-aliyun", diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index b760696c5e..58fda7ead8 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -42,7 +42,7 @@ from libs.helper import convert_datetime_to_date from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.time_parser import get_time_threshold from models.enums import WorkflowRunTriggeredFrom -from models.human_input import HumanInputForm +from models.human_input import HumanInputForm, HumanInputFormRecipient from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict from repositories.entities.workflow_pause import WorkflowPauseEntity @@ -63,6 +63,7 @@ class _WorkflowRunError(Exception): def _build_human_input_required_reason( reason_model: WorkflowPauseReason, form_model: HumanInputForm | None, + recipients: Sequence[HumanInputFormRecipient] = (), ) -> HumanInputRequired: form_content = "" inputs = [] @@ -89,7 +90,7 @@ def _build_human_input_required_reason( resolved_default_values = dict(definition.default_values) node_title = definition.node_title or node_title - return HumanInputRequired( + reason = HumanInputRequired( form_id=form_id, form_content=form_content, inputs=inputs, @@ -98,6 +99,7 @@ def _build_human_input_required_reason( node_title=node_title, resolved_default_values=resolved_default_values, ) + return reason class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): @@ -804,12 +806,23 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): form_stmt = select(HumanInputForm).where(HumanInputForm.id.in_(form_ids)) for form in session.scalars(form_stmt).all(): form_models[form.id] = form + recipients_by_form_id: dict[str, list[HumanInputFormRecipient]] = {} + if form_ids: + recipient_stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids)) + for recipient in session.scalars(recipient_stmt).all(): + recipients_by_form_id.setdefault(recipient.form_id, []).append(recipient) pause_reasons: list[PauseReason] = [] for reason in pause_reason_models: if reason.type_ == PauseReasonType.HUMAN_INPUT_REQUIRED: form_model = form_models.get(reason.form_id) - pause_reasons.append(_build_human_input_required_reason(reason, form_model)) + pause_reasons.append( + _build_human_input_required_reason( + reason, + form_model, + recipients_by_form_id.get(reason.form_id, ()), + ) + ) else: pause_reasons.append(reason.to_entity()) return pause_reasons diff --git a/api/services/account_service.py b/api/services/account_service.py index ccc4a7c1fa..b6554a3de7 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -112,6 +112,14 @@ REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS) class AccountService: + # Phase-bound token metadata for the change-email flow. Tokens carry the + # current phase so that downstream endpoints can enforce proper progression + CHANGE_EMAIL_TOKEN_PHASE_KEY = "email_change_phase" + CHANGE_EMAIL_PHASE_OLD = "old_email" + CHANGE_EMAIL_PHASE_OLD_VERIFIED = "old_email_verified" + CHANGE_EMAIL_PHASE_NEW = "new_email" + CHANGE_EMAIL_PHASE_NEW_VERIFIED = "new_email_verified" + reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1) email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1) email_code_login_rate_limiter = RateLimiter( @@ -576,13 +584,20 @@ class AccountService: raise ValueError("Email must be provided.") if not phase: raise ValueError("phase must be provided.") + if phase not in (cls.CHANGE_EMAIL_PHASE_OLD, cls.CHANGE_EMAIL_PHASE_NEW): + raise ValueError("phase must be one of old_email or new_email.") if cls.change_email_rate_limiter.is_rate_limited(account_email): from controllers.console.auth.error import EmailChangeRateLimitExceededError raise EmailChangeRateLimitExceededError(int(cls.change_email_rate_limiter.time_window / 60)) - code, token = cls.generate_change_email_token(account_email, account, old_email=old_email) + code, token = cls.generate_change_email_token( + account_email, + account, + old_email=old_email, + additional_data={cls.CHANGE_EMAIL_TOKEN_PHASE_KEY: phase}, + ) send_change_mail_task.delay( language=language, diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 2c9d815b64..d6c01e9dcc 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -164,6 +164,7 @@ class AppGenerateService: invoke_from=invoke_from, streaming=True, call_depth=0, + workflow_run_id=str(uuid.uuid4()), ) payload_json = payload.model_dump_json() @@ -185,6 +186,10 @@ class AppGenerateService: else: # Blocking mode: run synchronously and return JSON instead of SSE # Keep behaviour consistent with WORKFLOW blocking branch. + pause_config = PauseStateLayerConfig( + session_factory=session_factory.get_session_maker(), + state_owner_user_id=workflow.created_by, + ) advanced_generator = AdvancedChatAppGenerator() return rate_limit.generate( advanced_generator.convert_to_event_stream( @@ -196,6 +201,7 @@ class AppGenerateService: invoke_from=invoke_from, workflow_run_id=str(uuid.uuid4()), streaming=False, + pause_state_config=pause_config, ) ), request_id=request_id, diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index 5040fcc7e3..bd7758f1c0 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -5,6 +5,7 @@ import uuid from datetime import datetime from typing import TYPE_CHECKING +from cachetools.func import ttl_cache from pydantic import BaseModel, ConfigDict, Field, model_validator from configs import dify_config @@ -99,6 +100,7 @@ def try_join_default_workspace(account_id: str) -> None: class EnterpriseService: @classmethod + @ttl_cache(ttl=5) def get_info(cls): return EnterpriseRequest.send_request("GET", "/info") diff --git a/api/services/feature_service.py b/api/services/feature_service.py index a6b02630bf..9477c28bf3 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -177,6 +177,7 @@ class SystemFeatureModel(BaseModel): enable_change_email: bool = True plugin_manager: PluginManagerModel = PluginManagerModel() trial_models: list[str] = [] + enable_creators_platform: bool = False enable_trial_app: bool = False enable_explore_banner: bool = False @@ -241,6 +242,9 @@ class FeatureService: if dify_config.MARKETPLACE_ENABLED: system_features.enable_marketplace = True + if dify_config.CREATORS_PLATFORM_FEATURES_ENABLED: + system_features.enable_creators_platform = True + return system_features @classmethod diff --git a/api/services/file_service.py b/api/services/file_service.py index 79a935de4b..aab75eff2c 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -2,7 +2,7 @@ import base64 import hashlib import os import uuid -from collections.abc import Iterator, Sequence +from collections.abc import Generator, Sequence # Changed Iterator to Generator from contextlib import contextmanager, suppress from tempfile import NamedTemporaryFile from typing import Literal @@ -324,7 +324,7 @@ class FileService: def build_upload_files_zip_tempfile( *, upload_files: Sequence[UploadFile], - ) -> Iterator[str]: + ) -> Generator[str, None, None]: # Changed from Iterator[str] """ Build a ZIP from `UploadFile`s and yield a tempfile path. diff --git a/api/services/hit_testing_service.py b/api/services/hit_testing_service.py index ca84b2a3d8..2e5987dd28 100644 --- a/api/services/hit_testing_service.py +++ b/api/services/hit_testing_service.py @@ -1,10 +1,10 @@ import json import logging import time -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from core.app.app_config.entities import ModelConfig -from core.rag.datasource.retrieval_service import RetrievalService +from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService from core.rag.index_processor.constant.query_type import QueryType from core.rag.models.document import Document from core.rag.retrieval.dataset_retrieval import DatasetRetrieval @@ -36,6 +36,10 @@ default_retrieval_model = { } +class HitTestingRetrievalModelDict(DefaultRetrievalModelDict, total=False): + metadata_filtering_conditions: dict[str, Any] + + class HitTestingService: @classmethod def retrieve( @@ -51,17 +55,18 @@ class HitTestingService: start = time.perf_counter() # get retrieval model , if the model is not setting , using default - if not retrieval_model: - retrieval_model = dataset.retrieval_model or default_retrieval_model - assert isinstance(retrieval_model, dict) + resolved_retrieval_model = cast( + HitTestingRetrievalModelDict, + retrieval_model or dataset.retrieval_model or default_retrieval_model, + ) document_ids_filter = None - metadata_filtering_conditions = retrieval_model.get("metadata_filtering_conditions", {}) - if metadata_filtering_conditions and query: + metadata_filtering_conditions_raw = resolved_retrieval_model.get("metadata_filtering_conditions", {}) + if metadata_filtering_conditions_raw and query: dataset_retrieval = DatasetRetrieval() from core.rag.entities import MetadataFilteringCondition - metadata_filtering_conditions = MetadataFilteringCondition.model_validate(metadata_filtering_conditions) + metadata_filtering_conditions = MetadataFilteringCondition.model_validate(metadata_filtering_conditions_raw) metadata_filter_document_ids, metadata_condition = dataset_retrieval.get_metadata_filter_condition( dataset_ids=[dataset.id], @@ -78,19 +83,21 @@ class HitTestingService: if metadata_condition and not document_ids_filter: return cls.compact_retrieve_response(query, []) all_documents = RetrievalService.retrieve( - retrieval_method=RetrievalMethod(retrieval_model.get("search_method", RetrievalMethod.SEMANTIC_SEARCH)), + retrieval_method=RetrievalMethod( + resolved_retrieval_model.get("search_method", RetrievalMethod.SEMANTIC_SEARCH) + ), dataset_id=dataset.id, query=query, attachment_ids=attachment_ids, - top_k=retrieval_model.get("top_k", 4), - score_threshold=retrieval_model.get("score_threshold", 0.0) - if retrieval_model["score_threshold_enabled"] + top_k=resolved_retrieval_model.get("top_k", 4), + score_threshold=resolved_retrieval_model.get("score_threshold", 0.0) + if resolved_retrieval_model["score_threshold_enabled"] else 0.0, - reranking_model=retrieval_model.get("reranking_model", None) - if retrieval_model["reranking_enable"] + reranking_model=resolved_retrieval_model.get("reranking_model", None) + if resolved_retrieval_model["reranking_enable"] else None, - reranking_mode=retrieval_model.get("reranking_mode") or "reranking_model", - weights=retrieval_model.get("weights", None), + reranking_mode=resolved_retrieval_model.get("reranking_mode") or "reranking_model", + weights=resolved_retrieval_model.get("weights", None), document_ids_filter=document_ids_filter, ) diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index 7bd056b8a0..b8242ab3a5 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -26,7 +26,7 @@ from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.tool_label_manager import ToolLabelManager from core.tools.tool_manager import ToolManager from core.tools.utils.encryption import create_provider_encrypter -from core.tools.utils.system_oauth_encryption import decrypt_system_oauth_params +from core.tools.utils.system_encryption import decrypt_system_params from extensions.ext_database import db from extensions.ext_redis import redis_client from models.provider_ids import ToolProviderID @@ -521,7 +521,7 @@ class BuiltinToolManageService: ) if system_client: try: - oauth_params = decrypt_system_oauth_params(system_client.encrypted_oauth_params) + oauth_params = decrypt_system_params(system_client.encrypted_oauth_params) except Exception as e: raise ValueError(f"Error decrypting system oauth params: {e}") diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py index 6e14d996ea..b8a76e4945 100644 --- a/api/services/trigger/trigger_provider_service.py +++ b/api/services/trigger/trigger_provider_service.py @@ -14,7 +14,7 @@ from core.helper.provider_cache import NoOpProviderCredentialCache from core.helper.provider_encryption import ProviderConfigEncrypter, create_provider_encrypter from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.oauth import OAuthHandler -from core.tools.utils.system_oauth_encryption import decrypt_system_oauth_params +from core.tools.utils.system_encryption import decrypt_system_params from core.trigger.entities.api_entities import ( TriggerProviderApiEntity, TriggerProviderSubscriptionApiEntity, @@ -635,7 +635,7 @@ class TriggerProviderService: if system_client: try: - oauth_params = decrypt_system_oauth_params(system_client.encrypted_oauth_params) + oauth_params = decrypt_system_params(system_client.encrypted_oauth_params) except Exception as e: raise ValueError(f"Error decrypting system oauth params: {e}") diff --git a/api/services/workflow_event_snapshot_service.py b/api/services/workflow_event_snapshot_service.py index 601e9261fc..466f09605e 100644 --- a/api/services/workflow_event_snapshot_service.py +++ b/api/services/workflow_event_snapshot_service.py @@ -18,6 +18,7 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.apps.message_generator import MessageGenerator from core.app.entities.task_entities import ( + HumanInputRequiredResponse, MessageReplaceStreamResponse, NodeFinishStreamResponse, NodeStartStreamResponse, @@ -26,6 +27,10 @@ from core.app.entities.task_entities import ( WorkflowStartStreamResponse, ) from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext +from core.workflow.human_input_forms import load_form_tokens_by_form_id +from core.workflow.human_input_policy import HumanInputSurface, enrich_human_input_pause_reasons +from graphon.entities.pause_reason import PauseReasonType +from models.human_input import HumanInputForm from models.model import AppMode, Message from models.workflow import WorkflowNodeExecutionTriggeredFrom, WorkflowRun from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot @@ -59,8 +64,10 @@ def build_workflow_event_stream( tenant_id: str, app_id: str, session_maker: sessionmaker[Session], + human_input_surface: HumanInputSurface | None = None, idle_timeout: float = 300, ping_interval: float = 10.0, + close_on_pause: bool = True, ) -> Generator[Mapping[str, Any] | str, None, None]: topic = MessageGenerator.get_response_topic(app_mode, workflow_run.id) workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) @@ -115,13 +122,15 @@ def build_workflow_event_stream( message_context=message_context, pause_entity=pause_entity, resumption_context=resumption_context, + session_maker=session_maker, + human_input_surface=human_input_surface, ) for event in snapshot_events: last_msg_time = time.time() last_ping_time = last_msg_time yield event - if _is_terminal_event(event, include_paused=True): + if _is_terminal_event(event, close_on_pause=close_on_pause): return while True: @@ -146,7 +155,7 @@ def build_workflow_event_stream( last_msg_time = time.time() last_ping_time = last_msg_time yield event - if _is_terminal_event(event, include_paused=True): + if _is_terminal_event(event, close_on_pause=close_on_pause): return finally: buffer_state.stop_event.set() @@ -207,6 +216,8 @@ def _build_snapshot_events( message_context: MessageContext | None, pause_entity: WorkflowPauseEntity | None, resumption_context: WorkflowResumptionContext | None, + session_maker: sessionmaker[Session] | None = None, + human_input_surface: HumanInputSurface | None = None, ) -> list[Mapping[str, Any]]: events: list[Mapping[str, Any]] = [] @@ -241,12 +252,24 @@ def _build_snapshot_events( events.append(node_finished) if workflow_run.status == WorkflowExecutionStatus.PAUSED and pause_entity is not None: + for human_input_event in _build_human_input_required_events( + workflow_run_id=workflow_run.id, + task_id=task_id, + pause_entity=pause_entity, + session_maker=session_maker, + human_input_surface=human_input_surface, + ): + _apply_message_context(human_input_event, message_context) + events.append(human_input_event) + pause_event = _build_pause_event( workflow_run=workflow_run, workflow_run_id=workflow_run.id, task_id=task_id, pause_entity=pause_entity, resumption_context=resumption_context, + session_maker=session_maker, + human_input_surface=human_input_surface, ) if pause_event is not None: _apply_message_context(pause_event, message_context) @@ -314,6 +337,97 @@ def _build_node_started_event( return response.to_ignore_detail_dict() +def _build_human_input_required_events( + *, + workflow_run_id: str, + task_id: str, + pause_entity: WorkflowPauseEntity, + session_maker: sessionmaker[Session] | None, + human_input_surface: HumanInputSurface | None, +) -> list[dict[str, Any]]: + reasons = [reason.model_dump(mode="json") for reason in pause_entity.get_pause_reasons()] + human_input_form_ids = [ + form_id + for reason in reasons + if reason.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED + for form_id in [reason.get("form_id")] + if isinstance(form_id, str) + ] + + expiration_times_by_form_id: dict[str, int] = {} + display_in_ui_by_form_id: dict[str, bool] = {} + form_tokens_by_form_id: dict[str, str] = {} + if human_input_form_ids and session_maker is not None: + stmt = select(HumanInputForm.id, HumanInputForm.expiration_time, HumanInputForm.form_definition).where( + HumanInputForm.id.in_(human_input_form_ids) + ) + with session_maker() as session: + for form_id, expiration_time, form_definition in session.execute(stmt): + expiration_times_by_form_id[str(form_id)] = int(expiration_time.timestamp()) + try: + definition_payload = json.loads(form_definition) if form_definition else {} + except (TypeError, json.JSONDecodeError): + definition_payload = {} + display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui")) + form_tokens_by_form_id = load_form_tokens_by_form_id( + human_input_form_ids, + session=session, + surface=human_input_surface, + ) + + events: list[dict[str, Any]] = [] + for reason in reasons: + if reason.get("TYPE") != PauseReasonType.HUMAN_INPUT_REQUIRED: + continue + + form_id_raw = reason.get("form_id") + node_id_raw = reason.get("node_id") + node_title_raw = reason.get("node_title") + form_content_raw = reason.get("form_content") + if not isinstance(form_id_raw, str): + continue + if not isinstance(node_id_raw, str): + continue + if not isinstance(node_title_raw, str): + continue + if not isinstance(form_content_raw, str): + continue + form_id = form_id_raw + node_id = node_id_raw + node_title = node_title_raw + form_content = form_content_raw + + inputs = reason.get("inputs") + actions = reason.get("actions") + resolved_default_values = reason.get("resolved_default_values") + + expiration_time = expiration_times_by_form_id.get(form_id) + if expiration_time is None: + continue + + response = HumanInputRequiredResponse( + task_id=task_id, + workflow_run_id=workflow_run_id, + data=HumanInputRequiredResponse.Data( + form_id=form_id, + node_id=node_id, + node_title=node_title, + form_content=form_content, + inputs=inputs if isinstance(inputs, list) else [], + actions=actions if isinstance(actions, list) else [], + display_in_ui=display_in_ui_by_form_id.get(form_id, False), + form_token=form_tokens_by_form_id.get(form_id), + resolved_default_values=(resolved_default_values if isinstance(resolved_default_values, dict) else {}), + expiration_time=expiration_time, + ), + ) + payload = response.model_dump(mode="json") + payload["event"] = response.event.value + events.append(payload) + + return events + + def _build_node_finished_event( *, workflow_run_id: str, @@ -356,6 +470,8 @@ def _build_pause_event( task_id: str, pause_entity: WorkflowPauseEntity, resumption_context: WorkflowResumptionContext | None, + session_maker: sessionmaker[Session] | None, + human_input_surface: HumanInputSurface | None = None, ) -> dict[str, Any] | None: paused_nodes: list[str] = [] outputs: dict[str, Any] = {} @@ -365,6 +481,36 @@ def _build_pause_event( outputs = dict(WorkflowRuntimeTypeConverter().to_json_encodable(state.outputs or {})) reasons = [reason.model_dump(mode="json") for reason in pause_entity.get_pause_reasons()] + human_input_form_ids = [ + form_id + for reason in reasons + if reason.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED + for form_id in [reason.get("form_id")] + if isinstance(form_id, str) + ] + form_tokens_by_form_id: dict[str, str] = {} + expiration_times_by_form_id: dict[str, int] = {} + if human_input_form_ids and session_maker is not None: + with session_maker() as session: + form_tokens_by_form_id = load_form_tokens_by_form_id( + human_input_form_ids, + session=session, + surface=human_input_surface, + ) + stmt = select(HumanInputForm.id, HumanInputForm.expiration_time).where( + HumanInputForm.id.in_(human_input_form_ids) + ) + for row in session.execute(stmt): + form_id, expiration_time, *_rest = row + expiration_times_by_form_id[str(form_id)] = int(expiration_time.timestamp()) + # Reconnect paths must preserve the same pause-reason contract as live streams; + # otherwise clients see schema drift after resume. + reasons = enrich_human_input_pause_reasons( + reasons, + form_tokens_by_form_id=form_tokens_by_form_id, + expiration_times_by_form_id=expiration_times_by_form_id, + ) + response = WorkflowPauseStreamResponse( task_id=task_id, workflow_run_id=workflow_run_id, @@ -449,12 +595,19 @@ def _parse_event_message(message: bytes) -> Mapping[str, Any] | None: return event -def _is_terminal_event(event: Mapping[str, Any] | str, include_paused=False) -> bool: +def _is_terminal_event( + event: Mapping[str, Any] | str, + close_on_pause: bool = True, + *, + include_paused: bool | None = None, +) -> bool: + if include_paused is not None: + close_on_pause = include_paused if not isinstance(event, Mapping): return False event_type = event.get("event") if event_type == StreamEvent.WORKFLOW_FINISHED.value: return True - if include_paused: + if close_on_pause: return event_type == StreamEvent.WORKFLOW_PAUSED.value return False diff --git a/api/tasks/app_generate/workflow_execute_task.py b/api/tasks/app_generate/workflow_execute_task.py index 8f2f5f261e..bafecdec71 100644 --- a/api/tasks/app_generate/workflow_execute_task.py +++ b/api/tasks/app_generate/workflow_execute_task.py @@ -399,6 +399,8 @@ def _resume_advanced_chat( workflow_run_id: str, workflow_run: WorkflowRun, ) -> None: + resumed_generate_entity = generate_entity.model_copy(update={"stream": True}) + try: triggered_from = WorkflowRunTriggeredFrom(workflow_run.triggered_from) except ValueError: @@ -426,7 +428,7 @@ def _resume_advanced_chat( user=user, conversation=conversation, message=message, - application_generate_entity=generate_entity, + application_generate_entity=resumed_generate_entity, workflow_execution_repository=workflow_execution_repository, workflow_node_execution_repository=workflow_node_execution_repository, graph_runtime_state=graph_runtime_state, @@ -436,9 +438,8 @@ def _resume_advanced_chat( logger.exception("Failed to resume chatflow execution for workflow run %s", workflow_run_id) raise - if generate_entity.stream: - assert isinstance(response, Generator) - _publish_streaming_response(response, workflow_run_id, AppMode.ADVANCED_CHAT) + assert isinstance(response, Generator) + _publish_streaming_response(response, workflow_run_id, AppMode.ADVANCED_CHAT) def _resume_workflow( @@ -455,6 +456,8 @@ def _resume_workflow( workflow_run_repo, pause_entity, ) -> None: + resumed_generate_entity = generate_entity.model_copy(update={"stream": True}) + try: triggered_from = WorkflowRunTriggeredFrom(workflow_run.triggered_from) except ValueError: @@ -480,7 +483,7 @@ def _resume_workflow( app_model=app_model, workflow=workflow, user=user, - application_generate_entity=generate_entity, + application_generate_entity=resumed_generate_entity, graph_runtime_state=graph_runtime_state, workflow_execution_repository=workflow_execution_repository, workflow_node_execution_repository=workflow_node_execution_repository, @@ -490,11 +493,18 @@ def _resume_workflow( logger.exception("Failed to resume workflow execution for workflow run %s", workflow_run_id) raise - if generate_entity.stream: - assert isinstance(response, Generator) - _publish_streaming_response(response, workflow_run_id, AppMode.WORKFLOW) + assert isinstance(response, Generator) + _publish_streaming_response(response, workflow_run_id, AppMode.WORKFLOW) - workflow_run_repo.delete_workflow_pause(pause_entity) + try: + workflow_run_repo.delete_workflow_pause(pause_entity) + except Exception as exc: + if exc.__class__.__name__ != "_WorkflowRunError" or "WorkflowPause not found" not in str(exc): + raise + logger.info( + "Skipped deleting workflow pause %s after resume because it was already replaced or removed", + pause_entity.id, + ) @shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE, name="resume_app_execution") diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py index 64c93ac07c..d9828e19c5 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -2,27 +2,31 @@ from __future__ import annotations +import secrets from dataclasses import dataclass, field from datetime import datetime, timedelta from unittest.mock import Mock from uuid import uuid4 import pytest +from sqlalchemy import Engine, delete, select +from sqlalchemy.orm import Session, sessionmaker + +from core.workflow.human_input_adapter import DeliveryMethodType +from extensions.ext_storage import storage from graphon.entities import WorkflowExecution from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType from graphon.enums import WorkflowExecutionStatus from graphon.nodes.human_input.entities import FormDefinition, FormInput, UserAction from graphon.nodes.human_input.enums import FormInputType, HumanInputFormStatus -from sqlalchemy import Engine, delete, select -from sqlalchemy.orm import Session, sessionmaker - -from extensions.ext_storage import storage from libs.datetime_utils import naive_utc_now from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.human_input import ( + BackstageRecipientPayload, HumanInputDelivery, HumanInputForm, HumanInputFormRecipient, + RecipientType, ) from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowPause, WorkflowPauseReason, WorkflowRun from repositories.entities.workflow_pause import WorkflowPauseEntity @@ -628,12 +632,12 @@ class TestPrivateWorkflowPauseEntity: class TestBuildHumanInputRequiredReason: """Integration tests for _build_human_input_required_reason using real DB models.""" - def test_builds_reason_from_form_definition( + def test_prefers_standalone_web_app_token_when_available( self, db_session_with_containers: Session, test_scope: _TestScope, ) -> None: - """Build the graph pause reason from the stored form definition.""" + """Use the public standalone web-app token for service API payloads.""" expiration_time = naive_utc_now() form_definition = FormDefinition( @@ -660,6 +664,40 @@ class TestBuildHumanInputRequiredReason: db_session_with_containers.add(form_model) db_session_with_containers.flush() + delivery = HumanInputDelivery( + form_id=form_model.id, + delivery_method_type=DeliveryMethodType.WEBAPP, + channel_payload="{}", + ) + db_session_with_containers.add(delivery) + db_session_with_containers.flush() + + backstage_access_token = secrets.token_urlsafe(8) + backstage_recipient = HumanInputFormRecipient( + form_id=form_model.id, + delivery_id=delivery.id, + recipient_type=RecipientType.BACKSTAGE, + recipient_payload=BackstageRecipientPayload().model_dump_json(), + access_token=backstage_access_token, + ) + console_access_token = secrets.token_urlsafe(8) + console_recipient = HumanInputFormRecipient( + form_id=form_model.id, + delivery_id=delivery.id, + recipient_type=RecipientType.CONSOLE, + recipient_payload="{}", + access_token=console_access_token, + ) + web_app_access_token = secrets.token_urlsafe(8) + web_app_recipient = HumanInputFormRecipient( + form_id=form_model.id, + delivery_id=delivery.id, + recipient_type=RecipientType.STANDALONE_WEB_APP, + recipient_payload="{}", + access_token=web_app_access_token, + ) + db_session_with_containers.add_all([backstage_recipient, console_recipient, web_app_recipient]) + db_session_with_containers.flush() # Create a pause so the reason has a valid pause_id workflow_run = _create_workflow_run( db_session_with_containers, @@ -688,8 +726,15 @@ class TestBuildHumanInputRequiredReason: # Refresh to ensure we have DB-round-tripped objects db_session_with_containers.refresh(form_model) db_session_with_containers.refresh(reason_model) + db_session_with_containers.refresh(backstage_recipient) + db_session_with_containers.refresh(console_recipient) + db_session_with_containers.refresh(web_app_recipient) - reason = _build_human_input_required_reason(reason_model, form_model) + reason = _build_human_input_required_reason( + reason_model, + form_model, + [backstage_recipient, console_recipient, web_app_recipient], + ) assert isinstance(reason, HumanInputRequired) assert reason.node_title == "Ask Name" @@ -697,3 +742,92 @@ class TestBuildHumanInputRequiredReason: assert reason.inputs[0].output_variable_name == "name" assert reason.actions[0].id == "approve" assert reason.resolved_default_values == {"name": "Alice"} + assert not hasattr(reason, "form_token") + + def test_falls_back_to_console_token_when_web_app_token_missing( + self, + db_session_with_containers: Session, + test_scope: _TestScope, + ) -> None: + """Use the console token only when no standalone web-app token exists.""" + + expiration_time = naive_utc_now() + form_definition = FormDefinition( + form_content="content", + inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")], + user_actions=[UserAction(id="approve", title="Approve")], + rendered_content="rendered", + expiration_time=expiration_time, + default_values={"name": "Alice"}, + node_title="Ask Name", + display_in_ui=True, + ) + + form_model = HumanInputForm( + tenant_id=test_scope.tenant_id, + app_id=test_scope.app_id, + workflow_run_id=str(uuid4()), + node_id="node-1", + form_definition=form_definition.model_dump_json(), + rendered_content="rendered", + status=HumanInputFormStatus.WAITING, + expiration_time=expiration_time, + ) + db_session_with_containers.add(form_model) + db_session_with_containers.flush() + + delivery = HumanInputDelivery( + form_id=form_model.id, + delivery_method_type=DeliveryMethodType.WEBAPP, + channel_payload="{}", + ) + db_session_with_containers.add(delivery) + db_session_with_containers.flush() + + backstage_access_token = secrets.token_urlsafe(8) + backstage_recipient = HumanInputFormRecipient( + form_id=form_model.id, + delivery_id=delivery.id, + recipient_type=RecipientType.BACKSTAGE, + recipient_payload=BackstageRecipientPayload().model_dump_json(), + access_token=backstage_access_token, + ) + console_access_token = secrets.token_urlsafe(8) + console_recipient = HumanInputFormRecipient( + form_id=form_model.id, + delivery_id=delivery.id, + recipient_type=RecipientType.CONSOLE, + recipient_payload="{}", + access_token=console_access_token, + ) + db_session_with_containers.add_all([backstage_recipient, console_recipient]) + db_session_with_containers.flush() + + workflow_run = _create_workflow_run( + db_session_with_containers, + test_scope, + status=WorkflowExecutionStatus.RUNNING, + ) + pause = WorkflowPause( + workflow_id=test_scope.workflow_id, + workflow_run_id=workflow_run.id, + state_object_key=f"workflow-state-{uuid4()}.json", + ) + db_session_with_containers.add(pause) + db_session_with_containers.flush() + test_scope.state_keys.add(pause.state_object_key) + + reason_model = WorkflowPauseReason( + pause_id=pause.id, + type_=PauseReasonType.HUMAN_INPUT_REQUIRED, + form_id=form_model.id, + node_id="node-1", + message="", + ) + db_session_with_containers.add(reason_model) + db_session_with_containers.commit() + + reason = _build_human_input_required_reason(reason_model, form_model, [backstage_recipient, console_recipient]) + + assert isinstance(reason, HumanInputRequired) + assert not hasattr(reason, "form_token") diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py new file mode 100644 index 0000000000..e77e875081 --- /dev/null +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -0,0 +1,37 @@ +"""Unit tests for the standalone Swagger export helper.""" + +import importlib.util +import json +import sys +from pathlib import Path + + +def _load_generate_swagger_specs_module(): + api_dir = Path(__file__).resolve().parents[3] + script_path = api_dir / "dev" / "generate_swagger_specs.py" + + spec = importlib.util.spec_from_file_location("generate_swagger_specs", script_path) + assert spec + assert spec.loader + + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) # type: ignore[attr-defined] + return module + + +def test_generate_specs_writes_console_web_and_service_swagger_files(tmp_path): + module = _load_generate_swagger_specs_module() + + written_paths = module.generate_specs(tmp_path) + + assert [path.name for path in written_paths] == [ + "console-swagger.json", + "web-swagger.json", + "service-swagger.json", + ] + + for path in written_paths: + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["swagger"] == "2.0" + assert "paths" in payload diff --git a/api/tests/unit_tests/controllers/console/test_human_input_form.py b/api/tests/unit_tests/controllers/console/test_human_input_form.py index 232b6eee79..ebf803cac9 100644 --- a/api/tests/unit_tests/controllers/console/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/console/test_human_input_form.py @@ -122,6 +122,35 @@ def test_post_form_invalid_recipient_type(app, monkeypatch: pytest.MonkeyPatch) handler(api, form_token="token") +def test_post_form_rejects_webapp_recipient_type(app, monkeypatch: pytest.MonkeyPatch) -> None: + form = SimpleNamespace(tenant_id="tenant-1", recipient_type=RecipientType.STANDALONE_WEB_APP) + + class _ServiceStub: + def __init__(self, *_args, **_kwargs): + pass + + def get_form_by_token(self, _token): + return form + + monkeypatch.setattr("controllers.console.human_input_form.HumanInputService", _ServiceStub) + monkeypatch.setattr( + "controllers.console.human_input_form.current_account_with_tenant", + lambda: (SimpleNamespace(id="user-1"), "tenant-1"), + ) + monkeypatch.setattr("controllers.console.human_input_form.db", SimpleNamespace(engine=object())) + + api = ConsoleHumanInputFormApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/console/api/form/human_input/token", + method="POST", + json={"inputs": {"content": "ok"}, "action": "approve"}, + ): + with pytest.raises(NotFoundError): + handler(api, form_token="token") + + def test_post_form_success(app, monkeypatch: pytest.MonkeyPatch) -> None: submit_mock = Mock() form = SimpleNamespace(tenant_id="tenant-1", recipient_type=RecipientType.CONSOLE) diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py index c513be950b..26ff264f18 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -68,7 +68,10 @@ class TestChangeEmailSend: mock_features.return_value = SimpleNamespace(enable_change_email=True) mock_account = _build_account("current@example.com", "acc1") mock_current_account.return_value = (mock_account, None) - mock_get_change_data.return_value = {"email": "current@example.com"} + mock_get_change_data.return_value = { + "email": "current@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED, + } mock_send_email.return_value = "token-abc" with app.test_request_context( @@ -85,12 +88,55 @@ class TestChangeEmailSend: email="new@example.com", old_email="current@example.com", language="en-US", - phase="new_email", + phase=AccountService.CHANGE_EMAIL_PHASE_NEW, ) mock_extract_ip.assert_called_once() mock_is_ip_limit.assert_called_once_with("127.0.0.1") mock_csrf.assert_called_once() + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.send_change_email_email") + @patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False) + @patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_reject_new_email_phase_when_token_phase_is_not_old_verified( + self, + mock_features, + mock_csrf, + mock_extract_ip, + mock_is_ip_limit, + mock_send_email, + mock_get_change_data, + mock_current_account, + mock_db, + app, + ): + """GHSA-4q3w-q5mc-45rq: a phase-1 token must not unlock the new-email send step.""" + from controllers.console.auth.error import InvalidTokenError + + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + mock_account = _build_account("current@example.com", "acc1") + mock_current_account.return_value = (mock_account, None) + mock_get_change_data.return_value = { + "email": "current@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD, + } + + with app.test_request_context( + "/account/change-email", + method="POST", + json={"email": "New@Example.com", "language": "en-US", "phase": "new_email", "token": "token-123"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + with pytest.raises(InvalidTokenError): + ChangeEmailSendEmailApi().post() + + mock_send_email.assert_not_called() + class TestChangeEmailValidity: @patch("controllers.console.wraps.db") @@ -122,7 +168,12 @@ class TestChangeEmailValidity: mock_account = _build_account("user@example.com", "acc2") mock_current_account.return_value = (mock_account, None) mock_is_rate_limit.return_value = False - mock_get_data.return_value = {"email": "user@example.com", "code": "1234", "old_email": "old@example.com"} + mock_get_data.return_value = { + "email": "user@example.com", + "code": "1234", + "old_email": "old@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD, + } mock_generate_token.return_value = (None, "new-token") with app.test_request_context( @@ -138,11 +189,169 @@ class TestChangeEmailValidity: mock_add_rate.assert_not_called() mock_revoke_token.assert_called_once_with("token-123") mock_generate_token.assert_called_once_with( - "user@example.com", code="1234", old_email="old@example.com", additional_data={} + "user@example.com", + code="1234", + old_email="old@example.com", + additional_data={ + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED, + }, ) mock_reset_rate.assert_called_once_with("user@example.com") mock_csrf.assert_called_once() + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.generate_change_email_token") + @patch("controllers.console.workspace.account.AccountService.revoke_change_email_token") + @patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_upgrade_new_phase_token_to_new_verified( + self, + mock_features, + mock_csrf, + mock_is_rate_limit, + mock_get_data, + mock_add_rate, + mock_revoke_token, + mock_generate_token, + mock_reset_rate, + mock_current_account, + mock_db, + app, + ): + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + mock_current_account.return_value = (_build_account("old@example.com", "acc"), None) + mock_is_rate_limit.return_value = False + mock_get_data.return_value = { + "email": "new@example.com", + "code": "1234", + "old_email": "old@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW, + } + mock_generate_token.return_value = (None, "new-verified-token") + + with app.test_request_context( + "/account/change-email/validity", + method="POST", + json={"email": "new@example.com", "code": "1234", "token": "token-123"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + response = ChangeEmailCheckApi().post() + + assert response == {"is_valid": True, "email": "new@example.com", "token": "new-verified-token"} + mock_generate_token.assert_called_once_with( + "new@example.com", + code="1234", + old_email="old@example.com", + additional_data={ + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED, + }, + ) + + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.generate_change_email_token") + @patch("controllers.console.workspace.account.AccountService.revoke_change_email_token") + @patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_reject_validity_when_token_phase_is_unknown( + self, + mock_features, + mock_csrf, + mock_is_rate_limit, + mock_get_data, + mock_add_rate, + mock_revoke_token, + mock_generate_token, + mock_reset_rate, + mock_current_account, + mock_db, + app, + ): + """A token whose phase marker is a string but not a known transition must be rejected.""" + from controllers.console.auth.error import InvalidTokenError + + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + mock_current_account.return_value = (_build_account("old@example.com", "acc"), None) + mock_is_rate_limit.return_value = False + mock_get_data.return_value = { + "email": "user@example.com", + "code": "1234", + "old_email": "old@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: "something_else", + } + + with app.test_request_context( + "/account/change-email/validity", + method="POST", + json={"email": "user@example.com", "code": "1234", "token": "token-123"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + with pytest.raises(InvalidTokenError): + ChangeEmailCheckApi().post() + + mock_revoke_token.assert_not_called() + mock_generate_token.assert_not_called() + + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.generate_change_email_token") + @patch("controllers.console.workspace.account.AccountService.revoke_change_email_token") + @patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_reject_validity_when_token_has_no_phase( + self, + mock_features, + mock_csrf, + mock_is_rate_limit, + mock_get_data, + mock_add_rate, + mock_revoke_token, + mock_generate_token, + mock_reset_rate, + mock_current_account, + mock_db, + app, + ): + """A token minted without a phase marker (e.g. a hand-crafted token) must not validate.""" + from controllers.console.auth.error import InvalidTokenError + + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + mock_current_account.return_value = (_build_account("old@example.com", "acc"), None) + mock_is_rate_limit.return_value = False + mock_get_data.return_value = { + "email": "user@example.com", + "code": "1234", + "old_email": "old@example.com", + } + + with app.test_request_context( + "/account/change-email/validity", + method="POST", + json={"email": "user@example.com", "code": "1234", "token": "token-123"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + with pytest.raises(InvalidTokenError): + ChangeEmailCheckApi().post() + + mock_revoke_token.assert_not_called() + mock_generate_token.assert_not_called() + class TestChangeEmailReset: @patch("controllers.console.wraps.db") @@ -175,7 +384,11 @@ class TestChangeEmailReset: mock_current_account.return_value = (current_user, None) mock_is_freeze.return_value = False mock_check_unique.return_value = True - mock_get_data.return_value = {"old_email": "OLD@example.com"} + mock_get_data.return_value = { + "email": "new@example.com", + "old_email": "OLD@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED, + } mock_account_after_update = _build_account("new@example.com", "acc3-updated") mock_update_account.return_value = mock_account_after_update @@ -194,6 +407,155 @@ class TestChangeEmailReset: mock_send_notify.assert_called_once_with(email="new@example.com") mock_csrf.assert_called_once() + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email") + @patch("controllers.console.workspace.account.AccountService.update_account_email") + @patch("controllers.console.workspace.account.AccountService.revoke_change_email_token") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.check_email_unique") + @patch("controllers.console.workspace.account.AccountService.is_account_in_freeze") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_reject_reset_when_token_phase_is_not_new_verified( + self, + mock_features, + mock_csrf, + mock_is_freeze, + mock_check_unique, + mock_get_data, + mock_revoke_token, + mock_update_account, + mock_send_notify, + mock_current_account, + mock_db, + app, + ): + """GHSA-4q3w-q5mc-45rq PoC: phase-1 token must not be usable against /reset.""" + from controllers.console.auth.error import InvalidTokenError + + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + current_user = _build_account("old@example.com", "acc3") + mock_current_account.return_value = (current_user, None) + mock_is_freeze.return_value = False + mock_check_unique.return_value = True + # Simulate a token straight out of step #1 (phase=old_email) — exactly + # the replay used in the advisory PoC. + mock_get_data.return_value = { + "email": "old@example.com", + "old_email": "old@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD, + } + + with app.test_request_context( + "/account/change-email/reset", + method="POST", + json={"new_email": "attacker@example.com", "token": "token-from-step1"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + with pytest.raises(InvalidTokenError): + ChangeEmailResetApi().post() + + mock_revoke_token.assert_not_called() + mock_update_account.assert_not_called() + mock_send_notify.assert_not_called() + + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email") + @patch("controllers.console.workspace.account.AccountService.update_account_email") + @patch("controllers.console.workspace.account.AccountService.revoke_change_email_token") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.check_email_unique") + @patch("controllers.console.workspace.account.AccountService.is_account_in_freeze") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_reject_reset_when_token_email_differs_from_payload_new_email( + self, + mock_features, + mock_csrf, + mock_is_freeze, + mock_check_unique, + mock_get_data, + mock_revoke_token, + mock_update_account, + mock_send_notify, + mock_current_account, + mock_db, + app, + ): + """A verified token for address A must not be replayed to change to address B.""" + from controllers.console.auth.error import InvalidTokenError + + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + current_user = _build_account("old@example.com", "acc3") + mock_current_account.return_value = (current_user, None) + mock_is_freeze.return_value = False + mock_check_unique.return_value = True + mock_get_data.return_value = { + "email": "verified@example.com", + "old_email": "old@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED, + } + + with app.test_request_context( + "/account/change-email/reset", + method="POST", + json={"new_email": "attacker@example.com", "token": "token-verified"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + with pytest.raises(InvalidTokenError): + ChangeEmailResetApi().post() + + mock_revoke_token.assert_not_called() + mock_update_account.assert_not_called() + mock_send_notify.assert_not_called() + + +class TestAccountServiceSendChangeEmailEmail: + """Service-level coverage for the phase-bound changes in `send_change_email_email`.""" + + def test_should_raise_value_error_for_invalid_phase(self): + with pytest.raises(ValueError, match="phase must be one of"): + AccountService.send_change_email_email( + email="user@example.com", + old_email="user@example.com", + phase="old_email_verified", + ) + + @patch("services.account_service.send_change_mail_task") + @patch("services.account_service.AccountService.change_email_rate_limiter") + @patch("services.account_service.AccountService.generate_change_email_token") + def test_should_stamp_phase_into_generated_token( + self, + mock_generate_token, + mock_rate_limiter, + mock_mail_task, + ): + mock_rate_limiter.is_rate_limited.return_value = False + mock_generate_token.return_value = ("123456", "the-token") + + returned = AccountService.send_change_email_email( + email="user@example.com", + old_email="user@example.com", + language="en-US", + phase=AccountService.CHANGE_EMAIL_PHASE_NEW, + ) + + assert returned == "the-token" + mock_generate_token.assert_called_once_with( + "user@example.com", + None, + old_email="user@example.com", + additional_data={ + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW, + }, + ) + mock_mail_task.delay.assert_called_once() + mock_rate_limiter.increment_rate_limit.assert_called_once_with("user@example.com") + class TestAccountDeletionFeedback: @patch("controllers.console.wraps.db") diff --git a/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py b/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py index 51f76af172..0b3d7ef6d7 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py @@ -2,14 +2,17 @@ from unittest.mock import MagicMock, patch import pytest +from controllers.console import console_ns from controllers.console.workspace.endpoint import ( - EndpointCreateApi, - EndpointDeleteApi, + DeprecatedEndpointCreateApi, + DeprecatedEndpointDeleteApi, + DeprecatedEndpointUpdateApi, + EndpointCollectionApi, EndpointDisableApi, EndpointEnableApi, + EndpointItemApi, EndpointListApi, EndpointListForSinglePluginApi, - EndpointUpdateApi, ) from core.plugin.impl.exc import PluginPermissionDeniedError @@ -35,9 +38,9 @@ def patch_current_account(user_and_tenant): @pytest.mark.usefixtures("patch_current_account") -class TestEndpointCreateApi: +class TestEndpointCollectionApi: def test_create_success(self, app): - api = EndpointCreateApi() + api = EndpointCollectionApi() method = unwrap(api.post) payload = { @@ -55,7 +58,7 @@ class TestEndpointCreateApi: assert result["success"] is True def test_create_permission_denied(self, app): - api = EndpointCreateApi() + api = EndpointCollectionApi() method = unwrap(api.post) payload = { @@ -75,7 +78,7 @@ class TestEndpointCreateApi: method(api) def test_create_validation_error(self, app): - api = EndpointCreateApi() + api = EndpointCollectionApi() method = unwrap(api.post) payload = { @@ -91,6 +94,27 @@ class TestEndpointCreateApi: method(api) +@pytest.mark.usefixtures("patch_current_account") +class TestDeprecatedEndpointCreateApi: + def test_create_success(self, app): + api = DeprecatedEndpointCreateApi() + method = unwrap(api.post) + + payload = { + "plugin_unique_identifier": "plugin-1", + "name": "endpoint", + "settings": {"a": 1}, + } + + with ( + app.test_request_context("/", json=payload), + patch("controllers.console.workspace.endpoint.EndpointService.create_endpoint", return_value=True), + ): + result = method(api) + + assert result["success"] is True + + @pytest.mark.usefixtures("patch_current_account") class TestEndpointListApi: def test_list_success(self, app): @@ -146,9 +170,96 @@ class TestEndpointListForSinglePluginApi: @pytest.mark.usefixtures("patch_current_account") -class TestEndpointDeleteApi: +class TestEndpointItemApi: def test_delete_success(self, app): - api = EndpointDeleteApi() + api = EndpointItemApi() + method = unwrap(api.delete) + + with ( + app.test_request_context("/", method="DELETE"), + patch( + "controllers.console.workspace.endpoint.EndpointService.delete_endpoint", + return_value=True, + ) as mock_delete, + ): + result = method(api, "e1") + + assert result["success"] is True + mock_delete.assert_called_once_with(tenant_id="t1", user_id="u1", endpoint_id="e1") + + def test_delete_service_failure(self, app): + api = EndpointItemApi() + method = unwrap(api.delete) + + with ( + app.test_request_context("/", method="DELETE"), + patch("controllers.console.workspace.endpoint.EndpointService.delete_endpoint", return_value=False), + ): + result = method(api, "e1") + + assert result["success"] is False + + def test_update_success(self, app): + api = EndpointItemApi() + method = unwrap(api.patch) + + payload = { + "name": "new-name", + "settings": {"x": 1}, + } + + with ( + app.test_request_context("/", method="PATCH", json=payload), + patch( + "controllers.console.workspace.endpoint.EndpointService.update_endpoint", + return_value=True, + ) as mock_update, + ): + result = method(api, "e1") + + assert result["success"] is True + mock_update.assert_called_once_with( + tenant_id="t1", + user_id="u1", + endpoint_id="e1", + name="new-name", + settings={"x": 1}, + ) + + def test_update_validation_error(self, app): + api = EndpointItemApi() + method = unwrap(api.patch) + + payload = {"settings": {}} + + with ( + app.test_request_context("/", method="PATCH", json=payload), + ): + with pytest.raises(ValueError): + method(api, "e1") + + def test_update_service_failure(self, app): + api = EndpointItemApi() + method = unwrap(api.patch) + + payload = { + "name": "n", + "settings": {}, + } + + with ( + app.test_request_context("/", method="PATCH", json=payload), + patch("controllers.console.workspace.endpoint.EndpointService.update_endpoint", return_value=False), + ): + result = method(api, "e1") + + assert result["success"] is False + + +@pytest.mark.usefixtures("patch_current_account") +class TestDeprecatedEndpointDeleteApi: + def test_delete_success(self, app): + api = DeprecatedEndpointDeleteApi() method = unwrap(api.post) payload = {"endpoint_id": "e1"} @@ -162,7 +273,7 @@ class TestEndpointDeleteApi: assert result["success"] is True def test_delete_invalid_payload(self, app): - api = EndpointDeleteApi() + api = DeprecatedEndpointDeleteApi() method = unwrap(api.post) with ( @@ -172,7 +283,7 @@ class TestEndpointDeleteApi: method(api) def test_delete_service_failure(self, app): - api = EndpointDeleteApi() + api = DeprecatedEndpointDeleteApi() method = unwrap(api.post) payload = {"endpoint_id": "e1"} @@ -187,9 +298,9 @@ class TestEndpointDeleteApi: @pytest.mark.usefixtures("patch_current_account") -class TestEndpointUpdateApi: +class TestDeprecatedEndpointUpdateApi: def test_update_success(self, app): - api = EndpointUpdateApi() + api = DeprecatedEndpointUpdateApi() method = unwrap(api.post) payload = { @@ -207,7 +318,7 @@ class TestEndpointUpdateApi: assert result["success"] is True def test_update_validation_error(self, app): - api = EndpointUpdateApi() + api = DeprecatedEndpointUpdateApi() method = unwrap(api.post) payload = {"endpoint_id": "e1", "settings": {}} @@ -219,7 +330,7 @@ class TestEndpointUpdateApi: method(api) def test_update_service_failure(self, app): - api = EndpointUpdateApi() + api = DeprecatedEndpointUpdateApi() method = unwrap(api.post) payload = { @@ -237,6 +348,36 @@ class TestEndpointUpdateApi: assert result["success"] is False +class TestEndpointRouteMetadata: + def test_legacy_write_routes_are_marked_deprecated(self): + assert DeprecatedEndpointCreateApi.post.__apidoc__["deprecated"] is True + assert DeprecatedEndpointDeleteApi.post.__apidoc__["deprecated"] is True + assert DeprecatedEndpointUpdateApi.post.__apidoc__["deprecated"] is True + assert EndpointCollectionApi.post.__apidoc__.get("deprecated") is not True + assert EndpointItemApi.delete.__apidoc__.get("deprecated") is not True + assert EndpointItemApi.patch.__apidoc__.get("deprecated") is not True + + def test_canonical_and_legacy_write_routes_are_registered(self): + route_map = { + resource.__name__: urls + for resource, urls, _route_doc, _kwargs in console_ns.resources + if resource.__name__ + in { + "EndpointCollectionApi", + "EndpointItemApi", + "DeprecatedEndpointCreateApi", + "DeprecatedEndpointDeleteApi", + "DeprecatedEndpointUpdateApi", + } + } + + assert route_map["EndpointCollectionApi"] == ("/workspaces/current/endpoints",) + assert route_map["EndpointItemApi"] == ("/workspaces/current/endpoints/",) + assert route_map["DeprecatedEndpointCreateApi"] == ("/workspaces/current/endpoints/create",) + assert route_map["DeprecatedEndpointDeleteApi"] == ("/workspaces/current/endpoints/delete",) + assert route_map["DeprecatedEndpointUpdateApi"] == ("/workspaces/current/endpoints/update",) + + @pytest.mark.usefixtures("patch_current_account") class TestEndpointEnableApi: def test_enable_success(self, app): diff --git a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py new file mode 100644 index 0000000000..846d5368f3 --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py @@ -0,0 +1,707 @@ +"""Dedicated tests for HITL behavior exposed through the Service API.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import ANY, MagicMock, Mock + +import pytest + +import services.app_generate_service as ags_module +from controllers.service_api.app.workflow_events import WorkflowEventsApi +from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig +from core.app.apps.common import workflow_response_converter +from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter +from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity +from core.app.entities.queue_entities import QueueWorkflowPausedEvent +from core.app.entities.task_entities import ( + AdvancedChatPausedBlockingResponse, + HumanInputRequiredResponse, + WorkflowAppPausedBlockingResponse, + WorkflowPauseStreamResponse, +) +from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper +from core.workflow.human_input_policy import HumanInputSurface +from core.workflow.system_variables import build_system_variables +from graphon.entities import WorkflowStartReason +from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType +from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus +from graphon.nodes.human_input.entities import FormInput, UserAction +from graphon.nodes.human_input.enums import FormInputType +from graphon.runtime import GraphRuntimeState, VariablePool +from models.account import Account +from models.enums import CreatorUserRole +from models.model import AppMode +from models.workflow import WorkflowRun +from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot +from repositories.entities.workflow_pause import WorkflowPauseEntity +from services.app_generate_service import AppGenerateService +from services.workflow_event_snapshot_service import _build_snapshot_events +from tests.unit_tests.controllers.service_api.conftest import _unwrap + + +class _DummyRateLimit: + @staticmethod + def gen_request_key() -> str: + return "dummy-request-id" + + def __init__(self, client_id: str, max_active_requests: int) -> None: + self.client_id = client_id + self.max_active_requests = max_active_requests + + def enter(self, request_id: str | None = None) -> str: + return request_id or "dummy-request-id" + + def exit(self, request_id: str) -> None: + return None + + def generate(self, generator, request_id: str): + return generator + + +def _mock_repo_for_run(monkeypatch: pytest.MonkeyPatch, workflow_run): + workflow_events_module = sys.modules["controllers.service_api.app.workflow_events"] + repo = SimpleNamespace(get_workflow_run_by_id_and_tenant_id=lambda **_kwargs: workflow_run) + monkeypatch.setattr( + workflow_events_module.DifyAPIRepositoryFactory, + "create_api_workflow_run_repository", + lambda *_args, **_kwargs: repo, + ) + monkeypatch.setattr(workflow_events_module, "db", SimpleNamespace(engine=object())) + return workflow_events_module + + +def _build_service_api_pause_converter() -> WorkflowResponseConverter: + application_generate_entity = SimpleNamespace( + inputs={}, + files=[], + invoke_from=InvokeFrom.SERVICE_API, + app_config=SimpleNamespace(app_id="app-id", tenant_id="tenant-id"), + ) + system_variables = build_system_variables( + user_id="user", + app_id="app-id", + workflow_id="workflow-id", + workflow_execution_id="run-id", + ) + user = MagicMock(spec=Account) + user.id = "account-id" + user.name = "Tester" + user.email = "tester@example.com" + return WorkflowResponseConverter( + application_generate_entity=application_generate_entity, + user=user, + system_variables=system_variables, + ) + + +def _build_advanced_chat_paused_blocking_response() -> AdvancedChatPausedBlockingResponse: + data = AdvancedChatPausedBlockingResponse.Data( + id="msg-1", + mode="chat", + conversation_id="c1", + message_id="m1", + workflow_run_id="run-1", + answer="partial", + metadata={"usage": {"total_tokens": 1}}, + created_at=1, + paused_nodes=["node-1"], + reasons=[ + { + "type": PauseReasonType.HUMAN_INPUT_REQUIRED, + "form_id": "form-1", + "expiration_time": 100, + } + ], + status=WorkflowExecutionStatus.PAUSED, + elapsed_time=0.1, + total_tokens=0, + total_steps=0, + ) + return AdvancedChatPausedBlockingResponse(task_id="t1", data=data) + + +def _build_workflow_paused_blocking_response() -> WorkflowAppPausedBlockingResponse: + return WorkflowAppPausedBlockingResponse( + task_id="t1", + workflow_run_id="r1", + data=WorkflowAppPausedBlockingResponse.Data( + id="r1", + workflow_id="wf-1", + status=WorkflowExecutionStatus.PAUSED, + outputs={}, + error=None, + elapsed_time=0.5, + total_tokens=0, + total_steps=2, + created_at=1, + finished_at=None, + paused_nodes=["node-1"], + reasons=[{"TYPE": "human_input_required", "form_id": "form-1", "expiration_time": 100}], + ), + ) + + +@dataclass(frozen=True) +class _FakePauseEntity(WorkflowPauseEntity): + pause_id: str + workflow_run_id: str + paused_at_value: datetime + pause_reasons: Sequence[HumanInputRequired] + + @property + def id(self) -> str: + return self.pause_id + + @property + def workflow_execution_id(self) -> str: + return self.workflow_run_id + + def get_state(self) -> bytes: + raise AssertionError("state is not required for snapshot tests") + + @property + def resumed_at(self) -> datetime | None: + return None + + @property + def paused_at(self) -> datetime: + return self.paused_at_value + + def get_pause_reasons(self) -> Sequence[HumanInputRequired]: + return self.pause_reasons + + +def _build_workflow_run(status: WorkflowExecutionStatus) -> WorkflowRun: + return WorkflowRun( + id="run-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + type="workflow", + triggered_from="app-run", + version="v1", + graph=None, + inputs=json.dumps({"input": "value"}), + status=status, + outputs=json.dumps({}), + error=None, + elapsed_time=0.0, + total_tokens=0, + total_steps=0, + created_by_role=CreatorUserRole.END_USER, + created_by="user-1", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + +def _build_snapshot(status: WorkflowNodeExecutionStatus) -> WorkflowNodeExecutionSnapshot: + created_at = datetime(2024, 1, 1, tzinfo=UTC) + finished_at = datetime(2024, 1, 1, 0, 0, 5, tzinfo=UTC) + return WorkflowNodeExecutionSnapshot( + execution_id="exec-1", + node_id="node-1", + node_type="human-input", + title="Human Input", + index=1, + status=status.value, + elapsed_time=0.5, + created_at=created_at, + finished_at=finished_at, + iteration_id=None, + loop_id=None, + ) + + +def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant-1", + app_id="app-1", + app_mode=AppMode.WORKFLOW, + workflow_id="workflow-1", + ) + generate_entity = WorkflowAppGenerateEntity( + task_id=task_id, + app_config=app_config, + inputs={}, + files=[], + user_id="user-1", + stream=True, + invoke_from=InvokeFrom.EXPLORE, + call_depth=0, + workflow_execution_id="run-1", + ) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.register_paused_node("node-1") + runtime_state.outputs = {"result": "value"} + wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) + return WorkflowResumptionContext( + generate_entity=wrapper, + serialized_graph_runtime_state=runtime_state.dumps(), + ) + + +class TestHitlServiceApi: + # Service API event-stream continuation + def test_workflow_events_continue_on_pause_keeps_stream_open(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=None, + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + msg_generator = Mock() + msg_generator.retrieve_events.return_value = ["raw-event"] + workflow_generator = Mock() + workflow_generator.convert_to_event_stream.return_value = iter(["data: streamed\n\n"]) + monkeypatch.setattr(workflow_events_module, "MessageGenerator", lambda: msg_generator) + monkeypatch.setattr(workflow_events_module, "WorkflowAppGenerator", lambda: workflow_generator) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1&continue_on_pause=true", method="GET"): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.get_data(as_text=True) == "data: streamed\n\n" + msg_generator.retrieve_events.assert_called_once_with( + AppMode.WORKFLOW, + "run-1", + terminal_events=[], + ) + workflow_generator.convert_to_event_stream.assert_called_once_with(["raw-event"]) + + def test_workflow_events_snapshot_continue_on_pause_keeps_pause_open( + self, app, monkeypatch: pytest.MonkeyPatch + ) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=None, + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + msg_generator = Mock() + workflow_generator = Mock() + workflow_generator.convert_to_event_stream.return_value = iter(["data: snapshot\n\n"]) + snapshot_builder = Mock(return_value=["snapshot-events"]) + monkeypatch.setattr(workflow_events_module, "MessageGenerator", lambda: msg_generator) + monkeypatch.setattr(workflow_events_module, "WorkflowAppGenerator", lambda: workflow_generator) + monkeypatch.setattr(workflow_events_module, "build_workflow_event_stream", snapshot_builder) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context( + "/workflow/run-1/events?user=u1&include_state_snapshot=true&continue_on_pause=true", + method="GET", + ): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.get_data(as_text=True) == "data: snapshot\n\n" + msg_generator.retrieve_events.assert_not_called() + snapshot_builder.assert_called_once_with( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=ANY, + human_input_surface=HumanInputSurface.SERVICE_API, + close_on_pause=False, + ) + workflow_generator.convert_to_event_stream.assert_called_once_with(["snapshot-events"]) + + def test_advanced_chat_blocking_injects_pause_state_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(ags_module, "RateLimit", _DummyRateLimit) + + workflow = MagicMock() + workflow.created_by = "owner-id" + monkeypatch.setattr(AppGenerateService, "_get_workflow", lambda *args, **kwargs: workflow) + monkeypatch.setattr(ags_module.session_factory, "get_session_maker", lambda: "session-maker") + + generator_instance = MagicMock() + generator_instance.generate.return_value = {"result": "advanced-blocking"} + generator_instance.convert_to_event_stream.side_effect = lambda payload: payload + monkeypatch.setattr(ags_module, "AdvancedChatAppGenerator", lambda: generator_instance) + + app_model = MagicMock() + app_model.mode = AppMode.ADVANCED_CHAT + app_model.id = "app-id" + app_model.tenant_id = "tenant-id" + app_model.max_active_requests = 0 + app_model.is_agent = False + + user = MagicMock() + user.id = "user-id" + + result = AppGenerateService.generate( + app_model=app_model, + user=user, + args={"workflow_id": None, "query": "hi", "inputs": {}}, + invoke_from=InvokeFrom.SERVICE_API, + streaming=False, + ) + + assert result == {"result": "advanced-blocking"} + call_kwargs = generator_instance.generate.call_args.kwargs + assert call_kwargs["streaming"] is False + assert call_kwargs["pause_state_config"] is not None + assert call_kwargs["pause_state_config"].session_factory == "session-maker" + assert call_kwargs["pause_state_config"].state_owner_user_id == "owner-id" + + # Blocking payload contract + def test_advanced_chat_blocking_pause_payload_contract(self) -> None: + from core.app.apps.advanced_chat.generate_response_converter import AdvancedChatAppGenerateResponseConverter + + response = AdvancedChatAppGenerateResponseConverter.convert_blocking_full_response( + _build_advanced_chat_paused_blocking_response() + ) + + assert response["event"] == "workflow_paused" + assert response["workflow_run_id"] == "run-1" + assert response["answer"] == "partial" + assert response["data"]["reasons"][0]["type"] == PauseReasonType.HUMAN_INPUT_REQUIRED + assert response["data"]["reasons"][0]["expiration_time"] == 100 + assert "human_input_forms" not in response["data"] + + def test_workflow_blocking_pause_payload_contract(self) -> None: + from core.app.apps.workflow.generate_response_converter import WorkflowAppGenerateResponseConverter + + response = WorkflowAppGenerateResponseConverter.convert_blocking_full_response( + _build_workflow_paused_blocking_response() + ) + + assert response["workflow_run_id"] == "r1" + assert response["data"]["status"] == WorkflowExecutionStatus.PAUSED + assert response["data"]["paused_nodes"] == ["node-1"] + assert response["data"]["reasons"] == [ + {"TYPE": "human_input_required", "form_id": "form-1", "expiration_time": 100} + ] + assert "human_input_forms" not in response["data"] + + def test_advanced_chat_blocking_pipeline_pause_payload_contract(self) -> None: + from core.app.app_config.entities import AppAdditionalFeatures + from core.app.apps.advanced_chat.generate_task_pipeline import AdvancedChatAppGenerateTaskPipeline + from models.enums import MessageStatus + from models.model import EndUser + + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant", + app_id="app", + app_mode=AppMode.ADVANCED_CHAT, + additional_features=AppAdditionalFeatures(), + variables=[], + workflow_id="workflow-id", + ) + application_generate_entity = AdvancedChatAppGenerateEntity.model_construct( + task_id="task", + app_config=app_config, + inputs={}, + query="hello", + files=[], + user_id="user", + stream=False, + invoke_from=InvokeFrom.WEB_APP, + extras={}, + trace_manager=None, + workflow_run_id="run-id", + ) + pipeline = AdvancedChatAppGenerateTaskPipeline( + application_generate_entity=application_generate_entity, + workflow=SimpleNamespace(id="workflow-id", tenant_id="tenant", features_dict={}), + queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None), + conversation=SimpleNamespace(id="conv-id", mode=AppMode.ADVANCED_CHAT), + message=SimpleNamespace( + id="message-id", + query="hello", + created_at=datetime.utcnow(), + status=MessageStatus.NORMAL, + answer="", + ), + user=EndUser(tenant_id="tenant", type="session", name="tester", session_id="session"), + stream=False, + dialogue_count=1, + draft_var_saver_factory=lambda **kwargs: None, + ) + pipeline._task_state.answer = "partial answer" + pipeline._workflow_run_id = "run-id" + + def _gen(): + yield HumanInputRequiredResponse( + task_id="task", + workflow_run_id="run-id", + data=HumanInputRequiredResponse.Data( + form_id="form-1", + node_id="node-1", + node_title="Approval", + form_content="Need approval", + inputs=[], + actions=[UserAction(id="approve", title="Approve")], + display_in_ui=True, + form_token="token-1", + resolved_default_values={}, + expiration_time=123, + ), + ) + yield WorkflowPauseStreamResponse( + task_id="task", + workflow_run_id="run-id", + data=WorkflowPauseStreamResponse.Data( + workflow_run_id="run-id", + paused_nodes=["node-1"], + outputs={}, + reasons=[ + { + "type": PauseReasonType.HUMAN_INPUT_REQUIRED, + "form_id": "form-1", + "node_id": "node-1", + "expiration_time": 123, + }, + ], + status="paused", + created_at=1, + elapsed_time=0.1, + total_tokens=0, + total_steps=0, + ), + ) + + response = pipeline._to_blocking_response(_gen()) + + assert isinstance(response, AdvancedChatPausedBlockingResponse) + assert response.data.answer == "partial answer" + assert response.data.workflow_run_id == "run-id" + assert response.data.reasons[0]["form_id"] == "form-1" + assert response.data.reasons[0]["expiration_time"] == 123 + + def test_workflow_blocking_pipeline_pause_payload_contract(self, monkeypatch: pytest.MonkeyPatch) -> None: + from core.app.apps.workflow import generate_task_pipeline as workflow_pipeline_module + from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline + + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant", + app_id="app", + app_mode=AppMode.WORKFLOW, + additional_features=AppAdditionalFeatures(), + variables=[], + workflow_id="workflow-id", + ) + application_generate_entity = WorkflowAppGenerateEntity.model_construct( + task_id="task", + app_config=app_config, + inputs={}, + files=[], + user_id="user", + stream=False, + invoke_from=InvokeFrom.WEB_APP, + trace_manager=None, + workflow_execution_id="run-id", + extras={}, + call_depth=0, + ) + pipeline = WorkflowAppGenerateTaskPipeline( + application_generate_entity=application_generate_entity, + workflow=SimpleNamespace(id="workflow-id", tenant_id="tenant", features_dict={}), + queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None), + user=SimpleNamespace(id="user", session_id="session"), + stream=False, + draft_var_saver_factory=lambda **kwargs: None, + ) + monkeypatch.setattr(workflow_pipeline_module.time, "time", lambda: 1700000000) + + def _gen(): + yield HumanInputRequiredResponse( + task_id="task", + workflow_run_id="run", + data=HumanInputRequiredResponse.Data( + form_id="form-1", + node_id="node-1", + node_title="Human Input", + form_content="content", + expiration_time=1, + ), + ) + yield WorkflowPauseStreamResponse( + task_id="task", + workflow_run_id="run", + data=WorkflowPauseStreamResponse.Data( + workflow_run_id="run", + status=WorkflowExecutionStatus.PAUSED, + outputs={}, + paused_nodes=["node-1"], + reasons=[{"TYPE": "human_input_required", "form_id": "form-1", "expiration_time": 1}], + created_at=1, + elapsed_time=0.1, + total_tokens=0, + total_steps=0, + ), + ) + + response = pipeline._to_blocking_response(_gen()) + + assert isinstance(response, WorkflowAppPausedBlockingResponse) + assert response.data.status == WorkflowExecutionStatus.PAUSED + assert response.data.paused_nodes == ["node-1"] + assert response.data.reasons == [{"TYPE": "human_input_required", "form_id": "form-1", "expiration_time": 1}] + + def test_service_api_pause_event_serializes_hitl_reason(self, monkeypatch: pytest.MonkeyPatch) -> None: + converter = _build_service_api_pause_converter() + converter.workflow_start_to_stream_response( + task_id="task", + workflow_run_id="run-id", + workflow_id="workflow-id", + reason=WorkflowStartReason.INITIAL, + ) + + expiration_time = datetime(2024, 1, 1, tzinfo=UTC) + + class _FakeSession: + def execute(self, _stmt): + return [("form-1", expiration_time, '{"display_in_ui": true}')] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession()) + monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + workflow_response_converter, + "load_form_tokens_by_form_id", + lambda form_ids, session=None, surface=None: {"form-1": "token"}, + ) + + reason = HumanInputRequired( + form_id="form-1", + form_content="Rendered", + inputs=[ + FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="field", default=None), + ], + actions=[UserAction(id="approve", title="Approve")], + display_in_ui=True, + node_id="node-id", + node_title="Human Step", + form_token="token", + ) + queue_event = QueueWorkflowPausedEvent( + reasons=[reason], + outputs={"answer": "value"}, + paused_nodes=["node-id"], + ) + + runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0) + responses = converter.workflow_pause_to_stream_response( + event=queue_event, + task_id="task", + graph_runtime_state=runtime_state, + ) + + assert isinstance(responses[-1], WorkflowPauseStreamResponse) + pause_resp = responses[-1] + assert pause_resp.workflow_run_id == "run-id" + assert pause_resp.data.paused_nodes == ["node-id"] + assert pause_resp.data.outputs == {} + assert pause_resp.data.reasons[0]["TYPE"] == "human_input_required" + assert pause_resp.data.reasons[0]["form_id"] == "form-1" + assert pause_resp.data.reasons[0]["form_token"] == "token" + assert pause_resp.data.reasons[0]["expiration_time"] == int(expiration_time.timestamp()) + + assert isinstance(responses[0], HumanInputRequiredResponse) + hi_resp = responses[0] + assert hi_resp.data.form_id == "form-1" + assert hi_resp.data.node_id == "node-id" + assert hi_resp.data.node_title == "Human Step" + assert hi_resp.data.inputs[0].output_variable_name == "field" + assert hi_resp.data.actions[0].id == "approve" + assert hi_resp.data.display_in_ui is True + assert hi_resp.data.form_token == "token" + assert hi_resp.data.expiration_time == int(expiration_time.timestamp()) + + # Snapshot payload contract + def test_snapshot_events_include_pause_payload_contract(self, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED) + snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) + resumption_context = _build_resumption_context("task-ctx") + monkeypatch.setattr( + "services.workflow_event_snapshot_service.load_form_tokens_by_form_id", + lambda form_ids, session=None, surface=None: {"form-1": "wtok"}, + ) + + class _SessionContext: + def __init__(self, session): + self._session = session + + def __enter__(self): + return self._session + + def __exit__(self, exc_type, exc, tb): + return False + + def session_maker() -> _SessionContext: + return _SessionContext( + SimpleNamespace( + execute=lambda _stmt: [("form-1", datetime(2024, 1, 1, tzinfo=UTC), '{"display_in_ui": true}')], + ) + ) + + pause_entity = _FakePauseEntity( + pause_id="pause-1", + workflow_run_id="run-1", + paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), + pause_reasons=[ + HumanInputRequired( + form_id="form-1", + form_content="content", + node_id="node-1", + node_title="Human Input", + form_token="wtok", + ) + ], + ) + + events = _build_snapshot_events( + workflow_run=workflow_run, + node_snapshots=[snapshot], + task_id="task-ctx", + message_context=None, + pause_entity=pause_entity, + resumption_context=resumption_context, + session_maker=session_maker, + ) + + assert [event["event"] for event in events] == [ + "workflow_started", + "node_started", + "node_finished", + "human_input_required", + "workflow_paused", + ] + assert events[2]["data"]["status"] == WorkflowNodeExecutionStatus.PAUSED.value + assert events[3]["data"]["form_token"] == "wtok" + assert events[3]["data"]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) + pause_data = events[-1]["data"] + assert pause_data["paused_nodes"] == ["node-1"] + assert pause_data["outputs"] == {"result": "value"} + assert pause_data["reasons"][0]["TYPE"] == "human_input_required" + assert pause_data["reasons"][0]["form_token"] == "wtok" + assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) + assert pause_data["status"] == WorkflowExecutionStatus.PAUSED.value + assert pause_data["created_at"] == int(workflow_run.created_at.timestamp()) + assert pause_data["elapsed_time"] == workflow_run.elapsed_time + assert pause_data["total_tokens"] == workflow_run.total_tokens + assert pause_data["total_steps"] == workflow_run.total_steps diff --git a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py new file mode 100644 index 0000000000..531f722ceb --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py @@ -0,0 +1,184 @@ +"""Unit tests for Service API human input form endpoints.""" + +from __future__ import annotations + +import json +import sys +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from werkzeug.exceptions import NotFound + +from controllers.service_api.app.human_input_form import WorkflowHumanInputFormApi +from models.human_input import RecipientType +from tests.unit_tests.controllers.service_api.conftest import _unwrap + + +class TestWorkflowHumanInputFormApi: + def test_get_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + definition = SimpleNamespace( + model_dump=lambda: { + "rendered_content": "Rendered form content", + "inputs": [{"output_variable_name": "name"}], + "default_values": {"name": "Alice", "age": 30, "meta": {"k": "v"}}, + "user_actions": [{"id": "approve", "title": "Approve"}], + } + ) + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + get_definition=lambda: definition, + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/form/human_input/token-1", method="GET"): + response = handler(api, app_model=app_model, form_token="token-1") + + payload = json.loads(response.get_data(as_text=True)) + assert payload == { + "form_content": "Rendered form content", + "inputs": [{"output_variable_name": "name"}], + "resolved_default_values": {"name": "Alice", "age": "30", "meta": '{"k": "v"}'}, + "user_actions": [{"id": "approve", "title": "Approve"}], + "expiration_time": int(form.expiration_time.timestamp()), + } + service_mock.get_form_by_token.assert_called_once_with("token-1") + service_mock.ensure_form_active.assert_called_once_with(form) + + def test_get_form_not_in_app(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + form = SimpleNamespace( + app_id="another-app", + tenant_id="tenant-1", + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/form/human_input/token-1", method="GET"): + with pytest.raises(NotFound): + handler(api, app_model=app_model, form_token="token-1") + + @pytest.mark.parametrize( + "recipient_type", + [ + RecipientType.CONSOLE, + RecipientType.BACKSTAGE, + RecipientType.EMAIL_MEMBER, + RecipientType.EMAIL_EXTERNAL, + ], + ) + def test_get_rejects_non_service_api_recipient_types( + self, app, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType + ) -> None: + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=recipient_type, + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/form/human_input/token-1", method="GET"): + with pytest.raises(NotFound): + handler(api, app_model=app_model, form_token="token-1") + + service_mock.ensure_form_active.assert_not_called() + + def test_post_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.post) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context( + "/form/human_input/token-1", + method="POST", + json={"inputs": {"name": "Alice"}, "action": "approve", "user": "external-1"}, + ): + response, status = handler(api, app_model=app_model, end_user=end_user, form_token="token-1") + + assert response == {} + assert status == 200 + service_mock.submit_form_by_token.assert_called_once_with( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token-1", + selected_action_id="approve", + form_data={"name": "Alice"}, + submission_end_user_id="end-user-1", + ) + + @pytest.mark.parametrize( + "recipient_type", + [ + RecipientType.CONSOLE, + RecipientType.BACKSTAGE, + RecipientType.EMAIL_MEMBER, + RecipientType.EMAIL_EXTERNAL, + ], + ) + def test_post_rejects_non_service_api_recipient_types( + self, app, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType + ) -> None: + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=recipient_type, + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.post) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context( + "/form/human_input/token-1", + method="POST", + json={"inputs": {"name": "Alice"}, "action": "approve", "user": "external-1"}, + ): + with pytest.raises(NotFound): + handler(api, app_model=app_model, end_user=end_user, form_token="token-1") + + service_mock.submit_form_by_token.assert_not_called() diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py new file mode 100644 index 0000000000..f45a7f9632 --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py @@ -0,0 +1,166 @@ +"""Unit tests for Service API workflow event stream endpoints.""" + +from __future__ import annotations + +import json +import sys +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from werkzeug.exceptions import NotFound + +from controllers.service_api.app.error import NotWorkflowAppError +from controllers.service_api.app.workflow_events import WorkflowEventsApi +from models.enums import CreatorUserRole +from models.model import AppMode +from tests.unit_tests.controllers.service_api.conftest import _unwrap + + +def _mock_repo_for_run(monkeypatch: pytest.MonkeyPatch, workflow_run): + workflow_events_module = sys.modules["controllers.service_api.app.workflow_events"] + repo = SimpleNamespace(get_workflow_run_by_id_and_tenant_id=lambda **_kwargs: workflow_run) + monkeypatch.setattr( + workflow_events_module.DifyAPIRepositoryFactory, + "create_api_workflow_run_repository", + lambda *_args, **_kwargs: repo, + ) + monkeypatch.setattr(workflow_events_module, "db", SimpleNamespace(engine=object())) + return workflow_events_module + + +class TestWorkflowEventsApi: + def test_wrong_app_mode(self, app) -> None: + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(mode=AppMode.CHAT.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + with pytest.raises(NotWorkflowAppError): + handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + def test_workflow_run_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + _mock_repo_for_run(monkeypatch, workflow_run=None) + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + with pytest.raises(NotFound): + handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + def test_workflow_run_permission_denied(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.ACCOUNT, + created_by="another-user", + finished_at=None, + ) + _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + with pytest.raises(NotFound): + handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + def test_finished_run_returns_sse(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=datetime(2099, 1, 1, tzinfo=UTC), + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + monkeypatch.setattr( + workflow_events_module.WorkflowResponseConverter, + "workflow_run_result_to_finish_response", + lambda **_kwargs: SimpleNamespace( + model_dump=lambda mode="json": {"task_id": "run-1", "status": "succeeded"}, + event=SimpleNamespace(value="workflow_finished"), + ), + ) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.mimetype == "text/event-stream" + body = response.get_data(as_text=True).strip() + assert body.startswith("data: ") + payload = json.loads(body[len("data: ") :]) + assert payload["task_id"] == "run-1" + assert payload["event"] == "workflow_finished" + + def test_running_run_streams_events(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=None, + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + msg_generator = Mock() + msg_generator.retrieve_events.return_value = ["raw-event"] + workflow_generator = Mock() + workflow_generator.convert_to_event_stream.return_value = iter(["data: streamed\n\n"]) + monkeypatch.setattr(workflow_events_module, "MessageGenerator", lambda: msg_generator) + monkeypatch.setattr(workflow_events_module, "WorkflowAppGenerator", lambda: workflow_generator) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.get_data(as_text=True) == "data: streamed\n\n" + msg_generator.retrieve_events.assert_called_once_with( + AppMode.WORKFLOW, + "run-1", + terminal_events=None, + ) + workflow_generator.convert_to_event_stream.assert_called_once_with(["raw-event"]) + + def test_running_run_with_snapshot(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=None, + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + msg_generator = Mock() + workflow_generator = Mock() + workflow_generator.convert_to_event_stream.return_value = iter(["data: snapshot\n\n"]) + snapshot_builder = Mock(return_value=["snapshot-events"]) + monkeypatch.setattr(workflow_events_module, "MessageGenerator", lambda: msg_generator) + monkeypatch.setattr(workflow_events_module, "WorkflowAppGenerator", lambda: workflow_generator) + monkeypatch.setattr(workflow_events_module, "build_workflow_event_stream", snapshot_builder) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1&include_state_snapshot=true", method="GET"): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.get_data(as_text=True) == "data: snapshot\n\n" + msg_generator.retrieve_events.assert_not_called() + snapshot_builder.assert_called_once() + workflow_generator.convert_to_event_stream.assert_called_once_with(["snapshot-events"]) diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py index 12d5e7345d..288659b192 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py @@ -22,6 +22,8 @@ import pytest from werkzeug.exceptions import Forbidden, NotFound from controllers.service_api.dataset.document import ( + DeprecatedDocumentAddByTextApi, + DeprecatedDocumentUpdateByTextApi, DocumentAddByFileApi, DocumentAddByTextApi, DocumentApi, @@ -1005,7 +1007,7 @@ class TestDocumentAddByTextApi: # Act with app.test_request_context( - f"/datasets/{mock_dataset.id}/document/create_by_text", + f"/datasets/{mock_dataset.id}/document/create-by-text", method="POST", json={ "name": "Test Document", @@ -1037,7 +1039,7 @@ class TestDocumentAddByTextApi: # Act & Assert with app.test_request_context( - f"/datasets/{mock_dataset.id}/document/create_by_text", + f"/datasets/{mock_dataset.id}/document/create-by-text", method="POST", json={"name": "Test Document", "text": "Content"}, headers={"Authorization": "Bearer test_token"}, @@ -1066,7 +1068,7 @@ class TestDocumentAddByTextApi: # Act & Assert with app.test_request_context( - f"/datasets/{mock_dataset.id}/document/create_by_text", + f"/datasets/{mock_dataset.id}/document/create-by-text", method="POST", json={"name": "Test Document", "text": "Content"}, headers={"Authorization": "Bearer test_token"}, @@ -1093,6 +1095,20 @@ class TestArchivedDocumentImmutableError: assert error.code == 403 +class TestDocumentTextRouteDeprecation: + """Test that legacy underscore text routes stay marked deprecated.""" + + def test_create_by_text_legacy_alias_is_deprecated(self): + """Ensure only the legacy create-by-text alias is marked deprecated.""" + assert DeprecatedDocumentAddByTextApi.post.__apidoc__["deprecated"] is True + assert DocumentAddByTextApi.post.__apidoc__.get("deprecated") is not True + + def test_update_by_text_legacy_alias_is_deprecated(self): + """Ensure only the legacy update-by-text alias is marked deprecated.""" + assert DeprecatedDocumentUpdateByTextApi.post.__apidoc__["deprecated"] is True + assert DocumentUpdateByTextApi.post.__apidoc__.get("deprecated") is not True + + # ============================================================================= # Endpoint tests for DocumentUpdateByTextApi, DocumentAddByFileApi, # DocumentUpdateByFileApi. @@ -1162,7 +1178,7 @@ class TestDocumentUpdateByTextApiPost: doc_id = str(uuid.uuid4()) with app.test_request_context( - f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_text", + f"/datasets/{mock_dataset.id}/documents/{doc_id}/update-by-text", method="POST", json={"name": "Updated Doc", "text": "New content"}, headers={"Authorization": "Bearer test_token"}, @@ -1195,7 +1211,7 @@ class TestDocumentUpdateByTextApiPost: doc_id = str(uuid.uuid4()) with app.test_request_context( - f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_text", + f"/datasets/{mock_dataset.id}/documents/{doc_id}/update-by-text", method="POST", json={"name": "Doc", "text": "Content"}, headers={"Authorization": "Bearer test_token"}, diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py index f2df35d7d0..6debeb4fdd 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py @@ -1,7 +1,10 @@ from collections.abc import Generator +import pytest + from core.app.apps.advanced_chat.generate_response_converter import AdvancedChatAppGenerateResponseConverter from core.app.entities.task_entities import ( + AdvancedChatPausedBlockingResponse, ChatbotAppBlockingResponse, ChatbotAppStreamResponse, ErrorStreamResponse, @@ -10,7 +13,8 @@ from core.app.entities.task_entities import ( NodeStartStreamResponse, PingStreamResponse, ) -from graphon.enums import WorkflowNodeExecutionStatus +from graphon.entities.pause_reason import PauseReasonType +from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus class TestAdvancedChatGenerateResponseConverter: @@ -28,6 +32,37 @@ class TestAdvancedChatGenerateResponseConverter: response = AdvancedChatAppGenerateResponseConverter.convert_blocking_simple_response(blocking) assert "usage" not in response["metadata"] + def test_blocking_full_response_derives_pause_data_from_model_dump(self, monkeypatch: pytest.MonkeyPatch): + data = AdvancedChatPausedBlockingResponse.Data( + id="msg-1", + mode="chat", + conversation_id="c1", + message_id="m1", + workflow_run_id="run-1", + answer="partial", + metadata={"usage": {"total_tokens": 1}}, + created_at=1, + paused_nodes=["node-1"], + reasons=[{"type": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "form-1"}], + status=WorkflowExecutionStatus.PAUSED, + elapsed_time=0.1, + total_tokens=0, + total_steps=0, + ) + original_model_dump = type(data).model_dump + + def _model_dump_with_future_field(self, *args, **kwargs): + payload = original_model_dump(self, *args, **kwargs) + payload["future_field"] = "future-value" + return payload + + monkeypatch.setattr(type(data), "model_dump", _model_dump_with_future_field) + blocking = AdvancedChatPausedBlockingResponse(task_id="t1", data=data) + + response = AdvancedChatAppGenerateResponseConverter.convert_blocking_full_response(blocking) + + assert response["data"]["future_field"] == "future-value" + def test_stream_simple_response_includes_node_events(self): node_start = NodeStartStreamResponse( task_id="t1", diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py index 29fd63c063..64bcfa9a18 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py @@ -39,15 +39,19 @@ from core.app.entities.queue_entities import ( QueueWorkflowSucceededEvent, ) from core.app.entities.task_entities import ( + AdvancedChatPausedBlockingResponse, AnnotationReply, AnnotationReplyAccount, + HumanInputRequiredResponse, MessageAudioStreamResponse, MessageEndStreamResponse, PingStreamResponse, ) from core.base.tts.app_generator_tts_publisher import AudioTrunk from core.workflow.system_variables import build_system_variables +from graphon.entities.pause_reason import PauseReasonType from graphon.enums import BuiltinNodeTypes +from graphon.nodes.human_input.entities import UserAction from graphon.runtime import GraphRuntimeState, VariablePool from libs.datetime_utils import naive_utc_now from models.enums import MessageStatus @@ -123,6 +127,57 @@ class TestAdvancedChatGenerateTaskPipeline: assert response.data.answer == "done" assert response.data.metadata == {"k": "v"} + def test_to_blocking_response_falls_back_to_human_input_required_when_pause_event_missing(self): + pipeline = _make_pipeline() + pipeline._task_state.answer = "partial answer" + pipeline._workflow_run_id = "run-id" + pipeline._graph_runtime_state = GraphRuntimeState( + variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + start_at=0.0, + total_tokens=7, + node_run_steps=3, + ) + + def _gen(): + yield HumanInputRequiredResponse( + task_id="task", + workflow_run_id="run-id", + data=HumanInputRequiredResponse.Data( + form_id="form-1", + node_id="node-1", + node_title="Approval", + form_content="Need approval", + inputs=[], + actions=[UserAction(id="approve", title="Approve")], + display_in_ui=True, + form_token="token-1", + resolved_default_values={}, + expiration_time=123, + ), + ) + + response = pipeline._to_blocking_response(_gen()) + + assert isinstance(response, AdvancedChatPausedBlockingResponse) + assert response.data.workflow_run_id == "run-id" + assert response.data.status == "paused" + assert response.data.paused_nodes == ["node-1"] + assert response.data.reasons == [ + { + "TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED, + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Approval", + "form_content": "Need approval", + "inputs": [], + "actions": [{"id": "approve", "title": "Approve", "button_style": "default"}], + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {}, + "expiration_time": 123, + } + ] + def test_handle_text_chunk_event_updates_state(self): pipeline = _make_pipeline() pipeline._message_cycle_manager = SimpleNamespace( diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_generate_response_converter.py b/api/tests/unit_tests/core/app/apps/test_base_app_generate_response_converter.py new file mode 100644 index 0000000000..560652f8cb --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/test_base_app_generate_response_converter.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from collections.abc import Generator + +from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter +from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.entities.task_entities import ( + AppStreamResponse, + PingStreamResponse, + WorkflowAppBlockingResponse, + WorkflowAppStreamResponse, +) +from graphon.enums import WorkflowExecutionStatus + + +class _DummyConverter(AppGenerateResponseConverter[WorkflowAppBlockingResponse]): + blocking_full_calls: list[WorkflowAppBlockingResponse] = [] + blocking_simple_calls: list[WorkflowAppBlockingResponse] = [] + stream_full_calls: list[Generator[AppStreamResponse, None, None]] = [] + stream_simple_calls: list[Generator[AppStreamResponse, None, None]] = [] + + @classmethod + def reset(cls) -> None: + cls.blocking_full_calls = [] + cls.blocking_simple_calls = [] + cls.stream_full_calls = [] + cls.stream_simple_calls = [] + + @classmethod + def convert_blocking_full_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, object]: + cls.blocking_full_calls.append(blocking_response) + return {"kind": "blocking-full", "task_id": blocking_response.task_id} + + @classmethod + def convert_blocking_simple_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, object]: + cls.blocking_simple_calls.append(blocking_response) + return {"kind": "blocking-simple", "task_id": blocking_response.task_id} + + @classmethod + def convert_stream_full_response( + cls, stream_response: Generator[AppStreamResponse, None, None] + ) -> Generator[dict | str, None, None]: + cls.stream_full_calls.append(stream_response) + yield {"kind": "stream-full"} + + @classmethod + def convert_stream_simple_response( + cls, stream_response: Generator[AppStreamResponse, None, None] + ) -> Generator[dict | str, None, None]: + cls.stream_simple_calls.append(stream_response) + yield {"kind": "stream-simple"} + + +def _build_blocking_response() -> WorkflowAppBlockingResponse: + return WorkflowAppBlockingResponse( + task_id="task-1", + workflow_run_id="run-1", + data=WorkflowAppBlockingResponse.Data( + id="run-1", + workflow_id="workflow-1", + status=WorkflowExecutionStatus.SUCCEEDED, + outputs={"ok": True}, + error=None, + elapsed_time=0.1, + total_tokens=0, + total_steps=1, + created_at=1, + finished_at=2, + ), + ) + + +def _build_stream_response() -> Generator[AppStreamResponse, None, None]: + yield WorkflowAppStreamResponse( + workflow_run_id="run-1", + stream_response=PingStreamResponse(task_id="task-1"), + ) + + +def test_convert_routes_blocking_response_by_invoke_from() -> None: + _DummyConverter.reset() + blocking_response = _build_blocking_response() + + full_result = _DummyConverter.convert(blocking_response, InvokeFrom.SERVICE_API) + simple_result = _DummyConverter.convert(blocking_response, InvokeFrom.WEB_APP) + + assert full_result == {"kind": "blocking-full", "task_id": "task-1"} + assert simple_result == {"kind": "blocking-simple", "task_id": "task-1"} + assert _DummyConverter.blocking_full_calls == [blocking_response] + assert _DummyConverter.blocking_simple_calls == [blocking_response] + + +def test_convert_routes_stream_response_by_invoke_from() -> None: + _DummyConverter.reset() + + full_result = list(_DummyConverter.convert(_build_stream_response(), InvokeFrom.SERVICE_API)) + simple_result = list(_DummyConverter.convert(_build_stream_response(), InvokeFrom.WEB_APP)) + + assert full_result == [{"kind": "stream-full"}] + assert simple_result == [{"kind": "stream-simple"}] + assert len(_DummyConverter.stream_full_calls) == 1 + assert len(_DummyConverter.stream_simple_calls) == 1 diff --git a/api/tests/unit_tests/core/app/apps/test_message_generator.py b/api/tests/unit_tests/core/app/apps/test_message_generator.py index 25377e633e..90c9abf35c 100644 --- a/api/tests/unit_tests/core/app/apps/test_message_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_message_generator.py @@ -1,6 +1,7 @@ from unittest.mock import Mock, patch from core.app.apps.message_generator import MessageGenerator +from core.app.entities.task_entities import StreamEvent from models.model import AppMode @@ -23,7 +24,21 @@ class TestMessageGenerator: "core.app.apps.message_generator.stream_topic_events", return_value=iter([{"event": "ping"}]) ) as mock_stream, ): - events = list(MessageGenerator.retrieve_events(AppMode.WORKFLOW, "run-1", idle_timeout=1, ping_interval=2)) + events = list( + MessageGenerator.retrieve_events( + AppMode.WORKFLOW, + "run-1", + idle_timeout=1, + ping_interval=2, + terminal_events=[StreamEvent.WORKFLOW_FINISHED.value], + ) + ) assert events == [{"event": "ping"}] - mock_stream.assert_called_once() + mock_stream.assert_called_once_with( + topic="topic", + idle_timeout=1, + ping_interval=2, + on_subscribe=None, + terminal_events=[StreamEvent.WORKFLOW_FINISHED.value], + ) diff --git a/api/tests/unit_tests/core/app/apps/test_streaming_utils.py b/api/tests/unit_tests/core/app/apps/test_streaming_utils.py index a7714c56ce..58f0e47a4b 100644 --- a/api/tests/unit_tests/core/app/apps/test_streaming_utils.py +++ b/api/tests/unit_tests/core/app/apps/test_streaming_utils.py @@ -88,6 +88,10 @@ def test_normalize_terminal_events_defaults(): } +def test_normalize_terminal_events_empty_values(): + assert _normalize_terminal_events([]) == set({}) + + def test_stream_topic_events_emits_ping_and_idle_timeout(monkeypatch): topic = FakeTopic() times = [1000.0, 1000.0, 1001.0, 1001.0, 1002.0] @@ -106,3 +110,21 @@ def test_stream_topic_events_emits_ping_and_idle_timeout(monkeypatch): assert next(generator) == StreamEvent.PING.value # next receive yields None -> ping interval triggers assert next(generator) == StreamEvent.PING.value + + +def test_stream_topic_events_can_continue_past_pause(): + topic = FakeTopic() + topic.publish(json.dumps({"event": StreamEvent.WORKFLOW_PAUSED.value}).encode()) + topic.publish(json.dumps({"event": StreamEvent.WORKFLOW_FINISHED.value}).encode()) + + generator = stream_topic_events( + topic=topic, + idle_timeout=1.0, + terminal_events=[StreamEvent.WORKFLOW_FINISHED.value], + ) + + assert next(generator) == StreamEvent.PING.value + assert next(generator)["event"] == StreamEvent.WORKFLOW_PAUSED.value + assert next(generator)["event"] == StreamEvent.WORKFLOW_FINISHED.value + with pytest.raises(StopIteration): + next(generator) diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index 99433478d3..0bcc1029b0 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -36,11 +36,12 @@ from core.app.entities.queue_entities import ( ) from core.app.entities.task_entities import ( ErrorStreamResponse, + HumanInputRequiredResponse, MessageAudioEndStreamResponse, MessageAudioStreamResponse, PingStreamResponse, + WorkflowAppPausedBlockingResponse, WorkflowFinishStreamResponse, - WorkflowPauseStreamResponse, WorkflowStartStreamResponse, ) from core.base.tts.app_generator_tts_publisher import AudioTrunk @@ -91,27 +92,50 @@ def _make_pipeline(): class TestWorkflowGenerateTaskPipeline: - def test_to_blocking_response_handles_pause(self): + def test_to_blocking_response_falls_back_to_human_input_required_when_pause_event_missing(self): pipeline = _make_pipeline() + pipeline._graph_runtime_state = GraphRuntimeState( + variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + start_at=0.0, + total_tokens=5, + node_run_steps=2, + ) def _gen(): - yield WorkflowPauseStreamResponse( + yield HumanInputRequiredResponse( task_id="task", - workflow_run_id="run", - data=WorkflowPauseStreamResponse.Data( - workflow_run_id="run", - status=WorkflowExecutionStatus.PAUSED, - outputs={}, - created_at=1, - elapsed_time=0.1, - total_tokens=0, - total_steps=0, + workflow_run_id="run-id", + data=HumanInputRequiredResponse.Data( + form_id="form-1", + node_id="node-1", + node_title="Human Input", + form_content="content", + expiration_time=1, ), ) response = pipeline._to_blocking_response(_gen()) + assert isinstance(response, WorkflowAppPausedBlockingResponse) + assert response.workflow_run_id == "run-id" assert response.data.status == WorkflowExecutionStatus.PAUSED + assert response.data.created_at == 0 + assert response.data.paused_nodes == ["node-1"] + assert response.data.reasons == [ + { + "TYPE": "human_input_required", + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "content", + "inputs": [], + "actions": [], + "display_in_ui": False, + "form_token": None, + "resolved_default_values": {}, + "expiration_time": 1, + } + ] def test_to_blocking_response_handles_finish(self): pipeline = _make_pipeline() diff --git a/api/tests/unit_tests/core/helper/test_creators.py b/api/tests/unit_tests/core/helper/test_creators.py new file mode 100644 index 0000000000..df67d3f513 --- /dev/null +++ b/api/tests/unit_tests/core/helper/test_creators.py @@ -0,0 +1,106 @@ +"""Tests for the Creators Platform helper module.""" + +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from yarl import URL + + +@pytest.fixture(autouse=True) +def _patch_creators_url(monkeypatch): + """Patch the module-level creators_platform_api_url for all tests.""" + monkeypatch.setattr( + "core.helper.creators.creators_platform_api_url", + URL("https://creators.example.com"), + ) + + +class TestUploadDSL: + @patch("core.helper.creators.httpx.post") + def test_returns_claim_code(self, mock_post): + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = {"data": {"claim_code": "abc123"}} + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + from core.helper.creators import upload_dsl + + result = upload_dsl(b"app: demo", "demo.yaml") + + assert result == "abc123" + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + assert "anonymous-upload" in call_kwargs.args[0] + assert call_kwargs.kwargs["timeout"] == 30 + + @patch("core.helper.creators.httpx.post") + def test_raises_on_missing_claim_code(self, mock_post): + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = {"data": {}} + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + from core.helper.creators import upload_dsl + + with pytest.raises(ValueError, match="claim_code"): + upload_dsl(b"app: demo") + + @patch("core.helper.creators.httpx.post") + def test_raises_on_http_error(self, mock_post): + mock_response = MagicMock(spec=httpx.Response) + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Server Error", + request=MagicMock(), + response=MagicMock(), + ) + mock_post.return_value = mock_response + + from core.helper.creators import upload_dsl + + with pytest.raises(httpx.HTTPStatusError): + upload_dsl(b"app: demo") + + +class TestGetRedirectUrl: + @patch("core.helper.creators.dify_config") + def test_without_oauth_client_id(self, mock_config): + mock_config.CREATORS_PLATFORM_API_URL = "https://creators.example.com" + mock_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID = "" + + from core.helper.creators import get_redirect_url + + url = get_redirect_url("user-1", "claim-abc") + + assert "dsl_claim_code=claim-abc" in url + assert "oauth_code" not in url + assert url.startswith("https://creators.example.com") + + @patch("core.helper.creators.dify_config") + def test_with_oauth_client_id(self, mock_config): + mock_config.CREATORS_PLATFORM_API_URL = "https://creators.example.com" + mock_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID = "client-xyz" + + with patch( + "services.oauth_server.OAuthServerService.sign_oauth_authorization_code", + return_value="oauth-code-123", + ) as mock_sign: + from core.helper.creators import get_redirect_url + + url = get_redirect_url("user-1", "claim-abc") + + mock_sign.assert_called_once_with("client-xyz", "user-1") + assert "dsl_claim_code=claim-abc" in url + assert "oauth_code=oauth-code-123" in url + + @patch("core.helper.creators.dify_config") + def test_strips_trailing_slash(self, mock_config): + mock_config.CREATORS_PLATFORM_API_URL = "https://creators.example.com/" + mock_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID = "" + + from core.helper.creators import get_redirect_url + + url = get_redirect_url("user-1", "claim-abc") + + assert url.startswith("https://creators.example.com?") + assert "creators.example.com/?" not in url diff --git a/api/tests/unit_tests/core/test_model_manager.py b/api/tests/unit_tests/core/test_model_manager.py index f5efb78b61..9031c2b075 100644 --- a/api/tests/unit_tests/core/test_model_manager.py +++ b/api/tests/unit_tests/core/test_model_manager.py @@ -6,7 +6,7 @@ from graphon.model_runtime.entities.model_entities import ModelType from pytest_mock import MockerFixture from core.entities.provider_entities import ModelLoadBalancingConfiguration -from core.model_manager import LBModelManager +from core.model_manager import LBModelManager, ModelManager from extensions.ext_redis import redis_client @@ -40,6 +40,29 @@ def lb_model_manager(): return lb_model_manager +def test_model_manager_with_cache_enabled_reuses_stored_credentials(): + """With ``enable_credentials_cache=True``, later calls for the same key return cached creds.""" + provider_manager = MagicMock() + bundle = MagicMock() + bundle.configuration.provider.provider = "openai" + bundle.configuration.tenant_id = "tenant-1" + bundle.configuration.model_settings = [] + bundle.model_type_instance.model_type = ModelType.LLM + get_creds = MagicMock(return_value={"api_key": "first"}) + bundle.configuration.get_current_credentials = get_creds + provider_manager.get_provider_model_bundle.return_value = bundle + + manager = ModelManager(provider_manager, enable_credentials_cache=True) + first = manager.get_model_instance("tenant-1", "openai", ModelType.LLM, "gpt-4") + assert first.credentials == {"api_key": "first"} + get_creds.assert_called_once() + + get_creds.return_value = {"api_key": "second"} + second = manager.get_model_instance("tenant-1", "openai", ModelType.LLM, "gpt-4") + assert second.credentials == {"api_key": "first"} + get_creds.assert_called_once() + + def test_lb_model_manager_fetch_next(mocker: MockerFixture, lb_model_manager: LBModelManager): # initialize redis client redis_client.initialize(redis.Redis()) diff --git a/api/tests/unit_tests/core/test_provider_manager.py b/api/tests/unit_tests/core/test_provider_manager.py index f45b43082c..a5a542c94f 100644 --- a/api/tests/unit_tests/core/test_provider_manager.py +++ b/api/tests/unit_tests/core/test_provider_manager.py @@ -372,6 +372,78 @@ def test_get_configurations_binds_manager_runtime_to_provider_configuration( provider_configuration.bind_model_runtime.assert_called_once_with(manager._model_runtime) +def test_get_configurations_reuses_cached_result_for_same_tenant(mocker: MockerFixture, mock_provider_entity): + manager = _build_provider_manager(mocker) + provider_configuration = Mock() + provider_factory = Mock() + provider_factory.get_providers.return_value = [mock_provider_entity] + custom_configuration = SimpleNamespace(provider=None, models=[]) + system_configuration = SimpleNamespace(enabled=False, quota_configurations=[], current_quota_type=None) + + with ( + patch.object(manager, "_get_all_providers", return_value={"openai": []}) as mock_get_all_providers, + patch.object(manager, "_init_trial_provider_records", return_value={"openai": []}), + patch.object(manager, "_get_all_provider_models", return_value={"openai": []}), + patch.object(manager, "_get_all_preferred_model_providers", return_value={}), + patch.object(manager, "_get_all_provider_model_settings", return_value={}), + patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}), + patch.object(manager, "_get_all_provider_model_credentials", return_value={}), + patch.object(manager, "_to_custom_configuration", return_value=custom_configuration), + patch.object(manager, "_to_system_configuration", return_value=system_configuration), + patch.object(manager, "_to_model_settings", return_value=[]), + patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory) as mock_factory_cls, + patch( + "core.provider_manager.ProviderConfiguration", + return_value=provider_configuration, + ) as mock_provider_configuration, + ): + first = manager.get_configurations("tenant-id") + second = manager.get_configurations("tenant-id") + + assert first is second + mock_get_all_providers.assert_called_once_with("tenant-id") + mock_factory_cls.assert_called_once_with(model_runtime=manager._model_runtime) + mock_provider_configuration.assert_called_once() + provider_configuration.bind_model_runtime.assert_called_once_with(manager._model_runtime) + + +def test_clear_configurations_cache_rebuilds_requested_tenant(mocker: MockerFixture, mock_provider_entity): + manager = _build_provider_manager(mocker) + provider_factory = Mock() + provider_factory.get_providers.return_value = [mock_provider_entity] + custom_configuration = SimpleNamespace(provider=None, models=[]) + system_configuration = SimpleNamespace(enabled=False, quota_configurations=[], current_quota_type=None) + provider_configuration_first = Mock() + provider_configuration_second = Mock() + + with ( + patch.object(manager, "_get_all_providers", return_value={"openai": []}) as mock_get_all_providers, + patch.object(manager, "_init_trial_provider_records", return_value={"openai": []}), + patch.object(manager, "_get_all_provider_models", return_value={"openai": []}), + patch.object(manager, "_get_all_preferred_model_providers", return_value={}), + patch.object(manager, "_get_all_provider_model_settings", return_value={}), + patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}), + patch.object(manager, "_get_all_provider_model_credentials", return_value={}), + patch.object(manager, "_to_custom_configuration", return_value=custom_configuration), + patch.object(manager, "_to_system_configuration", return_value=system_configuration), + patch.object(manager, "_to_model_settings", return_value=[]), + patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory), + patch( + "core.provider_manager.ProviderConfiguration", + side_effect=[provider_configuration_first, provider_configuration_second], + ) as mock_provider_configuration, + ): + first = manager.get_configurations("tenant-id") + manager.clear_configurations_cache("tenant-id") + second = manager.get_configurations("tenant-id") + + assert first is not second + assert mock_get_all_providers.call_count == 2 + assert mock_provider_configuration.call_count == 2 + provider_configuration_first.bind_model_runtime.assert_called_once_with(manager._model_runtime) + provider_configuration_second.bind_model_runtime.assert_called_once_with(manager._model_runtime) + + def test_get_provider_model_bundle_returns_selected_model_type_instance(mocker: MockerFixture): manager = _build_provider_manager(mocker) provider_configuration = Mock() diff --git a/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py b/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py index 5691f33e65..6bb86ebe78 100644 --- a/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py +++ b/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py @@ -2,50 +2,50 @@ from __future__ import annotations import pytest -from core.tools.utils import system_oauth_encryption as oauth_encryption -from core.tools.utils.system_oauth_encryption import OAuthEncryptionError, SystemOAuthEncrypter +from core.tools.utils import system_encryption as encryption +from core.tools.utils.system_encryption import EncryptionError, SystemEncrypter -def test_system_oauth_encrypter_roundtrip(): - encrypter = SystemOAuthEncrypter(secret_key="test-secret") +def test_system_encrypter_roundtrip(): + encrypter = SystemEncrypter(secret_key="test-secret") payload = {"client_id": "cid", "client_secret": "csecret", "grant_type": "authorization_code"} - encrypted = encrypter.encrypt_oauth_params(payload) - decrypted = encrypter.decrypt_oauth_params(encrypted) + encrypted = encrypter.encrypt_params(payload) + decrypted = encrypter.decrypt_params(encrypted) assert encrypted assert dict(decrypted) == payload -def test_system_oauth_encrypter_decrypt_validates_input(): - encrypter = SystemOAuthEncrypter(secret_key="test-secret") +def test_system_encrypter_decrypt_validates_input(): + encrypter = SystemEncrypter(secret_key="test-secret") with pytest.raises(ValueError, match="must be a string"): - encrypter.decrypt_oauth_params(123) # type: ignore[arg-type] + encrypter.decrypt_params(123) # type: ignore[arg-type] with pytest.raises(ValueError, match="cannot be empty"): - encrypter.decrypt_oauth_params("") + encrypter.decrypt_params("") -def test_system_oauth_encrypter_raises_oauth_error_for_invalid_ciphertext(): - encrypter = SystemOAuthEncrypter(secret_key="test-secret") +def test_system_encrypter_raises_error_for_invalid_ciphertext(): + encrypter = SystemEncrypter(secret_key="test-secret") - with pytest.raises(OAuthEncryptionError, match="Decryption failed"): - encrypter.decrypt_oauth_params("not-base64") + with pytest.raises(EncryptionError, match="Decryption failed"): + encrypter.decrypt_params("not-base64") -def test_system_oauth_helpers_use_global_cached_instance(monkeypatch): - monkeypatch.setattr(oauth_encryption, "_oauth_encrypter", None) - monkeypatch.setattr("core.tools.utils.system_oauth_encryption.dify_config.SECRET_KEY", "global-secret") +def test_system_helpers_use_global_cached_instance(monkeypatch): + monkeypatch.setattr(encryption, "_encrypter", None) + monkeypatch.setattr("core.tools.utils.system_encryption.dify_config.SECRET_KEY", "global-secret") - first = oauth_encryption.get_system_oauth_encrypter() - second = oauth_encryption.get_system_oauth_encrypter() + first = encryption.get_system_encrypter() + second = encryption.get_system_encrypter() assert first is second - encrypted = oauth_encryption.encrypt_system_oauth_params({"k": "v"}) - assert oauth_encryption.decrypt_system_oauth_params(encrypted) == {"k": "v"} + encrypted = encryption.encrypt_system_params({"k": "v"}) + assert encryption.decrypt_system_params(encrypted) == {"k": "v"} -def test_create_system_oauth_encrypter_factory(): - encrypter = oauth_encryption.create_system_oauth_encrypter(secret_key="factory-secret") - assert isinstance(encrypter, SystemOAuthEncrypter) +def test_create_system_encrypter_factory(): + encrypter = encryption.create_system_encrypter(secret_key="factory-secret") + assert isinstance(encrypter, SystemEncrypter) diff --git a/api/tests/unit_tests/core/workflow/test_human_input_forms.py b/api/tests/unit_tests/core/workflow/test_human_input_forms.py index 6071a95a57..e508815b35 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_forms.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_forms.py @@ -1,6 +1,7 @@ from types import SimpleNamespace -from core.workflow.human_input_forms import load_form_tokens_by_form_id +from core.workflow.human_input_forms import _load_form_tokens_by_form_id, load_form_tokens_by_form_id +from core.workflow.human_input_policy import HumanInputSurface from models.human_input import RecipientType @@ -53,3 +54,50 @@ def test_load_form_tokens_by_form_id_ignores_unsupported_recipients() -> None: ) assert load_form_tokens_by_form_id(["form-1"], session=session) == {} + + +def test_load_form_tokens_by_form_id_uses_shared_priority() -> None: + session = _FakeSession( + recipients=[ + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + access_token="web-token", + ), + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.CONSOLE, + access_token="console-token", + ), + ] + ) + + assert _load_form_tokens_by_form_id(session, ["form-1"]) == {"form-1": "console-token"} + + +def test_load_form_tokens_by_form_id_uses_web_token_for_service_api_surface() -> None: + session = _FakeSession( + recipients=[ + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + access_token="web-token", + ), + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.CONSOLE, + access_token="console-token", + ), + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.BACKSTAGE, + access_token="backstage-token", + ), + ] + ) + + assert load_form_tokens_by_form_id( + ["form-1"], + session=session, + surface=HumanInputSurface.SERVICE_API, + ) == {"form-1": "web-token"} diff --git a/api/tests/unit_tests/core/workflow/test_human_input_policy.py b/api/tests/unit_tests/core/workflow/test_human_input_policy.py new file mode 100644 index 0000000000..e6d0366af5 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_human_input_policy.py @@ -0,0 +1,50 @@ +from core.workflow.human_input_policy import ( + HumanInputSurface, + get_preferred_form_token, + is_recipient_type_allowed_for_surface, +) +from models.human_input import RecipientType + + +def test_service_api_only_allows_public_webapp_forms() -> None: + assert is_recipient_type_allowed_for_surface( + RecipientType.STANDALONE_WEB_APP, + HumanInputSurface.SERVICE_API, + ) + assert not is_recipient_type_allowed_for_surface( + RecipientType.CONSOLE, + HumanInputSurface.SERVICE_API, + ) + assert not is_recipient_type_allowed_for_surface( + RecipientType.BACKSTAGE, + HumanInputSurface.SERVICE_API, + ) + assert not is_recipient_type_allowed_for_surface( + RecipientType.EMAIL_MEMBER, + HumanInputSurface.SERVICE_API, + ) + + +def test_console_only_allows_internal_console_surfaces() -> None: + assert is_recipient_type_allowed_for_surface( + RecipientType.CONSOLE, + HumanInputSurface.CONSOLE, + ) + assert is_recipient_type_allowed_for_surface( + RecipientType.BACKSTAGE, + HumanInputSurface.CONSOLE, + ) + assert not is_recipient_type_allowed_for_surface( + RecipientType.STANDALONE_WEB_APP, + HumanInputSurface.CONSOLE, + ) + + +def test_preferred_form_token_uses_shared_priority_order() -> None: + recipients = [ + (RecipientType.STANDALONE_WEB_APP, "web-token"), + (RecipientType.CONSOLE, "console-token"), + (RecipientType.BACKSTAGE, "backstage-token"), + ] + + assert get_preferred_form_token(recipients) == "backstage-token" diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py new file mode 100644 index 0000000000..ac4b087b91 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from graphon.nodes.human_input.entities import FormDefinition, FormInput, UserAction +from graphon.nodes.human_input.enums import FormInputType +from models.human_input import RecipientType +from repositories.sqlalchemy_api_workflow_run_repository import _build_human_input_required_reason + + +def _build_form_model() -> SimpleNamespace: + expiration_time = datetime(2024, 1, 1, tzinfo=UTC) + definition = FormDefinition( + form_content="content", + inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")], + user_actions=[UserAction(id="approve", title="Approve")], + rendered_content="rendered", + expiration_time=expiration_time, + default_values={"name": "Alice"}, + node_title="Ask Name", + display_in_ui=True, + ) + return SimpleNamespace( + id="form-1", + node_id="node-1", + form_definition=definition.model_dump_json(), + expiration_time=expiration_time, + ) + + +def _build_reason_model() -> SimpleNamespace: + return SimpleNamespace(form_id="form-1", node_id="node-1") + + +def test_build_human_input_required_reason_prefers_standalone_web_app_token() -> None: + reason = _build_human_input_required_reason( + _build_reason_model(), + _build_form_model(), + [ + SimpleNamespace(recipient_type=RecipientType.BACKSTAGE, access_token="btok"), + SimpleNamespace(recipient_type=RecipientType.CONSOLE, access_token="ctok"), + SimpleNamespace(recipient_type=RecipientType.STANDALONE_WEB_APP, access_token="wtok"), + ], + ) + + assert reason.node_title == "Ask Name" + assert reason.resolved_default_values == {"name": "Alice"} + assert not hasattr(reason, "form_token") + + +def test_build_human_input_required_reason_falls_back_to_console_token() -> None: + reason = _build_human_input_required_reason( + _build_reason_model(), + _build_form_model(), + [ + SimpleNamespace(recipient_type=RecipientType.BACKSTAGE, access_token="btok"), + SimpleNamespace(recipient_type=RecipientType.CONSOLE, access_token="ctok"), + ], + ) + + assert reason.node_id == "node-1" + assert reason.actions[0].id == "approve" + assert not hasattr(reason, "form_token") diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index c88daf6b1e..d3f9c5dd9f 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -328,7 +328,8 @@ class TestGenerate: streaming=False, ) assert result == {"result": "advanced-blocking"} - assert gen_spy.call_args.kwargs.get("streaming") is False + call_kwargs = gen_spy.call_args.kwargs + assert call_kwargs.get("streaming") is False retrieve_spy.assert_not_called() # -- ADVANCED_CHAT streaming -------------------------------------------- diff --git a/api/tests/unit_tests/services/test_trigger_provider_service.py b/api/tests/unit_tests/services/test_trigger_provider_service.py index ebf1b36610..6eba60e5f1 100644 --- a/api/tests/unit_tests/services/test_trigger_provider_service.py +++ b/api/tests/unit_tests/services/test_trigger_provider_service.py @@ -694,7 +694,7 @@ def test_get_oauth_client_should_return_decrypted_system_client_when_verified( _mock_get_trigger_provider(mocker, provider_controller) mocker.patch("services.trigger.trigger_provider_service.PluginService.is_plugin_verified", return_value=True) mocker.patch( - "services.trigger.trigger_provider_service.decrypt_system_oauth_params", + "services.trigger.trigger_provider_service.decrypt_system_params", return_value={"client_id": "system"}, ) @@ -716,7 +716,7 @@ def test_get_oauth_client_should_raise_error_when_system_decryption_fails( _mock_get_trigger_provider(mocker, provider_controller) mocker.patch("services.trigger.trigger_provider_service.PluginService.is_plugin_verified", return_value=True) mocker.patch( - "services.trigger.trigger_provider_service.decrypt_system_oauth_params", + "services.trigger.trigger_provider_service.decrypt_system_params", side_effect=RuntimeError("bad data"), ) diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index 79a2d30f57..ce0d94398d 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -280,7 +280,7 @@ class TestGetOauthClient: assert result == {"client_id": "id", "client_secret": "secret"} - @patch(f"{MODULE}.decrypt_system_oauth_params", return_value={"sys_key": "sys_val"}) + @patch(f"{MODULE}.decrypt_system_params", return_value={"sys_key": "sys_val"}) @patch(f"{MODULE}.PluginService") @patch(f"{MODULE}.create_provider_encrypter") @patch(f"{MODULE}.ToolManager") diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py index 4146fd312b..f8fe667dca 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -1,28 +1,37 @@ import json import queue -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import UTC, datetime +from itertools import cycle from threading import Event +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import MagicMock import pytest from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus from graphon.runtime import GraphRuntimeState, VariablePool +from sqlalchemy.orm import Session, sessionmaker from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity +from core.app.entities.task_entities import StreamEvent from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper from models.enums import CreatorUserRole from models.model import AppMode from models.workflow import WorkflowRun from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot from repositories.entities.workflow_pause import WorkflowPauseEntity +from services import workflow_event_snapshot_service as service_module from services.workflow_event_snapshot_service import ( BufferState, MessageContext, _build_snapshot_events, + _is_terminal_event, _resolve_task_id, + build_workflow_event_stream, ) @@ -125,50 +134,6 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: ) -def test_build_snapshot_events_includes_pause_event() -> None: - workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED) - snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) - resumption_context = _build_resumption_context("task-ctx") - pause_entity = _FakePauseEntity( - pause_id="pause-1", - workflow_run_id="run-1", - paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), - pause_reasons=[ - HumanInputRequired( - form_id="form-1", - form_content="content", - node_id="node-1", - node_title="Human Input", - ) - ], - ) - - events = _build_snapshot_events( - workflow_run=workflow_run, - node_snapshots=[snapshot], - task_id="task-ctx", - message_context=None, - pause_entity=pause_entity, - resumption_context=resumption_context, - ) - - assert [event["event"] for event in events] == [ - "workflow_started", - "node_started", - "node_finished", - "workflow_paused", - ] - assert events[2]["data"]["status"] == WorkflowNodeExecutionStatus.PAUSED.value - pause_data = events[-1]["data"] - assert pause_data["paused_nodes"] == ["node-1"] - assert pause_data["outputs"] == {"result": "value"} - assert pause_data["status"] == WorkflowExecutionStatus.PAUSED.value - assert pause_data["created_at"] == int(workflow_run.created_at.timestamp()) - assert pause_data["elapsed_time"] == workflow_run.elapsed_time - assert pause_data["total_tokens"] == workflow_run.total_tokens - assert pause_data["total_steps"] == workflow_run.total_steps - - def test_build_snapshot_events_applies_message_context() -> None: workflow_run = _build_workflow_run(WorkflowExecutionStatus.RUNNING) snapshot = _build_snapshot(WorkflowNodeExecutionStatus.SUCCEEDED) @@ -222,3 +187,656 @@ def test_resolve_task_id_priority(context_task_id, buffered_task_id, expected) - buffer_state.task_id_ready.set() task_id = _resolve_task_id(resumption_context, buffer_state, "run-1", wait_timeout=0.0) assert task_id == expected + + +def _build_workflow_run_additional(status: WorkflowExecutionStatus = WorkflowExecutionStatus.RUNNING) -> WorkflowRun: + return WorkflowRun( + id="run-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + type="workflow", + triggered_from="app-run", + version="v1", + graph=None, + inputs=json.dumps({"query": "hello"}), + status=status, + outputs=json.dumps({}), + error=None, + elapsed_time=1.2, + total_tokens=5, + total_steps=2, + created_by_role=CreatorUserRole.END_USER, + created_by="user-1", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + +def _build_resumption_context_additional(task_id: str) -> WorkflowResumptionContext: + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant-1", + app_id="app-1", + app_mode=AppMode.WORKFLOW, + workflow_id="workflow-1", + ) + generate_entity = WorkflowAppGenerateEntity( + task_id=task_id, + app_config=app_config, + inputs={}, + files=[], + user_id="user-1", + stream=True, + invoke_from=InvokeFrom.EXPLORE, + call_depth=0, + workflow_execution_id="run-1", + ) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.outputs = {"answer": "ok"} + wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) + return WorkflowResumptionContext( + generate_entity=wrapper, + serialized_graph_runtime_state=runtime_state.dumps(), + ) + + +class _SessionContext: + def __init__(self, session: Any) -> None: + self._session = session + + def __enter__(self) -> Any: + return self._session + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + +class _SessionMaker: + def __init__(self, session: Any) -> None: + self._session = session + + def __call__(self) -> _SessionContext: + return _SessionContext(self._session) + + +class _SubscriptionContext: + def __init__(self, subscription: Any) -> None: + self._subscription = subscription + + def __enter__(self) -> Any: + return self._subscription + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + +class _Topic: + def __init__(self, subscription: Any) -> None: + self._subscription = subscription + + def subscribe(self) -> _SubscriptionContext: + return _SubscriptionContext(self._subscription) + + +class _StaticSubscription: + def receive(self, timeout: int = 1) -> None: + return None + + +@dataclass(frozen=True) +class _PauseEntity(WorkflowPauseEntity): + state: bytes + + @property + def id(self) -> str: + return "pause-1" + + @property + def workflow_execution_id(self) -> str: + return "run-1" + + @property + def resumed_at(self) -> datetime | None: + return None + + @property + def paused_at(self) -> datetime: + return datetime(2024, 1, 1, tzinfo=UTC) + + def get_state(self) -> bytes: + return self.state + + def get_pause_reasons(self) -> list[Any]: + return [] + + +def test_get_message_context_should_return_none_when_no_message() -> None: + # Arrange + session = SimpleNamespace(scalar=MagicMock(return_value=None)) + session_maker = _SessionMaker(session) + + # Act + result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1") + + # Assert + assert result is None + + +def test_get_message_context_should_default_created_at_to_zero_when_message_has_no_timestamp() -> None: + # Arrange + message = SimpleNamespace( + id="msg-1", + conversation_id="conv-1", + created_at=None, + answer="answer", + ) + session = SimpleNamespace(scalar=MagicMock(return_value=message)) + session_maker = _SessionMaker(session) + + # Act + result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1") + + # Assert + assert result is not None + assert result.created_at == 0 + assert result.message_id == "msg-1" + assert result.conversation_id == "conv-1" + assert result.answer == "answer" + + +def test_load_resumption_context_should_return_none_when_pause_entity_missing() -> None: + # Arrange + + # Act + result = service_module._load_resumption_context(None) + + # Assert + assert result is None + + +def test_load_resumption_context_should_return_none_when_pause_entity_state_is_invalid() -> None: + # Arrange + pause_entity = _PauseEntity(state=b"not-a-valid-state") + + # Act + result = service_module._load_resumption_context(pause_entity) + + # Assert + assert result is None + + +def test_load_resumption_context_should_parse_valid_state_into_context() -> None: + # Arrange + context = _build_resumption_context_additional(task_id="task-ctx") + pause_entity = _PauseEntity(state=context.dumps().encode()) + + # Act + result = service_module._load_resumption_context(pause_entity) + + # Assert + assert result is not None + assert result.get_generate_entity().task_id == "task-ctx" + + +def test_resolve_task_id_should_return_workflow_run_id_when_buffer_state_is_missing() -> None: + # Arrange + + # Act + result = service_module._resolve_task_id( + resumption_context=None, + buffer_state=None, + workflow_run_id="run-1", + ) + + # Assert + assert result == "run-1" + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + (b'{"event":"node_started"}', {"event": "node_started"}), + (b"invalid-json", None), + (b"[]", None), + ], +) +def test_parse_event_message_should_parse_only_json_object( + payload: bytes, + expected: dict[str, Any] | None, +) -> None: + # Arrange + + # Act + result = service_module._parse_event_message(payload) + + # Assert + assert result == expected + + +def test_is_terminal_event_should_recognize_finished_and_optional_paused_events() -> None: + # Arrange + finished_event = {"event": StreamEvent.WORKFLOW_FINISHED.value} + paused_event = {"event": StreamEvent.WORKFLOW_PAUSED.value} + + # Act + is_finished = service_module._is_terminal_event(finished_event, close_on_pause=False) + paused_without_flag = service_module._is_terminal_event(paused_event, close_on_pause=False) + paused_with_flag = service_module._is_terminal_event(paused_event, close_on_pause=True) + + # Assert + assert is_finished is True + assert paused_without_flag is False + assert paused_with_flag is True + assert service_module._is_terminal_event(StreamEvent.PING.value, close_on_pause=True) is False + + +def test_apply_message_context_should_update_payload_when_context_exists() -> None: + # Arrange + payload: dict[str, Any] = {"event": "workflow_started"} + context = MessageContext(conversation_id="conv-1", message_id="msg-1", created_at=1700000000) + + # Act + service_module._apply_message_context(payload, context) + + # Assert + assert payload["conversation_id"] == "conv-1" + assert payload["message_id"] == "msg-1" + assert payload["created_at"] == 1700000000 + + +def test_start_buffering_should_capture_task_id_and_enqueue_event() -> None: + # Arrange + class Subscription: + def __init__(self) -> None: + self._calls = 0 + + def receive(self, timeout: int = 1) -> bytes | None: + self._calls += 1 + if self._calls == 1: + return b'{"event":"node_started","task_id":"task-1"}' + return None + + subscription = Subscription() + + # Act + buffer_state = service_module._start_buffering(subscription) + ready = buffer_state.task_id_ready.wait(timeout=1) + event = buffer_state.queue.get(timeout=1) + buffer_state.stop_event.set() + finished = buffer_state.done_event.wait(timeout=1) + + # Assert + assert ready is True + assert finished is True + assert buffer_state.task_id_hint == "task-1" + assert event["event"] == "node_started" + + +def test_start_buffering_should_drop_old_event_when_queue_is_full( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + class QueueWithSingleFull: + def __init__(self) -> None: + self._first_put = True + self.items: list[dict[str, Any]] = [{"event": "old"}] + + def put_nowait(self, item: dict[str, Any]) -> None: + if self._first_put: + self._first_put = False + raise queue.Full + self.items.append(item) + + def get_nowait(self) -> dict[str, Any]: + if not self.items: + raise queue.Empty + return self.items.pop(0) + + def empty(self) -> bool: + return len(self.items) == 0 + + fake_queue = QueueWithSingleFull() + monkeypatch.setattr(service_module.queue, "Queue", lambda maxsize=2048: fake_queue) + + class Subscription: + def __init__(self) -> None: + self._calls = 0 + + def receive(self, timeout: int = 1) -> bytes | None: + self._calls += 1 + if self._calls == 1: + return b'{"event":"node_started","task_id":"task-2"}' + return None + + subscription = Subscription() + + # Act + buffer_state = service_module._start_buffering(subscription) + ready = buffer_state.task_id_ready.wait(timeout=1) + buffer_state.stop_event.set() + finished = buffer_state.done_event.wait(timeout=1) + + # Assert + assert ready is True + assert finished is True + assert fake_queue.items[-1]["task_id"] == "task-2" + + +def test_start_buffering_should_set_done_event_when_subscription_raises() -> None: + # Arrange + class Subscription: + def receive(self, timeout: int = 1) -> bytes | None: + raise RuntimeError("subscription failure") + + subscription = Subscription() + + # Act + buffer_state = service_module._start_buffering(subscription) + finished = buffer_state.done_event.wait(timeout=1) + + # Assert + assert finished is True + + +def test_build_workflow_event_stream_should_emit_ping_and_terminal_snapshot_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr( + service_module, + "_get_message_context", + MagicMock(return_value=MessageContext("conv-1", "msg-1", 1700000000)), + ) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + buffer_state = BufferState( + queue=queue.Queue(), + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + monkeypatch.setattr( + service_module, + "_build_snapshot_events", + MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value, "task_id": "task-1"}]), + ) + + # Act + events = list( + build_workflow_event_stream( + app_mode=AppMode.ADVANCED_CHAT, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + ) + ) + + # Assert + assert events[0] == StreamEvent.PING.value + finished_event = cast(Mapping[str, Any], events[1]) + assert finished_event["event"] == StreamEvent.WORKFLOW_FINISHED.value + assert buffer_state.stop_event.is_set() is True + node_repo.get_execution_snapshots_by_workflow_run.assert_called_once() + called_kwargs = node_repo.get_execution_snapshots_by_workflow_run.call_args.kwargs + assert called_kwargs["workflow_run_id"] == "run-1" + + +def test_build_workflow_event_stream_should_emit_periodic_ping_and_stop_after_idle_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + monkeypatch.setattr(service_module, "_build_snapshot_events", MagicMock(return_value=[])) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + + class AlwaysEmptyQueue: + def empty(self) -> bool: + return False + + def get(self, timeout: int = 1) -> None: + raise queue.Empty + + buffer_state = BufferState( + queue=AlwaysEmptyQueue(), # type: ignore[arg-type] + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + time_values = cycle([0.0, 6.0, 21.0, 26.0]) + monkeypatch.setattr(service_module.time, "time", lambda: next(time_values)) + + # Act + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + idle_timeout=20.0, + ping_interval=5.0, + ) + ) + + # Assert + assert events == [StreamEvent.PING.value, StreamEvent.PING.value] + assert buffer_state.stop_event.is_set() is True + + +def test_build_workflow_event_stream_should_exit_when_buffer_done_and_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + monkeypatch.setattr(service_module, "_build_snapshot_events", MagicMock(return_value=[])) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + buffer_state = BufferState( + queue=queue.Queue(), + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + buffer_state.done_event.set() + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + + # Act + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + ) + ) + + # Assert + assert events == [StreamEvent.PING.value] + assert buffer_state.stop_event.is_set() is True + + +def test_build_workflow_event_stream_should_continue_when_pause_loading_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.PAUSED) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock(side_effect=RuntimeError("boom"))) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + snapshot_builder = MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value}]) + monkeypatch.setattr(service_module, "_build_snapshot_events", snapshot_builder) + buffer_state = BufferState( + queue=queue.Queue(), + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + + # Act + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + ) + ) + + # Assert + assert events[0] == StreamEvent.PING.value + assert snapshot_builder.call_args.kwargs["pause_entity"] is None + + +def test_is_terminal_event_respects_close_on_pause_flag() -> None: + pause_event = {"event": "workflow_paused"} + finish_event = {"event": "workflow_finished"} + + assert _is_terminal_event(pause_event, close_on_pause=True) is True + assert _is_terminal_event(pause_event, close_on_pause=False) is False + assert _is_terminal_event(finish_event, close_on_pause=False) is True + + +def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED) + snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) + resumption_context = _build_resumption_context("task-ctx") + monkeypatch.setattr( + service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"} + ) + session_maker = _SessionMaker( + SimpleNamespace( + execute=lambda _stmt: [("form-1", datetime(2024, 1, 1, tzinfo=UTC), '{"display_in_ui": true}')], + ) + ) + pause_entity = _FakePauseEntity( + pause_id="pause-1", + workflow_run_id="run-1", + paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), + pause_reasons=[ + HumanInputRequired( + form_id="form-1", + form_content="content", + node_id="node-1", + node_title="Human Input", + form_token="wtok", + ) + ], + ) + + events = _build_snapshot_events( + workflow_run=workflow_run, + node_snapshots=[snapshot], + task_id="task-ctx", + message_context=None, + pause_entity=pause_entity, + resumption_context=resumption_context, + session_maker=cast(sessionmaker[Session], session_maker), + ) + + assert events[-2]["event"] == StreamEvent.HUMAN_INPUT_REQUIRED.value + assert events[-2]["data"]["form_token"] == "wtok" + assert events[-2]["data"]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) + pause_data = events[-1]["data"] + assert pause_data["reasons"][0]["form_token"] == "wtok" + assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) + + +def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.PAUSED) + topic = _Topic(_StaticSubscription()) + pause_entity = _FakePauseEntity( + pause_id="pause-1", + workflow_run_id="run-1", + paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), + pause_reasons=[ + HumanInputRequired( + form_id="form-1", + form_content="content", + node_id="node-1", + node_title="Human Input", + ) + ], + ) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock(return_value=pause_entity)) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr( + service_module, "_load_resumption_context", MagicMock(return_value=_build_resumption_context("task-1")) + ) + monkeypatch.setattr( + service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"} + ) + + session = SimpleNamespace( + scalar=MagicMock(return_value=None), + execute=lambda _stmt: [("form-1", datetime(2024, 1, 1, tzinfo=UTC), '{"display_in_ui": true}')], + ) + session_maker = _SessionMaker(session) + + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=cast(sessionmaker[Session], session_maker), + ) + ) + + pause_event = cast(Mapping[str, Any], events[-1]) + assert pause_event["event"] == StreamEvent.WORKFLOW_PAUSED.value + assert pause_event["data"]["reasons"][0]["form_token"] == "wtok" + assert pause_event["data"]["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) diff --git a/api/tests/unit_tests/tasks/test_workflow_execute_task.py b/api/tests/unit_tests/tasks/test_workflow_execute_task.py index d3cf632b47..72508bef52 100644 --- a/api/tests/unit_tests/tasks/test_workflow_execute_task.py +++ b/api/tests/unit_tests/tasks/test_workflow_execute_task.py @@ -7,11 +7,17 @@ from unittest.mock import MagicMock import pytest -from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom +from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity from models.enums import CreatorUserRole from models.model import App, AppMode, Conversation from models.workflow import Workflow, WorkflowRun -from tasks.app_generate.workflow_execute_task import _publish_streaming_response, _resume_app_execution +from repositories.sqlalchemy_api_workflow_run_repository import _WorkflowRunError +from tasks.app_generate.workflow_execute_task import ( + _publish_streaming_response, + _resume_advanced_chat, + _resume_app_execution, + _resume_workflow, +) class _FakeSessionContext: @@ -38,12 +44,28 @@ def _build_advanced_chat_generate_entity(conversation_id: str | None) -> Advance ) +def _build_workflow_generate_entity(stream: bool) -> WorkflowAppGenerateEntity: + return WorkflowAppGenerateEntity( + task_id="task-id", + inputs={}, + files=[], + user_id="user-id", + stream=stream, + invoke_from=InvokeFrom.WEB_APP, + workflow_execution_id="workflow-run-id", + ) + + +def _single_event_generator(payload): + yield payload + + @pytest.fixture -def mock_topic(mocker) -> MagicMock: +def mock_topic(monkeypatch: pytest.MonkeyPatch) -> MagicMock: topic = MagicMock() - mocker.patch( + monkeypatch.setattr( "tasks.app_generate.workflow_execute_task.MessageBasedAppGenerator.get_response_topic", - return_value=topic, + lambda *_args, **_kwargs: topic, ) return topic @@ -67,31 +89,35 @@ def test_publish_streaming_response_coerces_string_uuid(mock_topic: MagicMock): mock_topic.publish.assert_called_once_with(json.dumps({"event": "bar"}).encode()) -def test_resume_app_execution_queries_message_by_conversation_and_workflow_run(mocker): +def test_resume_app_execution_queries_message_by_conversation_and_workflow_run(monkeypatch: pytest.MonkeyPatch): workflow_run_id = "run-id" conversation_id = "conversation-id" message = MagicMock() - mocker.patch("tasks.app_generate.workflow_execute_task.db", SimpleNamespace(engine=object())) + monkeypatch.setattr("tasks.app_generate.workflow_execute_task.db", SimpleNamespace(engine=object())) pause_entity = MagicMock() pause_entity.get_state.return_value = b"state" workflow_run_repo = MagicMock() workflow_run_repo.get_workflow_pause.return_value = pause_entity - mocker.patch( + monkeypatch.setattr( "tasks.app_generate.workflow_execute_task.DifyAPIRepositoryFactory.create_api_workflow_run_repository", - return_value=workflow_run_repo, + lambda *_args, **_kwargs: workflow_run_repo, ) generate_entity = _build_advanced_chat_generate_entity(conversation_id) resumption_context = MagicMock() resumption_context.serialized_graph_runtime_state = "{}" resumption_context.get_generate_entity.return_value = generate_entity - mocker.patch( - "tasks.app_generate.workflow_execute_task.WorkflowResumptionContext.loads", return_value=resumption_context + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.WorkflowResumptionContext.loads", + lambda *_args, **_kwargs: resumption_context, + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", + lambda *_args, **_kwargs: MagicMock(), ) - mocker.patch("tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", return_value=MagicMock()) workflow_run = SimpleNamespace( workflow_id="wf-id", @@ -120,10 +146,15 @@ def test_resume_app_execution_queries_message_by_conversation_and_workflow_run(m session.get.side_effect = _session_get session.scalar.return_value = message - mocker.patch("tasks.app_generate.workflow_execute_task.Session", return_value=_FakeSessionContext(session)) - mocker.patch("tasks.app_generate.workflow_execute_task._resolve_user_for_run", return_value=MagicMock()) - resume_advanced_chat = mocker.patch("tasks.app_generate.workflow_execute_task._resume_advanced_chat") - mocker.patch("tasks.app_generate.workflow_execute_task._resume_workflow") + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.Session", lambda *_args, **_kwargs: _FakeSessionContext(session) + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task._resolve_user_for_run", lambda *_args, **_kwargs: MagicMock() + ) + resume_advanced_chat = MagicMock() + monkeypatch.setattr("tasks.app_generate.workflow_execute_task._resume_advanced_chat", resume_advanced_chat) + monkeypatch.setattr("tasks.app_generate.workflow_execute_task._resume_workflow", MagicMock()) _resume_app_execution({"workflow_run_id": workflow_run_id}) @@ -144,29 +175,35 @@ def test_resume_app_execution_queries_message_by_conversation_and_workflow_run(m assert resume_advanced_chat.call_args.kwargs["message"] is message -def test_resume_app_execution_returns_early_when_advanced_chat_missing_conversation_id(mocker): +def test_resume_app_execution_returns_early_when_advanced_chat_missing_conversation_id( + monkeypatch: pytest.MonkeyPatch, +): workflow_run_id = "run-id" - mocker.patch("tasks.app_generate.workflow_execute_task.db", SimpleNamespace(engine=object())) + monkeypatch.setattr("tasks.app_generate.workflow_execute_task.db", SimpleNamespace(engine=object())) pause_entity = MagicMock() pause_entity.get_state.return_value = b"state" workflow_run_repo = MagicMock() workflow_run_repo.get_workflow_pause.return_value = pause_entity - mocker.patch( + monkeypatch.setattr( "tasks.app_generate.workflow_execute_task.DifyAPIRepositoryFactory.create_api_workflow_run_repository", - return_value=workflow_run_repo, + lambda *_args, **_kwargs: workflow_run_repo, ) generate_entity = _build_advanced_chat_generate_entity(conversation_id=None) resumption_context = MagicMock() resumption_context.serialized_graph_runtime_state = "{}" resumption_context.get_generate_entity.return_value = generate_entity - mocker.patch( - "tasks.app_generate.workflow_execute_task.WorkflowResumptionContext.loads", return_value=resumption_context + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.WorkflowResumptionContext.loads", + lambda *_args, **_kwargs: resumption_context, + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", + lambda *_args, **_kwargs: MagicMock(), ) - mocker.patch("tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", return_value=MagicMock()) workflow_run = SimpleNamespace( workflow_id="wf-id", @@ -191,12 +228,152 @@ def test_resume_app_execution_returns_early_when_advanced_chat_missing_conversat session.get.side_effect = _session_get - mocker.patch("tasks.app_generate.workflow_execute_task.Session", return_value=_FakeSessionContext(session)) - mocker.patch("tasks.app_generate.workflow_execute_task._resolve_user_for_run", return_value=MagicMock()) - resume_advanced_chat = mocker.patch("tasks.app_generate.workflow_execute_task._resume_advanced_chat") + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.Session", lambda *_args, **_kwargs: _FakeSessionContext(session) + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task._resolve_user_for_run", lambda *_args, **_kwargs: MagicMock() + ) + resume_advanced_chat = MagicMock() + monkeypatch.setattr("tasks.app_generate.workflow_execute_task._resume_advanced_chat", resume_advanced_chat) _resume_app_execution({"workflow_run_id": workflow_run_id}) session.scalar.assert_not_called() workflow_run_repo.resume_workflow_pause.assert_not_called() resume_advanced_chat.assert_not_called() + + +def test_resume_advanced_chat_publishes_events_for_originally_blocking_runs(monkeypatch: pytest.MonkeyPatch): + generate_entity = _build_advanced_chat_generate_entity(conversation_id="conversation-id") + generate_entity.stream = False + + generator_instance = MagicMock() + response_stream = _single_event_generator({"event": "message"}) + generator_instance.resume.return_value = response_stream + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.AdvancedChatAppGenerator", + lambda: generator_instance, + ) + + publish_streaming_response = MagicMock() + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task._publish_streaming_response", publish_streaming_response + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_execution_repository", + lambda **kwargs: MagicMock(), + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + lambda **kwargs: MagicMock(), + ) + + _resume_advanced_chat( + app_model=SimpleNamespace(id="app-id"), + workflow=SimpleNamespace(created_by="workflow-owner"), + user=MagicMock(), + conversation=SimpleNamespace(id="conversation-id"), + message=MagicMock(), + generate_entity=generate_entity, + graph_runtime_state=MagicMock(), + session_factory=MagicMock(), + pause_state_config=MagicMock(), + workflow_run_id="workflow-run-id", + workflow_run=SimpleNamespace(triggered_from="app_run"), + ) + + resumed_entity = generator_instance.resume.call_args.kwargs["application_generate_entity"] + assert resumed_entity.stream is True + publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.ADVANCED_CHAT) + + +def test_resume_workflow_publishes_events_for_originally_blocking_runs(monkeypatch: pytest.MonkeyPatch): + generate_entity = _build_workflow_generate_entity(stream=False) + + generator_instance = MagicMock() + response_stream = _single_event_generator({"event": "workflow_finished"}) + generator_instance.resume.return_value = response_stream + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.WorkflowAppGenerator", + lambda: generator_instance, + ) + + publish_streaming_response = MagicMock() + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task._publish_streaming_response", publish_streaming_response + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_execution_repository", + lambda **kwargs: MagicMock(), + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + lambda **kwargs: MagicMock(), + ) + workflow_run_repo = MagicMock() + pause_entity = MagicMock() + + _resume_workflow( + app_model=SimpleNamespace(id="app-id"), + workflow=SimpleNamespace(created_by="workflow-owner"), + user=MagicMock(), + generate_entity=generate_entity, + graph_runtime_state=MagicMock(), + session_factory=MagicMock(), + pause_state_config=MagicMock(), + workflow_run_id="workflow-run-id", + workflow_run=SimpleNamespace(triggered_from="app_run"), + workflow_run_repo=workflow_run_repo, + pause_entity=pause_entity, + ) + + resumed_entity = generator_instance.resume.call_args.kwargs["application_generate_entity"] + assert resumed_entity.stream is True + publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.WORKFLOW) + workflow_run_repo.delete_workflow_pause.assert_called_once_with(pause_entity) + + +def test_resume_workflow_ignores_missing_old_pause_after_repause(monkeypatch: pytest.MonkeyPatch): + generate_entity = _build_workflow_generate_entity(stream=False) + + generator_instance = MagicMock() + response_stream = _single_event_generator({"event": "workflow_paused"}) + generator_instance.resume.return_value = response_stream + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.WorkflowAppGenerator", + lambda: generator_instance, + ) + + publish_streaming_response = MagicMock() + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task._publish_streaming_response", publish_streaming_response + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_execution_repository", + lambda **kwargs: MagicMock(), + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + lambda **kwargs: MagicMock(), + ) + workflow_run_repo = MagicMock() + workflow_run_repo.delete_workflow_pause.side_effect = _WorkflowRunError("WorkflowPause not found: old-pause") + pause_entity = MagicMock() + + _resume_workflow( + app_model=SimpleNamespace(id="app-id"), + workflow=SimpleNamespace(created_by="workflow-owner"), + user=MagicMock(), + generate_entity=generate_entity, + graph_runtime_state=MagicMock(), + session_factory=MagicMock(), + pause_state_config=MagicMock(), + workflow_run_id="workflow-run-id", + workflow_run=SimpleNamespace(triggered_from="app_run"), + workflow_run_repo=workflow_run_repo, + pause_entity=pause_entity, + ) + + publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.WORKFLOW) + workflow_run_repo.delete_workflow_pause.assert_called_once_with(pause_entity) diff --git a/api/tests/unit_tests/utils/encryption/test_system_encryption.py b/api/tests/unit_tests/utils/encryption/test_system_encryption.py new file mode 100644 index 0000000000..0435facfdb --- /dev/null +++ b/api/tests/unit_tests/utils/encryption/test_system_encryption.py @@ -0,0 +1,619 @@ +import base64 +import hashlib +from unittest.mock import patch + +import pytest +from Crypto.Cipher import AES +from Crypto.Random import get_random_bytes +from Crypto.Util.Padding import pad + +from core.tools.utils.system_encryption import ( + EncryptionError, + SystemEncrypter, + create_system_encrypter, + decrypt_system_params, + encrypt_system_params, + get_system_encrypter, +) + + +class TestSystemEncrypter: + """Test cases for SystemEncrypter class""" + + def test_init_with_secret_key(self): + """Test initialization with provided secret key""" + secret_key = "test_secret_key" + encrypter = SystemEncrypter(secret_key=secret_key) + expected_key = hashlib.sha256(secret_key.encode()).digest() + assert encrypter.key == expected_key + + def test_init_with_none_secret_key(self): + """Test initialization with None secret key falls back to config""" + with patch("core.tools.utils.system_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "config_secret" + encrypter = SystemEncrypter(secret_key=None) + expected_key = hashlib.sha256(b"config_secret").digest() + assert encrypter.key == expected_key + + def test_init_with_empty_secret_key(self): + """Test initialization with empty secret key""" + encrypter = SystemEncrypter(secret_key="") + expected_key = hashlib.sha256(b"").digest() + assert encrypter.key == expected_key + + def test_init_without_secret_key_uses_config(self): + """Test initialization without secret key uses config""" + with patch("core.tools.utils.system_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "default_secret" + encrypter = SystemEncrypter() + expected_key = hashlib.sha256(b"default_secret").digest() + assert encrypter.key == expected_key + + def test_encrypt_params_basic(self): + """Test basic parameters encryption""" + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypter.encrypt_params(params) + + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + # Should be valid base64 + try: + base64.b64decode(encrypted) + except Exception: + pytest.fail("Encrypted result is not valid base64") + + def test_encrypt_params_empty_dict(self): + """Test encryption with empty dictionary""" + encrypter = SystemEncrypter("test_secret") + params = {} + + encrypted = encrypter.encrypt_params(params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_params_complex_data(self): + """Test encryption with complex data structures""" + encrypter = SystemEncrypter("test_secret") + params = { + "client_id": "test_id", + "client_secret": "test_secret", + "scopes": ["read", "write", "admin"], + "metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True}, + "numeric_value": 42, + "boolean_value": False, + "null_value": None, + } + + encrypted = encrypter.encrypt_params(params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_params_unicode_data(self): + """Test encryption with unicode data""" + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id", "client_secret": "test_secret", "description": "This is a test case 🚀"} + + encrypted = encrypter.encrypt_params(params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_params_large_data(self): + """Test encryption with large data""" + encrypter = SystemEncrypter("test_secret") + params = { + "client_id": "test_id", + "large_data": "x" * 10000, # 10KB of data + } + + encrypted = encrypter.encrypt_params(params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_params_invalid_input(self): + """Test encryption with invalid input types""" + encrypter = SystemEncrypter("test_secret") + + with pytest.raises(Exception): # noqa: B017 + encrypter.encrypt_params(None) + + with pytest.raises(Exception): # noqa: B017 + encrypter.encrypt_params("not_a_dict") + + def test_decrypt_params_basic(self): + """Test basic parameters decryption""" + encrypter = SystemEncrypter("test_secret") + original_params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_params_empty_dict(self): + """Test decryption of empty dictionary""" + encrypter = SystemEncrypter("test_secret") + original_params = {} + + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_params_complex_data(self): + """Test decryption with complex data structures""" + encrypter = SystemEncrypter("test_secret") + original_params = { + "client_id": "test_id", + "client_secret": "test_secret", + "scopes": ["read", "write", "admin"], + "metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True}, + "numeric_value": 42, + "boolean_value": False, + "null_value": None, + } + + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_params_unicode_data(self): + """Test decryption with unicode data""" + encrypter = SystemEncrypter("test_secret") + original_params = { + "client_id": "test_id", + "client_secret": "test_secret", + "description": "This is a test case 🚀", + } + + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_params_large_data(self): + """Test decryption with large data""" + encrypter = SystemEncrypter("test_secret") + original_params = { + "client_id": "test_id", + "large_data": "x" * 10000, # 10KB of data + } + + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_params_invalid_base64(self): + """Test decryption with invalid base64 data""" + encrypter = SystemEncrypter("test_secret") + + with pytest.raises(EncryptionError): + encrypter.decrypt_params("invalid_base64!") + + def test_decrypt_params_empty_string(self): + """Test decryption with empty string""" + encrypter = SystemEncrypter("test_secret") + + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_params("") + + assert "encrypted_data cannot be empty" in str(exc_info.value) + + def test_decrypt_params_non_string_input(self): + """Test decryption with non-string input""" + encrypter = SystemEncrypter("test_secret") + + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_params(123) + + assert "encrypted_data must be a string" in str(exc_info.value) + + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_params(None) + + assert "encrypted_data must be a string" in str(exc_info.value) + + def test_decrypt_params_too_short_data(self): + """Test decryption with too short encrypted data""" + encrypter = SystemEncrypter("test_secret") + + # Create data that's too short (less than 32 bytes) + short_data = base64.b64encode(b"short").decode() + + with pytest.raises(EncryptionError) as exc_info: + encrypter.decrypt_params(short_data) + + assert "Invalid encrypted data format" in str(exc_info.value) + + def test_decrypt_params_corrupted_data(self): + """Test decryption with corrupted data""" + encrypter = SystemEncrypter("test_secret") + + # Create corrupted data (valid base64 but invalid encrypted content) + corrupted_data = base64.b64encode(b"x" * 48).decode() # 48 bytes of garbage + + with pytest.raises(EncryptionError): + encrypter.decrypt_params(corrupted_data) + + def test_decrypt_params_wrong_key(self): + """Test decryption with wrong key""" + encrypter1 = SystemEncrypter("secret1") + encrypter2 = SystemEncrypter("secret2") + + original_params = {"client_id": "test_id", "client_secret": "test_secret"} + encrypted = encrypter1.encrypt_params(original_params) + + with pytest.raises(EncryptionError): + encrypter2.decrypt_params(encrypted) + + def test_encryption_decryption_consistency(self): + """Test that encryption and decryption are consistent""" + encrypter = SystemEncrypter("test_secret") + + test_cases = [ + {}, + {"simple": "value"}, + {"client_id": "id", "client_secret": "secret"}, + {"complex": {"nested": {"deep": "value"}}}, + {"unicode": "test 🚀"}, + {"numbers": 42, "boolean": True, "null": None}, + {"array": [1, 2, 3, "four", {"five": 5}]}, + ] + + for original_params in test_cases: + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == original_params, f"Failed for case: {original_params}" + + def test_encryption_randomness(self): + """Test that encryption produces different results for same input""" + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted1 = encrypter.encrypt_params(params) + encrypted2 = encrypter.encrypt_params(params) + + # Should be different due to random IV + assert encrypted1 != encrypted2 + + # But should decrypt to same result + decrypted1 = encrypter.decrypt_params(encrypted1) + decrypted2 = encrypter.decrypt_params(encrypted2) + assert decrypted1 == decrypted2 == params + + def test_different_secret_keys_produce_different_results(self): + """Test that different secret keys produce different encrypted results""" + encrypter1 = SystemEncrypter("secret1") + encrypter2 = SystemEncrypter("secret2") + + params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted1 = encrypter1.encrypt_params(params) + encrypted2 = encrypter2.encrypt_params(params) + + # Should produce different encrypted results + assert encrypted1 != encrypted2 + + # But each should decrypt correctly with its own key + decrypted1 = encrypter1.decrypt_params(encrypted1) + decrypted2 = encrypter2.decrypt_params(encrypted2) + assert decrypted1 == decrypted2 == params + + @patch("core.tools.utils.system_encryption.get_random_bytes") + def test_encrypt_params_crypto_error(self, mock_get_random_bytes): + """Test encryption when crypto operation fails""" + mock_get_random_bytes.side_effect = Exception("Crypto error") + + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id"} + + with pytest.raises(EncryptionError) as exc_info: + encrypter.encrypt_params(params) + + assert "Encryption failed" in str(exc_info.value) + + @patch("core.tools.utils.system_encryption.TypeAdapter") + def test_encrypt_params_serialization_error(self, mock_type_adapter): + """Test encryption when JSON serialization fails""" + mock_type_adapter.return_value.dump_json.side_effect = Exception("Serialization error") + + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id"} + + with pytest.raises(EncryptionError) as exc_info: + encrypter.encrypt_params(params) + + assert "Encryption failed" in str(exc_info.value) + + def test_decrypt_params_invalid_json(self): + """Test decryption with invalid JSON data""" + encrypter = SystemEncrypter("test_secret") + + # Create valid encrypted data but with invalid JSON content + iv = get_random_bytes(16) + cipher = AES.new(encrypter.key, AES.MODE_CBC, iv) + invalid_json = b"invalid json content" + padded_data = pad(invalid_json, AES.block_size) + encrypted_data = cipher.encrypt(padded_data) + combined = iv + encrypted_data + encoded = base64.b64encode(combined).decode() + + with pytest.raises(EncryptionError): + encrypter.decrypt_params(encoded) + + def test_key_derivation_consistency(self): + """Test that key derivation is consistent""" + secret_key = "test_secret" + encrypter1 = SystemEncrypter(secret_key) + encrypter2 = SystemEncrypter(secret_key) + + assert encrypter1.key == encrypter2.key + + # Keys should be 32 bytes (256 bits) + assert len(encrypter1.key) == 32 + + +class TestFactoryFunctions: + """Test cases for factory functions""" + + def test_create_system_encrypter_with_secret(self): + """Test factory function with secret key""" + secret_key = "test_secret" + encrypter = create_system_encrypter(secret_key) + + assert isinstance(encrypter, SystemEncrypter) + expected_key = hashlib.sha256(secret_key.encode()).digest() + assert encrypter.key == expected_key + + def test_create_system_encrypter_without_secret(self): + """Test factory function without secret key""" + with patch("core.tools.utils.system_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "config_secret" + encrypter = create_system_encrypter() + + assert isinstance(encrypter, SystemEncrypter) + expected_key = hashlib.sha256(b"config_secret").digest() + assert encrypter.key == expected_key + + def test_create_system_encrypter_with_none_secret(self): + """Test factory function with None secret key""" + with patch("core.tools.utils.system_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "config_secret" + encrypter = create_system_encrypter(None) + + assert isinstance(encrypter, SystemEncrypter) + expected_key = hashlib.sha256(b"config_secret").digest() + assert encrypter.key == expected_key + + +class TestGlobalEncrypterInstance: + """Test cases for global encrypter instance""" + + def test_get_system_encrypter_singleton(self): + """Test that get_system_encrypter returns singleton instance""" + # Clear the global instance first + import core.tools.utils.system_encryption + + core.tools.utils.system_encryption._encrypter = None + + encrypter1 = get_system_encrypter() + encrypter2 = get_system_encrypter() + + assert encrypter1 is encrypter2 + assert isinstance(encrypter1, SystemEncrypter) + + def test_get_system_encrypter_uses_config(self): + """Test that global encrypter uses config""" + # Clear the global instance first + import core.tools.utils.system_encryption + + core.tools.utils.system_encryption._encrypter = None + + with patch("core.tools.utils.system_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "global_secret" + encrypter = get_system_encrypter() + + expected_key = hashlib.sha256(b"global_secret").digest() + assert encrypter.key == expected_key + + +class TestConvenienceFunctions: + """Test cases for convenience functions""" + + def test_encrypt_system_params(self): + """Test encrypt_system_params convenience function""" + params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypt_system_params(params) + + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_decrypt_system_params(self): + """Test decrypt_system_params convenience function""" + params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypt_system_params(params) + decrypted = decrypt_system_params(encrypted) + + assert decrypted == params + + def test_convenience_functions_consistency(self): + """Test that convenience functions work consistently""" + test_cases = [ + {}, + {"simple": "value"}, + {"client_id": "id", "client_secret": "secret"}, + {"complex": {"nested": {"deep": "value"}}}, + {"unicode": "test 🚀"}, + {"numbers": 42, "boolean": True, "null": None}, + ] + + for original_params in test_cases: + encrypted = encrypt_system_params(original_params) + decrypted = decrypt_system_params(encrypted) + assert decrypted == original_params, f"Failed for case: {original_params}" + + def test_convenience_functions_with_errors(self): + """Test convenience functions with error conditions""" + # Test encryption with invalid input + with pytest.raises(Exception): # noqa: B017 + encrypt_system_params(None) + + # Test decryption with invalid input + with pytest.raises(ValueError): + decrypt_system_params("") + + with pytest.raises(ValueError): + decrypt_system_params(None) + + +class TestErrorHandling: + """Test cases for error handling""" + + def test_encryption_error_inheritance(self): + """Test that EncryptionError is a proper exception""" + error = EncryptionError("Test error") + assert isinstance(error, Exception) + assert str(error) == "Test error" + + def test_encryption_error_with_cause(self): + """Test EncryptionError with cause""" + original_error = ValueError("Original error") + error = EncryptionError("Wrapper error") + error.__cause__ = original_error + + assert isinstance(error, Exception) + assert str(error) == "Wrapper error" + assert error.__cause__ is original_error + + def test_error_messages_are_informative(self): + """Test that error messages are informative""" + encrypter = SystemEncrypter("test_secret") + + # Test empty string error + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_params("") + assert "encrypted_data cannot be empty" in str(exc_info.value) + + # Test non-string error + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_params(123) + assert "encrypted_data must be a string" in str(exc_info.value) + + # Test invalid format error + short_data = base64.b64encode(b"short").decode() + with pytest.raises(EncryptionError) as exc_info: + encrypter.decrypt_params(short_data) + assert "Invalid encrypted data format" in str(exc_info.value) + + +class TestEdgeCases: + """Test cases for edge cases and boundary conditions""" + + def test_very_long_secret_key(self): + """Test with very long secret key""" + long_secret = "x" * 10000 + encrypter = SystemEncrypter(long_secret) + + # Key should still be 32 bytes due to SHA-256 + assert len(encrypter.key) == 32 + + # Should still work normally + params = {"client_id": "test_id"} + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_special_characters_in_secret_key(self): + """Test with special characters in secret key""" + special_secret = "!@#$%^&*()_+-=[]{}|;':\",./<>?`~test🚀" + encrypter = SystemEncrypter(special_secret) + + params = {"client_id": "test_id"} + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_empty_values_in_params(self): + """Test with empty values in params""" + params = { + "client_id": "", + "client_secret": "", + "empty_dict": {}, + "empty_list": [], + "empty_string": "", + "zero": 0, + "false": False, + "none": None, + } + + encrypter = SystemEncrypter("test_secret") + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_deeply_nested_params(self): + """Test with deeply nested params""" + params = {"level1": {"level2": {"level3": {"level4": {"level5": {"deep_value": "found"}}}}}} + + encrypter = SystemEncrypter("test_secret") + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_params_with_all_json_types(self): + """Test with all JSON-supported data types""" + params = { + "string": "test_string", + "integer": 42, + "float": 3.14159, + "boolean_true": True, + "boolean_false": False, + "null_value": None, + "empty_string": "", + "array": [1, "two", 3.0, True, False, None], + "object": {"nested_string": "nested_value", "nested_number": 123, "nested_bool": True}, + } + + encrypter = SystemEncrypter("test_secret") + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + +class TestPerformance: + """Test cases for performance considerations""" + + def test_large_params(self): + """Test with large params""" + large_value = "x" * 100000 # 100KB + params = {"client_id": "test_id", "large_data": large_value} + + encrypter = SystemEncrypter("test_secret") + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_many_fields_params(self): + """Test with many fields in params""" + params = {f"field_{i}": f"value_{i}" for i in range(1000)} + + encrypter = SystemEncrypter("test_secret") + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_repeated_encryption_decryption(self): + """Test repeated encryption and decryption operations""" + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id", "client_secret": "test_secret"} + + # Test multiple rounds of encryption/decryption + for i in range(100): + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params diff --git a/api/tests/unit_tests/utils/oauth_encryption/test_system_oauth_encryption.py b/api/tests/unit_tests/utils/oauth_encryption/test_system_oauth_encryption.py deleted file mode 100644 index e2607f0fb1..0000000000 --- a/api/tests/unit_tests/utils/oauth_encryption/test_system_oauth_encryption.py +++ /dev/null @@ -1,619 +0,0 @@ -import base64 -import hashlib -from unittest.mock import patch - -import pytest -from Crypto.Cipher import AES -from Crypto.Random import get_random_bytes -from Crypto.Util.Padding import pad - -from core.tools.utils.system_oauth_encryption import ( - OAuthEncryptionError, - SystemOAuthEncrypter, - create_system_oauth_encrypter, - decrypt_system_oauth_params, - encrypt_system_oauth_params, - get_system_oauth_encrypter, -) - - -class TestSystemOAuthEncrypter: - """Test cases for SystemOAuthEncrypter class""" - - def test_init_with_secret_key(self): - """Test initialization with provided secret key""" - secret_key = "test_secret_key" - encrypter = SystemOAuthEncrypter(secret_key=secret_key) - expected_key = hashlib.sha256(secret_key.encode()).digest() - assert encrypter.key == expected_key - - def test_init_with_none_secret_key(self): - """Test initialization with None secret key falls back to config""" - with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: - mock_config.SECRET_KEY = "config_secret" - encrypter = SystemOAuthEncrypter(secret_key=None) - expected_key = hashlib.sha256(b"config_secret").digest() - assert encrypter.key == expected_key - - def test_init_with_empty_secret_key(self): - """Test initialization with empty secret key""" - encrypter = SystemOAuthEncrypter(secret_key="") - expected_key = hashlib.sha256(b"").digest() - assert encrypter.key == expected_key - - def test_init_without_secret_key_uses_config(self): - """Test initialization without secret key uses config""" - with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: - mock_config.SECRET_KEY = "default_secret" - encrypter = SystemOAuthEncrypter() - expected_key = hashlib.sha256(b"default_secret").digest() - assert encrypter.key == expected_key - - def test_encrypt_oauth_params_basic(self): - """Test basic OAuth parameters encryption""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted = encrypter.encrypt_oauth_params(oauth_params) - - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - # Should be valid base64 - try: - base64.b64decode(encrypted) - except Exception: - pytest.fail("Encrypted result is not valid base64") - - def test_encrypt_oauth_params_empty_dict(self): - """Test encryption with empty dictionary""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {} - - encrypted = encrypter.encrypt_oauth_params(oauth_params) - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - - def test_encrypt_oauth_params_complex_data(self): - """Test encryption with complex data structures""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = { - "client_id": "test_id", - "client_secret": "test_secret", - "scopes": ["read", "write", "admin"], - "metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True}, - "numeric_value": 42, - "boolean_value": False, - "null_value": None, - } - - encrypted = encrypter.encrypt_oauth_params(oauth_params) - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - - def test_encrypt_oauth_params_unicode_data(self): - """Test encryption with unicode data""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id", "client_secret": "test_secret", "description": "This is a test case 🚀"} - - encrypted = encrypter.encrypt_oauth_params(oauth_params) - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - - def test_encrypt_oauth_params_large_data(self): - """Test encryption with large data""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = { - "client_id": "test_id", - "large_data": "x" * 10000, # 10KB of data - } - - encrypted = encrypter.encrypt_oauth_params(oauth_params) - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - - def test_encrypt_oauth_params_invalid_input(self): - """Test encryption with invalid input types""" - encrypter = SystemOAuthEncrypter("test_secret") - - with pytest.raises(Exception): # noqa: B017 - encrypter.encrypt_oauth_params(None) - - with pytest.raises(Exception): # noqa: B017 - encrypter.encrypt_oauth_params("not_a_dict") - - def test_decrypt_oauth_params_basic(self): - """Test basic OAuth parameters decryption""" - encrypter = SystemOAuthEncrypter("test_secret") - original_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - - assert decrypted == original_params - - def test_decrypt_oauth_params_empty_dict(self): - """Test decryption of empty dictionary""" - encrypter = SystemOAuthEncrypter("test_secret") - original_params = {} - - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - - assert decrypted == original_params - - def test_decrypt_oauth_params_complex_data(self): - """Test decryption with complex data structures""" - encrypter = SystemOAuthEncrypter("test_secret") - original_params = { - "client_id": "test_id", - "client_secret": "test_secret", - "scopes": ["read", "write", "admin"], - "metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True}, - "numeric_value": 42, - "boolean_value": False, - "null_value": None, - } - - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - - assert decrypted == original_params - - def test_decrypt_oauth_params_unicode_data(self): - """Test decryption with unicode data""" - encrypter = SystemOAuthEncrypter("test_secret") - original_params = { - "client_id": "test_id", - "client_secret": "test_secret", - "description": "This is a test case 🚀", - } - - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - - assert decrypted == original_params - - def test_decrypt_oauth_params_large_data(self): - """Test decryption with large data""" - encrypter = SystemOAuthEncrypter("test_secret") - original_params = { - "client_id": "test_id", - "large_data": "x" * 10000, # 10KB of data - } - - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - - assert decrypted == original_params - - def test_decrypt_oauth_params_invalid_base64(self): - """Test decryption with invalid base64 data""" - encrypter = SystemOAuthEncrypter("test_secret") - - with pytest.raises(OAuthEncryptionError): - encrypter.decrypt_oauth_params("invalid_base64!") - - def test_decrypt_oauth_params_empty_string(self): - """Test decryption with empty string""" - encrypter = SystemOAuthEncrypter("test_secret") - - with pytest.raises(ValueError) as exc_info: - encrypter.decrypt_oauth_params("") - - assert "encrypted_data cannot be empty" in str(exc_info.value) - - def test_decrypt_oauth_params_non_string_input(self): - """Test decryption with non-string input""" - encrypter = SystemOAuthEncrypter("test_secret") - - with pytest.raises(ValueError) as exc_info: - encrypter.decrypt_oauth_params(123) - - assert "encrypted_data must be a string" in str(exc_info.value) - - with pytest.raises(ValueError) as exc_info: - encrypter.decrypt_oauth_params(None) - - assert "encrypted_data must be a string" in str(exc_info.value) - - def test_decrypt_oauth_params_too_short_data(self): - """Test decryption with too short encrypted data""" - encrypter = SystemOAuthEncrypter("test_secret") - - # Create data that's too short (less than 32 bytes) - short_data = base64.b64encode(b"short").decode() - - with pytest.raises(OAuthEncryptionError) as exc_info: - encrypter.decrypt_oauth_params(short_data) - - assert "Invalid encrypted data format" in str(exc_info.value) - - def test_decrypt_oauth_params_corrupted_data(self): - """Test decryption with corrupted data""" - encrypter = SystemOAuthEncrypter("test_secret") - - # Create corrupted data (valid base64 but invalid encrypted content) - corrupted_data = base64.b64encode(b"x" * 48).decode() # 48 bytes of garbage - - with pytest.raises(OAuthEncryptionError): - encrypter.decrypt_oauth_params(corrupted_data) - - def test_decrypt_oauth_params_wrong_key(self): - """Test decryption with wrong key""" - encrypter1 = SystemOAuthEncrypter("secret1") - encrypter2 = SystemOAuthEncrypter("secret2") - - original_params = {"client_id": "test_id", "client_secret": "test_secret"} - encrypted = encrypter1.encrypt_oauth_params(original_params) - - with pytest.raises(OAuthEncryptionError): - encrypter2.decrypt_oauth_params(encrypted) - - def test_encryption_decryption_consistency(self): - """Test that encryption and decryption are consistent""" - encrypter = SystemOAuthEncrypter("test_secret") - - test_cases = [ - {}, - {"simple": "value"}, - {"client_id": "id", "client_secret": "secret"}, - {"complex": {"nested": {"deep": "value"}}}, - {"unicode": "test 🚀"}, - {"numbers": 42, "boolean": True, "null": None}, - {"array": [1, 2, 3, "four", {"five": 5}]}, - ] - - for original_params in test_cases: - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == original_params, f"Failed for case: {original_params}" - - def test_encryption_randomness(self): - """Test that encryption produces different results for same input""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted1 = encrypter.encrypt_oauth_params(oauth_params) - encrypted2 = encrypter.encrypt_oauth_params(oauth_params) - - # Should be different due to random IV - assert encrypted1 != encrypted2 - - # But should decrypt to same result - decrypted1 = encrypter.decrypt_oauth_params(encrypted1) - decrypted2 = encrypter.decrypt_oauth_params(encrypted2) - assert decrypted1 == decrypted2 == oauth_params - - def test_different_secret_keys_produce_different_results(self): - """Test that different secret keys produce different encrypted results""" - encrypter1 = SystemOAuthEncrypter("secret1") - encrypter2 = SystemOAuthEncrypter("secret2") - - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted1 = encrypter1.encrypt_oauth_params(oauth_params) - encrypted2 = encrypter2.encrypt_oauth_params(oauth_params) - - # Should produce different encrypted results - assert encrypted1 != encrypted2 - - # But each should decrypt correctly with its own key - decrypted1 = encrypter1.decrypt_oauth_params(encrypted1) - decrypted2 = encrypter2.decrypt_oauth_params(encrypted2) - assert decrypted1 == decrypted2 == oauth_params - - @patch("core.tools.utils.system_oauth_encryption.get_random_bytes") - def test_encrypt_oauth_params_crypto_error(self, mock_get_random_bytes): - """Test encryption when crypto operation fails""" - mock_get_random_bytes.side_effect = Exception("Crypto error") - - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id"} - - with pytest.raises(OAuthEncryptionError) as exc_info: - encrypter.encrypt_oauth_params(oauth_params) - - assert "Encryption failed" in str(exc_info.value) - - @patch("core.tools.utils.system_oauth_encryption.TypeAdapter") - def test_encrypt_oauth_params_serialization_error(self, mock_type_adapter): - """Test encryption when JSON serialization fails""" - mock_type_adapter.return_value.dump_json.side_effect = Exception("Serialization error") - - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id"} - - with pytest.raises(OAuthEncryptionError) as exc_info: - encrypter.encrypt_oauth_params(oauth_params) - - assert "Encryption failed" in str(exc_info.value) - - def test_decrypt_oauth_params_invalid_json(self): - """Test decryption with invalid JSON data""" - encrypter = SystemOAuthEncrypter("test_secret") - - # Create valid encrypted data but with invalid JSON content - iv = get_random_bytes(16) - cipher = AES.new(encrypter.key, AES.MODE_CBC, iv) - invalid_json = b"invalid json content" - padded_data = pad(invalid_json, AES.block_size) - encrypted_data = cipher.encrypt(padded_data) - combined = iv + encrypted_data - encoded = base64.b64encode(combined).decode() - - with pytest.raises(OAuthEncryptionError): - encrypter.decrypt_oauth_params(encoded) - - def test_key_derivation_consistency(self): - """Test that key derivation is consistent""" - secret_key = "test_secret" - encrypter1 = SystemOAuthEncrypter(secret_key) - encrypter2 = SystemOAuthEncrypter(secret_key) - - assert encrypter1.key == encrypter2.key - - # Keys should be 32 bytes (256 bits) - assert len(encrypter1.key) == 32 - - -class TestFactoryFunctions: - """Test cases for factory functions""" - - def test_create_system_oauth_encrypter_with_secret(self): - """Test factory function with secret key""" - secret_key = "test_secret" - encrypter = create_system_oauth_encrypter(secret_key) - - assert isinstance(encrypter, SystemOAuthEncrypter) - expected_key = hashlib.sha256(secret_key.encode()).digest() - assert encrypter.key == expected_key - - def test_create_system_oauth_encrypter_without_secret(self): - """Test factory function without secret key""" - with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: - mock_config.SECRET_KEY = "config_secret" - encrypter = create_system_oauth_encrypter() - - assert isinstance(encrypter, SystemOAuthEncrypter) - expected_key = hashlib.sha256(b"config_secret").digest() - assert encrypter.key == expected_key - - def test_create_system_oauth_encrypter_with_none_secret(self): - """Test factory function with None secret key""" - with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: - mock_config.SECRET_KEY = "config_secret" - encrypter = create_system_oauth_encrypter(None) - - assert isinstance(encrypter, SystemOAuthEncrypter) - expected_key = hashlib.sha256(b"config_secret").digest() - assert encrypter.key == expected_key - - -class TestGlobalEncrypterInstance: - """Test cases for global encrypter instance""" - - def test_get_system_oauth_encrypter_singleton(self): - """Test that get_system_oauth_encrypter returns singleton instance""" - # Clear the global instance first - import core.tools.utils.system_oauth_encryption - - core.tools.utils.system_oauth_encryption._oauth_encrypter = None - - encrypter1 = get_system_oauth_encrypter() - encrypter2 = get_system_oauth_encrypter() - - assert encrypter1 is encrypter2 - assert isinstance(encrypter1, SystemOAuthEncrypter) - - def test_get_system_oauth_encrypter_uses_config(self): - """Test that global encrypter uses config""" - # Clear the global instance first - import core.tools.utils.system_oauth_encryption - - core.tools.utils.system_oauth_encryption._oauth_encrypter = None - - with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: - mock_config.SECRET_KEY = "global_secret" - encrypter = get_system_oauth_encrypter() - - expected_key = hashlib.sha256(b"global_secret").digest() - assert encrypter.key == expected_key - - -class TestConvenienceFunctions: - """Test cases for convenience functions""" - - def test_encrypt_system_oauth_params(self): - """Test encrypt_system_oauth_params convenience function""" - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted = encrypt_system_oauth_params(oauth_params) - - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - - def test_decrypt_system_oauth_params(self): - """Test decrypt_system_oauth_params convenience function""" - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted = encrypt_system_oauth_params(oauth_params) - decrypted = decrypt_system_oauth_params(encrypted) - - assert decrypted == oauth_params - - def test_convenience_functions_consistency(self): - """Test that convenience functions work consistently""" - test_cases = [ - {}, - {"simple": "value"}, - {"client_id": "id", "client_secret": "secret"}, - {"complex": {"nested": {"deep": "value"}}}, - {"unicode": "test 🚀"}, - {"numbers": 42, "boolean": True, "null": None}, - ] - - for original_params in test_cases: - encrypted = encrypt_system_oauth_params(original_params) - decrypted = decrypt_system_oauth_params(encrypted) - assert decrypted == original_params, f"Failed for case: {original_params}" - - def test_convenience_functions_with_errors(self): - """Test convenience functions with error conditions""" - # Test encryption with invalid input - with pytest.raises(Exception): # noqa: B017 - encrypt_system_oauth_params(None) - - # Test decryption with invalid input - with pytest.raises(ValueError): - decrypt_system_oauth_params("") - - with pytest.raises(ValueError): - decrypt_system_oauth_params(None) - - -class TestErrorHandling: - """Test cases for error handling""" - - def test_oauth_encryption_error_inheritance(self): - """Test that OAuthEncryptionError is a proper exception""" - error = OAuthEncryptionError("Test error") - assert isinstance(error, Exception) - assert str(error) == "Test error" - - def test_oauth_encryption_error_with_cause(self): - """Test OAuthEncryptionError with cause""" - original_error = ValueError("Original error") - error = OAuthEncryptionError("Wrapper error") - error.__cause__ = original_error - - assert isinstance(error, Exception) - assert str(error) == "Wrapper error" - assert error.__cause__ is original_error - - def test_error_messages_are_informative(self): - """Test that error messages are informative""" - encrypter = SystemOAuthEncrypter("test_secret") - - # Test empty string error - with pytest.raises(ValueError) as exc_info: - encrypter.decrypt_oauth_params("") - assert "encrypted_data cannot be empty" in str(exc_info.value) - - # Test non-string error - with pytest.raises(ValueError) as exc_info: - encrypter.decrypt_oauth_params(123) - assert "encrypted_data must be a string" in str(exc_info.value) - - # Test invalid format error - short_data = base64.b64encode(b"short").decode() - with pytest.raises(OAuthEncryptionError) as exc_info: - encrypter.decrypt_oauth_params(short_data) - assert "Invalid encrypted data format" in str(exc_info.value) - - -class TestEdgeCases: - """Test cases for edge cases and boundary conditions""" - - def test_very_long_secret_key(self): - """Test with very long secret key""" - long_secret = "x" * 10000 - encrypter = SystemOAuthEncrypter(long_secret) - - # Key should still be 32 bytes due to SHA-256 - assert len(encrypter.key) == 32 - - # Should still work normally - oauth_params = {"client_id": "test_id"} - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_special_characters_in_secret_key(self): - """Test with special characters in secret key""" - special_secret = "!@#$%^&*()_+-=[]{}|;':\",./<>?`~test🚀" - encrypter = SystemOAuthEncrypter(special_secret) - - oauth_params = {"client_id": "test_id"} - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_empty_values_in_oauth_params(self): - """Test with empty values in oauth params""" - oauth_params = { - "client_id": "", - "client_secret": "", - "empty_dict": {}, - "empty_list": [], - "empty_string": "", - "zero": 0, - "false": False, - "none": None, - } - - encrypter = SystemOAuthEncrypter("test_secret") - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_deeply_nested_oauth_params(self): - """Test with deeply nested oauth params""" - oauth_params = {"level1": {"level2": {"level3": {"level4": {"level5": {"deep_value": "found"}}}}}} - - encrypter = SystemOAuthEncrypter("test_secret") - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_oauth_params_with_all_json_types(self): - """Test with all JSON-supported data types""" - oauth_params = { - "string": "test_string", - "integer": 42, - "float": 3.14159, - "boolean_true": True, - "boolean_false": False, - "null_value": None, - "empty_string": "", - "array": [1, "two", 3.0, True, False, None], - "object": {"nested_string": "nested_value", "nested_number": 123, "nested_bool": True}, - } - - encrypter = SystemOAuthEncrypter("test_secret") - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - -class TestPerformance: - """Test cases for performance considerations""" - - def test_large_oauth_params(self): - """Test with large oauth params""" - large_value = "x" * 100000 # 100KB - oauth_params = {"client_id": "test_id", "large_data": large_value} - - encrypter = SystemOAuthEncrypter("test_secret") - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_many_fields_oauth_params(self): - """Test with many fields in oauth params""" - oauth_params = {f"field_{i}": f"value_{i}" for i in range(1000)} - - encrypter = SystemOAuthEncrypter("test_secret") - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_repeated_encryption_decryption(self): - """Test repeated encryption and decryption operations""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - # Test multiple rounds of encryption/decryption - for i in range(100): - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params diff --git a/api/uv.lock b/api/uv.lock index cf54fced75..239dbf5ac8 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -478,19 +478,19 @@ wheels = [ [[package]] name = "basedpyright" -version = "1.39.0" +version = "1.39.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodejs-wheel-binaries" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/f4/4a77cc1ffb3dab7391642cde30163961d8ee973e9e6b6740c7d15aa3d3ba/basedpyright-1.39.0.tar.gz", hash = "sha256:6666f51c378c7ac45877c4c1c7041ee0b5b83d755ebc82f898f47b6fafe0cc4f", size = 25357403, upload-time = "2026-04-01T12:27:41.92Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/47/08145d1bcc3083ed20059bdecbde404bd767f91b91e2764ec01cffec9f4b/basedpyright-1.39.0-py3-none-any.whl", hash = "sha256:91b8ad50bc85ee4a985b928f9368c35c99eee5a56c44e99b2442fa12ecc3d670", size = 12353868, upload-time = "2026-04-01T12:27:38.495Z" }, + { url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" }, ] [[package]] name = "bce-python-sdk" -version = "0.9.69" +version = "0.9.70" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "crc32c" }, @@ -498,9 +498,9 @@ dependencies = [ { name = "pycryptodome" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/9c/8fdaf7f9259002b5aa9101bb88252f6d05f65c4535bbca567457da84d765/bce_python_sdk-0.9.69.tar.gz", hash = "sha256:2aaa9f4fc118b3efb720a66d7a541789b7d838a1ddacb9f3c6faa6b75e1c7d23", size = 300008, upload-time = "2026-04-10T08:13:29.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/a9/7c21a9073eb9ad7e8cacf6f8a0e47c0d01ad7bf8fd8e0dc42164b117d60b/bce_python_sdk-0.9.70.tar.gz", hash = "sha256:3b37fd7448278dd33f745a6a23198a2cc2490fded9cb8d59b72500784853df4e", size = 299967, upload-time = "2026-04-14T12:02:42.034Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/3b/41c2985d1b3b3bd5cdf103b4156b08320268ee7a0617f2a40c34fdd377e9/bce_python_sdk-0.9.69-py3-none-any.whl", hash = "sha256:50fb94833b5f4931255296396081b85143101bd9a7a894efbf20d1f759779de5", size = 415659, upload-time = "2026-04-10T08:13:27.958Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2d/70fc866ff98d1f6bd75b0a4235694129b3c519b014254d7bcfc02ffe1bee/bce_python_sdk-0.9.70-py3-none-any.whl", hash = "sha256:fd1f31113e4a8dca314f040662b7caf07ec11cf896c5da232627a9a2c9d2e3a1", size = 415660, upload-time = "2026-04-14T12:02:40.034Z" }, ] [[package]] @@ -613,29 +613,29 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.88" +version = "1.42.91" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/bb/7d4435cca6fccf235dd40c891c731bcb9078e815917b57ebadd1e0ffabaf/boto3-1.42.88.tar.gz", hash = "sha256:2d22c70de5726918676a06f1a03acfb4d5d9ea92fc759354800b67b22aaeef19", size = 113238, upload-time = "2026-04-10T19:41:06.912Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/c0/98b8cec7ca22dde776df48c58940ae1abc425593959b7226e270760d726f/boto3-1.42.91.tar.gz", hash = "sha256:03d70532b17f7f84df37ca7e8c21553280454dea53ae12b15d1cfef9b16fcb8a", size = 113181, upload-time = "2026-04-17T19:31:06.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/2b/8bfddb39a19f5fbc16a869f1a394771e6223f07160dbc0ff6b38e05ea0ae/boto3-1.42.88-py3-none-any.whl", hash = "sha256:2d0f52c971503377e4370d2a83edee6f077ddb8e684366ff38df4f13581d9cfc", size = 140557, upload-time = "2026-04-10T19:41:05.309Z" }, + { url = "https://files.pythonhosted.org/packages/02/29/faba6521257c34085cc9b439ef98235b581772580f417fa3629728007270/boto3-1.42.91-py3-none-any.whl", hash = "sha256:04e72071cde022951ce7f81bd9933c90095ab8923e8ced61c8dacfe9edac0f5c", size = 140553, upload-time = "2026-04-17T19:31:02.57Z" }, ] [[package]] name = "boto3-stubs" -version = "1.42.88" +version = "1.42.92" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c7/d4dfbb4757cd72fd350ba666902ec3ac19e04d6be639e96cdad4543d4726/boto3_stubs-1.42.88.tar.gz", hash = "sha256:85215fb4938a94d1cf83cd8632f46ae7728b5ec88187d83468f393bbe64236d6", size = 102495, upload-time = "2026-04-10T19:55:57.526Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/b4/7f472d64a89f6aa6b8e8eeadc876667b7e4edfb526c6118efe2b2c98ba17/boto3_stubs-1.42.92.tar.gz", hash = "sha256:4bc934069c5e8c7b3cdd2442569dae14e8272fe207d445bd38aa578b8463638f", size = 102696, upload-time = "2026-04-20T19:55:19.858Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/6f/3befd72080aedbb4ad26b353a6e364645668664930ce49668fd0bab8f2b5/boto3_stubs-1.42.88-py3-none-any.whl", hash = "sha256:9e74350715ca8ccd63fc250f8eca9fa3161b3d1704339554344d72e4e21c5ed1", size = 70603, upload-time = "2026-04-10T19:55:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ce/2fe2c6456f8dc0b8bb8d80e05e154c7975ec058991bedf54f3aeed634b79/boto3_stubs-1.42.92-py3-none-any.whl", hash = "sha256:b3994e60f0133b2dd3d9a88ceaeef48fa6367d9a9429426e919575768a1ad9c6", size = 70666, upload-time = "2026-04-20T19:55:16.398Z" }, ] [package.optional-dependencies] @@ -645,16 +645,16 @@ bedrock-runtime = [ [[package]] name = "botocore" -version = "1.42.88" +version = "1.42.91" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/50/87966238f7aa3f7e5f87081185d5a407a95ede8b551e11bbe134ca3306dc/botocore-1.42.88.tar.gz", hash = "sha256:cbb59ee464662039b0c2c95a520cdf85b1e8ce00b72375ab9cd9f842cc001301", size = 15195331, upload-time = "2026-04-10T19:40:57.012Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/bc/a4b7c46471c2e789ad8c4c7acfd7f302fdb481d93ff870f441249b924ae6/botocore-1.42.91.tar.gz", hash = "sha256:d252e27bc454afdbf5ed3dc617aa423f2c855c081e98b7963093399483ecc698", size = 15213010, upload-time = "2026-04-17T19:30:50.793Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/46/ad14e41245adb8b0c83663ba13e822b68a0df08999dd250e75b0750fdf6c/botocore-1.42.88-py3-none-any.whl", hash = "sha256:032375b213305b6b81eedb269eaeefdf96f674620799bbf96117dca86052cc1a", size = 14876640, upload-time = "2026-04-10T19:40:53.663Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fc/24cc0a47c824f13933e210e9ad034b4fba22f7185b8d904c0fbf5a3b2be8/botocore-1.42.91-py3-none-any.whl", hash = "sha256:7a28c3cc6bfab5724ad18899d52402b776a0de7d87fa20c3c5270bcaaf199ce8", size = 14897344, upload-time = "2026-04-17T19:30:44.245Z" }, ] [[package]] @@ -719,11 +719,11 @@ wheels = [ [[package]] name = "cachetools" -version = "7.0.5" +version = "6.2.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, ] [[package]] @@ -1668,7 +1668,7 @@ requires-dist = [ { name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" }, { name = "azure-identity", specifier = ">=1.25.3,<2.0.0" }, { name = "bleach", specifier = ">=6.3.0" }, - { name = "boto3", specifier = ">=1.42.88" }, + { name = "boto3", specifier = ">=1.42.91" }, { name = "celery", specifier = ">=5.6.3" }, { name = "croniter", specifier = ">=6.2.2" }, { name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" }, @@ -1682,12 +1682,12 @@ requires-dist = [ { name = "gevent-websocket", specifier = ">=0.10.1" }, { name = "gmpy2", specifier = ">=2.3.0" }, { name = "google-api-python-client", specifier = ">=2.194.0" }, - { name = "google-cloud-aiplatform", specifier = ">=1.147.0,<2.0.0" }, + { name = "google-cloud-aiplatform", specifier = ">=1.148.1,<2.0.0" }, { name = "graphon", specifier = "~=0.2.2" }, { name = "gunicorn", specifier = ">=25.3.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, { name = "httpx-sse", specifier = "~=0.4.0" }, - { name = "json-repair", specifier = "~=0.59.2" }, + { name = "json-repair", specifier = "~=0.59.4" }, { name = "opentelemetry-distro", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-instrumentation-celery", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-instrumentation-flask", specifier = ">=0.62b0,<1.0.0" }, @@ -1707,18 +1707,18 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "basedpyright", specifier = ">=1.39.0" }, - { name = "boto3-stubs", specifier = ">=1.42.88" }, + { name = "basedpyright", specifier = ">=1.39.3" }, + { name = "boto3-stubs", specifier = ">=1.42.92" }, { name = "celery-types", specifier = ">=0.23.0" }, { name = "coverage", specifier = ">=7.13.4" }, { name = "dotenv-linter", specifier = ">=0.7.0" }, - { name = "faker", specifier = ">=20.1.0" }, - { name = "hypothesis", specifier = ">=6.151.12" }, + { name = "faker", specifier = ">=40.15.0" }, + { name = "hypothesis", specifier = ">=6.152.1" }, { name = "import-linter", specifier = ">=2.3" }, { name = "lxml-stubs", specifier = ">=0.5.1" }, { name = "mypy", specifier = ">=1.20.1" }, { name = "pandas-stubs", specifier = ">=3.0.0" }, - { name = "pyrefly", specifier = ">=0.61.1" }, + { name = "pyrefly", specifier = ">=0.62.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-benchmark", specifier = ">=5.2.3" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -1726,8 +1726,8 @@ dev = [ { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, - { name = "ruff", specifier = ">=0.15.10" }, - { name = "scipy-stubs", specifier = ">=1.15.3.0" }, + { name = "ruff", specifier = ">=0.15.11" }, + { name = "scipy-stubs", specifier = ">=1.17.1.4" }, { name = "testcontainers", specifier = ">=4.14.2" }, { name = "types-aiofiles", specifier = ">=25.1.0" }, { name = "types-beautifulsoup4", specifier = ">=4.12.0" }, @@ -1768,7 +1768,7 @@ dev = [ { name = "types-tensorflow", specifier = ">=2.18.0.20260408" }, { name = "types-tqdm", specifier = ">=4.67.3.20260408" }, { name = "types-ujson", specifier = ">=5.10.0" }, - { name = "xinference-client", specifier = ">=2.4.0" }, + { name = "xinference-client", specifier = ">=2.5.0" }, ] evaluation = [ { name = "deepeval", specifier = ">=2.0.0" }, @@ -1776,13 +1776,13 @@ evaluation = [ ] storage = [ { name = "azure-storage-blob", specifier = ">=12.28.0" }, - { name = "bce-python-sdk", specifier = ">=0.9.69" }, + { name = "bce-python-sdk", specifier = ">=0.9.70" }, { name = "cos-python-sdk-v5", specifier = ">=1.9.41" }, { name = "esdk-obs-python", specifier = ">=3.22.2" }, { name = "google-cloud-storage", specifier = ">=3.10.1" }, { name = "opendal", specifier = ">=0.46.0" }, { name = "oss2", specifier = ">=2.19.1" }, - { name = "supabase", specifier = ">=2.18.1" }, + { name = "supabase", specifier = ">=2.28.3" }, { name = "tos", specifier = ">=2.9.0" }, ] tools = [ @@ -1869,7 +1869,7 @@ vdb-upstash = [{ name = "dify-vdb-upstash", editable = "providers/vdb/vdb-upstas vdb-vastbase = [{ name = "dify-vdb-vastbase", editable = "providers/vdb/vdb-vastbase" }] vdb-vikingdb = [{ name = "dify-vdb-vikingdb", editable = "providers/vdb/vdb-vikingdb" }] vdb-weaviate = [{ name = "dify-vdb-weaviate", editable = "providers/vdb/vdb-weaviate" }] -vdb-xinference = [{ name = "xinference-client", specifier = ">=2.4.0" }] +vdb-xinference = [{ name = "xinference-client", specifier = ">=2.5.0" }] [[package]] name = "dify-trace-aliyun" @@ -2464,14 +2464,14 @@ wheels = [ [[package]] name = "faker" -version = "40.13.0" +version = "40.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/95/4822ffe94723553789aef783104f4f18fc20d7c4c68e1bbd633e11d09758/faker-40.13.0.tar.gz", hash = "sha256:a0751c84c3abac17327d7bb4c98e8afe70ebf7821e01dd7d0b15cd8856415525", size = 1962043, upload-time = "2026-04-06T16:44:55.68Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/13/6741787bd91c4109c7bed047d68273965cd52ce8a5f773c471b949334b6d/faker-40.15.0.tar.gz", hash = "sha256:20f3a6ec8c266b74d4c554e34118b21c3c2056c0b4a519d15c8decb3a4e6e795", size = 1967447, upload-time = "2026-04-17T20:05:27.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/8a/708103325edff16a0b0e004de0d37db8ba216a32713948c64d71f6d4a4c2/faker-40.13.0-py3-none-any.whl", hash = "sha256:c1298fd0d819b3688fb5fd358c4ba8f56c7c8c740b411fd3dbd8e30bf2c05019", size = 1994597, upload-time = "2026-04-06T16:44:53.698Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/a600f8f30d4505e89166de51dd121bd540ab8e560e8cf0901de00a81de8c/faker-40.15.0-py3-none-any.whl", hash = "sha256:71ab3c3370da9d2205ab74ffb0fd51273063ad562b3a3bb69d0026a20923e318", size = 2004447, upload-time = "2026-04-17T20:05:25.437Z" }, ] [[package]] @@ -2882,7 +2882,7 @@ wheels = [ [[package]] name = "google-cloud-aiplatform" -version = "1.147.0" +version = "1.148.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -2898,9 +2898,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/93/9bfcaaf1ceab12999a881ccf69ebd9b30f467ec5623989c66894e81fc139/google_cloud_aiplatform-1.147.0.tar.gz", hash = "sha256:b2e1b669ba37f02426e03eb13187eebf4cbfeaa0a3bfed37b5578abb375ab689", size = 10235245, upload-time = "2026-04-09T17:14:49.179Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/f3/b2a9417014c93858a2e3266134f931eefd972c2d410b25d7b8782fc6f143/google_cloud_aiplatform-1.148.1.tar.gz", hash = "sha256:75d605fba34e68714bd08e1e482755d0a6e3ae972805f809d088e686c30879e7", size = 10278758, upload-time = "2026-04-17T23:45:26.738Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/d2/1c1c582f6bbed9bbc0daa5acf3a5d98751ca8bc48584548d28569b8ce1a7/google_cloud_aiplatform-1.147.0-py2.py3-none-any.whl", hash = "sha256:29f7ae020718d3c45094f0475464e06a97f81b1572bea150ae6a1b22c5f45997", size = 8408951, upload-time = "2026-04-09T17:14:45.482Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/e3515d7bbba602c2b0f6a0da5431785e897252443682e4735d0e6873dc8f/google_cloud_aiplatform-1.148.1-py2.py3-none-any.whl", hash = "sha256:035101e2d8e65c6a706cc3930b2452de7ddcbde50dd130320fcea0d8b03b0c5a", size = 8434481, upload-time = "2026-04-17T23:45:22.919Z" }, ] [[package]] @@ -3435,14 +3435,14 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.151.12" +version = "6.152.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/ab/67ca321d1ab96fd3828b12142f1c258e2d4a668a025d06cd50ab3409787f/hypothesis-6.151.12.tar.gz", hash = "sha256:be485f503979af4c3dfa19e3fc2b967d0458e7f8c4e28128d7e215e0a55102e0", size = 463900, upload-time = "2026-04-08T19:40:06.205Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/b1/c32bcddb9aab9e3abc700f1f56faf14e7655c64a16ca47701a57362276ea/hypothesis-6.152.1.tar.gz", hash = "sha256:4f4ed934eee295dd84ee97592477d23e8dc03e9f12ae0ee30a4e7c9ef3fca3b0", size = 465029, upload-time = "2026-04-14T22:29:24.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/5a/6cecf134b631050a1f8605096adbe812483b60790d951470989d39b56860/hypothesis-6.151.12-py3-none-any.whl", hash = "sha256:37d4f3a768365c30571b11dfd7a6857a12173d933010b2c4ab65619f1b5952c5", size = 529656, upload-time = "2026-04-08T19:40:03.126Z" }, + { url = "https://files.pythonhosted.org/packages/5d/83/860fb3075e00b0fc19a22a2301bc3c96f00437558c3911bdd0a3573a4a53/hypothesis-6.152.1-py3-none-any.whl", hash = "sha256:40a3619d9e0cb97b018857c7986f75cf5de2e5ec0fa8a0b172d00747758f749e", size = 530752, upload-time = "2026-04-14T22:29:20.893Z" }, ] [[package]] @@ -3632,11 +3632,11 @@ wheels = [ [[package]] name = "json-repair" -version = "0.59.2" +version = "0.59.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/cb/a49f1661737a78098ce33668350590c981a4163055bc9a01e0cc688d896a/json_repair-0.59.2.tar.gz", hash = "sha256:1d8abb2fa94c4035a66ef9892ea3785dace8dcf09c583e6de781cfd31b278b3d", size = 48341, upload-time = "2026-04-11T15:55:41.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/41/4ae9c6e711647a41b4e0c04bce815113ce9c0286eff6dc6fb86979b2fb9f/json_repair-0.59.4.tar.gz", hash = "sha256:559ca1828f6f566530663cd96d64bee29f8282b9d2ff0e661e05fa87b4171ab3", size = 47624, upload-time = "2026-04-15T06:48:40.776Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/03/7afcecb4242d93b684708b47fb014abdc1922a01b38c0e30f1117ae74a83/json_repair-0.59.2-py3-none-any.whl", hash = "sha256:6ca6238519c24f671bcb05d1f38a0d6a452bb4ca5af82137595c5c2f1a0fb785", size = 46918, upload-time = "2026-04-11T15:55:39.817Z" }, + { url = "https://files.pythonhosted.org/packages/74/c4/ec3068436d2275731539b7a43fbc947f502bc3fe149856a5d00368c7b087/json_repair-0.59.4-py3-none-any.whl", hash = "sha256:46052e646bc0b0c39db672ebbf732f774f3c1a5bde81a54f0b0e19d3af4f45cd", size = 46697, upload-time = "2026-04-15T06:48:39.61Z" }, ] [[package]] @@ -4004,28 +4004,28 @@ wheels = [ [[package]] name = "lxml" -version = "6.0.2" +version = "6.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, ] [[package]] @@ -5179,16 +5179,17 @@ wheels = [ [[package]] name = "postgrest" -version = "1.1.1" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, { name = "httpx", extra = ["http2"] }, { name = "pydantic" }, + { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/3e/1b50568e1f5db0bdced4a82c7887e37326585faef7ca43ead86849cb4861/postgrest-1.1.1.tar.gz", hash = "sha256:f3bb3e8c4602775c75c844a31f565f5f3dd584df4d36d683f0b67d01a86be322", size = 15431, upload-time = "2025-06-23T19:21:34.742Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/60/9378ddd6e21b6005b34aeb42dc7a9ed9985c673c97c9b6a1858f9c52ebbd/postgrest-2.28.3.tar.gz", hash = "sha256:56336e9304950a78315ec7d6c8eb307cdb964d0878a7bec6111392ddb6c16a45", size = 13758, upload-time = "2026-03-20T14:38:06.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/71/188a50ea64c17f73ff4df5196ec1553a8f1723421eb2d1069c73bab47d78/postgrest-1.1.1-py3-none-any.whl", hash = "sha256:98a6035ee1d14288484bfe36235942c5fb2d26af6d8120dfe3efbe007859251a", size = 22366, upload-time = "2025-06-23T19:21:33.637Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/6eeb1d53d010d80e800204c1eee6b3d5419a6a2b985c364f56f36cf48cca/postgrest-2.28.3-py3-none-any.whl", hash = "sha256:5a44d6c6d509abdbe0f928c86f0dc31ef26bda36e0357129836ec54dfb50b083", size = 21865, upload-time = "2026-03-20T14:38:05.55Z" }, ] [[package]] @@ -5546,6 +5547,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyiceberg" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "click" }, + { name = "fsspec" }, + { name = "mmh3" }, + { name = "pydantic" }, + { name = "pyparsing" }, + { name = "pyroaring" }, + { name = "requests" }, + { name = "rich" }, + { name = "strictyaml" }, + { name = "tenacity" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/f0/7616676603fdbd05ab97816337a9b31be08a5f9e1ffd636260812b217e0f/pyiceberg-0.11.1.tar.gz", hash = "sha256:366fe0d5a74e3cf1d4e7cbf3c49e308da60e7835ea268667be9185388f05d7a5", size = 1076075, upload-time = "2026-03-03T00:10:27.61Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/84/a140466b7e0841207e6b77042e03d4ab3a4f9d47e00f0bbbcc5420792bbb/pyiceberg-0.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd423b8ee2f75fc9db09158875abe5e2c952a26ae5e521c3265ab2f9d3511ddf", size = 532981, upload-time = "2026-03-03T00:10:08.906Z" }, + { url = "https://files.pythonhosted.org/packages/17/10/6bedd784010f707680ffd0606d4d11394cf915f4f9f54ae16e8007e00ad4/pyiceberg-0.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e273242cdca56029af694d7ce18075d47a74d034326d663ff6dd2655a6f44825", size = 533188, upload-time = "2026-03-03T00:10:10.086Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a3/79db617c3cffc963efa8a332707079d3f22fd58067b31a208d358dd89b39/pyiceberg-0.11.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b347d3cc8510f8fbe191956fcda7da372ebb3302789acefca08e352345959003", size = 729546, upload-time = "2026-03-03T00:10:11.413Z" }, + { url = "https://files.pythonhosted.org/packages/06/64/acc11d230c33817bced80d9d947bb49e7bb3a429d76d906523e3df86faf8/pyiceberg-0.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba3a35b4648694783aeae5b77c235a57191c8b1b375c8602b03ae56a6cf4fe7", size = 730263, upload-time = "2026-03-03T00:10:13.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1a/fb067d5150c7309fbf5dd126c648a6afed6259e7bc924ba3c65d0f87a333/pyiceberg-0.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0f958cbca18d05846e3081dfff8575e73d45595441d659847479656dc76f91d", size = 724064, upload-time = "2026-03-03T00:10:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/c1/71/103fdba5b144d55f3bb07347893737cc1d8fd71308108a77b7817c92c544/pyiceberg-0.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c62636a1e9d8a1fc74ffb70383939b9cd93f2c9ee8e12015a50dd75c98a989e", size = 727239, upload-time = "2026-03-03T00:10:16.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/c3/4db64429304c58c039f8e842cd37a9a1c472f596c2868ed2a5d2907b17ed/pyiceberg-0.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d6b6f0c1e7dd8357f1ba56524bfc870d04ad3c00979db291784a7145497ad3b", size = 531309, upload-time = "2026-03-03T00:10:17.561Z" }, +] + [[package]] name = "pyjwt" version = "2.12.1" @@ -5715,19 +5745,39 @@ wheels = [ [[package]] name = "pyrefly" -version = "0.61.1" +version = "0.62.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/c8/52fce3f0e3718d9ff71d16af41cef925e58613741328004d3aa3fe585057/pyrefly-0.61.1.tar.gz", hash = "sha256:2a871320b7d2b28b8635064b620097d7091e84c49e4808d915ad31dad685d0f5", size = 5535788, upload-time = "2026-04-17T18:47:33.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/8874ed25781e7dd561c6d75fb4a7becf10a18d75b074f25b845cc334f781/pyrefly-0.62.0.tar.gz", hash = "sha256:da1fbe1075dc1e6c8e3134e9370b0a0e7a296061d782cca5bf83dbb8e4c10d7c", size = 5537672, upload-time = "2026-04-20T17:12:15.718Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/38/e94ff401405a05fbf81c9bbfa993a34ffd03be84812b545063c8efb56b44/pyrefly-0.61.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6e3ed857b99291fc4aa3b54ce22deb086c0174cf3a3775eccea7439efd16d925", size = 12969301, upload-time = "2026-04-17T18:47:06.036Z" }, - { url = "https://files.pythonhosted.org/packages/f3/be/53c7f9400696e46633c8cee8b6fd32ce7ab4a965ddf9ac4f4ea9e2034647/pyrefly-0.61.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cf6335c1baf9470ca8113f7ea8bdbd0b96081c82a911157c576cdfc8a67a9a87", size = 12475413, upload-time = "2026-04-17T18:47:08.863Z" }, - { url = "https://files.pythonhosted.org/packages/77/68/83cc3267620b14f81fa596a84efc7ebcf5c49f79b521499e85d1a4fca6d8/pyrefly-0.61.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844b5baddc2a631f69648a4756c54c97d86e4b9c07e335b216668e24390b77b6", size = 36074785, upload-time = "2026-04-17T18:47:11.845Z" }, - { url = "https://files.pythonhosted.org/packages/d8/00/e8d437995b8dcea022f5310bc873f5de1dcc71da4876d5be917ee9a93fef/pyrefly-0.61.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eaa294f90622c5b3743af8e9de4263447f22bb0e8b60c80cf83292adb4f2d14b", size = 38802979, upload-time = "2026-04-17T18:47:16.058Z" }, - { url = "https://files.pythonhosted.org/packages/16/3f/f1cbc58e8875608ae740d9575de95c8bc6d4dce202f82b4fe90005727618/pyrefly-0.61.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a8d8c3fe08b9593dce23ad4bc7c393891a379c2d580aa1f263182567721bd6f", size = 37029339, upload-time = "2026-04-17T18:47:19.601Z" }, - { url = "https://files.pythonhosted.org/packages/18/8c/0ff67041c88c28f48b10ce15758831d1e4e60f11db5bfc09dcffd5edb6ba/pyrefly-0.61.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:305f2086f4d7d796244b337884d96cf0d32435420336a77840ca369cf6fa06fd", size = 41595667, upload-time = "2026-04-17T18:47:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/62b8139b140931593a6b29334802ea6b86d033c0bfd9794950279732253b/pyrefly-0.61.1-py3-none-win32.whl", hash = "sha256:3271a019885a72c8dd064e928bb445af807771506842f5f2faaac17d8e6e73a5", size = 11963660, upload-time = "2026-04-17T18:47:25.86Z" }, - { url = "https://files.pythonhosted.org/packages/38/6e/73280243d12bec28f55b6edd4e70c5cf11e3d7de2395ecb4eb36cca7dab4/pyrefly-0.61.1-py3-none-win_amd64.whl", hash = "sha256:3e3763d5d76f505c5b8897db1446bde8e138d50a67751f2aa76d6c6034254836", size = 12804056, upload-time = "2026-04-17T18:47:28.674Z" }, - { url = "https://files.pythonhosted.org/packages/87/32/38ac5af84d96167412024abf5e2f49f15b777987a1942e7a442e8e5fef82/pyrefly-0.61.1-py3-none-win_arm64.whl", hash = "sha256:cef5631e2ab09702274ec2eaaafee28a114891cf85f2d31568b329727e3ff735", size = 12302467, upload-time = "2026-04-17T18:47:31.409Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ea/09bd9da7d5df294db800312fb415be2fefbaa5594178e9e49f44fa071aea/pyrefly-0.62.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d78ec4f126dee1fa76215b193b964490ce10e62a32d2787a72c51623658b803", size = 13020414, upload-time = "2026-04-20T17:11:43.617Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f0/f84afac4f220c4c8c801b779ee2ff28ad3f7731f4283c2e1b6ee9012e8c2/pyrefly-0.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a41a34902d20756264486f9e309f22633d100261bd960feea6e858a098d985d", size = 12515659, upload-time = "2026-04-20T17:11:46.59Z" }, + { url = "https://files.pythonhosted.org/packages/40/0b/620c39cefa9ae1b25ee7a2da9d8d3c278b095649cb8435c5e01ea64f7c17/pyrefly-0.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4666c6b65aea662e5f77b64dc91c091b7ea5cede6aa66c0f4cbae26480403583", size = 36228332, upload-time = "2026-04-20T17:11:50.523Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fb/47b8b76438c12761e509a3666cd5a99d4af7f21976ba8385feb475cbfe30/pyrefly-0.62.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1aefab798f47d37c13ded791192fee9b39a6d2b12e31f38ae06a1f80c4b26e22", size = 38995741, upload-time = "2026-04-20T17:11:54.702Z" }, + { url = "https://files.pythonhosted.org/packages/55/d2/03bd17673f61147cd5609cd7d6a1455eeccc17a07a7e141ed9931b0c42c0/pyrefly-0.62.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa986b50d56740da1d7ae7c660a505143cb9d286fa98cc7e5f4a759cc6eaa5d", size = 37205321, upload-time = "2026-04-20T17:11:58.9Z" }, + { url = "https://files.pythonhosted.org/packages/75/14/20ba7b7f2d182f9b7c1e24a3041dac9b5730ae28cfe1614a2c98706650f2/pyrefly-0.62.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32e9b175805c82ffb967e4708f4910bace7e1a12736907380cc9afdbaabb0efb", size = 41786834, upload-time = "2026-04-20T17:12:03.221Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c8/5a7ba88c4fa1b5090d877f70fa1b742b921b9e7d8d3f4b6b9b1ba1820850/pyrefly-0.62.0-py3-none-win32.whl", hash = "sha256:1cd98edc20cab5bac8016c9220ee66080e39bd22e7f0e9bb3e2c4e2be1555eed", size = 12010170, upload-time = "2026-04-20T17:12:06.791Z" }, + { url = "https://files.pythonhosted.org/packages/2e/78/d8f810de010ff2ed594c630c724fd817ef430963249e9eb396ce8f785e9d/pyrefly-0.62.0-py3-none-win_amd64.whl", hash = "sha256:6994f8ee7d6720325ee52207fbdaca98a799a1efe462bb5ba90c47160f7f3e6e", size = 12861816, upload-time = "2026-04-20T17:12:09.689Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a9/ac824ef6a3f50b7c0ec5974471f8f2cb205cd1edd53a5abbcf7ba37feb5d/pyrefly-0.62.0-py3-none-win_arm64.whl", hash = "sha256:362a5d47a5ac5aaa5258091e878a1759ff8b687d8cf462af1c516144f7b0108a", size = 12352977, upload-time = "2026-04-20T17:12:12.736Z" }, +] + +[[package]] +name = "pyroaring" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/71/134bcaf93d8734051ecbc164dabe695645849249e0fb24209b2dd88e8147/pyroaring-1.0.4.tar.gz", hash = "sha256:99d4217bdfeedc91b82efcec940175a9f9a9137c6476faf7ce5d9c9dd889c8e6", size = 189155, upload-time = "2026-03-19T13:57:27.932Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/a7/9f4977405d3a3fa02cf575951f03a9cda4c01efbe27a19230addee06acc2/pyroaring-1.0.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:924ee997ff1a0f2a184e39e153e9f77e0b928fe908d0aef63d03204c3ed90586", size = 322060, upload-time = "2026-03-19T13:56:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e9/32fd7125aea82a3d1d29b755edbc7bb531907638c68a5bcc767d20c2be4a/pyroaring-1.0.4-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:b7a527279cc378e893a543a2271d71321b57d21733915a5a14e587532e29265a", size = 685716, upload-time = "2026-03-19T13:56:06.431Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/6b78bd9d743c053fb3b0485a6b1f487e6a658123f06013bee55610b02120/pyroaring-1.0.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:d73979e1a3a6de2b7039dd9a545afa23f3b33f8b6f90a825390a34253d097f96", size = 363373, upload-time = "2026-03-19T13:56:07.707Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ff/188c16cdd75d841e50d2779ff7b5d1c5c915a6f23006ef3ab3680f48faca/pyroaring-1.0.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dab3d8577b143c64c1c1659b4d1c69d7fec5baa0d0c0181cdddf6b84f43af00a", size = 1914865, upload-time = "2026-03-19T13:56:09.22Z" }, + { url = "https://files.pythonhosted.org/packages/c8/12/ae7b4fa3682190597cbfb252be570358c5bc55f46f6b05db3fde66dfacc1/pyroaring-1.0.4-cp312-cp312-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dcc7d0133f46163b5390dd151e3305bd47289f7710c6e5444f38453d55b15d1c", size = 1742423, upload-time = "2026-03-19T13:56:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/f7/25/966ea0a9d857ac3f2af1eaebdbfd56e627507b890f8ff7752c32e8e57b57/pyroaring-1.0.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a8b8448c2f7af3b40f17dde23b6739f3b19cf8b24db6f817c549c870d50aae3", size = 2130698, upload-time = "2026-03-19T13:56:12.039Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/0d0320925cf8bc1fcb53db182359bd673bf6b434f31c6cc69e4f5312c55f/pyroaring-1.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:13ccc488cfe6a227945586090397f299fd40c1a0dc1ed25e8e58a4d068c1ed46", size = 2822746, upload-time = "2026-03-19T13:56:13.274Z" }, + { url = "https://files.pythonhosted.org/packages/b9/98/a5f6d619098e307baf71b14a4df955914e8092f459d19daf80fbbd651fa1/pyroaring-1.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:dd89ebb7496325fb1b3dbe290dad35bc4da8722b5e3afd2a71b1d6e8ad981725", size = 2657370, upload-time = "2026-03-19T13:56:14.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/4d8beb31e4f648326b9b39c8f056fc1cb41422ef4e2be17cb15432c7fd40/pyroaring-1.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06b15bc8e0c272c3bee0c8adc29130178cd792ad99af64d7d7a1eb3069a1e0b8", size = 3088618, upload-time = "2026-03-19T13:56:16.625Z" }, + { url = "https://files.pythonhosted.org/packages/ad/16/95e223db5e60a6def8abcc2a8ebc4c71df23148a602310973c9a6964e3c5/pyroaring-1.0.4-cp312-cp312-win32.whl", hash = "sha256:4dca094f1d0e18901fa3f6b8866fa14a0f9640b22f41f5fc278c20e15e70efee", size = 202455, upload-time = "2026-03-19T13:56:18.124Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2c/5e41a91822c3bbc735382939d354e2c10bae453b18fe5a133f7fdbb33ce9/pyroaring-1.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:63ab0dcb24933fc9d4ca8c9fa0440f7e177183975990f756a42cddad22fd66db", size = 257479, upload-time = "2026-03-19T13:56:19.25Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d7/7bc58e807e7d6739f4f5c45964a35927e1ff7f591e3943372097d29a00a7/pyroaring-1.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:226079645dd4098d3619ae5fc19bf9abfd2187a74aba94a8768443e637d406fa", size = 215516, upload-time = "2026-03-19T13:56:20.286Z" }, ] [[package]] @@ -6126,16 +6176,16 @@ wheels = [ [[package]] name = "realtime" -version = "2.7.0" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/ca/e408fbdb6b344bf529c7e8bf020372d21114fe538392c72089462edd26e5/realtime-2.7.0.tar.gz", hash = "sha256:6b9434eeba8d756c8faf94fc0a32081d09f250d14d82b90341170602adbb019f", size = 18860, upload-time = "2025-07-28T18:54:22.949Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/3d/ef6ed9221f98766f3a503e6e3ac68fa7ca25c117b383f1efc448294232ac/realtime-2.28.3.tar.gz", hash = "sha256:5cc83a6217874426799d8bf74e96d904ac6fa77c39fa8982fa99287947eb2cbf", size = 18723, upload-time = "2026-03-20T14:38:08.424Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/07/a5c7aef12f9a3497f5ad77157a37915645861e8b23b89b2ad4b0f11b48ad/realtime-2.7.0-py3-none-any.whl", hash = "sha256:d55a278803529a69d61c7174f16563a9cfa5bacc1664f656959694481903d99c", size = 22409, upload-time = "2025-07-28T18:54:21.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d5/659405f9d4c9b022b7ac02bd52986ccc081f211db081051440f46bf4f358/realtime-2.28.3-py3-none-any.whl", hash = "sha256:efe484d6d39024c7e00ef70f70be600142e9407e5d802de8c96e86e014ce3b36", size = 22378, upload-time = "2026-03-20T14:38:07.144Z" }, ] [[package]] @@ -6292,27 +6342,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" +version = "0.15.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] [[package]] @@ -6351,14 +6401,14 @@ wheels = [ [[package]] name = "scipy-stubs" -version = "1.17.1.3" +version = "1.17.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "optype", extra = ["numpy"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/59/59c6cc3f9970154b9ed6b1aff42a0185cdd60cef54adc0404b9e77972221/scipy_stubs-1.17.1.3.tar.gz", hash = "sha256:5eb87a8d23d726706259b012ebe76a4a96a9ae9e141fc59bf55fc8eac2ed9e0f", size = 392185, upload-time = "2026-03-22T22:11:58.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/75/d944a11fca64aa84fbb4bfcf613b758319c6103cb30a304a0e9727009d62/scipy_stubs-1.17.1.4.tar.gz", hash = "sha256:cae00c5207aa62ceb4bcadea202d9fbbf002e958f9e4de981720436b8d5c1802", size = 396980, upload-time = "2026-04-13T11:46:54.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/d4/94304532c0a75a55526119043dd44a9bd1541a21e14483cbb54261c527d2/scipy_stubs-1.17.1.3-py3-none-any.whl", hash = "sha256:7b91d3f05aa47da06fbca14eb6c5bb4c28994e9245fd250cc847e375bab31297", size = 597933, upload-time = "2026-03-22T22:11:56.525Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/334aa5a7a482ea89cb14d92f6a4d9ffa1e193e733144d4d14c7ffcb33583/scipy_stubs-1.17.1.4-py3-none-any.whl", hash = "sha256:e6e5c390fb864745bc3d5f591de81f5cb4f84403857d4f660acb5b6339956f5b", size = 604752, upload-time = "2026-04-13T11:46:53.135Z" }, ] [[package]] @@ -6617,16 +6667,18 @@ wheels = [ [[package]] name = "storage3" -version = "0.12.1" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, { name = "httpx", extra = ["http2"] }, - { name = "python-dateutil" }, + { name = "pydantic" }, + { name = "pyiceberg" }, + { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/e2/280fe75f65e7a3ca680b7843acfc572a63aa41230e3d3c54c66568809c85/storage3-0.12.1.tar.gz", hash = "sha256:32ea8f5eb2f7185c2114a4f6ae66d577722e32503f0a30b56e7ed5c7f13e6b48", size = 10198, upload-time = "2025-08-05T18:09:11.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/b5/18df59ba92951d74774eb0265072bf236ead5e3cbc4b802d8bf1cf3581a0/storage3-2.28.3.tar.gz", hash = "sha256:2b3f843cbd44c4a3b483ec076a12c27de88c0ad5358a43067ed44ef08292353f", size = 20109, upload-time = "2026-03-20T14:38:11.467Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3b/c5f8709fc5349928e591fee47592eeff78d29a7d75b097f96a4e01de028d/storage3-0.12.1-py3-none-any.whl", hash = "sha256:9da77fd4f406b019fdcba201e9916aefbf615ef87f551253ce427d8136459a34", size = 18420, upload-time = "2025-08-05T18:09:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a5/2dbe216954e026a8c2e2dc7dfa5fd7b1a1ae0824d10972e62462f4f15aca/storage3-2.28.3-py3-none-any.whl", hash = "sha256:bac35c5087619174448fdef6a337db4e3dfebf3de69f685bd706de93ddcdad69", size = 28239, upload-time = "2026-03-20T14:38:10.423Z" }, ] [[package]] @@ -6638,9 +6690,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, ] +[[package]] +name = "strictyaml" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/08/efd28d49162ce89c2ad61a88bd80e11fb77bc9f6c145402589112d38f8af/strictyaml-1.7.3.tar.gz", hash = "sha256:22f854a5fcab42b5ddba8030a0e4be51ca89af0267961c8d6cfa86395586c407", size = 115206, upload-time = "2023-03-10T12:50:27.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7c/a81ef5ef10978dd073a854e0fa93b5d8021d0594b639cc8f6453c3c78a1d/strictyaml-1.7.3-py3-none-any.whl", hash = "sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7", size = 123917, upload-time = "2023-03-10T12:50:17.242Z" }, +] + [[package]] name = "supabase" -version = "2.18.1" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -6649,37 +6713,39 @@ dependencies = [ { name = "storage3" }, { name = "supabase-auth" }, { name = "supabase-functions" }, + { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/d2/3b135af55dd5788bd47875bb81f99c870054b990c030e51fd641a61b10b5/supabase-2.18.1.tar.gz", hash = "sha256:205787b1fbb43d6bc997c06fe3a56137336d885a1b56ec10f0012f2a2905285d", size = 11549, upload-time = "2025-08-12T19:02:27.852Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/98/2f1c95a2269ce995a34f275760b1c2ee71ee7a75649238ca0470afdfc2ef/supabase-2.28.3.tar.gz", hash = "sha256:1200961e46cdec17c7c280a1e09a159544643eada2759591ea69835303a2e1a4", size = 9687, upload-time = "2026-03-20T14:38:13.272Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/33/0e0062fea22cfe01d466dee83f56b3ed40c89bdcbca671bafeba3fe86b92/supabase-2.18.1-py3-none-any.whl", hash = "sha256:4fdd7b7247178a847f97ecd34f018dcb4775e487c8ff46b1208a01c933691fe9", size = 18683, upload-time = "2025-08-12T19:02:26.68Z" }, + { url = "https://files.pythonhosted.org/packages/de/96/1b48eb664153401c22087bbf77f6a428965e830cc8e0d0c6d68324a28342/supabase-2.28.3-py3-none-any.whl", hash = "sha256:52a7ce4a1d2d55fa6d657bf4760672935058143a5bedc64165851be25ce01dbd", size = 16634, upload-time = "2026-03-20T14:38:12.319Z" }, ] [[package]] name = "supabase-auth" -version = "2.12.3" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "pydantic" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e9/3d6f696a604752803b9e389b04d454f4b26a29b5d155b257fea4af8dc543/supabase_auth-2.12.3.tar.gz", hash = "sha256:8d3b67543f3b27f5adbfe46b66990424c8504c6b08c1141ec572a9802761edc2", size = 38430, upload-time = "2025-07-04T06:49:22.906Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/6f/1bf81293374ba71183b321bf5dfd7151c3db0c2e24715f35783bc1c56385/supabase_auth-2.28.3.tar.gz", hash = "sha256:41c049da82f9d7fc2f111808e57e984015f128d033f58caa67fd76f428472807", size = 39160, upload-time = "2026-03-20T14:38:15.128Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/a6/4102d5fa08a8521d9432b4d10bb58fedbd1f92b211d1b45d5394f5cb9021/supabase_auth-2.12.3-py3-none-any.whl", hash = "sha256:15c7580e1313d30ffddeb3221cb3cdb87c2a80fd220bf85d67db19cd1668435b", size = 44417, upload-time = "2025-07-04T06:49:21.351Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d3/e012315aa895b434fa77bc475e2dfeb87119e67918ecca4d88a25f96814d/supabase_auth-2.28.3-py3-none-any.whl", hash = "sha256:e47c5caec7bbf3c258964d027fbbe99f3cc4a956d3a635f898c962b4d22832dd", size = 48378, upload-time = "2026-03-20T14:38:14.169Z" }, ] [[package]] name = "supabase-functions" -version = "0.10.1" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "strenum" }, + { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/e4/6df7cd4366396553449e9907c745862ebf010305835b2bac99933dd7db9d/supabase_functions-0.10.1.tar.gz", hash = "sha256:4779d33a1cc3d4aea567f586b16d8efdb7cddcd6b40ce367c5fb24288af3a4f1", size = 5025, upload-time = "2025-06-23T18:26:12.239Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/ea/59bf327960e5384fcc9e69afbdf97260a2cf2684a25c0731968a8a393b9c/supabase_functions-2.28.3.tar.gz", hash = "sha256:5a6255d60a263d44251c5ca250fcdde2408a8483a8bf31f4ac80255de8f3fcae", size = 4679, upload-time = "2026-03-20T14:38:16.742Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/06/060118a1e602c9bda8e4bf950bd1c8b5e1542349f2940ec57541266fabe1/supabase_functions-0.10.1-py3-none-any.whl", hash = "sha256:1db85e20210b465075aacee4e171332424f7305f9903c5918096be1423d6fcc5", size = 8275, upload-time = "2025-06-23T18:26:10.387Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ca/1e720f1347a88519e3d52b6d801cd031c3a7a5df66640c5dc6e81d925057/supabase_functions-2.28.3-py3-none-any.whl", hash = "sha256:eb30578866103fed9322c54e95dd68c2f1a4b6b177e129d9369edd364637904e", size = 8801, upload-time = "2026-03-20T14:38:15.883Z" }, ] [[package]] @@ -7902,7 +7968,7 @@ wheels = [ [[package]] name = "xinference-client" -version = "2.4.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -7910,9 +7976,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/f2/7640528fd4f816df19afe91d52332a658ad2d2bacb13471b0a27dbd0cf46/xinference_client-2.4.0.tar.gz", hash = "sha256:59de6d58f89126c8ff05136818e0756108e534858255d7c4c0673b804fd2d01d", size = 58386, upload-time = "2026-03-29T05:10:58.533Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/8a/4d7c72510f3c462195c2e7aa63559cafcf20f7d1901132d533b7498bab1c/xinference_client-2.5.0.tar.gz", hash = "sha256:0680324e2f438b8b208ca80e8a7e1c22e9152fce54f8c024c75e2ce57bfa5639", size = 58430, upload-time = "2026-04-13T07:21:40.145Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cf/9d27e0095cc28691c73ff186b33556790c7b87f046ca2ecd517c80272592/xinference_client-2.4.0-py3-none-any.whl", hash = "sha256:2f9478b00fe15643f281fe4c0643e74479c8b7837d377000ff120702cda81efc", size = 40012, upload-time = "2026-03-29T05:10:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/5b/dd/4fd501b8092c01f0775142850e3b601d743edf733077b756defe4a01cc37/xinference_client-2.5.0-py3-none-any.whl", hash = "sha256:bb90f069a2c30ac6ea7453ab37a0fadd34c28b655afa51fe20c18e67a361c269", size = 40006, upload-time = "2026-04-13T07:21:38.851Z" }, ] [[package]] diff --git a/docker/.env.example b/docker/.env.example index 8176155698..29741474fa 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -273,7 +273,7 @@ SQLALCHEMY_POOL_TIMEOUT=30 # Default is 100 # # Reference: https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS -POSTGRES_MAX_CONNECTIONS=100 +POSTGRES_MAX_CONNECTIONS=200 # Sets the amount of shared memory used for postgres's shared buffers. # Default is 128MB @@ -1467,6 +1467,11 @@ ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id} MARKETPLACE_ENABLED=true MARKETPLACE_API_URL=https://marketplace.dify.ai +# Creators Platform configuration +CREATORS_PLATFORM_FEATURES_ENABLED=true +CREATORS_PLATFORM_API_URL=https://creators.dify.ai +CREATORS_PLATFORM_OAUTH_CLIENT_ID= + FORCE_VERIFYING_SIGNATURE=true ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES=true diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index a10fdf77c6..60ba510f44 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -70,7 +70,7 @@ x-shared-env: &shared-api-worker-env SQLALCHEMY_POOL_PRE_PING: ${SQLALCHEMY_POOL_PRE_PING:-false} SQLALCHEMY_POOL_USE_LIFO: ${SQLALCHEMY_POOL_USE_LIFO:-false} SQLALCHEMY_POOL_TIMEOUT: ${SQLALCHEMY_POOL_TIMEOUT:-30} - POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-100} + POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-200} POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-128MB} POSTGRES_WORK_MEM: ${POSTGRES_WORK_MEM:-4MB} POSTGRES_MAINTENANCE_WORK_MEM: ${POSTGRES_MAINTENANCE_WORK_MEM:-64MB} @@ -629,6 +629,9 @@ x-shared-env: &shared-api-worker-env ENDPOINT_URL_TEMPLATE: ${ENDPOINT_URL_TEMPLATE:-http://localhost/e/{hook_id}} MARKETPLACE_ENABLED: ${MARKETPLACE_ENABLED:-true} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} + CREATORS_PLATFORM_FEATURES_ENABLED: ${CREATORS_PLATFORM_FEATURES_ENABLED:-true} + CREATORS_PLATFORM_API_URL: ${CREATORS_PLATFORM_API_URL:-https://creators.dify.ai} + CREATORS_PLATFORM_OAUTH_CLIENT_ID: ${CREATORS_PLATFORM_OAUTH_CLIENT_ID:-} FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true} ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES: ${ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES:-true} PLUGIN_STDIO_BUFFER_SIZE: ${PLUGIN_STDIO_BUFFER_SIZE:-1024} diff --git a/e2e/features/apps/app-detail-navigation.feature b/e2e/features/apps/app-detail-navigation.feature new file mode 100644 index 0000000000..7ac32039ec --- /dev/null +++ b/e2e/features/apps/app-detail-navigation.feature @@ -0,0 +1,26 @@ +@apps @authenticated @core +Feature: App detail navigation + + Scenario: Opening a workflow app navigates to the workflow editor + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + When I open the app from the app list + Then I should land on the workflow editor + + Scenario: Opening a chatbot app navigates to the configuration page + Given I am signed in as the default E2E admin + And a "chat" app has been created via API + When I open the app from the app list + Then I should land on the app configuration page + + Scenario: The develop tab is accessible from a workflow app + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + When I navigate to the app develop page + Then I should be on the app develop page + + Scenario: The overview tab is accessible from a workflow app + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + When I navigate to the app overview page + Then I should be on the app overview page diff --git a/e2e/features/apps/create-app.feature b/e2e/features/apps/create-app.feature index c0ca8ea4e0..d980bb9eb9 100644 --- a/e2e/features/apps/create-app.feature +++ b/e2e/features/apps/create-app.feature @@ -1,4 +1,4 @@ -@apps @authenticated +@apps @authenticated @core Feature: Create app Scenario: Create a new blank app and redirect to the editor Given I am signed in as the default E2E admin diff --git a/e2e/features/apps/create-chatbot-app.feature b/e2e/features/apps/create-chatbot-app.feature index 4f506e4f40..45f66aaa52 100644 --- a/e2e/features/apps/create-chatbot-app.feature +++ b/e2e/features/apps/create-chatbot-app.feature @@ -1,4 +1,4 @@ -@apps @authenticated +@apps @authenticated @core @mode-matrix Feature: Create Chatbot app Scenario: Create a new Chatbot app and redirect to the configuration page Given I am signed in as the default E2E admin diff --git a/e2e/features/apps/create-workflow-app.feature b/e2e/features/apps/create-workflow-app.feature index b88d94d899..2c11cf7a7a 100644 --- a/e2e/features/apps/create-workflow-app.feature +++ b/e2e/features/apps/create-workflow-app.feature @@ -1,4 +1,4 @@ -@apps @authenticated +@apps @authenticated @core @mode-matrix Feature: Create Workflow app Scenario: Create a new Workflow app and redirect to the workflow editor Given I am signed in as the default E2E admin diff --git a/e2e/features/apps/publish-app.feature b/e2e/features/apps/publish-app.feature new file mode 100644 index 0000000000..2d002d3cb7 --- /dev/null +++ b/e2e/features/apps/publish-app.feature @@ -0,0 +1,11 @@ +@apps @authenticated @core +Feature: Publish app + + Scenario: Publish a workflow app for the first time + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + And a minimal workflow draft has been synced + When I open the app from the app list + And I open the publish panel + And I publish the app + Then the app should be marked as published diff --git a/e2e/features/auth/sign-in.feature b/e2e/features/auth/sign-in.feature new file mode 100644 index 0000000000..a9a1e13626 --- /dev/null +++ b/e2e/features/auth/sign-in.feature @@ -0,0 +1,8 @@ +@auth @smoke @core @unauthenticated +Feature: Sign in + + Scenario: Sign in with valid credentials and reach the apps console + Given I am not signed in + When I open the sign-in page + And I sign in as the default E2E admin + Then I should be on the apps console diff --git a/e2e/features/auth/sign-out.feature b/e2e/features/auth/sign-out.feature index 9112f1220a..4446beaf76 100644 --- a/e2e/features/auth/sign-out.feature +++ b/e2e/features/auth/sign-out.feature @@ -1,4 +1,4 @@ -@auth @authenticated +@auth @authenticated @core Feature: Sign out Scenario: Sign out from the apps console Given I am signed in as the default E2E admin diff --git a/e2e/features/step-definitions/apps/app-detail-navigation.steps.ts b/e2e/features/step-definitions/apps/app-detail-navigation.steps.ts new file mode 100644 index 0000000000..c7f30b3e1b --- /dev/null +++ b/e2e/features/step-definitions/apps/app-detail-navigation.steps.ts @@ -0,0 +1,21 @@ +import type { DifyWorld } from '../../support/world' +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' + +When('I navigate to the app develop page', async function (this: DifyWorld) { + const appId = this.createdAppIds.at(-1) + await this.getPage().goto(`/app/${appId}/develop`) +}) + +When('I navigate to the app overview page', async function (this: DifyWorld) { + const appId = this.createdAppIds.at(-1) + await this.getPage().goto(`/app/${appId}/overview`) +}) + +Then('I should be on the app develop page', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/develop(?:\?.*)?$/, { timeout: 30_000 }) +}) + +Then('I should be on the app overview page', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/overview(?:\?.*)?$/, { timeout: 30_000 }) +}) diff --git a/e2e/features/step-definitions/apps/publish-app.steps.ts b/e2e/features/step-definitions/apps/publish-app.steps.ts new file mode 100644 index 0000000000..de4f5ee63f --- /dev/null +++ b/e2e/features/step-definitions/apps/publish-app.steps.ts @@ -0,0 +1,15 @@ +import type { DifyWorld } from '../../support/world' +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' + +When('I open the publish panel', async function (this: DifyWorld) { + await this.getPage().getByRole('button', { name: 'Publish' }).first().click() +}) + +When('I publish the app', async function (this: DifyWorld) { + await this.getPage().getByRole('button', { name: /Publish Update/ }).click() +}) + +Then('the app should be marked as published', async function (this: DifyWorld) { + await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 }) +}) diff --git a/e2e/features/step-definitions/auth/sign-in.steps.ts b/e2e/features/step-definitions/auth/sign-in.steps.ts new file mode 100644 index 0000000000..8f9e8e765c --- /dev/null +++ b/e2e/features/step-definitions/auth/sign-in.steps.ts @@ -0,0 +1,20 @@ +import type { DifyWorld } from '../../support/world' +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { adminCredentials } from '../../../fixtures/auth' + +When('I open the sign-in page', async function (this: DifyWorld) { + await this.getPage().goto('/signin') +}) + +When('I sign in as the default E2E admin', async function (this: DifyWorld) { + const page = this.getPage() + + await page.getByLabel('Email address').fill(adminCredentials.email) + await page.getByLabel('Password').fill(adminCredentials.password) + await page.getByRole('button', { name: 'Sign in' }).click() +}) + +Then('I should be on the apps console', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/, { timeout: 30_000 }) +}) diff --git a/e2e/features/step-definitions/common/app.steps.ts b/e2e/features/step-definitions/common/app.steps.ts new file mode 100644 index 0000000000..93e808e3c5 --- /dev/null +++ b/e2e/features/step-definitions/common/app.steps.ts @@ -0,0 +1,22 @@ +import type { DifyWorld } from '../../support/world' +import { Given, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { createTestApp, syncMinimalWorkflowDraft } from '../../../support/api' + +Given('a {string} app has been created via API', async function (this: DifyWorld, mode: string) { + const app = await createTestApp(`E2E ${Date.now()}`, mode) + this.createdAppIds.push(app.id) + this.lastCreatedAppName = app.name +}) + +Given('a minimal workflow draft has been synced', async function (this: DifyWorld) { + const appId = this.createdAppIds.at(-1)! + await syncMinimalWorkflowDraft(appId) +}) + +When('I open the app from the app list', async function (this: DifyWorld) { + const page = this.getPage() + await page.goto('/apps') + await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible() + await page.getByText(this.lastCreatedAppName!).click() +}) diff --git a/e2e/package.json b/e2e/package.json index 94fc857c0b..77d7db80f0 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -12,13 +12,14 @@ "e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down", "e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up", "e2e:reset": "tsx ./scripts/setup.ts reset", - "type-check": "tsc" + "type-check": "tsgo" }, "devDependencies": { "@cucumber/cucumber": "catalog:", "@dify/tsconfig": "workspace:*", "@playwright/test": "catalog:", "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", "tsx": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/e2e/support/api.ts b/e2e/support/api.ts index c6d6c98bde..7d9fd0264f 100644 --- a/e2e/support/api.ts +++ b/e2e/support/api.ts @@ -43,6 +43,34 @@ export async function createTestApp(name: string, mode = 'workflow'): Promise { + const ctx = await createApiContext() + try { + await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { + data: { + graph: { + nodes: [ + { + id: '1', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: '1', type: 'start', title: 'Start', variables: [] }, + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + }, + }) + } + finally { + await ctx.dispose() + } +} + export async function deleteTestApp(id: string): Promise { const ctx = await createApiContext() try { diff --git a/eslint-suppressions.json b/eslint-suppressions.json index dab0b4d9fc..187ab29ac6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -111,16 +111,6 @@ "count": 1 } }, - "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx": { "no-console": { "count": 19 @@ -129,11 +119,6 @@ "count": 3 } }, - "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx": { "no-restricted-imports": { "count": 1 @@ -493,11 +478,6 @@ "count": 1 } }, - "web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/dataset-config/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -544,11 +524,6 @@ "count": 1 } }, - "web/app/components/app/configuration/debug/chat-user-input.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx": { "ts/no-explicit-any": { "count": 6 @@ -594,7 +569,7 @@ }, "web/app/components/app/configuration/prompt-value-panel/index.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 } }, "web/app/components/app/configuration/prompt-value-panel/utils.ts": { @@ -691,7 +666,7 @@ }, "web/app/components/app/overview/settings/index.tsx": { "no-restricted-imports": { - "count": 3 + "count": 2 }, "react/set-state-in-effect": { "count": 3 @@ -930,9 +905,6 @@ } }, "web/app/components/base/chat/chat-with-history/inputs-form/content.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 3 } @@ -1046,9 +1018,6 @@ } }, "web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 3 } @@ -1086,21 +1055,11 @@ "count": 1 } }, - "web/app/components/base/date-and-time-picker/date-picker/index.tsx": { - "react/set-state-in-effect": { - "count": 4 - } - }, "web/app/components/base/date-and-time-picker/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 2 } }, - "web/app/components/base/date-and-time-picker/time-picker/index.tsx": { - "react/set-state-in-effect": { - "count": 2 - } - }, "web/app/components/base/date-and-time-picker/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -1121,11 +1080,6 @@ "count": 1 } }, - "web/app/components/base/emoji-picker/Inner.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/base/emoji-picker/index.tsx": { "no-restricted-imports": { "count": 1 @@ -1195,16 +1149,6 @@ "count": 5 } }, - "web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/features/new-feature-panel/moderation/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -1220,11 +1164,6 @@ }, "web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx": { "no-restricted-imports": { - "count": 2 - } - }, - "web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx": { - "ts/no-explicit-any": { "count": 1 } }, @@ -1878,11 +1817,6 @@ "count": 1 } }, - "web/app/components/base/portal-to-follow-elem/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/index.stories.tsx": { "no-console": { "count": 1 @@ -1906,11 +1840,6 @@ "count": 1 } }, - "web/app/components/base/prompt-editor/plugins/context-block/component.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/context-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -1940,11 +1869,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/plugins/history-block/component.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/history-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -1971,11 +1895,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/last-run-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -2263,21 +2182,6 @@ "count": 3 } }, - "web/app/components/billing/usage-info/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/datasets/common/document-picker/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/datasets/common/document-picker/preview-document-picker.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/common/image-previewer/index.tsx": { "no-irregular-whitespace": { "count": 1 @@ -2503,11 +2407,6 @@ "count": 4 } }, - "web/app/components/datasets/documents/components/documents-header.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/components/operations.tsx": { "no-restricted-imports": { "count": 1 @@ -2641,11 +2540,6 @@ "count": 3 } }, - "web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": { "ts/no-non-null-asserted-optional-chain": { "count": 1 @@ -2661,11 +2555,6 @@ "count": 5 } }, - "web/app/components/datasets/documents/detail/completed/hooks/use-search-filter.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/completed/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -2682,11 +2571,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/status-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/context.ts": { "ts/no-explicit-any": { "count": 1 @@ -2707,11 +2591,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/metadata/components/field-info.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx": { "ts/no-non-null-asserted-optional-chain": { "count": 1 @@ -2894,14 +2773,6 @@ "count": 1 } }, - "web/app/components/datasets/settings/permission-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/no-missing-key": { - "count": 1 - } - }, "web/app/components/datasets/settings/summary-index-setting.tsx": { "no-restricted-imports": { "count": 1 @@ -3069,21 +2940,11 @@ "count": 1 } }, - "web/app/components/header/account-setting/api-based-extension-page/selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/data-source-page-new/card.tsx": { "ts/no-explicit-any": { "count": 2 } }, - "web/app/components/header/account-setting/data-source-page-new/configure.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/data-source-page-new/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -3117,11 +2978,6 @@ "count": 1 } }, - "web/app/components/header/account-setting/language-page/index.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "web/app/components/header/account-setting/members-page/invite-modal/index.tsx": { "react/set-state-in-effect": { "count": 3 @@ -3138,14 +2994,6 @@ "count": 3 } }, - "web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/header/account-setting/model-provider-page/declarations.ts": { "erasable-syntax-only/enums": { "count": 11 @@ -3167,19 +3015,6 @@ "count": 4 } }, - "web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": { - "no-restricted-imports": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx": { "no-restricted-imports": { "count": 1 @@ -3225,7 +3060,7 @@ }, "web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 }, "ts/no-explicit-any": { "count": 6 @@ -3377,16 +3212,13 @@ }, "web/app/components/plugins/install-plugin/install-from-github/index.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 }, "ts/no-explicit-any": { "count": 3 } }, "web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx": { - "no-restricted-imports": { - "count": 2 - }, "ts/no-explicit-any": { "count": 1 } @@ -3411,11 +3243,6 @@ "count": 1 } }, - "web/app/components/plugins/marketplace/search-box/tags-filter.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": { "ts/no-explicit-any": { "count": 2 @@ -3447,14 +3274,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-auth/authorized/index.tsx": { - "no-restricted-imports": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/plugins/plugin-auth/authorized/item.tsx": { "no-restricted-imports": { "count": 1 @@ -3503,23 +3322,10 @@ } }, "web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 8 } }, - "web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3619,7 +3425,7 @@ "count": 3 }, "no-restricted-imports": { - "count": 3 + "count": 1 } }, "web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx": { @@ -3668,11 +3474,6 @@ "count": 2 } }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx": { "no-restricted-imports": { "count": 1 @@ -3693,11 +3494,6 @@ "count": 7 } }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -3713,11 +3509,6 @@ "count": 2 } }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { "ts/no-explicit-any": { "count": 5 @@ -3746,26 +3537,11 @@ "count": 1 } }, - "web/app/components/plugins/plugin-page/debug-info.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-page/empty/index.tsx": { "react/set-state-in-effect": { "count": 2 } }, - "web/app/components/plugins/plugin-page/filter-management/category-filter.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-page/index.tsx": { "no-restricted-imports": { "count": 1 @@ -3801,11 +3577,6 @@ "count": 1 } }, - "web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -3918,11 +3689,6 @@ "count": 1 } }, - "web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx": { "ts/no-explicit-any": { "count": 1 @@ -4024,9 +3790,6 @@ } }, "web/app/components/share/text-generation/run-once/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 1 }, @@ -4067,11 +3830,6 @@ "count": 1 } }, - "web/app/components/tools/labels/selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/tools/mcp/create-card.tsx": { "ts/no-explicit-any": { "count": 1 @@ -4211,15 +3969,7 @@ "count": 1 } }, - "web/app/components/workflow/block-selector/blocks.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/featured-tools.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 2 }, @@ -4228,9 +3978,6 @@ } }, "web/app/components/workflow/block-selector/featured-triggers.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 2 }, @@ -4248,11 +3995,6 @@ "count": 1 } }, - "web/app/components/workflow/block-selector/main.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/market-place-plugin/action.tsx": { "react/set-state-in-effect": { "count": 1 @@ -4268,26 +4010,11 @@ "count": 1 } }, - "web/app/components/workflow/block-selector/start-blocks.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/tabs.tsx": { "no-restricted-imports": { "count": 1 } }, - "web/app/components/workflow/block-selector/tool-picker.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/block-selector/tool/action-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -4299,9 +4026,6 @@ } }, "web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -4378,19 +4102,6 @@ "count": 1 } }, - "web/app/components/workflow/header/view-history.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "web/app/components/workflow/header/view-workflow-history.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/workflow/hooks-store/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -4489,14 +4200,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx": { - "no-restricted-imports": { - "count": 3 - }, - "ts/no-explicit-any": { - "count": 4 - } - }, "web/app/components/workflow/nodes/_base/components/agent-strategy.tsx": { "ts/no-empty-object-type": { "count": 1 @@ -4506,9 +4209,6 @@ } }, "web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 11 } @@ -4565,11 +4265,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx": { "no-restricted-imports": { "count": 1 @@ -4588,24 +4283,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/form-input-item.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 4 - } - }, - "web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/_base/components/help-link.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx": { "no-restricted-imports": { "count": 1 @@ -4698,11 +4375,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/variable/constant-field.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts": { "ts/no-explicit-any": { "count": 8 @@ -4728,22 +4400,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, - "web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/variable/var-type-picker.tsx": { "no-restricted-imports": { "count": 1 @@ -4836,22 +4492,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/agent/components/model-bar.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-empty-object-type": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/agent/components/tool-icon.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/unsupported-syntax": { - "count": 1 - } - }, "web/app/components/workflow/nodes/agent/default.ts": { "ts/no-explicit-any": { "count": 3 @@ -4923,11 +4563,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/code/dependency-picker.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/code/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -5058,11 +4693,6 @@ "count": 5 } }, - "web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -5083,16 +4713,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx": { "no-restricted-imports": { "count": 1 @@ -5143,41 +4763,21 @@ "count": 2 } }, - "web/app/components/workflow/nodes/if-else/components/condition-add.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/if-else/components/condition-list/condition-input.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx": { "no-restricted-imports": { "count": 1 } }, - "web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx": { "no-restricted-imports": { "count": 1 } }, - "web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/if-else/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -5208,16 +4808,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/iteration/panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/iteration/use-config.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/iteration/use-single-run-form-params.ts": { "ts/no-explicit-any": { "count": 6 @@ -5243,11 +4833,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts": { "ts/no-explicit-any": { "count": 2 @@ -5271,16 +4856,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": { "ts/no-explicit-any": { "count": 1 @@ -5296,11 +4871,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx": { "no-restricted-imports": { "count": 1 @@ -5311,16 +4881,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-retrieval/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -5345,17 +4905,6 @@ } }, "web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/list-operator/components/sub-variable-picker.tsx": { - "no-restricted-imports": { - "count": 2 - }, "ts/no-explicit-any": { "count": 1 } @@ -5386,14 +4935,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 4 - } - }, "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx": { "no-restricted-imports": { "count": 1 @@ -5419,17 +4960,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 2 - } - }, "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx": { "no-restricted-imports": { "count": 1 @@ -5440,11 +4970,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx": { "react/set-state-in-effect": { "count": 1 @@ -5501,61 +5026,31 @@ "count": 1 } }, - "web/app/components/workflow/nodes/loop/components/condition-add.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx": { "no-restricted-imports": { "count": 1 } }, - "web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/loop/components/condition-number-input.tsx": { "no-restricted-imports": { "count": 1 } }, - "web/app/components/workflow/nodes/loop/components/condition-wrap.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/loop/components/loop-variables/form-item.tsx": { "ts/no-explicit-any": { "count": 3 } }, - "web/app/components/workflow/nodes/loop/components/loop-variables/input-mode-selec.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx": { "ts/no-explicit-any": { "count": 4 } }, - "web/app/components/workflow/nodes/loop/components/loop-variables/variable-type-select.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/loop/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -5591,7 +5086,7 @@ }, "web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 }, "ts/no-explicit-any": { "count": 1 @@ -5696,11 +5191,6 @@ "count": 5 } }, - "web/app/components/workflow/nodes/tool/components/copy-id.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/tool/components/input-var-list.tsx": { "ts/no-explicit-any": { "count": 7 @@ -5808,11 +5298,6 @@ "count": 7 } }, - "web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx": { "no-restricted-imports": { "count": 1 @@ -5826,11 +5311,6 @@ "count": 10 } }, - "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": { "ts/no-non-null-asserted-optional-chain": { "count": 1 @@ -5843,7 +5323,7 @@ }, "web/app/components/workflow/nodes/trigger-webhook/panel.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 } }, "web/app/components/workflow/nodes/utils.ts": { @@ -5876,24 +5356,6 @@ "count": 1 } }, - "web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react-refresh/only-export-components": { - "count": 1 - } - }, - "web/app/components/workflow/note-node/note-editor/toolbar/command.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/note-node/note-editor/utils.ts": { "regexp/no-useless-quantifier": { "count": 1 @@ -6017,11 +5479,6 @@ "count": 1 } }, - "web/app/components/workflow/panel/version-history-panel/filter/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -6320,14 +5777,6 @@ "count": 5 } }, - "web/app/education-apply/search-input.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/education-apply/verify-state-modal.tsx": { "react/set-state-in-effect": { "count": 1 @@ -6368,11 +5817,6 @@ "count": 1 } }, - "web/app/signin/invite-settings/page.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/signin/layout.tsx": { "ts/no-explicit-any": { "count": 1 @@ -6380,7 +5824,7 @@ }, "web/app/signin/one-more-step.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 }, "ts/no-explicit-any": { "count": 1 diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index 651b117070..4a7fe2f22a 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -11,6 +11,27 @@ Shared design tokens, the `cn()` utility, a Tailwind CSS preset, and headless pr - Props pattern: `Omit & VariantProps & { /* custom */ }`. - When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath. +## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover + +Pick by the **trigger's purpose** and **a11y reach**, not visual richness. + +| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? | +| ------------- | --------------------- | -------------------------- | ------------------------- | ------------------------ | +| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) | +| `PreviewCard` | hover / focus | has a primary click target | supplementary preview | ❌ (via click target) | +| `Popover` | click / tap (+ hover) | **to open the popup** | anything, incl. long text | ✅ | + +Base UI decision rule ([docs]): + +> _"If the trigger's purpose is to open the popup itself, it's a popover. +> If the trigger's purpose is unrelated to opening the popup, it's a tooltip."_ + +Apply this first, then narrow: + +- `Tooltip` — ephemeral visual label. Trigger must already carry its own `aria-label` / visible text; tooltip mirrors it for sighted mouse/keyboard users. No interactive UI, no multi-line prose. Not dwell-able. +- `PreviewCard` — hover-revealed rich supplementary preview anchored to a trigger whose click goes somewhere (link, selectable row, jumpable chip). **Hard contract:** the popup MUST NOT contain information or actions unreachable from the trigger's click destination — touch and SR users can't open it. If the info is unique to the popup, switch to `Popover` (click or `openOnHover`) or move it to the click destination. Do not hand-roll "hover to open" on top of `Popover` to evade this split. +- `Popover` — any popup with its own interactions, or any "infotip" (`?` / `(i)` glyph whose sole purpose is to reveal help text). Pass `openOnHover` on `PopoverTrigger` for the infotip case — unlike `Tooltip` / `PreviewCard`, this stays accessible to touch and SR users because the popover still opens on tap and focus. + ## Border Radius: Figma Token → Tailwind Class Mapping The Figma design system uses `--radius/*` tokens whose scale is **offset by one step** from Tailwind CSS v4 defaults. When translating Figma specs to code, always use this mapping — never use `radius-*` as a CSS class, and never extend `borderRadius` in the preset. @@ -34,3 +55,5 @@ The Figma design system uses `--radius/*` tokens whose scale is **offset by one - **Do not** use `radius-*` as CSS class names. The old `@utility radius-*` definitions have been removed. - When the Figma MCP returns `rounded-[var(--radius/sm, 6px)]`, convert it to the standard Tailwind class from the table above (e.g. `rounded-md`). - For values without a standard Tailwind equivalent (10px, 20px, 28px), use arbitrary values like `rounded-[10px]`. + +[docs]: https://base-ui.com/react/components/tooltip#infotips diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 5e4e439e5f..e9c762073d 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -88,7 +88,7 @@ See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for t - `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives. - `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`. -- `pnpm -C packages/dify-ui type-check` — `tsc --noEmit` for this package only. +- `pnpm -C packages/dify-ui type-check` — `tsgo --noEmit` for this package only. See `[AGENTS.md](./AGENTS.md)` for: diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index e1b7a3c1ef..483db46986 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -49,6 +49,10 @@ "types": "./src/popover/index.tsx", "import": "./src/popover/index.tsx" }, + "./preview-card": { + "types": "./src/preview-card/index.tsx", + "import": "./src/preview-card/index.tsx" + }, "./scroll-area": { "types": "./src/scroll-area/index.tsx", "import": "./src/scroll-area/index.tsx" @@ -79,7 +83,7 @@ "storybook:build": "storybook build", "test": "vp test", "test:watch": "vp test --watch", - "type-check": "tsc" + "type-check": "tsgo" }, "peerDependencies": { "@base-ui/react": "catalog:", @@ -105,6 +109,7 @@ "@tailwindcss/vite": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@typescript/native-preview": "catalog:", "@vitejs/plugin-react": "catalog:", "@vitest/coverage-v8": "catalog:", "class-variance-authority": "catalog:", diff --git a/packages/dify-ui/src/popover/index.stories.tsx b/packages/dify-ui/src/popover/index.stories.tsx index dcea5018ab..802337634b 100644 --- a/packages/dify-ui/src/popover/index.stories.tsx +++ b/packages/dify-ui/src/popover/index.stories.tsx @@ -20,7 +20,7 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.', + component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.\n\nPass `openOnHover` on `PopoverTrigger` when the popup should also reveal on hover (see the **Infotip** story). Unlike `Tooltip` and `PreviewCard`, hover on `Popover` still falls back to tap/focus, so touch and screen-reader users can reach the content.', }, }, }, @@ -101,6 +101,48 @@ export const WithActions: Story = { ), } +export const Infotip: Story = { + parameters: { + docs: { + description: { + story: [ + 'The **infotip** pattern from [Base UI](https://base-ui.com/react/components/tooltip#infotips): an info glyph (`?`, `(i)`) whose sole purpose is to reveal explanatory text. Use `Popover` with `openOnHover` on the trigger — never `Tooltip`.', + '', + 'Why not `Tooltip`? Tooltips are disabled on touch devices and not announced to screen readers; descriptive help text hidden in them is unreachable for those users. Why not `PreviewCard`? PreviewCard\'s a11y contract requires the trigger to already own a primary click destination, but an info glyph has no other purpose.', + '', + 'Base UI rule of thumb: *"If the trigger\'s purpose is to open the popup itself, it\'s a popover. If the trigger\'s purpose is unrelated to opening the popup, it\'s a tooltip."*', + '', + 'Hover, tap, or focus the `?` icon to open. In the Dify app, reach for `@/app/components/base/infotip` (`{helpText}`) which wraps this pattern with consistent delays (300/200), typography, and `aria-label` plumbing.', + ].join('\n'), + }, + }, + }, + render: () => ( +
+ Usage priority + + + + + )} + /> + + Set which resource to use first when running models. The Trial quota will be used after the paid quota is exhausted. + + +
+ ), +} + const PLACEMENTS: Placement[] = [ 'top-start', 'top', diff --git a/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx b/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx new file mode 100644 index 0000000000..5d1e325051 --- /dev/null +++ b/packages/dify-ui/src/preview-card/__tests__/index.spec.tsx @@ -0,0 +1,127 @@ +import { render } from 'vitest-browser-react' +import { + PreviewCard, + PreviewCardContent, + PreviewCardTrigger, +} from '..' + +const renderWithSafeViewport = (ui: import('react').ReactNode) => render( +
+ {ui} +
, +) + +describe('PreviewCardContent', () => { + describe('Placement', () => { + it('should use bottom placement and default offsets when placement props are not provided', async () => { + const screen = await renderWithSafeViewport( + + Open} + /> + + Default content + + , + ) + + await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-side', 'bottom') + await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-align', 'center') + await expect.element(screen.getByRole('dialog', { name: 'default popup' })).toHaveTextContent('Default content') + }) + + it('should apply parsed custom placement and custom offsets when placement props are provided', async () => { + const screen = await renderWithSafeViewport( + + Open} + /> + + Custom placement content + + , + ) + + await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-side', 'top') + await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-align', 'end') + await expect.element(screen.getByRole('dialog', { name: 'custom popup' })).toHaveTextContent('Custom placement content') + }) + }) + + describe('Passthrough props', () => { + it('should forward positionerProps and popupProps when passthrough props are provided', async () => { + const onPopupClick = vi.fn() + + const screen = await render( + + Open} + /> + + Preview body + + , + ) + + const popup = screen.getByRole('dialog', { name: 'preview content' }) + await popup.click() + + await expect.element(screen.getByRole('group', { name: 'preview positioner' })).toHaveAttribute('id', 'preview-positioner-id') + await expect.element(popup).toHaveAttribute('id', 'preview-popup-id') + expect(onPopupClick).toHaveBeenCalledTimes(1) + }) + }) + + describe('Trigger click behavior', () => { + it('should forward the trigger click to the consumer handler so the primary action runs', async () => { + const onPrimaryClick = vi.fn() + + const screen = await renderWithSafeViewport( + + + Open + + )} + /> + + Preview body + + , + ) + + const trigger = screen.getByRole('button', { name: 'preview trigger' }) + await trigger.click() + + expect(onPrimaryClick).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/dify-ui/src/preview-card/index.stories.tsx b/packages/dify-ui/src/preview-card/index.stories.tsx new file mode 100644 index 0000000000..540ac08c1a --- /dev/null +++ b/packages/dify-ui/src/preview-card/index.stories.tsx @@ -0,0 +1,213 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { Placement } from '.' +import { useState } from 'react' +import { + createPreviewCardHandle, + PreviewCard, + PreviewCardContent, + PreviewCardTrigger, +} from '.' + +const rowButtonClassName + = 'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover' + +const triggerButtonClassName + = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover' + +const inlineLinkClassName + = 'text-text-accent underline decoration-text-accent/60 decoration-1 underline-offset-2 outline-hidden hover:decoration-text-accent focus-visible:rounded-xs focus-visible:no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-text-accent data-[popup-open]:decoration-text-accent' + +const meta = { + title: 'Base/UI/PreviewCard', + component: PreviewCard, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Hover- and focus-activated rich preview for triggers whose primary click has its own destination (following a link, selecting a row, jumping to a definition). Built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Never place information or actions in the popup that are not also reachable from the trigger\'s primary click destination. If that is unavoidable, add a separate click affordance (Popover) or move the unique content onto the destination.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +// --- Canonical: inline link preview --------------------------------------- +// Mirrors Base UI's own PreviewCard docs demo: an inline `` in a +// paragraph, hovering reveals a rich preview (image + summary) of the link's +// destination. The Wikipedia URL and Unsplash image are the exact assets used +// in base-ui.com's public docs so the story renders a real preview. +// https://base-ui.com/react/components/preview-card +const typographyPreview = createPreviewCardHandle() + +export const LinkPreview: Story = { + name: 'Link preview (canonical)', + parameters: { + docs: { + description: { + story: + 'The prototypical PreviewCard use case: an inline hyperlink with a rich hover preview of the destination. Uses a detached trigger + `createPreviewCardHandle()` so the trigger can sit inline in prose while the popup content is defined elsewhere. The trigger renders a real `` — click still follows the link; the preview is strictly supplementary.', + }, + }, + }, + render: () => ( +
+

+ The principles of good + {' '} + + typography + + {' '} + remain in the digital age. +

+ + + +
+ Station Hofplein signage in Rotterdam, Netherlands +

+ Typography + {' '} + is the art and science of arranging type to make written language legible, readable, and visually appealing. +

+
+
+
+
+ ), +} + +export const Supplementary: Story = { + name: 'Supplementary preview on a button trigger', + parameters: { + docs: { + description: { + story: + 'Application-level adaptation of the same semantic: the trigger is a `
+ )} + /> + +
+
gpt-4o
+
+ Multimodal flagship model. Vision, audio and 128k context. +
+
+
+ + ), +} + +const PLACEMENTS: Placement[] = [ + 'top-start', + 'top', + 'top-end', + 'right-start', + 'right', + 'right-end', + 'bottom-start', + 'bottom', + 'bottom-end', + 'left-start', + 'left', + 'left-end', +] + +const PlacementsDemo = () => { + const [placement, setPlacement] = useState('bottom') + + return ( +
+
+ {PLACEMENTS.map(value => ( + + ))} +
+ + Hover me} + /> + +
+
+ placement=" + {placement} + " +
+
+ Preview positions itself relative to the trigger. +
+
+
+
+
+ ) +} + +export const Placements: Story = { + parameters: { + layout: 'fullscreen', + }, + render: () => , +} + +const CustomDelayDemo = () => ( + + Snappy trigger} + /> + +
+
Fast hover
+
+ Base UI defaults (600ms / 300ms) are tuned for link previews. Override per trigger for denser UIs. +
+
+
+
+) + +export const CustomDelays: Story = { + render: () => , +} diff --git a/packages/dify-ui/src/preview-card/index.tsx b/packages/dify-ui/src/preview-card/index.tsx new file mode 100644 index 0000000000..771b15cf13 --- /dev/null +++ b/packages/dify-ui/src/preview-card/index.tsx @@ -0,0 +1,81 @@ +'use client' + +import type { ReactNode } from 'react' +import type { Placement } from '../placement' +import { PreviewCard as BasePreviewCard } from '@base-ui/react/preview-card' +import { cn } from '../cn' +import { parsePlacement } from '../placement' + +export type { Placement } + +/** + * PreviewCard is a hover/focus-triggered rich preview intended to supplement a + * trigger whose primary action is its own click destination (e.g. a link, a + * selectable row, a chip that jumps to a definition). + * + * A11y contract — match Base UI's guidance: + * - The popup MUST NOT contain information or actions that are not also + * reachable from the trigger's primary click destination. Touch and screen + * reader users cannot open the card and must be able to get the same + * information/actions without it. + * - If content is unique to the popup, either (a) add a separate click-triggered + * affordance (Popover) next to the trigger, or (b) move the unique content + * onto the click destination. + */ +export const PreviewCard = BasePreviewCard.Root +export const PreviewCardTrigger = BasePreviewCard.Trigger +export const createPreviewCardHandle = BasePreviewCard.createHandle + +type PreviewCardContentProps = { + children: ReactNode + placement?: Placement + sideOffset?: number + alignOffset?: number + className?: string + popupClassName?: string + positionerProps?: Omit< + BasePreviewCard.Positioner.Props, + 'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset' + > + popupProps?: Omit< + BasePreviewCard.Popup.Props, + 'children' | 'className' + > +} + +export function PreviewCardContent({ + children, + placement = 'bottom', + sideOffset = 8, + alignOffset = 0, + className, + popupClassName, + positionerProps, + popupProps, +}: PreviewCardContentProps) { + const { side, align } = parsePlacement(placement) + + return ( + + + + {children} + + + + ) +} diff --git a/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx b/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx index 3660c2c8e5..043835f697 100644 --- a/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/tooltip/__tests__/index.spec.tsx @@ -46,20 +46,7 @@ describe('TooltipContent', () => { }) }) - describe('Variant and popup props', () => { - it('should render popup content when variant is plain', async () => { - const screen = await render( - - Trigger - - Plain tooltip body - - , - ) - - await expect.element(screen.getByRole('tooltip', { name: 'plain tooltip' })).toHaveTextContent('Plain tooltip body') - }) - + describe('Popup props', () => { it('should forward popup props and handlers when popup props are provided', async () => { const onMouseEnter = vi.fn() @@ -83,7 +70,11 @@ describe('TooltipContent', () => { await expect.element(popup).toHaveAttribute('id', 'tooltip-popup-id') await expect.element(popup).toHaveAttribute('data-track-id', 'tooltip-track') - expect(onMouseEnter).toHaveBeenCalledTimes(1) + // Intent of the assertion is "handler is wired up". The exact call count + // depends on vitest-browser's pointer simulation and Base UI's internal + // pointer tracking (both of which may fire more than one enter event for + // a single `.hover()` action), so assert presence, not count. + expect(onMouseEnter).toHaveBeenCalled() }) it('should apply className to the popup and positionerClassName to the positioner', async () => { diff --git a/packages/dify-ui/src/tooltip/index.stories.tsx b/packages/dify-ui/src/tooltip/index.stories.tsx index dca3be32f3..902449d4a4 100644 --- a/packages/dify-ui/src/tooltip/index.stories.tsx +++ b/packages/dify-ui/src/tooltip/index.stories.tsx @@ -8,8 +8,8 @@ import { TooltipTrigger, } from '.' -const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover' const iconButtonClassName = 'inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-subtle bg-components-button-secondary-bg text-text-secondary shadow-xs hover:bg-state-base-hover' +const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover' const meta = { title: 'Base/UI/Tooltip', @@ -25,7 +25,7 @@ const meta = { layout: 'centered', docs: { description: { - component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement, offsets, and two style variants.', + component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement and offsets.\n\n**Usage contract** (mirrors the [Base UI tooltip guidelines](https://base-ui.com/react/components/tooltip#alternatives-to-tooltips)):\n\n- Tooltips are **supplementary visual labels** for sighted mouse and keyboard users. They are disabled on touch devices and are not announced to screen readers.\n- The trigger **must carry its own `aria-label` or visible text** that matches the tooltip — the tooltip does not replace labeling.\n- Keep content short and non-interactive (an icon-button label, a keyboard shortcut, one-word clarification).\n- **Do not** place descriptions, prose, links, or interactive controls inside a tooltip — touch and screen-reader users cannot reach them.\n- For hover-triggered rich previews that users move their cursor onto, use `PreviewCard` (dwell-able, structured content).\n- For an info icon that explains a concept (an "infotip"), or for any hover popup that needs interactive content or to reach touch/assistive-tech users, use `Popover` with `openOnHover` on the trigger.', }, }, }, @@ -35,47 +35,58 @@ const meta = { export default meta type Story = StoryObj -export const Default: Story = { - render: () => ( - - } - > - Hover me - - - Tooltips describe interactive elements without a click. - - - ), -} +const ICON_ACTIONS = [ + { icon: 'i-ri-pencil-line', label: 'Edit' }, + { icon: 'i-ri-file-copy-line', label: 'Duplicate' }, + { icon: 'i-ri-archive-line', label: 'Archive' }, + { icon: 'i-ri-delete-bin-line', label: 'Delete' }, +] as const -export const Plain: Story = { +export const IconButton: Story = { + name: 'Icon button (canonical)', parameters: { docs: { description: { - story: 'Use `variant="plain"` to render the popup without default chrome (background, padding, typography). Apply your own styling via `className` on `TooltipContent`.', + story: 'The canonical tooltip use case: an icon-only button surfaces its accessible label as a tooltip for sighted mouse and keyboard users. The trigger already carries `aria-label` — the tooltip mirrors that label visually; it does **not** replace it.', + }, + }, + }, + render: () => ( +
+ {ICON_ACTIONS.map(({ icon, label }) => ( + + + + + )} + /> + {label} + + ))} +
+ ), +} + +export const KeyboardShortcut: Story = { + parameters: { + docs: { + description: { + story: 'A short, supplementary hint that surfaces a keyboard shortcut next to a visible button label. The trigger is fully self-describing ("Save"); the tooltip only adds non-essential extra clarity for mouse/keyboard users.', }, }, }, render: () => ( } - > - Preview details - - -
- Dataset preview - - 32 documents • Last indexed 2 minutes ago - -
-
+ render={( + + )} + /> + ⌘S
), } @@ -116,14 +127,10 @@ const PlacementsDemo = () => { } - > - Anchor - + render={} + /> - placement=" - {placement} - " + {`placement="${placement}"`} @@ -133,113 +140,45 @@ const PlacementsDemo = () => { export const Placements: Story = { parameters: { layout: 'fullscreen', + docs: { + description: { + story: 'Placement reference. `placement` accepts the 12 standard side/align combinations; Base UI flips automatically if the tooltip would overflow the viewport.', + }, + }, }, render: () => , } -export const OnIconButtons: Story = { - parameters: { - docs: { - description: { - story: 'Tooltips are essential for icon-only buttons. The trigger is the button; the tooltip provides the accessible label and hover hint.', - }, - }, - }, - render: () => ( -
- - - - - )} - /> - Edit - - - - - - )} - /> - Duplicate - - - - - - )} - /> - Archive - - - - - - )} - /> - Delete - -
- ), -} - -export const LongContent: Story = { - render: () => ( - - } - > - What are tokens? - - - Tokens are the basic units a model reads. English text averages ~4 characters per token; non-Latin scripts often use more tokens per character. Both input and output count toward your quota. - - - ), -} - const DELAY_PRESETS: Array<{ label: string, delay: number }> = [ - { label: 'Instant (0ms)', delay: 0 }, - { label: 'Fast (150ms)', delay: 150 }, - { label: 'Default (600ms)', delay: 600 }, + { label: 'Instant', delay: 0 }, + { label: 'Fast', delay: 150 }, + { label: 'Default', delay: 600 }, ] -const DelayDemo = () => { - return ( -
- {DELAY_PRESETS.map(({ label, delay }) => ( - - - } - > - {label} - - - Appeared after - {delay} - ms hover delay. - - - - ))} -
- ) -} +const DelayDemo = () => ( +
+ {DELAY_PRESETS.map(({ label, delay }) => ( + + + + + + )} + /> + {`${label} (${delay}ms)`} + + + ))} +
+) export const WithDelay: Story = { parameters: { docs: { description: { - story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown.', + story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown. The Dify app root sets `delay={300} closeDelay={200}` — override locally only when the surrounding UX demands it.', }, }, }, diff --git a/packages/dify-ui/src/tooltip/index.tsx b/packages/dify-ui/src/tooltip/index.tsx index e0fcd7c5c3..1f9772ce2d 100644 --- a/packages/dify-ui/src/tooltip/index.tsx +++ b/packages/dify-ui/src/tooltip/index.tsx @@ -8,7 +8,28 @@ import { parsePlacement } from '../placement' export type { Placement } -type TooltipContentVariant = 'default' | 'plain' +/** + * Tooltip is an **ephemeral hint** tied to a trigger (typically an icon button, + * badge, or short label). It follows Base UI's Tooltip semantics: + * + * - Opens on pointer hover or keyboard focus on the trigger. + * - Closes as soon as the pointer leaves the trigger — the popup itself is + * **not dwell-able**; users cannot move their cursor onto the tooltip. + * - Must contain only short, non-interactive text. No links, buttons, form + * controls, or structured panels. + * + * If you need any of the following, use `PreviewCard` instead (hover-triggered + * rich preview that users can move their cursor onto): + * + * - Multi-line or structured content (icon + title + metadata) + * - Content the user needs to "stop and read" for more than ~1 second + * - Content wider than ~300px + * + * If you need interactive affordances (buttons, links, forms) use `Popover`. + */ +export const TooltipProvider = BaseTooltip.Provider +export const Tooltip = BaseTooltip.Root +export const TooltipTrigger = BaseTooltip.Trigger type TooltipContentProps = { children: ReactNode @@ -17,7 +38,6 @@ type TooltipContentProps = { alignOffset?: number positionerClassName?: string className?: string - variant?: TooltipContentVariant } & Omit export function TooltipContent({ @@ -27,7 +47,6 @@ export function TooltipContent({ alignOffset = 0, positionerClassName, className, - variant = 'default', ...props }: TooltipContentProps) { const { side, align } = parsePlacement(placement) @@ -43,7 +62,7 @@ export function TooltipContent({ > ) } - -export const TooltipProvider = BaseTooltip.Provider -export const Tooltip = BaseTooltip.Root -export const TooltipTrigger = BaseTooltip.Trigger diff --git a/packages/dify-ui/tsconfig.json b/packages/dify-ui/tsconfig.json index 10cffbcd76..514954c807 100644 --- a/packages/dify-ui/tsconfig.json +++ b/packages/dify-ui/tsconfig.json @@ -2,5 +2,7 @@ "extends": "@dify/tsconfig/react.json", "compilerOptions": { "types": ["vite-plus/test/globals"] - } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "tailwind.config.ts"], + "exclude": ["node_modules", "dist", "storybook-static", "coverage"] } diff --git a/packages/migrate-no-unchecked-indexed-access/package.json b/packages/migrate-no-unchecked-indexed-access/package.json index 5da8d4cb50..6a29f40338 100644 --- a/packages/migrate-no-unchecked-indexed-access/package.json +++ b/packages/migrate-no-unchecked-indexed-access/package.json @@ -8,9 +8,10 @@ }, "scripts": { "build": "vp pack", - "type-check": "tsc" + "type-check": "tsgo" }, "dependencies": { + "@typescript/native-preview": "catalog:", "typescript": "catalog:" }, "devDependencies": { diff --git a/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/run.ts b/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/run.ts index ad655e4f11..6eea6c2459 100644 --- a/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/run.ts +++ b/packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/run.ts @@ -117,17 +117,17 @@ async function runTypeCheck( await fs.mkdir(TYPECHECK_CACHE_DIR, { recursive: true }) - const tscArgs = ['exec', 'tsc', '--noEmit', '--pretty', 'false'] + const tsgoArgs = ['exec', 'tsgo', '--noEmit', '--pretty', 'false'] if (incremental) { - tscArgs.push('--incremental', '--tsBuildInfoFile', buildInfoPath) + tsgoArgs.push('--incremental', '--tsBuildInfoFile', buildInfoPath) } else { - tscArgs.push('--incremental', 'false') + tsgoArgs.push('--incremental', 'false') } - tscArgs.push('--project', projectPath) + tsgoArgs.push('--project', projectPath) try { - const { stdout, stderr } = await execFileAsync('pnpm', tscArgs, { + const { stdout, stderr } = await execFileAsync('pnpm', tsgoArgs, { cwd: projectDirectory, env: { ...process.env, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c276fae9e..9408bfb4b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,23 +7,23 @@ settings: catalogs: default: '@amplitude/analytics-browser': - specifier: 2.39.0 - version: 2.39.0 + specifier: 2.41.0 + version: 2.41.0 '@amplitude/plugin-session-replay-browser': - specifier: 1.27.7 - version: 1.27.7 + specifier: 1.27.10 + version: 1.27.10 '@antfu/eslint-config': specifier: 8.2.0 version: 8.2.0 '@base-ui/react': - specifier: 1.4.0 - version: 1.4.0 + specifier: 1.4.1 + version: 1.4.1 '@chromatic-com/storybook': specifier: 5.1.2 version: 5.1.2 '@cucumber/cucumber': - specifier: 12.8.0 - version: 12.8.0 + specifier: 12.8.1 + version: 12.8.1 '@egoist/tailwindcss-icons': specifier: 1.9.2 version: 1.9.2 @@ -88,11 +88,11 @@ catalogs: specifier: 4.7.0 version: 4.7.0 '@next/eslint-plugin-next': - specifier: 16.2.3 - version: 16.2.3 + specifier: 16.2.4 + version: 16.2.4 '@next/mdx': - specifier: 16.2.3 - version: 16.2.3 + specifier: 16.2.4 + version: 16.2.4 '@orpc/client': specifier: 1.13.14 version: 1.13.14 @@ -115,8 +115,8 @@ catalogs: specifier: 4.2.0 version: 4.2.0 '@sentry/react': - specifier: 10.48.0 - version: 10.48.0 + specifier: 10.49.0 + version: 10.49.0 '@storybook/addon-docs': specifier: 10.3.5 version: 10.3.5 @@ -148,35 +148,35 @@ catalogs: specifier: 0.13.11 version: 0.13.11 '@tailwindcss/postcss': - specifier: 4.2.2 - version: 4.2.2 + specifier: 4.2.4 + version: 4.2.4 '@tailwindcss/typography': specifier: 0.5.19 version: 0.5.19 '@tailwindcss/vite': - specifier: 4.2.2 - version: 4.2.2 + specifier: 4.2.4 + version: 4.2.4 '@tanstack/eslint-plugin-query': - specifier: 5.99.0 - version: 5.99.0 + specifier: 5.99.2 + version: 5.99.2 '@tanstack/react-devtools': specifier: 0.10.2 version: 0.10.2 '@tanstack/react-form': - specifier: 1.29.0 - version: 1.29.0 + specifier: 1.29.1 + version: 1.29.1 '@tanstack/react-form-devtools': - specifier: 0.2.21 - version: 0.2.21 + specifier: 0.2.22 + version: 0.2.22 '@tanstack/react-query': - specifier: 5.99.0 - version: 5.99.0 + specifier: 5.99.2 + version: 5.99.2 '@tanstack/react-query-devtools': - specifier: 5.99.0 - version: 5.99.0 + specifier: 5.99.2 + version: 5.99.2 '@tanstack/react-virtual': - specifier: 3.13.23 - version: 3.13.23 + specifier: 3.13.24 + version: 3.13.24 '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 @@ -190,14 +190,14 @@ catalogs: specifier: 14.6.1 version: 14.6.1 '@tsslint/cli': - specifier: 3.0.3 - version: 3.0.3 + specifier: 3.0.4 + version: 3.0.4 '@tsslint/compat-eslint': - specifier: 3.0.3 - version: 3.0.3 + specifier: 3.0.4 + version: 3.0.4 '@tsslint/config': - specifier: 3.0.3 - version: 3.0.3 + specifier: 3.0.4 + version: 3.0.4 '@types/js-cookie': specifier: 3.0.6 version: 3.0.6 @@ -223,14 +223,14 @@ catalogs: specifier: 1.15.9 version: 1.15.9 '@typescript-eslint/eslint-plugin': - specifier: 8.58.2 - version: 8.58.2 + specifier: 8.59.0 + version: 8.59.0 '@typescript-eslint/parser': - specifier: 8.58.2 - version: 8.58.2 + specifier: 8.59.0 + version: 8.59.0 '@typescript/native-preview': - specifier: 7.0.0-dev.20260413.1 - version: 7.0.0-dev.20260413.1 + specifier: 7.0.0-dev.20260422.1 + version: 7.0.0-dev.20260422.1 '@vitejs/plugin-react': specifier: 6.0.1 version: 6.0.1 @@ -238,8 +238,8 @@ catalogs: specifier: 0.5.24 version: 0.5.24 '@vitest/coverage-v8': - specifier: 4.1.4 - version: 4.1.4 + specifier: 4.1.5 + version: 4.1.5 abcjs: specifier: 6.6.2 version: 6.6.2 @@ -277,8 +277,8 @@ catalogs: specifier: 10.6.0 version: 10.6.0 dompurify: - specifier: 3.4.0 - version: 3.4.0 + specifier: 3.4.1 + version: 3.4.1 echarts: specifier: 6.0.0 version: 6.0.0 @@ -298,11 +298,11 @@ catalogs: specifier: 5.6.0 version: 5.6.0 es-toolkit: - specifier: 1.45.1 - version: 1.45.1 + specifier: 1.46.0 + version: 1.46.0 eslint: - specifier: 10.2.0 - version: 10.2.0 + specifier: 10.2.1 + version: 10.2.1 eslint-markdown: specifier: 0.6.1 version: 0.6.1 @@ -322,8 +322,8 @@ catalogs: specifier: 0.5.2 version: 0.5.2 eslint-plugin-sonarjs: - specifier: 4.0.2 - version: 4.0.2 + specifier: 4.0.3 + version: 4.0.3 eslint-plugin-storybook: specifier: 10.3.5 version: 10.3.5 @@ -346,8 +346,8 @@ catalogs: specifier: 1.11.13 version: 1.11.13 i18next: - specifier: 26.0.4 - version: 26.0.4 + specifier: 26.0.6 + version: 26.0.6 i18next-resources-to-backend: specifier: 1.2.1 version: 1.2.1 @@ -376,11 +376,11 @@ catalogs: specifier: 0.16.45 version: 0.16.45 knip: - specifier: 6.4.1 - version: 6.4.1 + specifier: 6.6.1 + version: 6.6.1 ky: - specifier: 2.0.0 - version: 2.0.0 + specifier: 2.0.2 + version: 2.0.2 lamejs: specifier: 1.2.1 version: 1.2.1 @@ -388,8 +388,8 @@ catalogs: specifier: 0.43.0 version: 0.43.0 loro-crdt: - specifier: 1.10.8 - version: 1.10.8 + specifier: 1.11.1 + version: 1.11.1 mermaid: specifier: 11.14.0 version: 11.14.0 @@ -403,8 +403,8 @@ catalogs: specifier: 1.0.0 version: 1.0.0 next: - specifier: 16.2.3 - version: 16.2.3 + specifier: 16.2.4 + version: 16.2.4 next-themes: specifier: 0.4.6 version: 0.4.6 @@ -418,8 +418,8 @@ catalogs: specifier: 1.59.1 version: 1.59.1 postcss: - specifier: 8.5.9 - version: 8.5.9 + specifier: 8.5.10 + version: 8.5.10 qrcode.react: specifier: 4.2.0 version: 4.2.0 @@ -502,8 +502,8 @@ catalogs: specifier: 3.5.0 version: 3.5.0 tailwindcss: - specifier: 4.2.2 - version: 4.2.2 + specifier: 4.2.4 + version: 4.2.4 tldts: specifier: 7.0.28 version: 7.0.28 @@ -511,8 +511,8 @@ catalogs: specifier: 4.21.0 version: 4.21.0 typescript: - specifier: 6.0.2 - version: 6.0.2 + specifier: 6.0.3 + version: 6.0.3 uglify-js: specifier: 3.19.3 version: 3.19.3 @@ -532,8 +532,8 @@ catalogs: specifier: 12.0.0-beta.1 version: 12.0.0-beta.1 vite-plus: - specifier: 0.1.18 - version: 0.1.18 + specifier: 0.1.19 + version: 0.1.19 vitest-browser-react: specifier: 2.2.0 version: 2.2.0 @@ -574,8 +574,8 @@ overrides: svgo@>=3.0.0 <3.3.3: 3.3.3 tar@<=7.5.10: 7.5.11 undici@>=7.0.0 <7.24.0: 7.24.0 - vite: npm:@voidzero-dev/vite-plus-core@0.1.18 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.18 + vite: npm:@voidzero-dev/vite-plus-core@0.1.19 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.19 yaml@>=2.0.0 <2.8.3: 2.8.3 yauzl@<3.2.1: 3.2.1 @@ -585,31 +585,31 @@ importers: devDependencies: '@antfu/eslint-config': specifier: 'catalog:' - version: 8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.3)(@types/node@25.6.0)(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(happy-dom@20.9.0)(jiti@2.6.1)(oxlint@1.60.0(oxlint-tsgolint@0.20.0))(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.4)(@types/node@25.6.0)(@typescript-eslint/typescript-estree@8.59.0(typescript@6.0.3))(@typescript-eslint/utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.6.1)))(eslint@10.2.1(jiti@2.6.1))(happy-dom@20.9.0)(jiti@2.6.1)(oxlint@1.60.0(oxlint-tsgolint@0.21.1))(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) eslint: specifier: 'catalog:' - version: 10.2.0(jiti@2.6.1) + version: 10.2.1(jiti@2.6.1) eslint-markdown: specifier: 'catalog:' - version: 0.6.1(eslint@10.2.0(jiti@2.6.1)) + version: 0.6.1(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-markdown-preferences: specifier: 'catalog:' - version: 0.41.1(@eslint/markdown@8.0.1)(eslint@10.2.0(jiti@2.6.1)) + version: 0.41.1(@eslint/markdown@8.0.1)(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-no-barrel-files: specifier: 'catalog:' - version: 1.3.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + version: 1.3.1(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.18 - version: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@0.1.19 + version: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) e2e: devDependencies: '@cucumber/cucumber': specifier: 'catalog:' - version: 12.8.0 + version: 12.8.1 '@dify/tsconfig': specifier: workspace:* version: link:../packages/tsconfig @@ -619,18 +619,21 @@ importers: '@types/node': specifier: 'catalog:' version: 25.6.0 + '@typescript/native-preview': + specifier: 'catalog:' + version: 7.0.0-dev.20260422.1 tsx: specifier: 'catalog:' version: 4.21.0 typescript: specifier: 'catalog:' - version: 6.0.2 + version: 6.0.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.18 - version: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@0.1.19 + version: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) packages/dify-ui: dependencies: @@ -643,7 +646,7 @@ importers: devDependencies: '@base-ui/react': specifier: 'catalog:' - version: 1.4.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@chromatic-com/storybook': specifier: 'catalog:' version: 5.1.2(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) @@ -652,13 +655,13 @@ importers: version: link:../tsconfig '@egoist/tailwindcss-icons': specifier: 'catalog:' - version: 1.9.2(tailwindcss@4.2.2) + version: 1.9.2(tailwindcss@4.2.4) '@iconify-json/ri': specifier: 'catalog:' version: 1.2.10 '@storybook/addon-docs': specifier: 'catalog:' - version: 10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + version: 10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/addon-links': specifier: 'catalog:' version: 10.3.5(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) @@ -667,22 +670,25 @@ importers: version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/react-vite': specifier: 'catalog:' - version: 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + version: 10.3.5(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.2(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 4.2.4(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) '@types/react': specifier: 'catalog:' version: 19.2.14 '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@typescript/native-preview': + specifier: 'catalog:' + version: 7.0.0-dev.20260422.1 '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) class-variance-authority: specifier: 'catalog:' version: 0.7.1 @@ -700,19 +706,19 @@ importers: version: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tailwindcss: specifier: 'catalog:' - version: 4.2.2 + version: 4.2.4 typescript: specifier: 'catalog:' - version: 6.0.2 + version: 6.0.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.18 - version: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@0.1.19 + version: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) vitest-browser-react: specifier: 'catalog:' - version: 2.2.0(@types/node@25.6.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 2.2.0(@types/node@25.6.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) packages/iconify-collections: devDependencies: @@ -725,9 +731,12 @@ importers: packages/migrate-no-unchecked-indexed-access: dependencies: + '@typescript/native-preview': + specifier: 'catalog:' + version: 7.0.0-dev.20260422.1 typescript: specifier: 'catalog:' - version: 6.0.2 + version: 6.0.3 devDependencies: '@dify/tsconfig': specifier: workspace:* @@ -736,11 +745,11 @@ importers: specifier: 'catalog:' version: 25.6.0 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.18 - version: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@0.1.19 + version: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) packages/tsconfig: {} @@ -751,46 +760,49 @@ importers: version: link:../../packages/tsconfig '@eslint/js': specifier: 'catalog:' - version: 10.0.1(eslint@10.2.0(jiti@2.6.1)) + version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) '@types/node': specifier: 'catalog:' version: 25.6.0 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + version: 8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript/native-preview': + specifier: 'catalog:' + version: 7.0.0-dev.20260422.1 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) eslint: specifier: 'catalog:' - version: 10.2.0(jiti@2.6.1) + version: 10.2.1(jiti@2.6.1) typescript: specifier: 'catalog:' - version: 6.0.2 + version: 6.0.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.18 - version: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@0.1.19 + version: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@0.1.18 - version: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-test@0.1.19 + version: '@voidzero-dev/vite-plus-test@0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' web: dependencies: '@amplitude/analytics-browser': specifier: 'catalog:' - version: 2.39.0 + version: 2.41.0 '@amplitude/plugin-session-replay-browser': specifier: 'catalog:' - version: 1.27.7(@amplitude/rrweb@2.0.0-alpha.37) + version: 1.27.10(@amplitude/rrweb@2.0.0-alpha.37) '@base-ui/react': specifier: 'catalog:' - version: 1.4.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@emoji-mart/data': specifier: 'catalog:' version: 1.2.1 @@ -841,13 +853,13 @@ importers: version: 1.13.14 '@orpc/tanstack-query': specifier: 'catalog:' - version: 1.13.14(@orpc/client@1.13.14)(@tanstack/query-core@5.99.0) + version: 1.13.14(@orpc/client@1.13.14)(@tanstack/query-core@5.99.2) '@remixicon/react': specifier: 'catalog:' version: 4.9.0(react@19.2.5) '@sentry/react': specifier: 'catalog:' - version: 10.48.0(react@19.2.5) + version: 10.49.0(react@19.2.5) '@streamdown/math': specifier: 'catalog:' version: 1.0.2(react@19.2.5) @@ -856,19 +868,19 @@ importers: version: 3.2.5 '@t3-oss/env-nextjs': specifier: 'catalog:' - version: 0.13.11(typescript@6.0.2)(valibot@1.3.1(typescript@6.0.2))(zod@4.3.6) + version: 0.13.11(typescript@6.0.3)(valibot@1.3.1(typescript@6.0.3))(zod@4.3.6) '@tailwindcss/typography': specifier: 'catalog:' - version: 0.5.19(tailwindcss@4.2.2) + version: 0.5.19(tailwindcss@4.2.4) '@tanstack/react-form': specifier: 'catalog:' - version: 1.29.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.29.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-query': specifier: 'catalog:' - version: 5.99.0(react@19.2.5) + version: 5.99.2(react@19.2.5) '@tanstack/react-virtual': specifier: 'catalog:' - version: 3.13.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) abcjs: specifier: 'catalog:' version: 6.6.2 @@ -898,7 +910,7 @@ importers: version: 10.6.0 dompurify: specifier: 'catalog:' - version: 3.4.0 + version: 3.4.1 echarts: specifier: 'catalog:' version: 6.0.0 @@ -919,7 +931,7 @@ importers: version: 5.6.0 es-toolkit: specifier: 'catalog:' - version: 1.45.1 + version: 1.46.0 fast-deep-equal: specifier: 'catalog:' version: 3.1.3 @@ -934,7 +946,7 @@ importers: version: 1.11.13 i18next: specifier: 'catalog:' - version: 26.0.4(typescript@6.0.2) + version: 26.0.6(typescript@6.0.3) i18next-resources-to-backend: specifier: 'catalog:' version: 1.2.1 @@ -961,7 +973,7 @@ importers: version: 0.16.45 ky: specifier: 'catalog:' - version: 2.0.0 + version: 2.0.2 lamejs: specifier: 'catalog:' version: 1.2.1 @@ -970,7 +982,7 @@ importers: version: 0.43.0 loro-crdt: specifier: 'catalog:' - version: 1.10.8 + version: 1.11.1 mermaid: specifier: 'catalog:' version: 11.14.0 @@ -985,13 +997,13 @@ importers: version: 1.0.0 next: specifier: 'catalog:' - version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) nuqs: specifier: 'catalog:' - version: 2.8.9(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 2.8.9(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) pinyin-pro: specifier: 'catalog:' version: 3.28.1 @@ -1018,7 +1030,7 @@ importers: version: 5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-i18next: specifier: 'catalog:' - version: 16.5.8(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2) + version: 16.5.8(i18next@26.0.6(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) react-multi-email: specifier: 'catalog:' version: 1.0.25(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1091,7 +1103,7 @@ importers: devDependencies: '@antfu/eslint-config': specifier: 'catalog:' - version: 8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.3)(@types/node@25.6.0)(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(happy-dom@20.9.0)(jiti@2.6.1)(oxlint@1.60.0(oxlint-tsgolint@0.20.0))(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.4)(@types/node@25.6.0)(@typescript-eslint/typescript-estree@8.59.0(typescript@6.0.3))(@typescript-eslint/utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.6.1)))(eslint@10.2.1(jiti@2.6.1))(happy-dom@20.9.0)(jiti@2.6.1)(oxlint@1.60.0(oxlint-tsgolint@0.21.1))(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) '@chromatic-com/storybook': specifier: 'catalog:' version: 5.1.2(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) @@ -1103,10 +1115,10 @@ importers: version: link:../packages/tsconfig '@egoist/tailwindcss-icons': specifier: 'catalog:' - version: 1.9.2(tailwindcss@4.2.2) + version: 1.9.2(tailwindcss@4.2.4) '@eslint-react/eslint-plugin': specifier: 'catalog:' - version: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + version: 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@hono/node-server': specifier: 'catalog:' version: 1.19.14(hono@4.12.14) @@ -1130,16 +1142,16 @@ importers: version: 3.1.1 '@next/eslint-plugin-next': specifier: 'catalog:' - version: 16.2.3 + version: 16.2.4 '@next/mdx': specifier: 'catalog:' - version: 16.2.3(@mdx-js/loader@3.1.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5)) + version: 16.2.4(@mdx-js/loader@3.1.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5)) '@rgrove/parse-xml': specifier: 'catalog:' version: 4.2.0 '@storybook/addon-docs': specifier: 'catalog:' - version: 10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + version: 10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/addon-links': specifier: 'catalog:' version: 10.3.5(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) @@ -1151,28 +1163,28 @@ importers: version: 10.3.5(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/nextjs-vite': specifier: 'catalog:' - version: 10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + version: 10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) '@storybook/react': specifier: 'catalog:' - version: 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + version: 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) '@tailwindcss/postcss': specifier: 'catalog:' - version: 4.2.2 + version: 4.2.4 '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.2(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 4.2.4(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) '@tanstack/eslint-plugin-query': specifier: 'catalog:' - version: 5.99.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + version: 5.99.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@tanstack/react-devtools': specifier: 'catalog:' version: 0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-form-devtools': specifier: 'catalog:' - version: 0.2.21(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11) + version: 0.2.22(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11) '@tanstack/react-query-devtools': specifier: 'catalog:' - version: 5.99.0(@tanstack/react-query@5.99.0(react@19.2.5))(react@19.2.5) + version: 5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5) '@testing-library/dom': specifier: 'catalog:' version: 10.4.1 @@ -1187,13 +1199,13 @@ importers: version: 14.6.1(@testing-library/dom@10.4.1) '@tsslint/cli': specifier: 'catalog:' - version: 3.0.3(@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2) + version: 3.0.4(@tsslint/compat-eslint@3.0.4(jiti@2.6.1)(typescript@6.0.3))(typescript@6.0.3) '@tsslint/compat-eslint': specifier: 'catalog:' - version: 3.0.3(jiti@2.6.1)(typescript@6.0.2) + version: 3.0.4(jiti@2.6.1)(typescript@6.0.3) '@tsslint/config': specifier: 'catalog:' - version: 3.0.3(@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2) + version: 3.0.4(@tsslint/compat-eslint@3.0.4(jiti@2.6.1)(typescript@6.0.3))(typescript@6.0.3) '@types/js-cookie': specifier: 'catalog:' version: 3.0.6 @@ -1220,19 +1232,19 @@ importers: version: 1.15.9 '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260413.1 + version: 7.0.0-dev.20260422.1 '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.24(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 0.5.24(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) agentation: specifier: 'catalog:' version: 3.0.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1241,31 +1253,31 @@ importers: version: 1.5.1 eslint: specifier: 'catalog:' - version: 10.2.0(jiti@2.6.1) + version: 10.2.1(jiti@2.6.1) eslint-markdown: specifier: 'catalog:' - version: 0.6.1(eslint@10.2.0(jiti@2.6.1)) + version: 0.6.1(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-better-tailwindcss: specifier: 'catalog:' - version: 4.4.1(eslint@10.2.0(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.2.2)(typescript@6.0.2) + version: 4.4.1(eslint@10.2.1(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.21.1))(tailwindcss@4.2.4)(typescript@6.0.3) eslint-plugin-hyoban: specifier: 'catalog:' - version: 0.14.1(eslint@10.2.0(jiti@2.6.1)) + version: 0.14.1(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-markdown-preferences: specifier: 'catalog:' - version: 0.41.1(@eslint/markdown@8.0.1)(eslint@10.2.0(jiti@2.6.1)) + version: 0.41.1(@eslint/markdown@8.0.1)(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-no-barrel-files: specifier: 'catalog:' - version: 1.3.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + version: 1.3.1(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) eslint-plugin-react-refresh: specifier: 'catalog:' - version: 0.5.2(eslint@10.2.0(jiti@2.6.1)) + version: 0.5.2(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-sonarjs: specifier: 'catalog:' - version: 4.0.2(eslint@10.2.0(jiti@2.6.1)) + version: 4.0.3(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-storybook: specifier: 'catalog:' - version: 10.3.5(eslint@10.2.0(jiti@2.6.1))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + version: 10.3.5(eslint@10.2.1(jiti@2.6.1))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) happy-dom: specifier: 'catalog:' version: 20.9.0 @@ -1274,10 +1286,10 @@ importers: version: 4.12.14 knip: specifier: 'catalog:' - version: 6.4.1(@emnapi/runtime@1.9.1) + version: 6.6.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) postcss: specifier: 'catalog:' - version: 8.5.9 + version: 8.5.10 react-server-dom-webpack: specifier: 'catalog:' version: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1286,34 +1298,34 @@ importers: version: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tailwindcss: specifier: 'catalog:' - version: 4.2.2 + version: 4.2.4 tsx: specifier: 'catalog:' version: 4.21.0 typescript: specifier: 'catalog:' - version: 6.0.2 + version: 6.0.3 uglify-js: specifier: 'catalog:' version: 3.19.3 vinext: specifier: 'catalog:' - version: 0.0.41(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.2) + version: 0.0.41(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.3) vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.1.18 - version: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@0.1.19 + version: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' vite-plugin-inspect: specifier: 'catalog:' - version: 12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0) + version: 12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(typescript@6.0.3)(ws@8.20.0) vite-plus: specifier: 'catalog:' - version: 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@0.1.18 - version: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-test@0.1.19 + version: '@voidzero-dev/vite-plus-test@0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' vitest-canvas-mock: specifier: 'catalog:' - version: 1.1.4(@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 1.1.4(@voidzero-dev/vite-plus-test@0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) packages: @@ -1324,17 +1336,17 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@amplitude/analytics-browser@2.39.0': - resolution: {integrity: sha512-sTNGGjiubsDs1NqKsTXp0ykCaSIzjaGclMRHlnO7JBatqK0f/Knl0cfn1a7XBFuTVix/M5nrWATsKv6+0dSpMg==} + '@amplitude/analytics-browser@2.41.0': + resolution: {integrity: sha512-zCfsm4mvytJRCvXxc04vfI0gmDkVUsfFXwoPl6l3g6uo9xC6Z22heDWot4NLUpeqKbQGBWJLYSzaD08HigXZNA==} - '@amplitude/analytics-client-common@2.4.43': - resolution: {integrity: sha512-R5n3cfnVNLk32BE2DbCp4xpn39mfmjMUjvOO9kt5dLFdF0cozb9MCawVyZJQVfnJJT6k5NMoswdUBu7Ul0nbRw==} + '@amplitude/analytics-client-common@2.4.45': + resolution: {integrity: sha512-2lQRpLEiZp3hqFXSpGgzsOVeXCaDwW8hCKJZeXWB6GGcLsGn0ssEC7RNxLpUMNWCctCF7Dfr9a4MSVe54jtiPw==} '@amplitude/analytics-connector@1.6.4': resolution: {integrity: sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==} - '@amplitude/analytics-core@2.45.0': - resolution: {integrity: sha512-vWRYbXu2Grs1GM+WHo03RPtbaPs5sJm21YQcAow9JASvtoY4xNqItIeRydCJQWtFHhbbxY41n+CVW6mzDP6aBA==} + '@amplitude/analytics-core@2.47.0': + resolution: {integrity: sha512-LLffKoq7nhEtFtXz/QGcimlcS3vYugEW14JdAeZE03k2empShrAhCzigHL3Xiz+ywW9KC3inUalnbxybVhU0YA==} '@amplitude/analytics-types@2.11.1': resolution: {integrity: sha512-wFEgb0t99ly2uJKm5oZ28Lti0Kh5RecR5XBkwfUpDzn84IoCIZ8GJTsMw/nThu8FZFc7xFDA4UAt76zhZKrs9A==} @@ -1342,26 +1354,29 @@ packages: '@amplitude/experiment-core@0.7.2': resolution: {integrity: sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==} - '@amplitude/plugin-autocapture-browser@1.25.2': - resolution: {integrity: sha512-AWzIX0uit60Q742rH/96/n88e+3BaVZa4+7Xs+BeuuIOyrljOZlQKzH23Lxzkl0DgbNb5+MMqWds0pov3DV5TA==} + '@amplitude/plugin-autocapture-browser@1.26.0': + resolution: {integrity: sha512-LCLsMr8usQJK6R6VjCjmiJ3ZRICh0QJ6xbDEwAm5XhuLFGRNsB2b9eRHlvalsPrTXR+b4Hjr71/dh3XNYZ9rqw==} - '@amplitude/plugin-custom-enrichment-browser@0.1.4': - resolution: {integrity: sha512-vxuQocn8YGE2wMLZUmotRG8c6RijoaQAsHKDQEO56CNk3WhSecgSGMnlHcUcOYIzwfXKFj4MxRJS386kdDHV+Q==} + '@amplitude/plugin-custom-enrichment-browser@0.1.6': + resolution: {integrity: sha512-oAVR5biFh7kMm4XOji7r684TA/VOwK8N1OLMdACQdwBl8MPiBLJDIPWtkVW5iSXyIjwYkOlrjygtnkei1q2S8g==} - '@amplitude/plugin-network-capture-browser@1.9.13': - resolution: {integrity: sha512-8uzTQFbP+dvqJX+S39KqKw+EheJW8JCWT/xlXT55vtTU/ZTFeF074QnHFEKUPewpYXpwKXgJky8PDoMk0b46Qw==} + '@amplitude/plugin-event-property-attribution-browser@0.1.1': + resolution: {integrity: sha512-2YHF/O+WVX0VxTAh3Jh77Ib+LeUl1xbyF1qW2YzGurY8uBUeAd62+7qFaXQSBWk1qMiTguxjKXrbbtxssfWWWg==} - '@amplitude/plugin-page-url-enrichment-browser@0.7.5': - resolution: {integrity: sha512-0Q7P5vsue/s92i3zevVDVJf9AiHkbxGdwkB8iV2oWgkXtglzWugwr//qN+muHmXdi1ZWxRjm93CW+jQJVripgw==} + '@amplitude/plugin-network-capture-browser@1.9.15': + resolution: {integrity: sha512-PkFWjKyOkkzw/9yKKJ2sa19F2Uo9NiSAR0l0NmELcO8h4TVJdfc4HlvM68AnWJ15nkFHh+UoG7SHwb7vp7ZC3Q==} - '@amplitude/plugin-page-view-tracking-browser@2.9.6': - resolution: {integrity: sha512-/4lG2lXIB6qbQNf1VYQ5fDOnvInPEtYuOgvmyLfuZ6PvHVFUu4NZtoOVdAcy0R9x76rNyCpRXxdL78p9Ra1ANA==} + '@amplitude/plugin-page-url-enrichment-browser@0.7.7': + resolution: {integrity: sha512-P67Xmi5/oDFZOO2DfsAvvDS280WdzVsl6JTPvgJc4+WJ1YypbYFA7S87LUIiwtuvgnHXFsgOjNUI36bOEVTW4w==} - '@amplitude/plugin-session-replay-browser@1.27.7': - resolution: {integrity: sha512-KcGMFaBGqZAOm1Gdzio9d95IL3Nmp5J1xOu1PD0NAPYLfW1MyoyA5PFIIlMqqVf1DoCjmgqP7AY4swetU2tpWg==} + '@amplitude/plugin-page-view-tracking-browser@2.10.1': + resolution: {integrity: sha512-XEk0Z7ZfN6gV0h1R2hOZkby/SUTIbGU8SgWR8gt4O+DEx+pxfTQEuCM2ya1YaCV2h1SBrTK4bnIHgPax/4/HoA==} - '@amplitude/plugin-web-vitals-browser@1.1.28': - resolution: {integrity: sha512-gs4Y1eOuVUEDwYEJF82f/GmgQ7iM4Y/eZTkftJKjFsBNbrPro2CuLymfdAcC+QuVfyrp3qAiWcSGnjDXA6ZbQg==} + '@amplitude/plugin-session-replay-browser@1.27.10': + resolution: {integrity: sha512-AWvAtiQ9/T52DCXS3hcjtHQs4GvZxM7rxgs24DgxqFY2uwCTTnI78le4U7nPWhSrj02YK+3b8y7QN3mm23lHyQ==} + + '@amplitude/plugin-web-vitals-browser@1.1.30': + resolution: {integrity: sha512-nLZk2dTHG8pLd/fFH0zdIhWnu4u+oPc/DKBYXwZ4zk6YKOkl0V+sbDUNGNnZWlOWRykq+0rkOX/WnUyClvMtaQ==} '@amplitude/rrdom@2.0.0-alpha.37': resolution: {integrity: sha512-u4dSnBtlbJ8oU5P/Ywl2RLqvjqWbkl4ScMUbvQA7in4pWcx+0NRN+VVjLZXQcd8Fn7E/rcxjeUh7e7HfwvdasQ==} @@ -1395,8 +1410,8 @@ packages: '@amplitude/rrweb@2.0.0-alpha.37': resolution: {integrity: sha512-jJkSpPYiVgOZB422pb2jOJJn3pvb5E5f9vKK8CEmUlk2mVAl6kPQzW98mb05M65OJFj5nn9tSe9h5r5+Cl93ag==} - '@amplitude/session-replay-browser@1.36.0': - resolution: {integrity: sha512-HZpNRMRAiLbzGF84DzF+ZH5WztJH4tVe2e/FzYJ2r27Sgf2gftCmzCB9pN8BXXcHKYtQK8/Qol+PTmSIzvyvEw==} + '@amplitude/session-replay-browser@1.37.0': + resolution: {integrity: sha512-65KC35dK2yxHoBTDTZeJC8qPchj4lFqTuNjBbH1jaV3hzYoRrGA/xWXLZgxlFvc/7yvcGBbTUW2TeGMAeW6FUg==} '@amplitude/targeting@0.2.0': resolution: {integrity: sha512-/50ywTrC4hfcfJVBbh5DFbqMPPfaIOivZeb5Gb+OGM03QrA+lsUqdvtnKLNuWtceD4H6QQ2KFzPJ5aAJLyzVDA==} @@ -1539,8 +1554,8 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.4.0': - resolution: {integrity: sha512-QcqdVbr/+ba2/RAKJIV1PV6S02Q5+r6a4Eym8ndBw+ZbBILkkmQAyRxXCg/pArrHnkrGeU8goe26aw0h6eE8pg==} + '@base-ui/react@1.4.1': + resolution: {integrity: sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw==} engines: {node: '>=14.0.0'} peerDependencies: '@date-fns/tz': ^1.2.0 @@ -1549,11 +1564,15 @@ packages: react: ^17 || ^18 || ^19 react-dom: ^17 || ^18 || ^19 peerDependenciesMeta: + '@date-fns/tz': + optional: true '@types/react': optional: true + date-fns: + optional: true - '@base-ui/utils@0.2.7': - resolution: {integrity: sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA==} + '@base-ui/utils@0.2.8': + resolution: {integrity: sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -1630,8 +1649,8 @@ packages: '@cucumber/cucumber-expressions@19.0.0': resolution: {integrity: sha512-4FKoOQh2Uf6F6/Ln+1OxuK8LkTg6PyAqekhf2Ix8zqV2M54sH+m7XNJNLhOFOAW/t9nxzRbw2CcvXbCLjcvHZg==} - '@cucumber/cucumber@12.8.0': - resolution: {integrity: sha512-sRG2QMAgCic4Uq1q+5LRzApEHiNGX5rhQY/GuOJZ9BIySrGPA9pevB0imJsZvdzt9scaWyIM3c7dIf4Dp1YQRA==} + '@cucumber/cucumber@12.8.1': + resolution: {integrity: sha512-hCXxiStjbZsRVZlV+CMywkqBtJ6RZTQeXSBZGPHm1YoIOI6YB8pCo0KlnJMmxfKfoeUKagtQMNPnpJBXwhkUjQ==} engines: {node: 20 || 22 || >=24} hasBin: true @@ -1655,8 +1674,8 @@ packages: peerDependencies: '@cucumber/messages': '>=18' - '@cucumber/junit-xml-formatter@0.13.2': - resolution: {integrity: sha512-worYkxjeOWJV+b7WkgJekWgFHlIhbuocnFK3hP+pMYXqZMmkXsxAorYPjeF8KyLnZXajw5fKHS2bM9rQIUI7Zw==} + '@cucumber/junit-xml-formatter@0.13.3': + resolution: {integrity: sha512-w9ujOxiuKDtU6fLzJz+wp4Sgp5Xu6ba7ls00LHJccVmQU0Ba7zs+AHnv3iIgPjKZAQe1w8x93dr8Gaubh7Vqkg==} peerDependencies: '@cucumber/messages': '*' @@ -1674,8 +1693,8 @@ packages: '@cucumber/cucumber': '>=7.0.0' '@cucumber/messages': '*' - '@cucumber/query@14.7.0': - resolution: {integrity: sha512-fiqZ4gMEgYjmbuWproF/YeCdD5y+gD2BqgBIGbpihOsx6UlNsyzoDSfO+Tny0q65DxfK+pHo2UkPyEl7dO7wmQ==} + '@cucumber/query@15.0.1': + resolution: {integrity: sha512-FMfT3orJblRsOxvU2doECBvQmauizYlj+5JsM8atAKKPbnQTj7v2/OrnuykvQpfZNBf19DYbRq1e832vllRP/g==} peerDependencies: '@cucumber/messages': '*' @@ -1698,9 +1717,18 @@ packages: peerDependencies: tailwindcss: '*' + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + '@emnapi/runtime@1.9.1': resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emoji-mart/data@1.2.1': resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} @@ -1936,8 +1964,8 @@ packages: resolution: {integrity: sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-array@0.23.4': - resolution: {integrity: sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==} + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/config-helpers@0.2.3': @@ -1948,6 +1976,10 @@ packages: resolution: {integrity: sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/core@0.14.0': resolution: {integrity: sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1964,6 +1996,10 @@ packages: resolution: {integrity: sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/css-tree@4.0.1': resolution: {integrity: sha512-2fCSKRwoUHntYq9J1Lm28s2zeoCSNh1Cbk6Tg7k7ViwOnveIfZwPRFGwBglz+dzw2MHe5w5Fo9+VJfqL9nco2w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1997,8 +2033,8 @@ packages: resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@3.0.4': - resolution: {integrity: sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==} + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/plugin-kit@0.3.5': @@ -2013,8 +2049,8 @@ packages: resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.7.0': - resolution: {integrity: sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==} + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@floating-ui/core@1.7.5': @@ -2399,20 +2435,26 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@neoconfetti/react@1.0.0': resolution: {integrity: sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==} '@next/env@16.0.0': resolution: {integrity: sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==} - '@next/env@16.2.3': - resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==} + '@next/env@16.2.4': + resolution: {integrity: sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==} - '@next/eslint-plugin-next@16.2.3': - resolution: {integrity: sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA==} + '@next/eslint-plugin-next@16.2.4': + resolution: {integrity: sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==} - '@next/mdx@16.2.3': - resolution: {integrity: sha512-mm7XNfPagSIcN8jFtozB9toeh5ESES0KCLRoo0gu6xydijvnIrV7dRIK3akNL3Tecc8AHX1FNzYZOZTeFU6RCw==} + '@next/mdx@16.2.4': + resolution: {integrity: sha512-e/3bgla+/oF3vDlndI0eFPa0bnP47HPVA0InsAJi7Jr3DwV8WpEGuOcm/3PdI5/93FfNiBhMVeVHZpm1sFlmJw==} peerDependencies: '@mdx-js/loader': '>=0.15.0' '@mdx-js/react': '>=0.15.0' @@ -2422,54 +2464,54 @@ packages: '@mdx-js/react': optional: true - '@next/swc-darwin-arm64@16.2.3': - resolution: {integrity: sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==} + '@next/swc-darwin-arm64@16.2.4': + resolution: {integrity: sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.3': - resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==} + '@next/swc-darwin-x64@16.2.4': + resolution: {integrity: sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.3': - resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} + '@next/swc-linux-arm64-gnu@16.2.4': + resolution: {integrity: sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.3': - resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} + '@next/swc-linux-arm64-musl@16.2.4': + resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.3': - resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} + '@next/swc-linux-x64-gnu@16.2.4': + resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.3': - resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} + '@next/swc-linux-x64-musl@16.2.4': + resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.3': - resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} + '@next/swc-win32-arm64-msvc@16.2.4': + resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.3': - resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==} + '@next/swc-win32-x64-msvc@16.2.4': + resolution: {integrity: sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2534,142 +2576,139 @@ packages: resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@oxc-parser/binding-android-arm-eabi@0.121.0': - resolution: {integrity: sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==} + '@oxc-parser/binding-android-arm-eabi@0.126.0': + resolution: {integrity: sha512-svyoHt25J4741QJ5aa4R+h0iiBeSRt63Lr3aAZcxy2c/NeSE1IfDeMnSij6rIg7EjxkdlXzz613wUjeCeilBNA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.121.0': - resolution: {integrity: sha512-/Dd1xIXboYAicw+twT2utxPD7bL8qh7d3ej0qvaYIMj3/EgIrGR+tSnjCUkiCT6g6uTC0neSS4JY8LxhdSU/sA==} + '@oxc-parser/binding-android-arm64@0.126.0': + resolution: {integrity: sha512-hPEBRKgplp1mG9GkINFsr4JVMDNrGJLOqfDaadTWpAoTnzYR5Rmv8RMvB3hJZpiNvbk1aacopdHUP1pggMQ/cw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.121.0': - resolution: {integrity: sha512-A0jNEvv7QMtCO1yk205t3DWU9sWUjQ2KNF0hSVO5W9R9r/R1BIvzG01UQAfmtC0dQm7sCrs5puixurKSfr2bRQ==} + '@oxc-parser/binding-darwin-arm64@0.126.0': + resolution: {integrity: sha512-ccRpu9sdYmznePJQG5halhs0FW5tw5a8zRSoZXOzM1OjoeZ4jiRRruFiPclsD59edoVAK1l83dvfjWz1nQi6lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.121.0': - resolution: {integrity: sha512-SsHzipdxTKUs3I9EOAPmnIimEeJOemqRlRDOp9LIj+96wtxZejF51gNibmoGq8KoqbT1ssAI5po/E3J+vEtXGA==} + '@oxc-parser/binding-darwin-x64@0.126.0': + resolution: {integrity: sha512-CHB4zVjNSKqx8Fw9pHowzQQnjjuq04i4Ng0Avj+DixlwhwAoMYqlFbocYIlbg+q3zOLGlm7vEHm83jqEMitnyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.121.0': - resolution: {integrity: sha512-v1APOTkCp+RWOIDAHRoaeW/UoaHF15a60E8eUL6kUQXh+i4K7PBwq2Wi7jm8p0ymID5/m/oC1w3W31Z/+r7HQw==} + '@oxc-parser/binding-freebsd-x64@0.126.0': + resolution: {integrity: sha512-RQ3nEJdcDKBfBjmLJ3Vl1d0KQERPV1P8eUrnBm7+VTYyoaJSPLVFuPg1mlD1hk3n0/879VLFMfusFkBal4ssWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': - resolution: {integrity: sha512-PmqPQuqHZyFVWA4ycr0eu4VnTMmq9laOHZd+8R359w6kzuNZPvmmunmNJ8ybkm769A0nCoVp3TJ6dUz7B3FYIQ==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.126.0': + resolution: {integrity: sha512-onipc2wCDA7Bauzb4KK1mab0GsEDf4ujiIfWECdnmY/2LlzAoX3xdQRLAUyEDB1kn3yilHBrkmXDdHluyHXxiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': - resolution: {integrity: sha512-vF24htj+MOH+Q7y9A8NuC6pUZu8t/C2Fr/kDOi2OcNf28oogr2xadBPXAbml802E8wRAVfbta6YLDQTearz+jw==} + '@oxc-parser/binding-linux-arm-musleabihf@0.126.0': + resolution: {integrity: sha512-5BuJJPohrV5NJ8lmcYOMbfRCUGoYH5J9HZHeuqOLwkHXWAuPMN3X1h8bC/2mWjmosdbfTtmyIdX3spS/TkqKNg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.121.0': - resolution: {integrity: sha512-wjH8cIG2Lu/3d64iZpbYr73hREMgKAfu7fqpXjgM2S16y2zhTfDIp8EQjxO8vlDtKP5Rc7waZW72lh8nZtWrpA==} + '@oxc-parser/binding-linux-arm64-gnu@0.126.0': + resolution: {integrity: sha512-r2KApRgm2pOJaduRm6GOT8x0whcr67AyejNkSdzPt34GJ+Y3axcXN2mwlTs+8lfO/SSmpO5ZJGYiHYnxEE0jkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.121.0': - resolution: {integrity: sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==} + '@oxc-parser/binding-linux-arm64-musl@0.126.0': + resolution: {integrity: sha512-FQ+MMh7MT0Dr/u8+RWmWKlfoeWPQyHDbhhxJShJlYtROXXPHsRs9EvmQOZZ3sx4Nn7JU8NX+oyw2YzQ7anBJcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': - resolution: {integrity: sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==} + '@oxc-parser/binding-linux-ppc64-gnu@0.126.0': + resolution: {integrity: sha512-Wv/T8C98hRQhGTlx2XFyLn5raRMp9U1lOQD+YnXNgAr7wHbJJpZ8mDBU7Rw+M3WytGcGTFcr6kqgfyQeHVtLbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': - resolution: {integrity: sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==} + '@oxc-parser/binding-linux-riscv64-gnu@0.126.0': + resolution: {integrity: sha512-DHx1rT1zauW0ZbLHOiQh5AC9Xs3UkWx2XmfZHs+7nnWYr3sagrufoUQC+/XPwwjMIlCFXiFGM0sFh3TyOCZwqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.121.0': - resolution: {integrity: sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==} + '@oxc-parser/binding-linux-riscv64-musl@0.126.0': + resolution: {integrity: sha512-umDc2mTShH0U2zcEYf8mIJ163seLJNn54ZUZYeI5jD4qlg9izPwoLrC2aNPKlMJTu6u/ysmQWiEvIiaAG+INkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.121.0': - resolution: {integrity: sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==} + '@oxc-parser/binding-linux-s390x-gnu@0.126.0': + resolution: {integrity: sha512-PXXeWayclRtO1pxQEeCpiqIglQdhK2mAI2VX5xnsWdImzSB5GpoQ8TNw7vTCKk2k+GZuxl+q1knncidjCyUP9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.121.0': - resolution: {integrity: sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==} + '@oxc-parser/binding-linux-x64-gnu@0.126.0': + resolution: {integrity: sha512-wzocjxm34TbB3bFlqG65JiLtvf6ZDg2ZxRkLLbgXwDQUNU+0MPjQN8zy/0jBKNA5fnPLk3XeVdZ7Uin+7+CVkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.121.0': - resolution: {integrity: sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==} + '@oxc-parser/binding-linux-x64-musl@0.126.0': + resolution: {integrity: sha512-e83uftP60jmkPs2+CW6T6A1GYzN2H6IumDAiTntv9WyHR73PI3ImHNBkYqnA3ukeKI3xjcCbhSh9QeJWmufxGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.121.0': - resolution: {integrity: sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==} + '@oxc-parser/binding-openharmony-arm64@0.126.0': + resolution: {integrity: sha512-4WiOILHnPrTDY2/L4mE6PZCYwLN1d3ghma6BuTJ452CCgzRMt3uFplCtR+o3r9zdUWJYb370UizpI9CUcWXr1A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.121.0': - resolution: {integrity: sha512-5TFISkPTymKvsmIlKasPVTPuWxzCcrT8pM+p77+mtQbIZDd1UC8zww4CJcRI46kolmgrEX6QpKO8AvWMVZ+ifw==} + '@oxc-parser/binding-wasm32-wasi@0.126.0': + resolution: {integrity: sha512-Y17hhnrQTrxgAxAyAq401vnN9URsAL4s5AjqpG1NDsXSlhe1yBNnns+rC2P6xcMoitgX5nKH2ryYt9oiFRlzLw==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-parser/binding-win32-arm64-msvc@0.121.0': - resolution: {integrity: sha512-V0pxh4mql4XTt3aiEtRNUeBAUFOw5jzZNxPABLaOKAWrVzSr9+XUaB095lY7jqMf5t8vkfh8NManGB28zanYKw==} + '@oxc-parser/binding-win32-arm64-msvc@0.126.0': + resolution: {integrity: sha512-Znug1u1iRvT4VC3jANz6nhGBHsFwEFMxuimYpJFwMtsB6H5FcEoZRMmH26tHkSTD03JvDmG+gB65W3ajLjPcSw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.121.0': - resolution: {integrity: sha512-4Ob1qvYMPnlF2N9rdmKdkQFdrq16QVcQwBsO8yiPZXof0fHKFF+LmQV501XFbi7lHyrKm8rlJRfQ/M8bZZPVLw==} + '@oxc-parser/binding-win32-ia32-msvc@0.126.0': + resolution: {integrity: sha512-qrw7mx5hFFTxVSXToOA40hpnjgNB/DJprZchtB4rDKNLKqkD3F26HbzaQeH1nxAKej0efSZfJd5Sw3qdtOLGhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.121.0': - resolution: {integrity: sha512-BOp1KCzdboB1tPqoCPXgntgFs0jjeSyOXHzgxVFR7B/qfr3F8r4YDacHkTOUNXtDgM8YwKnkf3rE5gwALYX7NA==} + '@oxc-parser/binding-win32-x64-msvc@0.126.0': + resolution: {integrity: sha512-ibB1s+mPUFXvS7MFJO2jpw/aCNs/P6ifnWlRyTYB+WYBpniOiCcHQQskZneJtwcjQMDRol3RGG3ihoYnzXSY4w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxc-project/runtime@0.124.0': - resolution: {integrity: sha512-sSg6n37J3w3mM4odFvRqzQENf6+qxKnvStr/gU0FgRRg1VE/4MqryLd9PJmE0a7K5xlDfbrctBtSagaFH6ij9Q==} + '@oxc-project/runtime@0.126.0': + resolution: {integrity: sha512-oksjxfqDNmIYMGlIgLzYgnz5YjZax27RtQezsPpKEGo9AC5LOaIGHsivCCeaAWdCtPnRyjZXM/7svreCC8kZVQ==} engines: {node: ^20.19.0 || >=22.12.0} - '@oxc-project/types@0.121.0': - resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==} - - '@oxc-project/types@0.124.0': - resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + '@oxc-project/types@0.126.0': + resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==} '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} @@ -2901,33 +2940,33 @@ packages: cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.20.0': - resolution: {integrity: sha512-KKQcIHZHMxqpHUA1VXIbOG6chNCFkUWbQy6M+AFVtPKkA/3xAeJkJ3njoV66bfzwPHRcWQO+kcj5XqtbkjakoA==} + '@oxlint-tsgolint/darwin-arm64@0.21.1': + resolution: {integrity: sha512-7TLjyWe4wG9saJc992VWmaHq2hwKfOEEVTjheReXJXaDhavMZI4X9a6nKhbEng4IVkYtzjD2jw16vw2WFXLYLw==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.20.0': - resolution: {integrity: sha512-7HeVMuclGfG+NLZi2ybY0T4fMI7/XxO/208rJk+zEIloKkVnlh11Wd241JMGwgNFXn+MLJbOqOfojDb2Dt4L1g==} + '@oxlint-tsgolint/darwin-x64@0.21.1': + resolution: {integrity: sha512-7wf9Wf75nTzA7zpL9myhFe2RKvfuqGUOADNvUooCjEWvh7hmPz3lSEqTMh5Z/VQhzsG04mM9ACyghxhRzq7zFw==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.20.0': - resolution: {integrity: sha512-zxhUwz+WSxE6oWlZLK2z2ps9yC6ebmgoYmjAl0Oa48+GqkZ56NVgo+wb8DURNv6xrggzHStQxqQxe3mK51HZag==} + '@oxlint-tsgolint/linux-arm64@0.21.1': + resolution: {integrity: sha512-IPuQN/Vd0Rjklg/cCGBbQyUuRBp2f6LQXpZYwk5ivOR6V/+CgiYsv8pn/PVY7gjeyoNvPQrXB7xMjHUO2YZbdw==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.20.0': - resolution: {integrity: sha512-/1l6FnahC9im8PK+Ekkx/V3yetO/PzZnJegE2FXcv/iXEhbeVxP/ouiTYcUQu9shT1FWJCSNti1VJHH+21Y1dg==} + '@oxlint-tsgolint/linux-x64@0.21.1': + resolution: {integrity: sha512-d1niGuTbh2qiv7dR7tqkbOcM5cIR63of0lMBFdEQavL1KrJV8zuRdwdi68K7MNGdgoR+J5A9ajpGGvsHwp1bPg==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.20.0': - resolution: {integrity: sha512-oPZ5Yz8sVdo7P/5q+i3IKeix31eFZ55JAPa1+RGPoe9PoaYVsdMvR6Jvib6YtrqoJnFPlg3fjEjlEPL8VBKYJA==} + '@oxlint-tsgolint/win32-arm64@0.21.1': + resolution: {integrity: sha512-ICu9y2JLnFPvFqstnWPPNqBM8LK8BWw2OTeaR0UgEMm4hOSbrZAKv1/hwZYyiLqnCNjBL87AGSQIgTHCYlsipw==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.20.0': - resolution: {integrity: sha512-4stx8RHj3SP9vQyRF/yZbz5igtPvYMEUR8CUoha4BVNZihi39DpCR8qkU7lpjB5Ga1DRMo2pHaA4bdTOMaY4mw==} + '@oxlint-tsgolint/win32-x64@0.21.1': + resolution: {integrity: sha512-cTEFCFjCj6iXfrSHcvajSPNqhEA4TxSzU3gFxbdGSAUTNXGToU99IbdhWAPSbhcucoym0XE4Zl7E41NiSkNTug==} cpu: [x64] os: [win32] @@ -3371,32 +3410,32 @@ packages: rollup: optional: true - '@sentry-internal/browser-utils@10.48.0': - resolution: {integrity: sha512-SCiTLBXzugFKxev6NoKYBIhQoDk0gUh0AVVVepCBqfCJiWBG01Zvv0R5tCVohr4cWRllkQ8mlBdNQd/I7s9tdA==} + '@sentry-internal/browser-utils@10.49.0': + resolution: {integrity: sha512-n0QRx0Ysx6mPfIydTkz7VP0FmwM+/EqMZiRqdsU3aTYsngE9GmEDV0OL1bAy6a8N/C1xf9vntkuAtj6N/8Z51w==} engines: {node: '>=18'} - '@sentry-internal/feedback@10.48.0': - resolution: {integrity: sha512-tGkEyOM1HDS9qebDphUMEnyk3qq/50AnuTBiFmMJyjNzowylVGmRRk0sr3xkmbVHCDXQCiYnDmSVlJ2x4SDMrQ==} + '@sentry-internal/feedback@10.49.0': + resolution: {integrity: sha512-JNsUBGv0faCFE7MeZUH99Y9lU9qq3LBALbLxpE1x7ngNrQnVYRlcFgdqaD/btNBKr8awjYL8gmcSkHBWskGqLQ==} engines: {node: '>=18'} - '@sentry-internal/replay-canvas@10.48.0': - resolution: {integrity: sha512-9nWuN2z4O+iwbTfuYV5ZmngBgJU/ZxfOo47A5RJP3Nu/kl59aJ1lUhILYOKyeNOIC/JyeERmpIcTxnlPXQzZ3Q==} + '@sentry-internal/replay-canvas@10.49.0': + resolution: {integrity: sha512-7D/NrgH1Qwx5trDYaaTSSJmCb1yVQQLqFG4G/S9x2ltzl9876lSGJL8UeW8ReNQgF3CDAcwbmm/9aXaVSBUNZA==} engines: {node: '>=18'} - '@sentry-internal/replay@10.48.0': - resolution: {integrity: sha512-sevRTePfuk4PNuz9KAKpmTZEomAU0aLXyIhOwA0OnUDdxPhkY8kq5lwDbuxTHv6DQUjUX3YgFbY45VH1JEqHKA==} + '@sentry-internal/replay@10.49.0': + resolution: {integrity: sha512-IEy4lwHVMiRE3JAcn+kFKjsTgalDOCSTf20SoFd+nkt6rN/k1RDyr4xpdfF//Kj3UdeTmbuibYjK5H/FLhhnGg==} engines: {node: '>=18'} - '@sentry/browser@10.48.0': - resolution: {integrity: sha512-4jt2zX2ExgFcNe2x+W+/k81fmDUsOrquGtt028CiGuDuma6kEsWBI4JbooT1jhj2T+eeUxe3YGbM23Zhh7Ghhw==} + '@sentry/browser@10.49.0': + resolution: {integrity: sha512-bGCHc+wK2Dx67YoSbmtlt04alqWfQ+dasD/GVipVOq50gvw/BBIDHTEWRJEjACl+LrvszeY54V+24p8z4IgysA==} engines: {node: '>=18'} - '@sentry/core@10.48.0': - resolution: {integrity: sha512-h8F+fXVwYC9ro5ZaO8V+v3vqc0awlXHGblEAuVxSGgh4IV/oFX+QVzXeDTTrFOFS6v/Vn5vAyu240eJrJAS6/g==} + '@sentry/core@10.49.0': + resolution: {integrity: sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==} engines: {node: '>=18'} - '@sentry/react@10.48.0': - resolution: {integrity: sha512-uc93vKjmu6gNns+JAX4qquuxWpAMit0uGPA1TYlMjct9NG1uX3TkDPJAr9Pgd1lOXx8mKqCmj5fK33QeExMpPw==} + '@sentry/react@10.49.0': + resolution: {integrity: sha512-WdfJve0orTiumr25Ozgs2p2KaJR9xV82Z5V9IYBi0TadsurSWK6xI6SAFjw84tQht9Fp8q4UCn3QYCnApF4BfA==} engines: {node: '>=18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x @@ -3630,69 +3669,69 @@ packages: zod: optional: true - '@tailwindcss/node@4.2.2': - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} - '@tailwindcss/oxide-android-arm64@4.2.2': - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.2': - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + '@tailwindcss/oxide-darwin-arm64@4.2.4': + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.2': - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + '@tailwindcss/oxide-darwin-x64@4.2.4': + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.2': - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + '@tailwindcss/oxide-freebsd-x64@4.2.4': + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -3703,32 +3742,32 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.2': - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} engines: {node: '>= 20'} - '@tailwindcss/postcss@4.2.2': - resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + '@tailwindcss/postcss@4.2.4': + resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==} '@tailwindcss/typography@0.5.19': resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.2.2': - resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + '@tailwindcss/vite@4.2.4': + resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 @@ -3776,8 +3815,8 @@ packages: engines: {node: '>=18'} hasBin: true - '@tanstack/eslint-plugin-query@5.99.0': - resolution: {integrity: sha512-jVp1AEL7S7BeuQvH5SN1F5UdrNW/AbryKDeWUUMeAKNzh9C+Ik/bRSa/HeuJLlmaN+WOUkdDFbtCK0go7BxnUQ==} + '@tanstack/eslint-plugin-query@5.99.2': + resolution: {integrity: sha512-xiazL4CWOHJRDDgs5ZkfW98qlEAisakFDKh1Djc3BIk84tsvt3ow52AC2EiWSMY1q13IB4UI4jSo7yXlC3NL6g==} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ^5.4.0 || ^6.0.0 @@ -3785,11 +3824,11 @@ packages: typescript: optional: true - '@tanstack/form-core@1.29.0': - resolution: {integrity: sha512-uyeKEdJBfbj0bkBSwvSYVRtWLOaXvfNX3CeVw1HqGOXVLxpBBGAqWdYLc+UoX/9xcoFwFXrjR9QqMPzvwm2yyQ==} + '@tanstack/form-core@1.29.1': + resolution: {integrity: sha512-NIYPO36eEu7nSWvMpbFDQaBWyVtnH/C8fsZ3/XpJUT4uOWgmxsiUvHGbTbDNIQTXAKIkhwEl0sUrqBNn2SfUnw==} - '@tanstack/form-devtools@0.2.21': - resolution: {integrity: sha512-8mxR1/QDw37mNVSFsr4ZN8+bdamH9LU1/iQ3I7/sfTzFmMsNzUOysX3OZf053eaS4Gaw44PT0pH7U0FWD98QKw==} + '@tanstack/form-devtools@0.2.22': + resolution: {integrity: sha512-hMrKwu+73O2LeHj78vi48oaAH4jZi/U92hrHmkvxDy3E72c+PbxDJBbM9rXUK4h0GPbOzfaZ235SruJ0lfuOYA==} peerDependencies: solid-js: 1.9.11 @@ -3797,11 +3836,11 @@ packages: resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} - '@tanstack/query-core@5.99.0': - resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==} + '@tanstack/query-core@5.99.2': + resolution: {integrity: sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==} - '@tanstack/query-devtools@5.99.0': - resolution: {integrity: sha512-m4ufXaJ8FjWXw7xDtyzE/6fkZAyQFg9WrbMrUpt8ZecRJx58jiFOZ2lxZMphZdIpAnIeto/S8stbwLKLusyckQ==} + '@tanstack/query-devtools@5.99.2': + resolution: {integrity: sha512-TEF1d+RYO9l8oeCwgzmOHIgKwAzXQmw2s/ny2bW8qeg2OMkkLjALfVEivgCMR3OL/jVdMmeTPX56WrV+uvYJFg==} '@tanstack/react-devtools@0.10.2': resolution: {integrity: sha512-1BmZyxOrI5SqmRJ5MgkYZNNdnlLsJxQRI2YgorrAvcF2MxK6x5RcuStvD8+YlXoMw3JtNukPxoITirKAnKYDQA==} @@ -3812,13 +3851,13 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-form-devtools@0.2.21': - resolution: {integrity: sha512-WBQ7NOcb3FM9UA4juZVyWUyJkyl62vHFbEBybZuvBFw3wq/v9pDGS01Ye8kepGXDg1+LQsOOxyDR65AKsdqSYQ==} + '@tanstack/react-form-devtools@0.2.22': + resolution: {integrity: sha512-CXa+U6QrF8QOGL+sCIIcwzHb1K+hfNjBA5PwSmxm32Oxpu8fK/60M3SbE9UM9439MR/GQiIoeBW2FFyKh73apw==} peerDependencies: react: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-form@1.29.0': - resolution: {integrity: sha512-jj425NNX0QKqbUzqSNiYI3HCPHSk2df47acXCJyXczWOTmG81ECZGkgofgqamFsSU9kMiH6Di5RLUnftrlhWSw==} + '@tanstack/react-form@1.29.1': + resolution: {integrity: sha512-hVHk4g0phd0HxRsv2ry6Xt8BqmalT55Q3cokhJBCC1St0hcGZhgwJJbohm9atao45BPG9e55DGvtbwExqZe35g==} peerDependencies: '@tanstack/react-start': '*' react: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3826,14 +3865,14 @@ packages: '@tanstack/react-start': optional: true - '@tanstack/react-query-devtools@5.99.0': - resolution: {integrity: sha512-CqqX7LCU9yOfCY/vBURSx2YSD83ryfX+QkfkaKionTfg1s2Hdm572Ro99gW3QPoJjzvsj1HM4pnN4nbDy3MXKA==} + '@tanstack/react-query-devtools@5.99.2': + resolution: {integrity: sha512-8txkK9A9XBNTB8RoxVgfp6W3qwBr25tNP10L4yu3KuyhAdEvccECfIRzesSwMVk/wpVVioAr+hbMtUkMMF+WVw==} peerDependencies: - '@tanstack/react-query': ^5.99.0 + '@tanstack/react-query': ^5.99.2 react: ^18 || ^19 - '@tanstack/react-query@5.99.0': - resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==} + '@tanstack/react-query@5.99.2': + resolution: {integrity: sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==} peerDependencies: react: ^18 || ^19 @@ -3843,8 +3882,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-virtual@3.13.23': - resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3852,8 +3891,8 @@ packages: '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} - '@tanstack/virtual-core@3.13.23': - resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + '@tanstack/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} '@teppeis/multimaps@3.0.0': resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==} @@ -3888,22 +3927,22 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tsslint/cli@3.0.3': - resolution: {integrity: sha512-Pt1AuEZoh+dK4QYt95oCjBdBp2h2iYY9pSerf9BTLgfsjeyEsNk7Juhn51sFlAuEnWDNvI8mLULzsIkayd0nUQ==} + '@tsslint/cli@3.0.4': + resolution: {integrity: sha512-jvSYZEJKhDp02CyvLe7thGYp/uMW860kC8hDIMnZAGp3JMDkM2dU1kl550li4qiYXFkS8v5AU1nR2RyIn3khvw==} engines: {node: '>=22.6.0'} hasBin: true peerDependencies: typescript: '*' - '@tsslint/compat-eslint@3.0.3': - resolution: {integrity: sha512-UGWrE4fu8fUCLkc+zMQNsEfuEkGHjndpa5oSQmzhmo9BQJYAqqH1s2kGIiDsAYwaQTUts4SjclXaITq3pZhkrA==} + '@tsslint/compat-eslint@3.0.4': + resolution: {integrity: sha512-zWurlYWaSfK62uf5n7GMa0C7pcYOXbYjMeBfd3w0RmCZzk5gBhNSJdSNXNmbDXUuM/3RH03PpqHuUIktCGB52g==} - '@tsslint/config@3.0.3': - resolution: {integrity: sha512-3yFyM4Sj+0LxwmcokwNPuS9pWUBMIhO8vwHiG4vGuquTvF4cgZqDPyQ3GN4hDb5qAZ56iqYtMoBEiSZXlJDYPQ==} + '@tsslint/config@3.0.4': + resolution: {integrity: sha512-2VfGdG35wrcosUxxsoUD46LOI1lEJWhQFpDROhos2JOwwVPIQqp66hl9MOYjkBpt8zYVWvdcDWIOIT9QIpDL3A==} engines: {node: '>=22.6.0'} hasBin: true peerDependencies: - '@tsslint/compat-eslint': 3.0.0-alpha.0 + '@tsslint/compat-eslint': ^3.0.0 tsl: ^1.0.28 peerDependenciesMeta: '@tsslint/compat-eslint': @@ -3911,12 +3950,12 @@ packages: tsl: optional: true - '@tsslint/core@3.0.3': - resolution: {integrity: sha512-EpCKw34f2XyypH5xlxKCwnTgPGpZxbPXfvpwddT3DCxsIzUDJY4SpVJULAZFPAjJd49vopG0kNhXn0C/b+kHcg==} + '@tsslint/core@3.0.4': + resolution: {integrity: sha512-hzvO/8zZfds9k7ZREyE5h2pnKkukZsAD81F7rq/k9AOv//Wmi2OxXyxmhmv98/ZoieOK5nSrrzh8+mh7GtkrEw==} engines: {node: '>=22.6.0'} - '@tsslint/types@3.0.3': - resolution: {integrity: sha512-3Jlb5UTPrzqu1D1qOrzjwy0QW2n41A1+ILKvzgViFrtiTwurM5Tav6V7Y4AFxO0xatCA0VHAzzifK0r5znaKbw==} + '@tsslint/types@3.0.4': + resolution: {integrity: sha512-z/LXFUSGCxrh/WfkVmlyRwCVjAr2H1/v6EDvVTuXX/3ZEO+Ss9UqgEGgnTnQn3TLSLJa2pEaIY3Hsz0Y9TsuyA==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -4138,6 +4177,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/eslint-plugin@8.59.0': + resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.58.2': resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4145,22 +4192,45 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.59.0': + resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.58.2': resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.59.0': + resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.58.2': resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.59.0': + resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.58.2': resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.59.0': + resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.58.2': resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4168,16 +4238,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.59.0': + resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.58.2': resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.59.0': + resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.58.2': resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.59.0': + resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.58.2': resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4185,47 +4272,58 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.59.0': + resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.58.2': resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260413.1': - resolution: {integrity: sha512-CDgxIPvAWRCfOiQKvSk4wUkAoRW4Cy6vfAUBPNHSeLalIt43ToF0LOAsa5uLyRGsftjfMYY0A4qFOmgDvBhgzQ==} + '@typescript-eslint/visitor-keys@8.59.0': + resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260422.1': + resolution: {integrity: sha512-W/lGgoEfbdI/QWYqcNP0fSa4DHQKKEMLzDPsE6fA64zmfCNsTO9M7ttK0acKiLsGB16pr0lubuMDRNN5kXyQ8w==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260413.1': - resolution: {integrity: sha512-oiMmUtNMaqBh+eUogX53ichcEf7d+7upC0qa7xS9zWl85XEPKlrZCZpZ79yixw1PkdpjqJJigI11bmCi/JVv+g==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260422.1': + resolution: {integrity: sha512-6tZ2yAcKLBIghwKyC74vDqb/7rB99fTpERv9f64iA1tMh6l+WHIuQb6z3mIFVOYBIl2pN9CYasURLroKYtUz1w==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260413.1': - resolution: {integrity: sha512-hPKanfs9c+7953gIYw13CNxN0HqFAOfJjnWk4SHqSBe3Pj9pxoeJvvRWlofp5C833eOZK6gZB7ll0/uNb0djtA==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260422.1': + resolution: {integrity: sha512-7HL4E7kP0ociYB8R4+QuIbzfT3pjdesNY+ax/q6fP3IMd3/QNAL/qsm/NaokjXke+I7uYxKqQ8Qo/t5MSv/r+A==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260413.1': - resolution: {integrity: sha512-0lSXBzBVsxIGrFv/PxoswzMptsnU6BgSk7GMAUt/o1dVw36R2XrSs538vwKnujaJwt4iIdMS0uGdpUC5s9jkzQ==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260422.1': + resolution: {integrity: sha512-EWP1Jq2I8MMSkoF9D6ztXgRmnUy2KcaZfL9FYcdm3Am6ZYuI6/SCR3HVIVYbaixAJXe/qUh5MN3LzJbl/4hefQ==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260413.1': - resolution: {integrity: sha512-8Cr477HRmHZ5YyLfikNvw7qp3/WmnRjzIzJhUDrAx5173OBe8BdyV9jPemFHKDPqwI1AUMTijvptOFoQE7429w==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260422.1': + resolution: {integrity: sha512-fDqkLf2Hv7X1Cy1B5OMcljPt/+8GpnTxFM9rDCFrYAPgOolIQJ9qwkb+xGfvAtxkkE5sZIvGPcqjP9PWQHt2qw==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260413.1': - resolution: {integrity: sha512-ulJD9ZbIQyTBIDx8zzAzQLtbvQDGHSWrNRgkgBU5Os2NTYADQRco4pU747R9wZPMLopy3IeNck6m8vwPoYMk1g==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260422.1': + resolution: {integrity: sha512-l1tDnyNQSqxFkKz683dD8EORQtcQqZyWkTDnRtHmaPg2mTRxhxSekL/HcsHx/1/DoGTfl310O+CmXzd2mTq3pQ==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260413.1': - resolution: {integrity: sha512-x7DsSXnLQBf5XBBR8luHf1Nc/T1eByUmrOSEThW6825UB7lHoPlqKdhIoUNnTnS4nXQMxLwcusD4P1EP23GPJw==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260422.1': + resolution: {integrity: sha512-VQbDQlp1bjV5nnHagQLXQAhid3S48l1OToIBjvqlw18s0V0YSgoyNL6E/rE7FBdkGrTLf/rtKjo42IZnt3tvqA==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260413.1': - resolution: {integrity: sha512-twzr3V4QLEbXaESuI2DqdzutOVFGpkY3VZDR9sF8YlLsAXkwyQvZo58cKM77mZcsHoCR4lCYcdTatWTTa/+8tw==} + '@typescript/native-preview@7.0.0-dev.20260422.1': + resolution: {integrity: sha512-8CR8zHFlLpSL5OXY4Wbz2DmiDOoat1JBMkydZUHwQIS4cpoTN7SHjk2BN8i51XHUy0jMF5airL0TlY3GOfZmKg==} hasBin: true '@ungap/structured-clone@1.3.0': @@ -4293,10 +4391,10 @@ packages: react-server-dom-webpack: optional: true - '@vitest/coverage-v8@4.1.4': - resolution: {integrity: sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==} + '@vitest/coverage-v8@4.1.5': + resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} peerDependencies: - '@vitest/browser': 4.1.4 + '@vitest/browser': 4.1.5 peerDependenciesMeta: '@vitest/browser': optional: true @@ -4320,8 +4418,8 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.1.4': - resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} @@ -4329,16 +4427,16 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.1.4': - resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} - '@voidzero-dev/vite-plus-core@0.1.18': - resolution: {integrity: sha512-3PmXOL26yHzlw8ET9SwXCmglGzUYq2fOTYf2t0mxvVIs7ua3bnf6tOnmR+6YX5k1Ez26B0ooYzx+znc8k+CAMw==} + '@voidzero-dev/vite-plus-core@0.1.19': + resolution: {integrity: sha512-BTmz50juSDolIN4Vtu5iVaPONV1XSrMB5V+9IoBhhxdogfvp7PBhaHuAcPjTN2RTVowhLZXoo8mn+aHjq//bkw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@arethetypeswrong/core': ^0.18.1 - '@tsdown/css': 0.21.8 - '@tsdown/exe': 0.21.8 + '@tsdown/css': 0.21.9 + '@tsdown/exe': 0.21.9 '@types/node': ^20.19.0 || >=22.12.0 '@vitejs/devtools': ^0.1.0 esbuild: 0.27.2 @@ -4392,48 +4490,48 @@ packages: yaml: optional: true - '@voidzero-dev/vite-plus-darwin-arm64@0.1.18': - resolution: {integrity: sha512-bw2pWWE8RZRELWjXcdxdmRaOaYjmGmsxEm23TxvGxQXFb7k9l51W8tpjxariPGLxrEl+Cw5u601IL5LASaPJ5w==} + '@voidzero-dev/vite-plus-darwin-arm64@0.1.19': + resolution: {integrity: sha512-6MY/RiaRXKJ6wD/ftZnf+ohEqU68zHp3bVWetIw9dakcPL7TXoiIkDoechmZXCh+5eqxehvap4eh2eNEvWSM1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@voidzero-dev/vite-plus-darwin-x64@0.1.18': - resolution: {integrity: sha512-8TFj6yJNsumoH+yFc+6zf3g2UuzvrPHq2FAAVORffaVZ29PWnDSsXjegaIBmoAtGO5Xb4lcilQx7NoF9hONrZg==} + '@voidzero-dev/vite-plus-darwin-x64@0.1.19': + resolution: {integrity: sha512-jV6ygWCarMFW5DRqRyFkB2jpRDiAlLYzyQu0HZfYNoxfdNyO7isfuR5X6gV+ji7J3Kp0RZOiGrQUCjxTPqZg5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.18': - resolution: {integrity: sha512-xHRqncKanOZ0zNnZSufL4Yx/gWrIFkCjU6jFzCukBOOCrcemq3SrALPHrNf+Nw1RLwNptGUZn2Vx/IjRLzUQDw==} + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.19': + resolution: {integrity: sha512-jIWMgAok77aDuTK2kCQXn4Zp7pnUM56BvKhHCvnAmsF4yrs1KLQfH6YOdQMnVbNjQDneQgqdwHVDnkOfJRokYw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.18': - resolution: {integrity: sha512-CA6XxZbkT8lYwWzS2yAj6exr7nHl3R8Sz+ZdOhYCU4yR2qvzGatdVgFr7oPnrkHLF426cHJ172rmNNj8NKie/w==} + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.19': + resolution: {integrity: sha512-fUuXUqCl3zMbS5QpMJzewVjrpbtzlwuzYQSh5q59CMq65uCXT07amJzmuAFReDEMrwEAmjGgbamJ1ctLAYCxrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.18': - resolution: {integrity: sha512-xBO3MtLGVASPjH/GDRxexfLCT0othVpiFMdEQ83Y+woVNbrrzcdQTGFUuFG4cAiMhtmjytyFwPBtZ76BWsDO3w==} + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.19': + resolution: {integrity: sha512-xFVGMo1Yo5p9gABpOSSGgu5LhhMQs6qVXU7xL+NAGnaVViAYujNuOhCpBk2yK4Cy98KiNOjwnR5jG0TnRd22xg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.18': - resolution: {integrity: sha512-ADNis6SMarY7i8+b2ynUJ1PiqCHqnVwY7EQ+fSGug5zZ+W/cZq14+VWPxOvGR9LJk+iol8XuqsHy4BaV2+gjzw==} + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.19': + resolution: {integrity: sha512-iEDxL85v/C01yF2EJKknkjDhKbgY10NL9/sZ4HxezWykePK6QpYY5ClWGL7gIi+YFp8rtAdRPKlrf0mTlYMvxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-test@0.1.18': - resolution: {integrity: sha512-dovC2kJgiwMI8ay0i+3NvQGCDWPj8HQB2ONP/HbdJ5/XQVPq13+BihnCq8/ztz6uGhiDD8Nu4OZ3RgB14uvTfA==} + '@voidzero-dev/vite-plus-test@0.1.19': + resolution: {integrity: sha512-KK0lfqyiEOEykp3hrcHT49f1j3M3t15ZKCuO+e9KbDRambU7tdz70xoHCKkRXcFgnds9gqi09PSLVy1k8XN+Hg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@edge-runtime/vm': '*' @@ -4463,14 +4561,14 @@ packages: jsdom: optional: true - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.18': - resolution: {integrity: sha512-EcDETMHG8xgjIlMizIu/wf0UtRZLGz+lHFvYFZVCkz4vLLz93a06vZ+3Oi9xY2Kc8aOHsCf8Gj5/dox/03cscw==} + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.19': + resolution: {integrity: sha512-2GGeGr2mtXLjV9O8CXEEZkV6O8q8rMBhq8fj5fyaSuBe5FQ1OxGYYMDqNBxvbg+hSUw0ThKK6qmirj5fF2e/iw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.18': - resolution: {integrity: sha512-jBgL4ZjSJJu3FDcrqj4muzbr0WKlU6Ym1ilHQnq8R+2TRvE0AtvAMMuphICDslZGi6EK3fwJ+r2Lv7GU1AipQA==} + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.19': + resolution: {integrity: sha512-//xUNHQnd+p4Xd4rlObAvum3DW1ugbWZ+kfaqD7biHQ9HQwHF28WSpJ3+d31vLUHj4o3DXYSA67g1Bq2d4tVgg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -4698,8 +4796,8 @@ packages: caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} - canvas@3.2.2: - resolution: {integrity: sha512-duEt4h1HHu9sJZyVKfLRXR6tsKPY7cEELzxSRJkwddOXYvQT3P/+es98SV384JA0zMOZ5s+9gatnGfM6sL4Drg==} + canvas@3.2.3: + resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==} engines: {node: ^18.12.0 || >= 20.9.0} capital-case@1.0.4: @@ -5189,8 +5287,8 @@ packages: resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} engines: {node: '>=20'} - dompurify@3.4.0: - resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + dompurify@3.4.1: + resolution: {integrity: sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -5284,8 +5382,8 @@ packages: es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - es-toolkit@1.45.1: - resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + es-toolkit@1.46.0: + resolution: {integrity: sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==} esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -5495,8 +5593,8 @@ packages: peerDependencies: eslint: '>=9.38.0' - eslint-plugin-sonarjs@4.0.2: - resolution: {integrity: sha512-BTcT1zr1iTbmJtVlcesISwnXzh+9uhf9LEOr+RRNf4kR8xA0HQTPft4oiyOCzCOGKkpSJxjR8ZYF6H7VPyplyw==} + eslint-plugin-sonarjs@4.0.3: + resolution: {integrity: sha512-5drkJKLC9qQddIiaATV0e8+ygbUc7b0Ti6VB7M2d3jmKNh3X0RaiIJYTs3dr9xnlhlrxo+/s1FoO3Jgv6O/c7g==} peerDependencies: eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 @@ -5573,8 +5671,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.2.0: - resolution: {integrity: sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==} + eslint@10.2.1: + resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -5786,6 +5884,9 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -5927,8 +6028,8 @@ packages: i18next-resources-to-backend@1.2.1: resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==} - i18next@26.0.4: - resolution: {integrity: sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA==} + i18next@26.0.6: + resolution: {integrity: sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -6184,8 +6285,8 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - knip@6.4.1: - resolution: {integrity: sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw==} + knip@6.6.1: + resolution: {integrity: sha512-SOmqh25vuAfdynGoDr/kMCxIuD5+PkMIfMSGQeMqfrxwuPTANvJKcVttLgGZjjkATALqukSe/hhDVqcwNkf92g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -6195,8 +6296,8 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - ky@2.0.0: - resolution: {integrity: sha512-KzI4Vz5AbZFAUFYGx28PCSfFWUo6/qj9Br/P6KRwDieE1xfdz0tIONepJcLw/1xLocN13GgvfJGasa+pfSkbHg==} + ky@2.0.2: + resolution: {integrity: sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==} engines: {node: '>=22'} lamejs@1.2.1: @@ -6337,8 +6438,8 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loro-crdt@1.10.8: - resolution: {integrity: sha512-GvH8fSJST1VDHRGzlQml80pBYoFbIP4ULeV1S8fD4ffmA8m+icoPORyVUW2AkJBY3dxKIcMMn0WqaJmpCmnbkQ==} + loro-crdt@1.11.1: + resolution: {integrity: sha512-R+Ksyy2FPYoOfJAkVY6BqGk11AtlgWZ1B91V/G7TaQxitxuvUvMd1URhO33LYfFUIT2CSn0Nikl+bbRZ2RGuZg==} loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -6604,6 +6705,10 @@ packages: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -6679,8 +6784,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.3: - resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} + next@16.2.4: + resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -6781,8 +6886,8 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - oxc-parser@0.121.0: - resolution: {integrity: sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==} + oxc-parser@0.126.0: + resolution: {integrity: sha512-FktCvLby/mOHyuijZt22+nOt10dS24gGUZE3XwIbUg7Kf4+rer3/5T7RgwzazlNuVsCjPloZ3p8E+4ONT3A8Kw==} engines: {node: ^20.19.0 || >=22.12.0} oxc-resolver@11.19.1: @@ -6793,8 +6898,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-tsgolint@0.20.0: - resolution: {integrity: sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==} + oxlint-tsgolint@0.21.1: + resolution: {integrity: sha512-O2hxiT14C2HJkwzBU6CQBFPoagSd/IcV+Tt3e3UUaXFwbW4BO5DSDPSSboc3UM5MIDY+MLyepvtQwBQafNxWdw==} hasBin: true oxlint@1.60.0: @@ -6980,6 +7085,10 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.9: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} @@ -7634,8 +7743,8 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.2.2: - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + tailwindcss@4.2.4: + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} tapable@2.3.2: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} @@ -7812,8 +7921,8 @@ packages: resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} engines: {node: '>=20'} - typescript@6.0.2: - resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -8035,8 +8144,8 @@ packages: storybook: ^0.0.0-0 || ^9.0.0 || ^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - vite-plus@0.1.18: - resolution: {integrity: sha512-RiWUoOmQiJMtd4Dfm6WD0v0Selqh/nQzmaGVIrkfnr+2s5UxGVZy7n2TCO5ZnR7w9noMIgtUAQN8GtKhwHEiOQ==} + vite-plus@0.1.19: + resolution: {integrity: sha512-QWuTqkO/a8Q7I3hHnYdvwlJa7mcc6hgh99/8CHoRb27pgo+z1ux+NGYYCZPJHKVtatAtVRaQQvy4cEQBHyB87A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -8285,27 +8394,28 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@amplitude/analytics-browser@2.39.0': + '@amplitude/analytics-browser@2.41.0': dependencies: - '@amplitude/analytics-core': 2.45.0 - '@amplitude/plugin-autocapture-browser': 1.25.2 - '@amplitude/plugin-custom-enrichment-browser': 0.1.4 - '@amplitude/plugin-network-capture-browser': 1.9.13 - '@amplitude/plugin-page-url-enrichment-browser': 0.7.5 - '@amplitude/plugin-page-view-tracking-browser': 2.9.6 - '@amplitude/plugin-web-vitals-browser': 1.1.28 + '@amplitude/analytics-core': 2.47.0 + '@amplitude/plugin-autocapture-browser': 1.26.0 + '@amplitude/plugin-custom-enrichment-browser': 0.1.6 + '@amplitude/plugin-event-property-attribution-browser': 0.1.1 + '@amplitude/plugin-network-capture-browser': 1.9.15 + '@amplitude/plugin-page-url-enrichment-browser': 0.7.7 + '@amplitude/plugin-page-view-tracking-browser': 2.10.1 + '@amplitude/plugin-web-vitals-browser': 1.1.30 tslib: 2.8.1 - '@amplitude/analytics-client-common@2.4.43': + '@amplitude/analytics-client-common@2.4.45': dependencies: '@amplitude/analytics-connector': 1.6.4 - '@amplitude/analytics-core': 2.45.0 + '@amplitude/analytics-core': 2.47.0 '@amplitude/analytics-types': 2.11.1 tslib: 2.8.1 '@amplitude/analytics-connector@1.6.4': {} - '@amplitude/analytics-core@2.45.0': + '@amplitude/analytics-core@2.47.0': dependencies: '@amplitude/analytics-connector': 1.6.4 '@types/zen-observable': 0.8.3 @@ -8319,48 +8429,53 @@ snapshots: dependencies: js-base64: 3.7.8 - '@amplitude/plugin-autocapture-browser@1.25.2': + '@amplitude/plugin-autocapture-browser@1.26.0': dependencies: - '@amplitude/analytics-core': 2.45.0 + '@amplitude/analytics-core': 2.47.0 tslib: 2.8.1 - '@amplitude/plugin-custom-enrichment-browser@0.1.4': + '@amplitude/plugin-custom-enrichment-browser@0.1.6': dependencies: - '@amplitude/analytics-core': 2.45.0 + '@amplitude/analytics-core': 2.47.0 tslib: 2.8.1 - '@amplitude/plugin-network-capture-browser@1.9.13': + '@amplitude/plugin-event-property-attribution-browser@0.1.1': dependencies: - '@amplitude/analytics-core': 2.45.0 + '@amplitude/analytics-core': 2.47.0 tslib: 2.8.1 - '@amplitude/plugin-page-url-enrichment-browser@0.7.5': + '@amplitude/plugin-network-capture-browser@1.9.15': dependencies: - '@amplitude/analytics-core': 2.45.0 + '@amplitude/analytics-core': 2.47.0 tslib: 2.8.1 - '@amplitude/plugin-page-view-tracking-browser@2.9.6': + '@amplitude/plugin-page-url-enrichment-browser@0.7.7': dependencies: - '@amplitude/analytics-core': 2.45.0 + '@amplitude/analytics-core': 2.47.0 tslib: 2.8.1 - '@amplitude/plugin-session-replay-browser@1.27.7(@amplitude/rrweb@2.0.0-alpha.37)': + '@amplitude/plugin-page-view-tracking-browser@2.10.1': dependencies: - '@amplitude/analytics-client-common': 2.4.43 - '@amplitude/analytics-core': 2.45.0 + '@amplitude/analytics-core': 2.47.0 + tslib: 2.8.1 + + '@amplitude/plugin-session-replay-browser@1.27.10(@amplitude/rrweb@2.0.0-alpha.37)': + dependencies: + '@amplitude/analytics-client-common': 2.4.45 + '@amplitude/analytics-core': 2.47.0 '@amplitude/analytics-types': 2.11.1 '@amplitude/rrweb-plugin-console-record': 2.0.0-alpha.36(@amplitude/rrweb@2.0.0-alpha.37) '@amplitude/rrweb-record': 2.0.0-alpha.36 - '@amplitude/session-replay-browser': 1.36.0(@amplitude/rrweb@2.0.0-alpha.37) + '@amplitude/session-replay-browser': 1.37.0(@amplitude/rrweb@2.0.0-alpha.37) idb-keyval: 6.2.2 tslib: 2.8.1 transitivePeerDependencies: - '@amplitude/rrweb' - rollup - '@amplitude/plugin-web-vitals-browser@1.1.28': + '@amplitude/plugin-web-vitals-browser@1.1.30': dependencies: - '@amplitude/analytics-core': 2.45.0 + '@amplitude/analytics-core': 2.47.0 tslib: 2.8.1 web-vitals: 5.1.0 @@ -8384,7 +8499,7 @@ snapshots: '@amplitude/rrweb-snapshot@2.0.0-alpha.37': dependencies: - postcss: 8.5.9 + postcss: 8.5.10 '@amplitude/rrweb-types@2.0.0-alpha.36': {} @@ -8405,10 +8520,10 @@ snapshots: base64-arraybuffer: 1.0.2 mitt: 3.0.1 - '@amplitude/session-replay-browser@1.36.0(@amplitude/rrweb@2.0.0-alpha.37)': + '@amplitude/session-replay-browser@1.37.0(@amplitude/rrweb@2.0.0-alpha.37)': dependencies: - '@amplitude/analytics-client-common': 2.4.43 - '@amplitude/analytics-core': 2.45.0 + '@amplitude/analytics-client-common': 2.4.45 + '@amplitude/analytics-core': 2.47.0 '@amplitude/analytics-types': 2.11.1 '@amplitude/experiment-core': 0.7.2 '@amplitude/rrweb-packer': 2.0.0-alpha.36 @@ -8426,56 +8541,56 @@ snapshots: '@amplitude/targeting@0.2.0': dependencies: - '@amplitude/analytics-client-common': 2.4.43 - '@amplitude/analytics-core': 2.45.0 + '@amplitude/analytics-client-common': 2.4.45 + '@amplitude/analytics-core': 2.47.0 '@amplitude/analytics-types': 2.11.1 '@amplitude/experiment-core': 0.7.2 idb: 8.0.0 tslib: 2.8.1 - '@antfu/eslint-config@8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@next/eslint-plugin-next@16.2.3)(@types/node@25.6.0)(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)))(eslint@10.2.0(jiti@2.6.1))(happy-dom@20.9.0)(jiti@2.6.1)(oxlint@1.60.0(oxlint-tsgolint@0.20.0))(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': + '@antfu/eslint-config@8.2.0(@eslint-react/eslint-plugin@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.4)(@types/node@25.6.0)(@typescript-eslint/typescript-estree@8.59.0(typescript@6.0.3))(@typescript-eslint/utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.6.1)))(eslint@10.2.1(jiti@2.6.1))(happy-dom@20.9.0)(jiti@2.6.1)(oxlint@1.60.0(oxlint-tsgolint@0.21.1))(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.2.0 - '@e18e/eslint-plugin': 0.3.0(eslint@10.2.0(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.20.0)) - '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.2.0(jiti@2.6.1)) + '@e18e/eslint-plugin': 0.3.0(eslint@10.2.1(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.21.1)) + '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.2.1(jiti@2.6.1)) '@eslint/markdown': 8.0.1 - '@stylistic/eslint-plugin': 5.10.0(eslint@10.2.0(jiti@2.6.1)) - '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/parser': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@vitest/eslint-plugin': 1.6.15(@types/node@25.6.0)(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(eslint@10.2.0(jiti@2.6.1))(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + '@stylistic/eslint-plugin': 5.10.0(eslint@10.2.1(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/parser': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@vitest/eslint-plugin': 1.6.15(@types/node@25.6.0)(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(eslint@10.2.1(jiti@2.6.1))(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) ansis: 4.2.0 cac: 7.0.0 - eslint: 10.2.0(jiti@2.6.1) - eslint-config-flat-gitignore: 2.3.0(eslint@10.2.0(jiti@2.6.1)) + eslint: 10.2.1(jiti@2.6.1) + eslint-config-flat-gitignore: 2.3.0(eslint@10.2.1(jiti@2.6.1)) eslint-flat-config-utils: 3.1.0 - eslint-merge-processors: 2.0.0(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-antfu: 3.2.2(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-command: 3.5.2(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-import-lite: 0.6.0(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-jsdoc: 62.9.0(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-jsonc: 3.1.2(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-n: 17.24.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + eslint-merge-processors: 2.0.0(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-antfu: 3.2.2(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-command: 3.5.2(@typescript-eslint/typescript-estree@8.59.0(typescript@6.0.3))(@typescript-eslint/utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-import-lite: 0.6.0(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-jsdoc: 62.9.0(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-jsonc: 3.1.2(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-n: 17.24.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) eslint-plugin-no-only-tests: 3.3.0 - eslint-plugin-perfectionist: 5.8.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-pnpm: 1.6.0(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-regexp: 3.1.0(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-toml: 1.3.1(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-unicorn: 64.0.0(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)) - eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.2.0(jiti@2.6.1)))(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.2.0(jiti@2.6.1))) - eslint-plugin-yml: 3.3.1(eslint@10.2.0(jiti@2.6.1)) - eslint-processor-vue-blocks: 2.0.0(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-perfectionist: 5.8.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-pnpm: 1.6.0(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-regexp: 3.1.0(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-toml: 1.3.1(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-unicorn: 64.0.0(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.2.1(jiti@2.6.1)))(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.2.1(jiti@2.6.1))) + eslint-plugin-yml: 3.3.1(eslint@10.2.1(jiti@2.6.1)) + eslint-processor-vue-blocks: 2.0.0(eslint@10.2.1(jiti@2.6.1)) globals: 17.5.0 local-pkg: 1.1.2 parse-gitignore: 2.0.0 toml-eslint-parser: 1.0.3 - vue-eslint-parser: 10.4.0(eslint@10.2.0(jiti@2.6.1)) + vue-eslint-parser: 10.4.0(eslint@10.2.1(jiti@2.6.1)) yaml-eslint-parser: 2.0.0 optionalDependencies: - '@eslint-react/eslint-plugin': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@next/eslint-plugin-next': 16.2.3 - eslint-plugin-react-refresh: 0.5.2(eslint@10.2.0(jiti@2.6.1)) + '@eslint-react/eslint-plugin': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@next/eslint-plugin-next': 16.2.4 + eslint-plugin-react-refresh: 0.5.2(eslint@10.2.1(jiti@2.6.1)) transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -8622,10 +8737,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@base-ui/react@1.4.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@base-ui/react@1.4.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 - '@base-ui/utils': 0.2.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@base-ui/utils': 0.2.8(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@floating-ui/utils': 0.2.11 react: 19.2.5 @@ -8634,7 +8749,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@base-ui/utils@0.2.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@base-ui/utils@0.2.8(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 '@floating-ui/utils': 0.2.11 @@ -8752,7 +8867,7 @@ snapshots: dependencies: regexp-match-indices: 1.0.2 - '@cucumber/cucumber@12.8.0': + '@cucumber/cucumber@12.8.1': dependencies: '@cucumber/ci-environment': 13.0.0 '@cucumber/cucumber-expressions': 19.0.0 @@ -8760,10 +8875,10 @@ snapshots: '@cucumber/gherkin-streams': 6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0))(@cucumber/messages@32.2.0) '@cucumber/gherkin-utils': 11.0.0 '@cucumber/html-formatter': 23.0.0(@cucumber/messages@32.2.0) - '@cucumber/junit-xml-formatter': 0.13.2(@cucumber/messages@32.2.0) + '@cucumber/junit-xml-formatter': 0.13.3(@cucumber/messages@32.2.0) '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.2.0) '@cucumber/messages': 32.2.0 - '@cucumber/pretty-formatter': 1.0.1(@cucumber/cucumber@12.8.0)(@cucumber/messages@32.2.0) + '@cucumber/pretty-formatter': 1.0.1(@cucumber/cucumber@12.8.1)(@cucumber/messages@32.2.0) '@cucumber/tag-expressions': 9.1.0 assertion-error-formatter: 3.0.0 capital-case: 1.0.4 @@ -8818,10 +8933,10 @@ snapshots: dependencies: '@cucumber/messages': 32.2.0 - '@cucumber/junit-xml-formatter@0.13.2(@cucumber/messages@32.2.0)': + '@cucumber/junit-xml-formatter@0.13.3(@cucumber/messages@32.2.0)': dependencies: '@cucumber/messages': 32.2.0 - '@cucumber/query': 14.7.0(@cucumber/messages@32.2.0) + '@cucumber/query': 15.0.1(@cucumber/messages@32.2.0) '@teppeis/multimaps': 3.0.0 luxon: 3.7.2 xmlbuilder: 15.1.1 @@ -8836,16 +8951,16 @@ snapshots: class-transformer: 0.5.1 reflect-metadata: 0.2.2 - '@cucumber/pretty-formatter@1.0.1(@cucumber/cucumber@12.8.0)(@cucumber/messages@32.2.0)': + '@cucumber/pretty-formatter@1.0.1(@cucumber/cucumber@12.8.1)(@cucumber/messages@32.2.0)': dependencies: - '@cucumber/cucumber': 12.8.0 + '@cucumber/cucumber': 12.8.1 '@cucumber/messages': 32.2.0 ansi-styles: 5.2.0 cli-table3: 0.6.5 figures: 3.2.0 ts-dedent: 2.2.0 - '@cucumber/query@14.7.0(@cucumber/messages@32.2.0)': + '@cucumber/query@15.0.1(@cucumber/messages@32.2.0)': dependencies: '@cucumber/messages': 32.2.0 '@teppeis/multimaps': 3.0.0 @@ -8853,29 +8968,45 @@ snapshots: '@cucumber/tag-expressions@9.1.0': {} - '@e18e/eslint-plugin@0.3.0(eslint@10.2.0(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.20.0))': + '@e18e/eslint-plugin@0.3.0(eslint@10.2.1(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.21.1))': dependencies: - eslint-plugin-depend: 1.5.0(eslint@10.2.0(jiti@2.6.1)) + eslint-plugin-depend: 1.5.0(eslint@10.2.1(jiti@2.6.1)) optionalDependencies: - eslint: 10.2.0(jiti@2.6.1) - oxlint: 1.60.0(oxlint-tsgolint@0.20.0) + eslint: 10.2.1(jiti@2.6.1) + oxlint: 1.60.0(oxlint-tsgolint@0.21.1) - '@egoist/tailwindcss-icons@1.9.2(tailwindcss@4.2.2)': + '@egoist/tailwindcss-icons@1.9.2(tailwindcss@4.2.4)': dependencies: '@iconify/utils': 3.1.0 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true '@emnapi/runtime@1.9.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emoji-mart/data@1.2.1': {} '@es-joy/jsdoccomment@0.84.0': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/types': 8.59.0 comment-parser: 1.4.5 esquery: 1.7.0 jsdoc-type-pratt-parser: 7.1.1 @@ -8883,7 +9014,7 @@ snapshots: '@es-joy/jsdoccomment@0.86.0': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/types': 8.59.0 comment-parser: 1.4.6 esquery: 1.7.0 jsdoc-type-pratt-parser: 7.2.0 @@ -8968,15 +9099,15 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.2.0(jiti@2.6.1))': + '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.2.1(jiti@2.6.1))': dependencies: escape-string-regexp: 4.0.0 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) ignore: 7.0.5 - '@eslint-community/eslint-utils@4.9.1(eslint@10.2.0(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.6.1))': dependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/eslint-utils@4.9.1(eslint@9.27.0(jiti@2.6.1))': @@ -8986,77 +9117,77 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint-react/ast@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': + '@eslint-react/ast@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) + '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) string-ts: 2.3.1 - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/core@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': + '@eslint-react/core@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/ast': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/shared': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/eslint-plugin@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': + '@eslint-react/eslint-plugin@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/shared': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) - eslint-plugin-react-dom: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-react-naming-convention: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-react-rsc: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-react-web-api: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint-plugin-react-x: 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) + eslint-plugin-react-dom: 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-react-naming-convention: 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-react-rsc: 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-react-web-api: 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-react-x: 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/shared@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': + '@eslint-react/shared@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 6.0.2 + typescript: 6.0.3 zod: 4.3.6 transitivePeerDependencies: - supports-color - '@eslint-react/var@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': + '@eslint-react/var@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/ast': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/shared': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint/compat@2.0.3(eslint@10.2.0(jiti@2.6.1))': + '@eslint/compat@2.0.3(eslint@10.2.1(jiti@2.6.1))': dependencies: '@eslint/core': 1.2.0 optionalDependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) '@eslint/config-array@0.20.1': dependencies: @@ -9066,9 +9197,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-array@0.23.4': + '@eslint/config-array@0.23.5': dependencies: - '@eslint/object-schema': 3.0.4 + '@eslint/object-schema': 3.0.5 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.4 transitivePeerDependencies: @@ -9080,6 +9211,10 @@ snapshots: dependencies: '@eslint/core': 1.2.0 + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + '@eslint/core@0.14.0': dependencies: '@types/json-schema': 7.0.15 @@ -9096,6 +9231,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/css-tree@4.0.1': dependencies: mdn-data: 2.27.1 @@ -9115,9 +9254,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@10.0.1(eslint@10.2.0(jiti@2.6.1))': + '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.6.1))': optionalDependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) '@eslint/js@9.27.0': {} @@ -9153,7 +9292,7 @@ snapshots: '@eslint/object-schema@2.1.7': {} - '@eslint/object-schema@3.0.4': {} + '@eslint/object-schema@3.0.5': {} '@eslint/plugin-kit@0.3.5': dependencies: @@ -9170,9 +9309,9 @@ snapshots: '@eslint/core': 1.2.0 levn: 0.4.1 - '@eslint/plugin-kit@0.7.0': + '@eslint/plugin-kit@0.7.1': dependencies: - '@eslint/core': 1.2.0 + '@eslint/core': 1.2.1 levn: 0.4.1 '@floating-ui/core@1.7.5': @@ -9219,7 +9358,7 @@ snapshots: '@floating-ui/react': 0.26.28(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@react-aria/focus': 3.21.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@react-aria/interactions': 3.27.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/react-virtual': 3.13.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/react-virtual': 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) use-sync-external-store: 1.6.0(react@19.2.5) @@ -9386,13 +9525,13 @@ snapshots: dependencies: minipass: 7.1.3 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(typescript@6.0.3)': dependencies: glob: 13.0.6 - react-docgen-typescript: 2.4.0(typescript@6.0.2) - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + react-docgen-typescript: 2.4.0(typescript@6.0.3) + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -9635,9 +9774,17 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - '@napi-rs/wasm-runtime@1.1.2(@emnapi/runtime@1.9.1)': + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: - '@emnapi/runtime': 1.9.1 + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 '@tybys/wasm-util': 0.10.1 optional: true @@ -9645,41 +9792,41 @@ snapshots: '@next/env@16.0.0': {} - '@next/env@16.2.3': {} + '@next/env@16.2.4': {} - '@next/eslint-plugin-next@16.2.3': + '@next/eslint-plugin-next@16.2.4': dependencies: fast-glob: 3.3.1 - '@next/mdx@16.2.3(@mdx-js/loader@3.1.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5))': + '@next/mdx@16.2.4(@mdx-js/loader@3.1.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5))': dependencies: source-map: 0.7.6 optionalDependencies: '@mdx-js/loader': 3.1.1 '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5) - '@next/swc-darwin-arm64@16.2.3': + '@next/swc-darwin-arm64@16.2.4': optional: true - '@next/swc-darwin-x64@16.2.3': + '@next/swc-darwin-x64@16.2.4': optional: true - '@next/swc-linux-arm64-gnu@16.2.3': + '@next/swc-linux-arm64-gnu@16.2.4': optional: true - '@next/swc-linux-arm64-musl@16.2.3': + '@next/swc-linux-arm64-musl@16.2.4': optional: true - '@next/swc-linux-x64-gnu@16.2.3': + '@next/swc-linux-x64-gnu@16.2.4': optional: true - '@next/swc-linux-x64-musl@16.2.3': + '@next/swc-linux-x64-musl@16.2.4': optional: true - '@next/swc-win32-arm64-msvc@16.2.3': + '@next/swc-win32-arm64-msvc@16.2.4': optional: true - '@next/swc-win32-x64-msvc@16.2.3': + '@next/swc-win32-x64-msvc@16.2.4': optional: true '@nodelib/fs.scandir@2.1.5': @@ -9752,86 +9899,83 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/tanstack-query@1.13.14(@orpc/client@1.13.14)(@tanstack/query-core@5.99.0)': + '@orpc/tanstack-query@1.13.14(@orpc/client@1.13.14)(@tanstack/query-core@5.99.2)': dependencies: '@orpc/client': 1.13.14 '@orpc/shared': 1.13.14 - '@tanstack/query-core': 5.99.0 + '@tanstack/query-core': 5.99.2 transitivePeerDependencies: - '@opentelemetry/api' '@ota-meshi/ast-token-store@0.3.0': {} - '@oxc-parser/binding-android-arm-eabi@0.121.0': + '@oxc-parser/binding-android-arm-eabi@0.126.0': optional: true - '@oxc-parser/binding-android-arm64@0.121.0': + '@oxc-parser/binding-android-arm64@0.126.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.121.0': + '@oxc-parser/binding-darwin-arm64@0.126.0': optional: true - '@oxc-parser/binding-darwin-x64@0.121.0': + '@oxc-parser/binding-darwin-x64@0.126.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.121.0': + '@oxc-parser/binding-freebsd-x64@0.126.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.126.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.126.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.121.0': + '@oxc-parser/binding-linux-arm64-gnu@0.126.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.121.0': + '@oxc-parser/binding-linux-arm64-musl@0.126.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.126.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.126.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.121.0': + '@oxc-parser/binding-linux-riscv64-musl@0.126.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.121.0': + '@oxc-parser/binding-linux-s390x-gnu@0.126.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.121.0': + '@oxc-parser/binding-linux-x64-gnu@0.126.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.121.0': + '@oxc-parser/binding-linux-x64-musl@0.126.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.121.0': + '@oxc-parser/binding-openharmony-arm64@0.126.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/runtime@1.9.1)': + '@oxc-parser/binding-wasm32-wasi@0.126.0': dependencies: - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/runtime@1.9.1) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.121.0': + '@oxc-parser/binding-win32-arm64-msvc@0.126.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.121.0': + '@oxc-parser/binding-win32-ia32-msvc@0.126.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.121.0': + '@oxc-parser/binding-win32-x64-msvc@0.126.0': optional: true - '@oxc-project/runtime@0.124.0': {} + '@oxc-project/runtime@0.126.0': {} - '@oxc-project/types@0.121.0': {} - - '@oxc-project/types@0.124.0': {} + '@oxc-project/types@0.126.0': {} '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true @@ -9881,9 +10025,9 @@ snapshots: '@oxc-resolver/binding-openharmony-arm64@11.19.1': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/runtime@1.9.1)': + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/runtime@1.9.1) + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -9955,22 +10099,22 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.45.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.20.0': + '@oxlint-tsgolint/darwin-arm64@0.21.1': optional: true - '@oxlint-tsgolint/darwin-x64@0.20.0': + '@oxlint-tsgolint/darwin-x64@0.21.1': optional: true - '@oxlint-tsgolint/linux-arm64@0.20.0': + '@oxlint-tsgolint/linux-arm64@0.21.1': optional: true - '@oxlint-tsgolint/linux-x64@0.20.0': + '@oxlint-tsgolint/linux-x64@0.21.1': optional: true - '@oxlint-tsgolint/win32-arm64@0.20.0': + '@oxlint-tsgolint/win32-arm64@0.21.1': optional: true - '@oxlint-tsgolint/win32-x64@0.20.0': + '@oxlint-tsgolint/win32-x64@0.21.1': optional: true '@oxlint/binding-android-arm-eabi@1.60.0': @@ -10349,38 +10493,38 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.4 - '@sentry-internal/browser-utils@10.48.0': + '@sentry-internal/browser-utils@10.49.0': dependencies: - '@sentry/core': 10.48.0 + '@sentry/core': 10.49.0 - '@sentry-internal/feedback@10.48.0': + '@sentry-internal/feedback@10.49.0': dependencies: - '@sentry/core': 10.48.0 + '@sentry/core': 10.49.0 - '@sentry-internal/replay-canvas@10.48.0': + '@sentry-internal/replay-canvas@10.49.0': dependencies: - '@sentry-internal/replay': 10.48.0 - '@sentry/core': 10.48.0 + '@sentry-internal/replay': 10.49.0 + '@sentry/core': 10.49.0 - '@sentry-internal/replay@10.48.0': + '@sentry-internal/replay@10.49.0': dependencies: - '@sentry-internal/browser-utils': 10.48.0 - '@sentry/core': 10.48.0 + '@sentry-internal/browser-utils': 10.49.0 + '@sentry/core': 10.49.0 - '@sentry/browser@10.48.0': + '@sentry/browser@10.49.0': dependencies: - '@sentry-internal/browser-utils': 10.48.0 - '@sentry-internal/feedback': 10.48.0 - '@sentry-internal/replay': 10.48.0 - '@sentry-internal/replay-canvas': 10.48.0 - '@sentry/core': 10.48.0 + '@sentry-internal/browser-utils': 10.49.0 + '@sentry-internal/feedback': 10.49.0 + '@sentry-internal/replay': 10.49.0 + '@sentry-internal/replay-canvas': 10.49.0 + '@sentry/core': 10.49.0 - '@sentry/core@10.48.0': {} + '@sentry/core@10.49.0': {} - '@sentry/react@10.48.0(react@19.2.5)': + '@sentry/react@10.49.0(react@19.2.5)': dependencies: - '@sentry/browser': 10.48.0 - '@sentry/core': 10.48.0 + '@sentry/browser': 10.49.0 + '@sentry/core': 10.49.0 react: 19.2.5 '@shikijs/core@4.0.2': @@ -10470,10 +10614,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': + '@storybook/addon-docs@10.3.5(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5) - '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) react: 19.2.5 @@ -10503,24 +10647,24 @@ snapshots: storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 - '@storybook/builder-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': + '@storybook/builder-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: - '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + '@storybook/csf-plugin': 10.3.5(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': + '@storybook/csf-plugin@10.3.5(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.2 - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' '@storybook/global@5.0.0': {} @@ -10529,20 +10673,20 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - '@storybook/nextjs-vite@10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)': + '@storybook/nextjs-vite@10.3.5(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3)': dependencies: - '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) - '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) - '@storybook/react-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) - next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) + '@storybook/react-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) + next: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5) - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' + vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -10557,12 +10701,12 @@ snapshots: react-dom: 19.2.5(react@19.2.5) storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@storybook/react-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)': + '@storybook/react-vite@10.3.5(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3)': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(typescript@6.0.3) '@rollup/pluginutils': 5.3.0 - '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) - '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + '@storybook/builder-vite': 10.3.5(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3) empathic: 2.0.0 magic-string: 0.30.21 react: 19.2.5 @@ -10571,7 +10715,7 @@ snapshots: resolve: 1.22.11 storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tsconfig-paths: 4.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' transitivePeerDependencies: - esbuild - rollup @@ -10579,17 +10723,17 @@ snapshots: - typescript - webpack - '@storybook/react@10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)': + '@storybook/react@10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) react: 19.2.5 react-docgen: 8.0.3 - react-docgen-typescript: 2.4.0(typescript@6.0.2) + react-docgen-typescript: 2.4.0(typescript@6.0.3) react-dom: 19.2.5(react@19.2.5) storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -10602,11 +10746,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@stylistic/eslint-plugin@5.10.0(eslint@10.2.0(jiti@2.6.1))': + '@stylistic/eslint-plugin@5.10.0(eslint@10.2.1(jiti@2.6.1))': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) - '@typescript-eslint/types': 8.58.2 - eslint: 10.2.0(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@typescript-eslint/types': 8.59.0 + eslint: 10.2.1(jiti@2.6.1) eslint-visitor-keys: 4.2.1 espree: 10.4.0 estraverse: 5.3.0 @@ -10622,21 +10766,21 @@ snapshots: dependencies: tslib: 2.8.1 - '@t3-oss/env-core@0.13.11(typescript@6.0.2)(valibot@1.3.1(typescript@6.0.2))(zod@4.3.6)': + '@t3-oss/env-core@0.13.11(typescript@6.0.3)(valibot@1.3.1(typescript@6.0.3))(zod@4.3.6)': optionalDependencies: - typescript: 6.0.2 - valibot: 1.3.1(typescript@6.0.2) + typescript: 6.0.3 + valibot: 1.3.1(typescript@6.0.3) zod: 4.3.6 - '@t3-oss/env-nextjs@0.13.11(typescript@6.0.2)(valibot@1.3.1(typescript@6.0.2))(zod@4.3.6)': + '@t3-oss/env-nextjs@0.13.11(typescript@6.0.3)(valibot@1.3.1(typescript@6.0.3))(zod@4.3.6)': dependencies: - '@t3-oss/env-core': 0.13.11(typescript@6.0.2)(valibot@1.3.1(typescript@6.0.2))(zod@4.3.6) + '@t3-oss/env-core': 0.13.11(typescript@6.0.3)(valibot@1.3.1(typescript@6.0.3))(zod@4.3.6) optionalDependencies: - typescript: 6.0.2 - valibot: 1.3.1(typescript@6.0.2) + typescript: 6.0.3 + valibot: 1.3.1(typescript@6.0.3) zod: 4.3.6 - '@tailwindcss/node@4.2.2': + '@tailwindcss/node@4.2.4': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.20.1 @@ -10644,78 +10788,78 @@ snapshots: lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 - '@tailwindcss/oxide-android-arm64@4.2.2': + '@tailwindcss/oxide-android-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.2': + '@tailwindcss/oxide-darwin-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.2': + '@tailwindcss/oxide-darwin-x64@4.2.4': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.2': + '@tailwindcss/oxide-freebsd-x64@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.2': + '@tailwindcss/oxide-linux-x64-musl@4.2.4': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.2': + '@tailwindcss/oxide-wasm32-wasi@4.2.4': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': optional: true - '@tailwindcss/oxide@4.2.2': + '@tailwindcss/oxide@4.2.4': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-x64': 4.2.2 - '@tailwindcss/oxide-freebsd-x64': 4.2.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + '@tailwindcss/oxide-android-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-x64': 4.2.4 + '@tailwindcss/oxide-freebsd-x64': 4.2.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/postcss@4.2.2': + '@tailwindcss/postcss@4.2.4': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.2.2 - '@tailwindcss/oxide': 4.2.2 - postcss: 8.5.9 - tailwindcss: 4.2.2 + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + postcss: 8.5.10 + tailwindcss: 4.2.4 - '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)': + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 - '@tailwindcss/vite@4.2.2(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': + '@tailwindcss/vite@4.2.4(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))': dependencies: - '@tailwindcss/node': 4.2.2 - '@tailwindcss/oxide': 4.2.2 - tailwindcss: 4.2.2 - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + tailwindcss: 4.2.4 + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' '@tanstack/devtools-client@0.0.6': dependencies: @@ -10761,26 +10905,26 @@ snapshots: - csstype - utf-8-validate - '@tanstack/eslint-plugin-query@5.99.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': + '@tanstack/eslint-plugin-query@5.99.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@tanstack/form-core@1.29.0': + '@tanstack/form-core@1.29.1': dependencies: '@tanstack/devtools-event-client': 0.4.3 '@tanstack/pacer-lite': 0.1.1 '@tanstack/store': 0.9.3 - '@tanstack/form-devtools@0.2.21(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11)': + '@tanstack/form-devtools@0.2.22(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11)': dependencies: '@tanstack/devtools-ui': 0.5.1(csstype@3.2.3) '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.14)(react@19.2.5)(solid-js@1.9.11) - '@tanstack/form-core': 1.29.0 + '@tanstack/form-core': 1.29.1 clsx: 2.1.1 dayjs: 1.11.20 goober: 2.1.18(csstype@3.2.3) @@ -10794,9 +10938,9 @@ snapshots: '@tanstack/pacer-lite@0.1.1': {} - '@tanstack/query-core@5.99.0': {} + '@tanstack/query-core@5.99.2': {} - '@tanstack/query-devtools@5.99.0': {} + '@tanstack/query-devtools@5.99.2': {} '@tanstack/react-devtools@0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: @@ -10810,10 +10954,10 @@ snapshots: - csstype - utf-8-validate - '@tanstack/react-form-devtools@0.2.21(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11)': + '@tanstack/react-form-devtools@0.2.22(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11)': dependencies: '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.14)(react@19.2.5)(solid-js@1.9.11) - '@tanstack/form-devtools': 0.2.21(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11) + '@tanstack/form-devtools': 0.2.22(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11) react: 19.2.5 transitivePeerDependencies: - '@types/react' @@ -10822,23 +10966,23 @@ snapshots: - solid-js - vue - '@tanstack/react-form@1.29.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-form@1.29.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/form-core': 1.29.0 + '@tanstack/form-core': 1.29.1 '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 transitivePeerDependencies: - react-dom - '@tanstack/react-query-devtools@5.99.0(@tanstack/react-query@5.99.0(react@19.2.5))(react@19.2.5)': + '@tanstack/react-query-devtools@5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/query-devtools': 5.99.0 - '@tanstack/react-query': 5.99.0(react@19.2.5) + '@tanstack/query-devtools': 5.99.2 + '@tanstack/react-query': 5.99.2(react@19.2.5) react: 19.2.5 - '@tanstack/react-query@5.99.0(react@19.2.5)': + '@tanstack/react-query@5.99.2(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.99.0 + '@tanstack/query-core': 5.99.2 react: 19.2.5 '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': @@ -10848,15 +10992,15 @@ snapshots: react-dom: 19.2.5(react@19.2.5) use-sync-external-store: 1.6.0(react@19.2.5) - '@tanstack/react-virtual@3.13.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/virtual-core': 3.13.23 + '@tanstack/virtual-core': 3.14.0 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) '@tanstack/store@0.9.3': {} - '@tanstack/virtual-core@3.13.23': {} + '@tanstack/virtual-core@3.14.0': {} '@teppeis/multimaps@3.0.0': {} @@ -10894,46 +11038,46 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tsslint/cli@3.0.3(@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2)': + '@tsslint/cli@3.0.4(@tsslint/compat-eslint@3.0.4(jiti@2.6.1)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@clack/prompts': 0.8.2 - '@tsslint/config': 3.0.3(@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2) - '@tsslint/core': 3.0.3 + '@tsslint/config': 3.0.4(@tsslint/compat-eslint@3.0.4(jiti@2.6.1)(typescript@6.0.3))(typescript@6.0.3) + '@tsslint/core': 3.0.4 '@volar/language-core': 2.4.28 '@volar/language-hub': 0.0.1 '@volar/typescript': 2.4.28 minimatch: 10.2.4 - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - '@tsslint/compat-eslint' - tsl - '@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2)': + '@tsslint/compat-eslint@3.0.4(jiti@2.6.1)(typescript@6.0.3)': dependencies: - '@tsslint/types': 3.0.3 - '@typescript-eslint/parser': 8.58.2(eslint@9.27.0(jiti@2.6.1))(typescript@6.0.2) + '@tsslint/types': 3.0.4 + '@typescript-eslint/parser': 8.59.0(eslint@9.27.0(jiti@2.6.1))(typescript@6.0.3) eslint: 9.27.0(jiti@2.6.1) transitivePeerDependencies: - jiti - supports-color - typescript - '@tsslint/config@3.0.3(@tsslint/compat-eslint@3.0.3(jiti@2.6.1)(typescript@6.0.2))(typescript@6.0.2)': + '@tsslint/config@3.0.4(@tsslint/compat-eslint@3.0.4(jiti@2.6.1)(typescript@6.0.3))(typescript@6.0.3)': dependencies: - '@tsslint/types': 3.0.3 + '@tsslint/types': 3.0.4 minimatch: 10.2.4 - ts-api-utils: 2.5.0(typescript@6.0.2) + ts-api-utils: 2.5.0(typescript@6.0.3) optionalDependencies: - '@tsslint/compat-eslint': 3.0.3(jiti@2.6.1)(typescript@6.0.2) + '@tsslint/compat-eslint': 3.0.4(jiti@2.6.1)(typescript@6.0.3) transitivePeerDependencies: - typescript - '@tsslint/core@3.0.3': + '@tsslint/core@3.0.4': dependencies: - '@tsslint/types': 3.0.3 + '@tsslint/types': 3.0.4 minimatch: 10.2.4 - '@tsslint/types@3.0.3': {} + '@tsslint/types@3.0.4': {} '@tybys/wasm-util@0.10.1': dependencies: @@ -11171,52 +11315,89 @@ snapshots: '@types/zen-observable@0.8.3': {} - '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/parser': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.58.2 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/type-utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.0 + eslint: 10.2.1(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.2.0(jiti@2.6.1) - typescript: 6.0.2 + eslint: 10.2.1(jiti@2.6.1) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.2(eslint@9.27.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 8.58.2 + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.0 + debug: 4.4.3(supports-color@8.1.1) + eslint: 10.2.1(jiti@2.6.1) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.0(eslint@9.27.0(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.0 debug: 4.4.3(supports-color@8.1.1) eslint: 9.27.0(jiti@2.6.1) - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.2(typescript@6.0.2)': + '@typescript-eslint/project-service@8.58.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@6.0.3) '@typescript-eslint/types': 8.58.2 debug: 4.4.3(supports-color@8.1.1) - typescript: 6.0.2 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.3) + '@typescript-eslint/types': 8.59.0 + debug: 4.4.3(supports-color@8.1.1) + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -11225,47 +11406,96 @@ snapshots: '@typescript-eslint/types': 8.58.2 '@typescript-eslint/visitor-keys': 8.58.2 - '@typescript-eslint/tsconfig-utils@8.58.2(typescript@6.0.2)': + '@typescript-eslint/scope-manager@8.59.0': dependencies: - typescript: 6.0.2 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/visitor-keys': 8.59.0 - '@typescript-eslint/type-utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.58.2(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/tsconfig-utils@8.59.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) debug: 4.4.3(supports-color@8.1.1) - eslint: 10.2.0(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + eslint: 10.2.1(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 10.2.1(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.58.2': {} - '@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2)': + '@typescript-eslint/types@8.59.0': {} + + '@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.58.2(typescript@6.0.2) - '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@6.0.2) + '@typescript-eslint/project-service': 8.58.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@6.0.3) '@typescript-eslint/types': 8.58.2 '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': + '@typescript-eslint/typescript-estree@8.59.0(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + '@typescript-eslint/project-service': 8.59.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.3) + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/visitor-keys': 8.59.0 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) - typescript: 6.0.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -11274,36 +11504,41 @@ snapshots: '@typescript-eslint/types': 8.58.2 eslint-visitor-keys: 5.0.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260413.1': + '@typescript-eslint/visitor-keys@8.59.0': + dependencies: + '@typescript-eslint/types': 8.59.0 + eslint-visitor-keys: 5.0.1 + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260422.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260413.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260422.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260413.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260422.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260413.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260422.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260413.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260422.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260413.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260422.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260413.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260422.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260413.1': + '@typescript/native-preview@7.0.0-dev.20260422.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260413.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260413.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260413.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260413.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260413.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260413.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260413.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260422.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260422.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260422.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260422.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260422.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260422.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260422.1 '@ungap/structured-clone@1.3.0': {} @@ -11311,56 +11546,56 @@ snapshots: dependencies: unpic: 4.2.2 - '@unpic/react@1.0.2(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@unpic/react@1.0.2(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@unpic/core': 1.0.3 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) optionalDependencies: - next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@upsetjs/venn.js@2.0.0': optionalDependencies: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@valibot/to-json-schema@1.6.0(valibot@1.3.1(typescript@6.0.2))': + '@valibot/to-json-schema@1.6.0(valibot@1.3.1(typescript@6.0.3))': dependencies: - valibot: 1.3.1(typescript@6.0.2) + valibot: 1.3.1(typescript@6.0.3) '@vercel/og@0.8.6': dependencies: '@resvg/resvg-wasm': 2.4.0 satori: 0.16.0 - '@vitejs/devtools-kit@0.1.11(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0)': + '@vitejs/devtools-kit@0.1.11(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(typescript@6.0.3)(ws@8.20.0)': dependencies: - '@vitejs/devtools-rpc': 0.1.11(typescript@6.0.2)(ws@8.20.0) + '@vitejs/devtools-rpc': 0.1.11(typescript@6.0.3)(ws@8.20.0) birpc: 4.0.0 ohash: 2.0.11 - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' transitivePeerDependencies: - typescript - ws - '@vitejs/devtools-rpc@0.1.11(typescript@6.0.2)(ws@8.20.0)': + '@vitejs/devtools-rpc@0.1.11(typescript@6.0.3)(ws@8.20.0)': dependencies: birpc: 4.0.0 ohash: 2.0.11 p-limit: 7.3.0 structured-clone-es: 2.0.0 - valibot: 1.3.1(typescript@6.0.2) + valibot: 1.3.1(typescript@6.0.3) optionalDependencies: ws: 8.20.0 transitivePeerDependencies: - typescript - '@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': + '@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' - '@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + '@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': dependencies: '@rolldown/pluginutils': 1.0.0-rc.15 es-module-lexer: 2.0.0 @@ -11371,15 +11606,15 @@ snapshots: srvx: 0.11.15 strip-literal: 3.1.0 turbo-stream: 3.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' + vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) optionalDependencies: react-server-dom-webpack: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': + '@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.4 + '@vitest/utils': 4.1.5 ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -11388,7 +11623,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitest: '@voidzero-dev/vite-plus-test@0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -11418,15 +11653,15 @@ snapshots: - vite - yaml - '@vitest/eslint-plugin@1.6.15(@types/node@25.6.0)(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(eslint@10.2.0(jiti@2.6.1))(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': + '@vitest/eslint-plugin@1.6.15(@types/node@25.6.0)(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(eslint@10.2.1(jiti@2.6.1))(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)': dependencies: - '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) - vitest: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) + vitest: '@voidzero-dev/vite-plus-test@0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - typescript: 6.0.2 + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -11469,7 +11704,7 @@ snapshots: dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.1.4': + '@vitest/pretty-format@4.1.5': dependencies: tinyrainbow: 3.1.0 @@ -11483,16 +11718,16 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.1.4': + '@vitest/utils@4.1.5': dependencies: - '@vitest/pretty-format': 4.1.4 + '@vitest/pretty-format': 4.1.5 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': + '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)': dependencies: - '@oxc-project/runtime': 0.124.0 - '@oxc-project/types': 0.124.0 + '@oxc-project/runtime': 0.126.0 + '@oxc-project/types': 0.126.0 lightningcss: 1.32.0 postcss: 8.5.9 optionalDependencies: @@ -11501,32 +11736,32 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.21.0 - typescript: 6.0.2 + typescript: 6.0.3 yaml: 2.8.3 - '@voidzero-dev/vite-plus-darwin-arm64@0.1.18': + '@voidzero-dev/vite-plus-darwin-arm64@0.1.19': optional: true - '@voidzero-dev/vite-plus-darwin-x64@0.1.18': + '@voidzero-dev/vite-plus-darwin-x64@0.1.19': optional: true - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.18': + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.19': optional: true - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.18': + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.19': optional: true - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.18': + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.19': optional: true - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.18': + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.19': optional: true - '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': + '@voidzero-dev/vite-plus-test@0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + '@voidzero-dev/vite-plus-core': 0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) es-module-lexer: 1.7.0 obug: 2.1.1 pixelmatch: 7.1.0 @@ -11536,11 +11771,11 @@ snapshots: tinybench: 2.9.0 tinyexec: 1.0.4 tinyglobby: 0.2.16 - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' ws: 8.20.0 optionalDependencies: '@types/node': 25.6.0 - '@vitest/coverage-v8': 4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + '@vitest/coverage-v8': 4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) happy-dom: 20.9.0 transitivePeerDependencies: - '@arethetypeswrong/core' @@ -11563,10 +11798,10 @@ snapshots: - utf-8-validate - yaml - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.18': + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.19': optional: true - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.18': + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.19': optional: true '@volar/language-core@2.4.28': @@ -11770,7 +12005,7 @@ snapshots: caniuse-lite@1.0.30001781: {} - canvas@3.2.2: + canvas@3.2.3: dependencies: node-addon-api: 7.1.1 prebuild-install: 7.1.3 @@ -12275,7 +12510,7 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 - dompurify@3.4.0: + dompurify@3.4.1: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -12369,7 +12604,7 @@ snapshots: es-module-lexer@2.0.0: {} - es-toolkit@1.45.1: {} + es-toolkit@1.46.0: {} esast-util-from-estree@2.0.0: dependencies: @@ -12424,93 +12659,93 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-compat-utils@0.5.1(eslint@10.2.0(jiti@2.6.1)): + eslint-compat-utils@0.5.1(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) semver: 7.7.4 - eslint-config-flat-gitignore@2.3.0(eslint@10.2.0(jiti@2.6.1)): + eslint-config-flat-gitignore@2.3.0(eslint@10.2.1(jiti@2.6.1)): dependencies: - '@eslint/compat': 2.0.3(eslint@10.2.0(jiti@2.6.1)) - eslint: 10.2.0(jiti@2.6.1) + '@eslint/compat': 2.0.3(eslint@10.2.1(jiti@2.6.1)) + eslint: 10.2.1(jiti@2.6.1) eslint-flat-config-utils@3.1.0: dependencies: '@eslint/config-helpers': 0.5.4 pathe: 2.0.3 - eslint-json-compat-utils@0.2.3(eslint@10.2.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0): + eslint-json-compat-utils@0.2.3(eslint@10.2.1(jiti@2.6.1))(jsonc-eslint-parser@3.1.0): dependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) esquery: 1.7.0 jsonc-eslint-parser: 3.1.0 - eslint-markdown@0.6.1(eslint@10.2.0(jiti@2.6.1)): + eslint-markdown@0.6.1(eslint@10.2.1(jiti@2.6.1)): dependencies: '@eslint/markdown': 7.5.1 micromark-util-normalize-identifier: 2.0.1 parse5: 8.0.0 optionalDependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) transitivePeerDependencies: - supports-color - eslint-merge-processors@2.0.0(eslint@10.2.0(jiti@2.6.1)): + eslint-merge-processors@2.0.0(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) - eslint-plugin-antfu@3.2.2(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-antfu@3.2.2(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) - eslint-plugin-better-tailwindcss@4.4.1(eslint@10.2.0(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.2.2)(typescript@6.0.2): + eslint-plugin-better-tailwindcss@4.4.1(eslint@10.2.1(jiti@2.6.1))(oxlint@1.60.0(oxlint-tsgolint@0.21.1))(tailwindcss@4.2.4)(typescript@6.0.3): dependencies: '@eslint/css-tree': 4.0.1 - '@valibot/to-json-schema': 1.6.0(valibot@1.3.1(typescript@6.0.2)) + '@valibot/to-json-schema': 1.6.0(valibot@1.3.1(typescript@6.0.3)) enhanced-resolve: 5.20.1 jiti: 2.6.1 synckit: 0.11.12 tailwind-csstree: 0.3.1 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 tsconfig-paths-webpack-plugin: 4.2.0 - valibot: 1.3.1(typescript@6.0.2) + valibot: 1.3.1(typescript@6.0.3) optionalDependencies: - eslint: 10.2.0(jiti@2.6.1) - oxlint: 1.60.0(oxlint-tsgolint@0.20.0) + eslint: 10.2.1(jiti@2.6.1) + oxlint: 1.60.0(oxlint-tsgolint@0.21.1) transitivePeerDependencies: - '@eslint/css' - typescript - eslint-plugin-command@3.5.2(@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2))(@typescript-eslint/utils@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-command@3.5.2(@typescript-eslint/typescript-estree@8.59.0(typescript@6.0.3))(@typescript-eslint/utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1)): dependencies: '@es-joy/jsdoccomment': 0.84.0 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) - eslint-plugin-depend@1.5.0(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-depend@1.5.0(eslint@10.2.1(jiti@2.6.1)): dependencies: empathic: 2.0.0 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) module-replacements: 2.11.0 semver: 7.7.4 - eslint-plugin-es-x@7.8.0(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-es-x@7.8.0(eslint@10.2.1(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - eslint: 10.2.0(jiti@2.6.1) - eslint-compat-utils: 0.5.1(eslint@10.2.0(jiti@2.6.1)) + eslint: 10.2.1(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@10.2.1(jiti@2.6.1)) - eslint-plugin-hyoban@0.14.1(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-hyoban@0.14.1(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) - eslint-plugin-import-lite@0.6.0(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-import-lite@0.6.0(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) - eslint-plugin-jsdoc@62.9.0(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-jsdoc@62.9.0(eslint@10.2.1(jiti@2.6.1)): dependencies: '@es-joy/jsdoccomment': 0.86.0 '@es-joy/resolve.exports': 1.2.0 @@ -12518,7 +12753,7 @@ snapshots: comment-parser: 1.4.6 debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) espree: 11.2.0 esquery: 1.7.0 html-entities: 2.6.0 @@ -12530,27 +12765,27 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-jsonc@3.1.2(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-jsonc@3.1.2(eslint@10.2.1(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) '@eslint/core': 1.2.0 '@eslint/plugin-kit': 0.6.1 '@ota-meshi/ast-token-store': 0.3.0 diff-sequences: 29.6.3 - eslint: 10.2.0(jiti@2.6.1) - eslint-json-compat-utils: 0.2.3(eslint@10.2.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0) + eslint: 10.2.1(jiti@2.6.1) + eslint-json-compat-utils: 0.2.3(eslint@10.2.1(jiti@2.6.1))(jsonc-eslint-parser@3.1.0) jsonc-eslint-parser: 3.1.0 natural-compare: 1.4.0 synckit: 0.11.12 transitivePeerDependencies: - '@eslint/json' - eslint-plugin-markdown-preferences@0.41.1(@eslint/markdown@8.0.1)(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-markdown-preferences@0.41.1(@eslint/markdown@8.0.1)(eslint@10.2.1(jiti@2.6.1)): dependencies: '@eslint/markdown': 8.0.1 diff-sequences: 29.6.3 emoji-regex-xs: 2.0.1 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) mdast-util-from-markdown: 2.0.3 mdast-util-frontmatter: 2.0.1 mdast-util-gfm: 3.1.0 @@ -12565,44 +12800,44 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-n@17.24.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-n@17.24.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) enhanced-resolve: 5.20.1 - eslint: 10.2.0(jiti@2.6.1) - eslint-plugin-es-x: 7.8.0(eslint@10.2.0(jiti@2.6.1)) + eslint: 10.2.1(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@10.2.1(jiti@2.6.1)) get-tsconfig: 4.13.7 globals: 15.15.0 globrex: 0.1.2 ignore: 5.3.2 semver: 7.7.4 - ts-declaration-location: 1.0.7(typescript@6.0.2) + ts-declaration-location: 1.0.7(typescript@6.0.3) transitivePeerDependencies: - typescript - eslint-plugin-no-barrel-files@1.3.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-no-barrel-files@1.3.1(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) transitivePeerDependencies: - supports-color - typescript eslint-plugin-no-only-tests@3.3.0: {} - eslint-plugin-perfectionist@5.8.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-perfectionist@5.8.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) natural-orderby: 5.0.0 transitivePeerDependencies: - supports-color - typescript - eslint-plugin-pnpm@1.6.0(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-pnpm@1.6.0(eslint@10.2.1(jiti@2.6.1)): dependencies: empathic: 2.0.0 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) jsonc-eslint-parser: 3.1.0 pathe: 2.0.3 pnpm-workspace-yaml: 1.6.0 @@ -12610,150 +12845,150 @@ snapshots: yaml: 2.8.3 yaml-eslint-parser: 2.0.0 - eslint-plugin-react-dom@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-react-dom@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/core': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/ast': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/core': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/shared': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) compare-versions: 6.1.1 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-naming-convention@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-react-naming-convention@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/core': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/ast': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/core': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/shared': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) compare-versions: 6.1.1 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) string-ts: 2.3.1 ts-pattern: 5.9.0 - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.5.2(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) - eslint-plugin-react-rsc@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-react-rsc@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/ast': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/shared': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-web-api@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-react-web-api@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/core': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/ast': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/core': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/shared': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) birecord: 0.1.1 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-x@3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2): + eslint-plugin-react-x@3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-react/ast': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/core': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/shared': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - '@eslint-react/var': 3.0.0(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@eslint-react/ast': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/core': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/shared': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) compare-versions: 6.1.1 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) string-ts: 2.3.1 - ts-api-utils: 2.5.0(typescript@6.0.2) + ts-api-utils: 2.5.0(typescript@6.0.3) ts-pattern: 5.9.0 - typescript: 6.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-regexp@3.1.0(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-regexp@3.1.0(eslint@10.2.1(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 comment-parser: 1.4.6 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) jsdoc-type-pratt-parser: 7.2.0 refa: 0.12.1 regexp-ast-analysis: 0.7.1 scslre: 0.3.0 - eslint-plugin-sonarjs@4.0.2(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-sonarjs@4.0.3(eslint@10.2.1(jiti@2.6.1)): dependencies: '@eslint-community/regexpp': 4.12.2 builtin-modules: 3.3.0 bytes: 3.1.2 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) functional-red-black-tree: 1.0.1 globals: 17.5.0 jsx-ast-utils-x: 0.1.0 lodash.merge: 4.6.2 - minimatch: 10.2.4 + minimatch: 10.2.5 scslre: 0.3.0 semver: 7.7.4 - ts-api-utils: 2.5.0(typescript@6.0.2) - typescript: 6.0.2 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 - eslint-plugin-storybook@10.3.5(eslint@10.2.0(jiti@2.6.1))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2): + eslint-plugin-storybook@10.3.5(eslint@10.2.1(jiti@2.6.1))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3): dependencies: - '@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) - eslint: 10.2.0(jiti@2.6.1) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + eslint: 10.2.1(jiti@2.6.1) storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-toml@1.3.1(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-toml@1.3.1(eslint@10.2.1(jiti@2.6.1)): dependencies: '@eslint/core': 1.2.0 '@eslint/plugin-kit': 0.6.1 '@ota-meshi/ast-token-store': 0.3.0 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) toml-eslint-parser: 1.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-unicorn@64.0.0(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-unicorn@64.0.0(eslint@10.2.1(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) change-case: 5.4.4 ci-info: 4.4.0 clean-regexp: 1.0.0 core-js-compat: 3.49.0 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) find-up-simple: 1.0.1 globals: 17.5.0 indent-string: 5.0.0 @@ -12765,27 +13000,27 @@ snapshots: semver: 7.7.4 strip-indent: 4.1.1 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) - eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.2.0(jiti@2.6.1)))(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.2.0(jiti@2.6.1))): + eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.2.1(jiti@2.6.1)))(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.2.1(jiti@2.6.1))): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) - eslint: 10.2.0(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + eslint: 10.2.1(jiti@2.6.1) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 semver: 7.7.4 - vue-eslint-parser: 10.4.0(eslint@10.2.0(jiti@2.6.1)) + vue-eslint-parser: 10.4.0(eslint@10.2.1(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@stylistic/eslint-plugin': 5.10.0(eslint@10.2.0(jiti@2.6.1)) - '@typescript-eslint/parser': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) + '@stylistic/eslint-plugin': 5.10.0(eslint@10.2.1(jiti@2.6.1)) + '@typescript-eslint/parser': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) - eslint-plugin-yml@3.3.1(eslint@10.2.0(jiti@2.6.1)): + eslint-plugin-yml@3.3.1(eslint@10.2.1(jiti@2.6.1)): dependencies: '@eslint/core': 1.2.0 '@eslint/plugin-kit': 0.6.1 @@ -12793,15 +13028,15 @@ snapshots: debug: 4.4.3(supports-color@8.1.1) diff-sequences: 29.6.3 escape-string-regexp: 5.0.0 - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) natural-compare: 1.4.0 yaml-eslint-parser: 2.0.0 transitivePeerDependencies: - supports-color - eslint-processor-vue-blocks@2.0.0(eslint@10.2.0(jiti@2.6.1)): + eslint-processor-vue-blocks@2.0.0(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) eslint-scope@8.4.0: dependencies: @@ -12821,14 +13056,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.2.0(jiti@2.6.1): + eslint@10.2.1(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.4 - '@eslint/config-helpers': 0.5.4 - '@eslint/core': 1.2.0 - '@eslint/plugin-kit': 0.7.0 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -13089,6 +13324,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: optional: true @@ -13326,11 +13565,11 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@26.0.4(typescript@6.0.2): + i18next@26.0.6(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.2 optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 iconify-import-svg@0.2.0: dependencies: @@ -13514,20 +13753,19 @@ snapshots: khroma@2.1.0: {} - knip@6.4.1(@emnapi/runtime@1.9.1): + knip@6.6.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): dependencies: - '@nodelib/fs.walk': 1.2.8 - fast-glob: 3.3.3 + fdir: 6.5.0(picomatch@4.0.4) formatly: 0.3.0 - get-tsconfig: 4.13.7 + get-tsconfig: 4.14.0 jiti: 2.6.1 minimist: 1.2.8 - oxc-parser: 0.121.0(@emnapi/runtime@1.9.1) - oxc-resolver: 11.19.1(@emnapi/runtime@1.9.1) - picocolors: 1.1.1 + oxc-parser: 0.126.0 + oxc-resolver: 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) picomatch: 4.0.4 smol-toml: 1.6.1 strip-json-comments: 5.0.3 + tinyglobby: 0.2.16 unbash: 2.2.0 yaml: 2.8.3 zod: 4.3.6 @@ -13541,7 +13779,7 @@ snapshots: kolorist@1.8.0: {} - ky@2.0.0: {} + ky@2.0.2: {} lamejs@1.2.1: dependencies: @@ -13656,7 +13894,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - loro-crdt@1.10.8: {} + loro-crdt@1.11.1: {} loupe@3.2.1: {} @@ -14242,6 +14480,10 @@ snapshots: dependencies: brace-expansion: 5.0.5 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.13 @@ -14304,9 +14546,9 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - '@next/env': 16.2.3 + '@next/env': 16.2.4 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.12 caniuse-lite: 1.0.30001781 @@ -14315,14 +14557,14 @@ snapshots: react-dom: 19.2.5(react@19.2.5) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.3 - '@next/swc-darwin-x64': 16.2.3 - '@next/swc-linux-arm64-gnu': 16.2.3 - '@next/swc-linux-arm64-musl': 16.2.3 - '@next/swc-linux-x64-gnu': 16.2.3 - '@next/swc-linux-x64-musl': 16.2.3 - '@next/swc-win32-arm64-msvc': 16.2.3 - '@next/swc-win32-x64-msvc': 16.2.3 + '@next/swc-darwin-arm64': 16.2.4 + '@next/swc-darwin-x64': 16.2.4 + '@next/swc-linux-arm64-gnu': 16.2.4 + '@next/swc-linux-arm64-musl': 16.2.4 + '@next/swc-linux-x64-gnu': 16.2.4 + '@next/swc-linux-x64-musl': 16.2.4 + '@next/swc-win32-arm64-msvc': 16.2.4 + '@next/swc-win32-x64-msvc': 16.2.4 '@playwright/test': 1.59.1 sharp: 0.34.5 transitivePeerDependencies: @@ -14356,12 +14598,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.8.9(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5): + nuqs@2.8.9(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.5 optionalDependencies: - next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) object-assign@4.1.1: {} @@ -14410,35 +14652,32 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - oxc-parser@0.121.0(@emnapi/runtime@1.9.1): + oxc-parser@0.126.0: dependencies: - '@oxc-project/types': 0.121.0 + '@oxc-project/types': 0.126.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.121.0 - '@oxc-parser/binding-android-arm64': 0.121.0 - '@oxc-parser/binding-darwin-arm64': 0.121.0 - '@oxc-parser/binding-darwin-x64': 0.121.0 - '@oxc-parser/binding-freebsd-x64': 0.121.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.121.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.121.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.121.0 - '@oxc-parser/binding-linux-arm64-musl': 0.121.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.121.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.121.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.121.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.121.0 - '@oxc-parser/binding-linux-x64-gnu': 0.121.0 - '@oxc-parser/binding-linux-x64-musl': 0.121.0 - '@oxc-parser/binding-openharmony-arm64': 0.121.0 - '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/runtime@1.9.1) - '@oxc-parser/binding-win32-arm64-msvc': 0.121.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.121.0 - '@oxc-parser/binding-win32-x64-msvc': 0.121.0 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@oxc-parser/binding-android-arm-eabi': 0.126.0 + '@oxc-parser/binding-android-arm64': 0.126.0 + '@oxc-parser/binding-darwin-arm64': 0.126.0 + '@oxc-parser/binding-darwin-x64': 0.126.0 + '@oxc-parser/binding-freebsd-x64': 0.126.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.126.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.126.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.126.0 + '@oxc-parser/binding-linux-arm64-musl': 0.126.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.126.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.126.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.126.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.126.0 + '@oxc-parser/binding-linux-x64-gnu': 0.126.0 + '@oxc-parser/binding-linux-x64-musl': 0.126.0 + '@oxc-parser/binding-openharmony-arm64': 0.126.0 + '@oxc-parser/binding-wasm32-wasi': 0.126.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.126.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.126.0 + '@oxc-parser/binding-win32-x64-msvc': 0.126.0 - oxc-resolver@11.19.1(@emnapi/runtime@1.9.1): + oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.19.1 '@oxc-resolver/binding-android-arm64': 11.19.1 @@ -14456,7 +14695,7 @@ snapshots: '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 '@oxc-resolver/binding-linux-x64-musl': 11.19.1 '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/runtime@1.9.1) + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 @@ -14488,16 +14727,16 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.45.0 '@oxfmt/binding-win32-x64-msvc': 0.45.0 - oxlint-tsgolint@0.20.0: + oxlint-tsgolint@0.21.1: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.20.0 - '@oxlint-tsgolint/darwin-x64': 0.20.0 - '@oxlint-tsgolint/linux-arm64': 0.20.0 - '@oxlint-tsgolint/linux-x64': 0.20.0 - '@oxlint-tsgolint/win32-arm64': 0.20.0 - '@oxlint-tsgolint/win32-x64': 0.20.0 + '@oxlint-tsgolint/darwin-arm64': 0.21.1 + '@oxlint-tsgolint/darwin-x64': 0.21.1 + '@oxlint-tsgolint/linux-arm64': 0.21.1 + '@oxlint-tsgolint/linux-x64': 0.21.1 + '@oxlint-tsgolint/win32-arm64': 0.21.1 + '@oxlint-tsgolint/win32-x64': 0.21.1 - oxlint@1.60.0(oxlint-tsgolint@0.20.0): + oxlint@1.60.0(oxlint-tsgolint@0.21.1): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.60.0 '@oxlint/binding-android-arm64': 1.60.0 @@ -14518,7 +14757,7 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.60.0 '@oxlint/binding-win32-ia32-msvc': 1.60.0 '@oxlint/binding-win32-x64-msvc': 1.60.0 - oxlint-tsgolint: 0.20.0 + oxlint-tsgolint: 0.21.1 p-limit@3.1.0: dependencies: @@ -14616,7 +14855,7 @@ snapshots: pdfjs-dist@4.4.168: optionalDependencies: - canvas: 3.2.2 + canvas: 3.2.3 path2d: 0.2.2 pend@1.2.0: {} @@ -14695,6 +14934,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.9: dependencies: nanoid: 3.3.11 @@ -14778,9 +15023,9 @@ snapshots: prop-types: 15.8.1 react: 19.2.5 - react-docgen-typescript@2.4.0(typescript@6.0.2): + react-docgen-typescript@2.4.0(typescript@6.0.3): dependencies: - typescript: 6.0.2 + typescript: 6.0.3 react-docgen@8.0.3: dependencies: @@ -14827,16 +15072,16 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - react-i18next@16.5.8(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2): + react-i18next@16.5.8(i18next@26.0.6(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 26.0.4(typescript@6.0.2) + i18next: 26.0.6(typescript@6.0.3) react: 19.2.5 use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: react-dom: 19.2.5(react@19.2.5) - typescript: 6.0.2 + typescript: 6.0.3 react-is@16.13.1: {} @@ -15489,7 +15734,7 @@ snapshots: tailwind-merge@3.5.0: {} - tailwindcss@4.2.2: {} + tailwindcss@4.2.4: {} tapable@2.3.2: {} @@ -15585,24 +15830,24 @@ snapshots: trough@2.2.0: {} - ts-api-utils@2.5.0(typescript@6.0.2): + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: - typescript: 6.0.2 + typescript: 6.0.3 ts-debounce@4.0.0: {} - ts-declaration-location@1.0.7(typescript@6.0.2): + ts-declaration-location@1.0.7(typescript@6.0.3): dependencies: picomatch: 4.0.4 - typescript: 6.0.2 + typescript: 6.0.3 ts-dedent@2.2.0: {} ts-pattern@5.9.0: {} - tsconfck@3.1.6(typescript@6.0.2): + tsconfck@3.1.6(typescript@6.0.3): optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 tsconfig-paths-webpack-plugin@4.2.0: dependencies: @@ -15649,7 +15894,7 @@ snapshots: dependencies: tagged-tag: 1.0.0 - typescript@6.0.2: {} + typescript@6.0.3: {} ufo@1.6.3: {} @@ -15798,9 +16043,9 @@ snapshots: uuid@13.0.0: {} - valibot@1.3.1(typescript@6.0.2): + valibot@1.3.1(typescript@6.0.3): optionalDependencies: - typescript: 6.0.2 + typescript: 6.0.3 validate-npm-package-license@3.0.4: dependencies: @@ -15822,20 +16067,20 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@0.0.41(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.2): + vinext@0.0.41(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.3): dependencies: - '@unpic/react': 1.0.2(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@unpic/react': 1.0.2(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@vercel/og': 0.8.6 - '@vitejs/plugin-react': 6.0.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + '@vitejs/plugin-react': 6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) magic-string: 0.30.21 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' vite-plugin-commonjs: 0.10.4 - vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) + vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(typescript@6.0.3) optionalDependencies: '@mdx-js/rollup': 3.1.1 - '@vitejs/plugin-rsc': 0.5.24(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + '@vitejs/plugin-rsc': 0.5.24(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) react-server-dom-webpack: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) transitivePeerDependencies: - next @@ -15855,9 +16100,9 @@ snapshots: fast-glob: 3.3.3 magic-string: 0.30.21 - vite-plugin-inspect@12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0): + vite-plugin-inspect@12.0.0-beta.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(typescript@6.0.3)(ws@8.20.0): dependencies: - '@vitejs/devtools-kit': 0.1.11(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2)(ws@8.20.0) + '@vitejs/devtools-kit': 0.1.11(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(typescript@6.0.3)(ws@8.20.0) ansis: 4.2.0 error-stack-parser-es: 1.0.5 obug: 2.1.1 @@ -15866,43 +16111,43 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' transitivePeerDependencies: - typescript - ws - vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2): + vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.3): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 magic-string: 0.30.21 module-alias: 2.3.4 - next: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) storybook: 10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2) + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' + vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(typescript@6.0.3) transitivePeerDependencies: - supports-color - typescript - vite-plus@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3): + vite-plus@0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3): dependencies: - '@oxc-project/types': 0.124.0 - '@voidzero-dev/vite-plus-core': 0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - '@voidzero-dev/vite-plus-test': 0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + '@oxc-project/types': 0.126.0 + '@voidzero-dev/vite-plus-core': 0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) + '@voidzero-dev/vite-plus-test': 0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) oxfmt: 0.45.0 - oxlint: 1.60.0(oxlint-tsgolint@0.20.0) - oxlint-tsgolint: 0.20.0 + oxlint: 1.60.0(oxlint-tsgolint@0.21.1) + oxlint-tsgolint: 0.21.1 optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.1.18 - '@voidzero-dev/vite-plus-darwin-x64': 0.1.18 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.18 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.18 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.18 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.18 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.18 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.18 + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.19 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.19 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.19 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.19 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.19 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.19 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.19 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.19 transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -15933,36 +16178,36 @@ snapshots: - vite - yaml - vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2): + vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(typescript@6.0.3): dependencies: debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 - tsconfck: 3.1.6(typescript@6.0.2) + tsconfck: 3.1.6(typescript@6.0.3) optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' transitivePeerDependencies: - supports-color - typescript - vite-tsconfig-paths@6.1.1(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2): + vite-tsconfig-paths@6.1.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(typescript@6.0.3): dependencies: debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 - tsconfck: 3.1.6(typescript@6.0.2) - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + tsconfck: 3.1.6(typescript@6.0.3) + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' transitivePeerDependencies: - supports-color - typescript - vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): + vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)): optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' - vitest-browser-react@2.2.0(@types/node@25.6.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3): + vitest-browser-react@2.2.0(@types/node@25.6.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3): dependencies: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - vitest: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitest: '@voidzero-dev/vite-plus-test@0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -15996,11 +16241,11 @@ snapshots: - vite - yaml - vitest-canvas-mock@1.1.4(@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): + vitest-canvas-mock@1.1.4(@voidzero-dev/vite-plus-test@0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)): dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: '@voidzero-dev/vite-plus-test@0.1.18(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.18(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitest: '@voidzero-dev/vite-plus-test@0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' void-elements@3.1.0: {} @@ -16021,10 +16266,10 @@ snapshots: vscode-uri@3.1.0: {} - vue-eslint-parser@10.4.0(eslint@10.2.0(jiti@2.6.1)): + vue-eslint-parser@10.4.0(eslint@10.2.1(jiti@2.6.1)): dependencies: debug: 4.4.3(supports-color@8.1.1) - eslint: 10.2.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 espree: 11.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f02d05b233..0d78fed290 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -42,17 +42,17 @@ overrides: svgo@>=3.0.0 <3.3.3: 3.3.3 tar@<=7.5.10: 7.5.11 undici@>=7.0.0 <7.24.0: 7.24.0 - vite: npm:@voidzero-dev/vite-plus-core@0.1.18 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.18 + vite: npm:@voidzero-dev/vite-plus-core@0.1.19 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.19 yaml@>=2.0.0 <2.8.3: 2.8.3 yauzl@<3.2.1: 3.2.1 catalog: - '@amplitude/analytics-browser': 2.39.0 - '@amplitude/plugin-session-replay-browser': 1.27.7 + '@amplitude/analytics-browser': 2.41.0 + '@amplitude/plugin-session-replay-browser': 1.27.10 '@antfu/eslint-config': 8.2.0 - '@base-ui/react': 1.4.0 + '@base-ui/react': 1.4.1 '@chromatic-com/storybook': 5.1.2 - '@cucumber/cucumber': 12.8.0 + '@cucumber/cucumber': 12.8.1 '@egoist/tailwindcss-icons': 1.9.2 '@emoji-mart/data': 1.2.1 '@eslint-react/eslint-plugin': 3.0.0 @@ -75,8 +75,8 @@ catalog: '@mdx-js/react': 3.1.1 '@mdx-js/rollup': 3.1.1 '@monaco-editor/react': 4.7.0 - '@next/eslint-plugin-next': 16.2.3 - '@next/mdx': 16.2.3 + '@next/eslint-plugin-next': 16.2.4 + '@next/mdx': 16.2.4 '@orpc/client': 1.13.14 '@orpc/contract': 1.13.14 '@orpc/openapi-client': 1.13.14 @@ -84,7 +84,7 @@ catalog: '@playwright/test': 1.59.1 '@remixicon/react': 4.9.0 '@rgrove/parse-xml': 4.2.0 - '@sentry/react': 10.48.0 + '@sentry/react': 10.49.0 '@storybook/addon-docs': 10.3.5 '@storybook/addon-links': 10.3.5 '@storybook/addon-onboarding': 10.3.5 @@ -95,23 +95,23 @@ catalog: '@streamdown/math': 1.0.2 '@svgdotjs/svg.js': 3.2.5 '@t3-oss/env-nextjs': 0.13.11 - '@tailwindcss/postcss': 4.2.2 + '@tailwindcss/postcss': 4.2.4 '@tailwindcss/typography': 0.5.19 - '@tailwindcss/vite': 4.2.2 - '@tanstack/eslint-plugin-query': 5.99.0 + '@tailwindcss/vite': 4.2.4 + '@tanstack/eslint-plugin-query': 5.99.2 '@tanstack/react-devtools': 0.10.2 - '@tanstack/react-form': 1.29.0 - '@tanstack/react-form-devtools': 0.2.21 - '@tanstack/react-query': 5.99.0 - '@tanstack/react-query-devtools': 5.99.0 - '@tanstack/react-virtual': 3.13.23 + '@tanstack/react-form': 1.29.1 + '@tanstack/react-form-devtools': 0.2.22 + '@tanstack/react-query': 5.99.2 + '@tanstack/react-query-devtools': 5.99.2 + '@tanstack/react-virtual': 3.13.24 '@testing-library/dom': 10.4.1 '@testing-library/jest-dom': 6.9.1 '@testing-library/react': 16.3.2 '@testing-library/user-event': 14.6.1 - '@tsslint/cli': 3.0.3 - '@tsslint/compat-eslint': 3.0.3 - '@tsslint/config': 3.0.3 + '@tsslint/cli': 3.0.4 + '@tsslint/compat-eslint': 3.0.4 + '@tsslint/config': 3.0.4 '@types/js-cookie': 3.0.6 '@types/js-yaml': 4.0.9 '@types/negotiator': 0.6.4 @@ -120,12 +120,12 @@ catalog: '@types/react': 19.2.14 '@types/react-dom': 19.2.3 '@types/sortablejs': 1.15.9 - '@typescript-eslint/eslint-plugin': 8.58.2 - '@typescript-eslint/parser': 8.58.2 - '@typescript/native-preview': 7.0.0-dev.20260413.1 + '@typescript-eslint/eslint-plugin': 8.59.0 + '@typescript-eslint/parser': 8.59.0 + '@typescript/native-preview': 7.0.0-dev.20260422.1 '@vitejs/plugin-react': 6.0.1 '@vitejs/plugin-rsc': 0.5.24 - '@vitest/coverage-v8': 4.1.4 + '@vitest/coverage-v8': 4.1.5 abcjs: 6.6.2 agentation: 3.0.2 ahooks: 3.9.7 @@ -138,22 +138,22 @@ catalog: cron-parser: 5.5.0 dayjs: 1.11.20 decimal.js: 10.6.0 - dompurify: 3.4.0 + dompurify: 3.4.1 echarts: 6.0.0 echarts-for-react: 3.0.6 elkjs: 0.11.1 embla-carousel-autoplay: 8.6.0 embla-carousel-react: 8.6.0 emoji-mart: 5.6.0 - es-toolkit: 1.45.1 - eslint: 10.2.0 + es-toolkit: 1.46.0 + eslint: 10.2.1 eslint-markdown: 0.6.1 eslint-plugin-better-tailwindcss: 4.4.1 eslint-plugin-hyoban: 0.14.1 eslint-plugin-markdown-preferences: 0.41.1 eslint-plugin-no-barrel-files: 1.3.1 eslint-plugin-react-refresh: 0.5.2 - eslint-plugin-sonarjs: 4.0.2 + eslint-plugin-sonarjs: 4.0.3 eslint-plugin-storybook: 10.3.5 fast-deep-equal: 3.1.3 happy-dom: 20.9.0 @@ -161,7 +161,7 @@ catalog: hono: 4.12.14 html-entities: 2.6.0 html-to-image: 1.11.13 - i18next: 26.0.4 + i18next: 26.0.6 i18next-resources-to-backend: 1.2.1 iconify-import-svg: 0.2.0 immer: 11.1.4 @@ -171,21 +171,21 @@ catalog: js-yaml: 4.1.1 jsonschema: 1.5.0 katex: 0.16.45 - knip: 6.4.1 - ky: 2.0.0 + knip: 6.6.1 + ky: 2.0.2 lamejs: 1.2.1 lexical: 0.43.0 - loro-crdt: 1.10.8 + loro-crdt: 1.11.1 mermaid: 11.14.0 mime: 4.1.0 mitt: 3.0.1 negotiator: 1.0.0 - next: 16.2.3 + next: 16.2.4 next-themes: 0.4.6 nuqs: 2.8.9 pinyin-pro: 3.28.1 playwright: 1.59.1 - postcss: 8.5.9 + postcss: 8.5.10 qrcode.react: 4.2.0 qs: 6.15.1 react: 19.2.5 @@ -213,19 +213,19 @@ catalog: streamdown: 2.5.0 string-ts: 2.3.1 tailwind-merge: 3.5.0 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 tldts: 7.0.28 tsx: 4.21.0 - typescript: 6.0.2 + typescript: 6.0.3 uglify-js: 3.19.3 unist-util-visit: 5.1.0 use-context-selector: 2.0.0 uuid: 13.0.0 vinext: 0.0.41 - vite: npm:@voidzero-dev/vite-plus-core@0.1.18 + vite: npm:@voidzero-dev/vite-plus-core@0.1.19 vite-plugin-inspect: 12.0.0-beta.1 - vite-plus: 0.1.18 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.18 + vite-plus: 0.1.19 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.19 vitest-browser-react: 2.2.0 vitest-canvas-mock: 1.1.4 zod: 4.3.6 diff --git a/sdks/nodejs-client/package.json b/sdks/nodejs-client/package.json index 28ebcb89c2..a8b9426e92 100644 --- a/sdks/nodejs-client/package.json +++ b/sdks/nodejs-client/package.json @@ -48,7 +48,7 @@ "build": "vp pack", "lint": "eslint", "lint:fix": "eslint --fix", - "type-check": "tsc", + "type-check": "tsgo", "test": "vp test", "test:coverage": "vp test --coverage", "publish:check": "./scripts/publish.sh --dry-run", @@ -60,6 +60,7 @@ "@types/node": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/parser": "catalog:", + "@typescript/native-preview": "catalog:", "@vitest/coverage-v8": "catalog:", "eslint": "catalog:", "typescript": "catalog:", diff --git a/sdks/nodejs-client/src/http/client.test.ts b/sdks/nodejs-client/src/http/client.test.ts index af859801c6..4dc2087782 100644 --- a/sdks/nodejs-client/src/http/client.test.ts +++ b/sdks/nodejs-client/src/http/client.test.ts @@ -39,7 +39,7 @@ const jsonResponse = ( ...init, headers: { "content-type": "application/json", - ...(init.headers ?? {}), + ...init.headers, }, }); @@ -47,7 +47,7 @@ const textResponse = (body: string, init: ResponseInit = {}): Response => new Response(body, { ...init, headers: { - ...(init.headers ?? {}), + ...init.headers, }, }); diff --git a/sdks/nodejs-client/src/index.test.ts b/sdks/nodejs-client/src/index.test.ts index 8d56b994c4..b323371891 100644 --- a/sdks/nodejs-client/src/index.test.ts +++ b/sdks/nodejs-client/src/index.test.ts @@ -14,7 +14,7 @@ const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => ...init, headers: { "content-type": "application/json", - ...(init.headers ?? {}), + ...init.headers, }, }); diff --git a/sdks/nodejs-client/vite.config.ts b/sdks/nodejs-client/vite.config.ts index 8d89508682..68ced551a3 100644 --- a/sdks/nodejs-client/vite.config.ts +++ b/sdks/nodejs-client/vite.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ pack: { entry: ["src/index.ts"], format: ["esm"], + platform: "node", dts: true, clean: true, sourcemap: true, diff --git a/web/__mocks__/__tests__/base-ui-popover.spec.tsx b/web/__mocks__/__tests__/base-ui-popover.spec.tsx new file mode 100644 index 0000000000..3b5b741ca0 --- /dev/null +++ b/web/__mocks__/__tests__/base-ui-popover.spec.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '../base-ui-popover' + +type PopoverHarnessProps = { + useRenderElement?: boolean + preventDefaultOnTrigger?: boolean +} + +const PopoverHarness = ({ + useRenderElement = false, + preventDefaultOnTrigger = false, +}: PopoverHarnessProps) => { + const [open, setOpen] = React.useState(false) + + return ( +
+
outside
+ + { + if (preventDefaultOnTrigger) + event.preventDefault() + }} + > + toggle + + ) + : undefined} + > + fallback trigger + + } + popupProps={{ 'data-popup': 'true' } as unknown as React.HTMLAttributes} + > +
popover body
+
+
+
{open ? 'open' : 'closed'}
+
+ ) +} + +describe('base-ui-popover mock', () => { + it('should toggle popover content from the fallback trigger and expose content props', () => { + render() + + expect(screen.getByTestId('open-state')).toHaveTextContent('closed') + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('popover-trigger')) + + expect(screen.getByTestId('open-state')).toHaveTextContent('open') + expect(screen.getByTestId('popover-content')).toHaveAttribute('data-placement', 'bottom-start') + expect(screen.getByTestId('popover-content')).toHaveAttribute('data-side-offset', '4') + expect(screen.getByTestId('popover-content')).toHaveAttribute('data-align-offset', '8') + expect(screen.getByTestId('popover-content')).toHaveAttribute('data-positioner', 'true') + expect(screen.getByTestId('popover-content')).toHaveAttribute('data-popup', 'true') + expect(screen.getByTestId('popover-content')).toHaveClass('custom-content') + }) + + it('should keep the popover open on inside clicks and close it on outside clicks or escape', () => { + render() + + fireEvent.click(screen.getByTestId('custom-trigger')) + expect(screen.getByTestId('open-state')).toHaveTextContent('open') + + fireEvent.mouseDown(screen.getByTestId('popover-content')) + expect(screen.getByTestId('open-state')).toHaveTextContent('open') + + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.getByTestId('open-state')).toHaveTextContent('closed') + + fireEvent.click(screen.getByTestId('custom-trigger')) + expect(screen.getByTestId('open-state')).toHaveTextContent('open') + + fireEvent.mouseDown(screen.getByTestId('outside-area')) + expect(screen.getByTestId('open-state')).toHaveTextContent('closed') + }) + + it('should preserve rendered trigger props and respect preventDefault', () => { + render() + + fireEvent.click(screen.getByTestId('custom-trigger')) + + expect(screen.getByTestId('custom-trigger')).toHaveAttribute('data-popover-trigger', 'true') + expect(screen.getByTestId('open-state')).toHaveTextContent('closed') + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + }) + + it('should keep the popover closed when the fallback trigger click is prevented', () => { + const handleClick = (event: React.MouseEvent) => { + event.preventDefault() + } + + render( +
+ + + fallback trigger + + +
popover body
+
+
+
, + ) + + fireEvent.click(screen.getByTestId('popover-trigger')) + + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + }) +}) diff --git a/web/__mocks__/base-ui-popover.tsx b/web/__mocks__/base-ui-popover.tsx new file mode 100644 index 0000000000..8818f60f4e --- /dev/null +++ b/web/__mocks__/base-ui-popover.tsx @@ -0,0 +1,154 @@ +import type { ReactNode } from 'react' +import * as React from 'react' + +const PopoverContext = React.createContext({ + open: false, + onOpenChange: (_open: boolean) => {}, +}) + +type PopoverProps = { + children?: ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} + +type PopoverTriggerProps = React.HTMLAttributes & { + children?: ReactNode + nativeButton?: boolean + render?: React.ReactElement +} + +type PopoverContentProps = React.HTMLAttributes & { + children?: ReactNode + placement?: string + sideOffset?: number + alignOffset?: number + positionerProps?: React.HTMLAttributes + popupProps?: React.HTMLAttributes +} + +export const Popover = ({ + children, + open = false, + onOpenChange, +}: PopoverProps) => { + React.useEffect(() => { + if (!open) + return + + const handleMouseDown = (event: MouseEvent) => { + const target = event.target as Element | null + if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]')) + return + + onOpenChange?.(false) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') + onOpenChange?.(false) + } + + document.addEventListener('mousedown', handleMouseDown) + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('mousedown', handleMouseDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [open, onOpenChange]) + + return ( + {}), + }} + > +
+ {children} +
+
+ ) +} + +export const PopoverTrigger = ({ + children, + render, + nativeButton: _nativeButton, + onClick, + ...props +}: PopoverTriggerProps) => { + const { open, onOpenChange } = React.useContext(PopoverContext) + const node = render ?? children + + if (React.isValidElement(node)) { + const triggerElement = node as React.ReactElement> + const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes & { 'data-testid'?: string } + + return React.cloneElement(triggerElement, { + ...props, + ...childProps, + 'data-testid': childProps['data-testid'] ?? 'popover-trigger', + 'data-popover-trigger': 'true', + 'onClick': (event: React.MouseEvent) => { + childProps.onClick?.(event) + onClick?.(event) + if (event.defaultPrevented) + return + onOpenChange(!open) + }, + }) + } + + return ( +
{ + onClick?.(event) + if (event.defaultPrevented) + return + onOpenChange(!open) + }} + {...props} + > + {node} +
+ ) +} + +export const PopoverContent = ({ + children, + className, + placement, + sideOffset, + alignOffset, + positionerProps, + popupProps, + ...props +}: PopoverContentProps) => { + const { open } = React.useContext(PopoverContext) + + if (!open) + return null + + return ( +
+ {children} +
+ ) +} + +export const PopoverClose = ({ children }: { children?: ReactNode }) => <>{children} +export const PopoverTitle = ({ children }: { children?: ReactNode }) => <>{children} +export const PopoverDescription = ({ children }: { children?: ReactNode }) => <>{children} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx index b5da0e4ca5..002f8f3bf1 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx @@ -1,14 +1,17 @@ 'use client' import type { FC } from 'react' import type { PeriodParams } from '@/app/components/app/overview/app-chart' -import type { Item } from '@/app/components/base/select' import type { I18nKeysByPrefix } from '@/types/i18n' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import dayjs from 'dayjs' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { SimpleSelect } from '@/app/components/base/select' type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'> +type TimePeriodOption = { + value: string + name: string +} type Props = { periodMapping: { [key: string]: { value: number, name: TimePeriodName } } @@ -24,8 +27,18 @@ const LongTimeRangePicker: FC = ({ queryDateFormat, }) => { const { t } = useTranslation() + const items = React.useMemo(() => { + return Object.entries(periodMapping).map(([key, period]) => ({ + value: key, + name: t(`filter.period.${period.name}`, { ns: 'appLog' }), + })) + }, [periodMapping, t]) + const [value, setValue] = React.useState('2') + const selectedItem = React.useMemo(() => { + return items.find(item => item.value === value) ?? null + }, [items, value]) - const handleSelect = React.useCallback((item: Item) => { + const handleSelect = React.useCallback((item: TimePeriodOption) => { const id = item.value const value = periodMapping[id]?.value ?? '-1' const name = item.name || t('filter.period.allTime', { ns: 'appLog' }) @@ -55,13 +68,30 @@ const LongTimeRangePicker: FC = ({ }, [onSelect, periodMapping, queryDateFormat, t]) return ( - ({ value: k, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))} - className="mt-0 w-40!" - notClearable={true} - onSelect={handleSelect} - defaultValue="2" - /> + ) } export default React.memo(LongTimeRangePicker) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx index a89b77e9e3..c028a184ed 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx @@ -1,19 +1,22 @@ 'use client' import type { FC } from 'react' import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart' -import type { Item } from '@/app/components/base/select' import type { I18nKeysByPrefix } from '@/types/i18n' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' +import { RiArrowDownSLine } from '@remixicon/react' import dayjs from 'dayjs' import * as React from 'react' -import { useCallback } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { SimpleSelect } from '@/app/components/base/select' const today = dayjs() type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'> +type TimePeriodOption = { + value: number + name: string +} type Props = { isCustomRange: boolean @@ -27,8 +30,19 @@ const RangeSelector: FC = ({ onSelect, }) => { const { t } = useTranslation() + const [open, setOpen] = useState(false) + const items = useMemo(() => { + return ranges.map(range => ({ + ...range, + name: t(`filter.period.${range.name}`, { ns: 'appLog' }), + })) + }, [ranges, t]) + const [value, setValue] = useState('0') + const selectedItem = useMemo(() => { + return items.find(item => String(item.value) === value) ?? null + }, [items, value]) - const handleSelectRange = useCallback((item: Item) => { + const handleSelectRange = useCallback((item: TimePeriodOption) => { const { name, value } = item let period: TimeRange | null = null if (value === 0) { @@ -42,44 +56,38 @@ const RangeSelector: FC = ({ onSelect({ query: period!, name }) }, [onSelect]) - const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => { - return ( -
-
{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : item?.name}
- -
- ) - }, [isCustomRange]) - - const renderOption = useCallback(({ item, selected }: { item: Item, selected: boolean }) => { - return ( - <> - {selected && ( - - - )} - {item.name} - - ) - }, []) return ( - ({ ...v, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))} - className="mt-0 w-40!" - notClearable={true} - onSelect={handleSelectRange} - defaultValue={0} - wrapperClassName="h-8" - optionWrapClassName="w-[200px] translate-x-[-24px]" - renderTrigger={renderTrigger} - optionClassName="flex items-center py-0 pl-7 pr-2 h-8" - renderOption={renderOption} - /> + ) } export default React.memo(RangeSelector) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx index 29de1a1eae..6d7c178728 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx @@ -3,13 +3,13 @@ import type { FC } from 'react' import type { PopupProps } from './config-popup' import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' -import { useCallback, useRef, useState } from 'react' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' +import * as React from 'react' +import { useState } from 'react' import ConfigPopup from './config-popup' type Props = { @@ -25,36 +25,31 @@ const ConfigBtn: FC = ({ children, ...popupProps }) => { - const [open, doSetOpen] = useState(false) - const openRef = useRef(open) - const setOpen = useCallback((v: boolean) => { - doSetOpen(v) - openRef.current = v - }, [doSetOpen]) - - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) + const [open, setOpen] = useState(false) if (popupProps.readOnly && !hasConfigured) return null return ( - - -
- {children} -
-
- + + {children} + + )} + /> + - -
+ + ) } export default React.memo(ConfigBtn) diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index dd95dc04ba..55666db193 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -16,9 +16,9 @@ import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' +import { setOAuthPendingRedirect } from '@/app/signin/utils/post-login-redirect' import { useRouter, useSearchParams } from '@/next/navigation' -import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common' +import { isLegacyBase401, useLogout, userProfileQueryOptions } from '@/service/use-common' import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth' function buildReturnUrl(pathname: string, search: string) { @@ -73,14 +73,17 @@ export default function OAuthAuthorize() { const userProfile = userProfileResp?.profile const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri) const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp() + const { mutateAsync: logout } = useLogout() const hasNotifiedRef = useRef(false) const isLoading = isOAuthLoading || isProfileLoading - const onLoginSwitchClick = () => { + const onLoginSwitchClick = async () => { try { - const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`) - setPostLoginRedirect(returnUrl) - router.push('/signin') + const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`) + setOAuthPendingRedirect(returnUrl) + if (isLoggedIn) + await logout() + router.push(`/signin?redirect_url=${encodeURIComponent(returnUrl)}`) } catch { router.push('/signin') diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx index 6788fc700b..f7e83a95f9 100644 --- a/web/app/components/app-initializer.tsx +++ b/web/app/components/app-initializer.tsx @@ -88,7 +88,7 @@ export const AppInitializer = ({ return } - const redirectUrl = resolvePostLoginRedirect() + const redirectUrl = resolvePostLoginRedirect(searchParams) if (redirectUrl) { location.replace(redirectUrl) return diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index 7b69c94b47..e0942a9706 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -17,6 +17,15 @@ import DatasetSidebarDropdown from './dataset-sidebar-dropdown' import NavLink from './nav-link' import ToggleButton from './toggle-button' +const isShortcutFromInputArea = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) + return false + + return target.tagName === 'INPUT' + || target.tagName === 'TEXTAREA' + || target.isContentEditable +} + type IAppDetailNavProps = { iconType?: 'app' | 'dataset' navigation: Array<{ @@ -74,6 +83,9 @@ const AppDetailNav = ({ }, [appSidebarExpand, setAppSidebarExpand]) useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => { + if (isShortcutFromInputArea(e.target)) + return + e.preventDefault() handleToggle() }, { exactMatch: true, useCapture: true }) diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index f8da2dda9c..227068183a 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -92,8 +92,11 @@ vi.mock('@/service/explore', () => ({ fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args), })) +const mockPublishToCreatorsPlatform = vi.fn() + vi.mock('@/service/apps', () => ({ fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), + publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args), })) vi.mock('@/service/use-apps', () => ({ @@ -549,6 +552,76 @@ describe('AppPublisher', () => { }) }) + it('should show marketplace button and open redirect URL on success', async () => { + mockPublishToCreatorsPlatform.mockResolvedValue({ redirect_url: 'https://marketplace.example.com/publish?code=abc' }) + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + renderWithSystemFeatures( + , + { systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true } }, + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.publishToMarketplace')) + + await waitFor(() => { + expect(mockPublishToCreatorsPlatform).toHaveBeenCalledWith({ appID: 'app-1' }) + expect(windowOpenSpy).toHaveBeenCalledWith('https://marketplace.example.com/publish?code=abc', '_blank') + }) + + windowOpenSpy.mockRestore() + }) + + it('should show toast error when publish to marketplace fails', async () => { + mockPublishToCreatorsPlatform.mockRejectedValue(new Error('network error')) + + renderWithSystemFeatures( + , + { systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true } }, + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.publishToMarketplace')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('common.publishToMarketplaceFailed') + }) + }) + + it('should disable marketplace button when not yet published', () => { + renderWithSystemFeatures( + , + { systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true } }, + ) + + fireEvent.click(screen.getByText('common.publish')) + const marketplaceButton = screen.getByText('common.publishToMarketplace').closest('a, button, div[role="button"]') as HTMLElement + expect(marketplaceButton).toBeInTheDocument() + // clicking should not call the API because publishedAt is undefined + fireEvent.click(screen.getByText('common.publishToMarketplace')) + expect(mockPublishToCreatorsPlatform).not.toHaveBeenCalled() + }) + + it('should hide marketplace button when enable_creators_platform is false', () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + expect(screen.queryByText('common.publishToMarketplace')).not.toBeInTheDocument() + }) + it('should keep access control open when app detail is unavailable during confirmation', async () => { mockAppDetail = null diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 1b0c06b2b4..3a1fcdf868 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -9,6 +9,7 @@ import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/type import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' +import { RiStoreLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { useKeyPress } from 'ahooks' import { @@ -39,7 +40,7 @@ import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' -import { fetchAppDetailDirect } from '@/service/apps' +import { fetchAppDetailDirect, publishToCreatorsPlatform } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' import { systemFeaturesQueryOptions } from '@/service/system-features' import { useConvertWorkflowTypeMutation } from '@/service/use-apps' @@ -56,6 +57,7 @@ import { PublisherActionsSection, PublisherSummarySection, } from './sections' +import SuggestedAction from './suggested-action' import { getDisabledFunctionTooltip, getPublisherAppUrl, @@ -147,6 +149,7 @@ const AppPublisher = ({ const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false) const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('') const [workflowLaunchValues, setWorkflowLaunchValues] = useState>({}) + const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) const workflowStore = useContext(WorkflowContext) const appDetail = useAppStore(state => state.appDetail) @@ -441,6 +444,23 @@ const AppPublisher = ({ setWorkflowLaunchDialogOpen(false) }, [supportedWorkflowLaunchVariables, workflowLaunchTargetUrl, workflowLaunchValues]) + const handlePublishToMarketplace = useCallback(async () => { + if (!appDetail?.id || publishingToMarketplace) + return + setPublishingToMarketplace(true) + try { + const res = await publishToCreatorsPlatform({ appID: appDetail.id }) + if (res.redirect_url) + window.open(res.redirect_url, '_blank') + } + catch { + toast.error(t('common.publishToMarketplaceFailed', { ns: 'workflow' })) + } + finally { + setPublishingToMarketplace(false) + } + }, [appDetail?.id, publishingToMarketplace, t]) + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { e.preventDefault() if (publishDisabled || published) @@ -568,6 +588,19 @@ const AppPublisher = ({ workflowToolAvailable={workflowToolAvailable} workflowToolMessage={workflowToolMessage} /> + {systemFeatures.enable_creators_platform && ( +
+ } + disabled={!publishedAt || publishingToMarketplace} + onClick={handlePublishToMarketplace} + > + {publishingToMarketplace + ? t('common.publishingToMarketplace', { ns: 'workflow' }) + : t('common.publishToMarketplace', { ns: 'workflow' })} + +
+ )} ) } diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx index 8638405fcc..3b906d0db3 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -28,16 +28,31 @@ vi.mock('@/context/i18n', () => ({ })) vi.mock('@/app/components/base/file-uploader', () => ({ - FileUploaderInAttachmentWrapper: ({ onChange }: { onChange: (files: Array>) => void }) => ( - + FileUploaderInAttachmentWrapper: ({ + onChange, + value, + fileConfig, + }: { + onChange: (files?: Array>) => void + value: Array> + fileConfig: Record + }) => ( +
+ {JSON.stringify(value)} + {JSON.stringify(fileConfig)} + + +
), })) @@ -61,12 +76,6 @@ vi.mock('@/app/components/base/checkbox', () => ({ ), })) -vi.mock('@/app/components/base/select', () => ({ - default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => ( - - ), -})) - vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { const actual = await importOriginal() @@ -75,6 +84,7 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
+ {children}
), @@ -115,8 +125,8 @@ vi.mock('../../config-select', () => ({ })) vi.mock('../../config-string', () => ({ - default: ({ onChange }: { onChange: (value: number) => void }) => ( - + default: ({ onChange, maxLength }: { onChange: (value: number) => void, maxLength: number }) => ( + ), })) @@ -253,4 +263,150 @@ describe('ConfigModalFormFields', () => { fireEvent.click(screen.getByText('json-editor')) expect(jsonProps.onJSONSchemaChange).toHaveBeenCalledWith('{\n "type": "object"\n}') }) + + it('should update text input metadata and clear empty defaults for string inputs', () => { + const textProps = createBaseProps() + textProps.isStringInput = true + textProps.tempPayload = { + ...textProps.tempPayload, + type: InputVarType.textInput, + default: 'hello', + } + + render() + + const variableInput = screen.getByDisplayValue('question') + + fireEvent.click(screen.getByText('type-selector')) + fireEvent.change(variableInput, { target: { value: 'prompt' } }) + fireEvent.blur(variableInput) + fireEvent.change(screen.getByDisplayValue('Question'), { target: { value: 'Prompt Label' } }) + fireEvent.click(screen.getByText('config-string')) + fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: '' } }) + + expect(textProps.onTypeChange).toHaveBeenCalledWith({ value: InputVarType.select }) + expect(textProps.onVarNameChange).toHaveBeenCalled() + expect(textProps.onVarKeyBlur).toHaveBeenCalled() + expect(textProps.payloadChangeHandlers.label).toHaveBeenCalledWith('Prompt Label') + expect(textProps.payloadChangeHandlers.max_length).toHaveBeenCalledWith(64) + expect(textProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined) + }) + + it('should clear select defaults and apply uploader fallback values', () => { + const selectProps = createBaseProps() + selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: 'alpha' } + selectProps.options = ['alpha', ' ', 'beta'] + render() + + fireEvent.click(screen.getByText('ui-select-empty')) + expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined) + + const singleFallbackProps = createBaseProps() + singleFallbackProps.tempPayload = { + ...singleFallbackProps.tempPayload, + type: InputVarType.singleFile, + default: undefined, + } + render() + + expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('[]') + expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_types":["document"]') + expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_upload_methods":["remote_url"]') + expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"number_limits":1') + fireEvent.click(screen.getAllByTestId('upload-empty-file')[0]!) + expect(singleFallbackProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined) + + const multiFallbackProps = createBaseProps() + multiFallbackProps.tempPayload = { + ...multiFallbackProps.tempPayload, + type: InputVarType.multiFiles, + default: undefined, + max_length: undefined, + } + render() + + expect(screen.getAllByTestId('file-uploader-value')[1]).toHaveTextContent('[]') + expect(screen.getAllByTestId('file-uploader-config')[1]).toHaveTextContent('"number_limits":5') + fireEvent.click(screen.getAllByTestId('upload-empty-file')[1]!) + expect(multiFallbackProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined) + }) + + it('should clear number defaults and skip rendering the default selector when options are missing', () => { + const numberProps = createBaseProps() + numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: '9' } + render() + + fireEvent.change(screen.getByDisplayValue('9'), { target: { value: '' } }) + expect(numberProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined) + + const selectWithoutOptionsProps = createBaseProps() + selectWithoutOptionsProps.tempPayload = { ...selectWithoutOptionsProps.tempPayload, type: InputVarType.select } + selectWithoutOptionsProps.options = undefined + render() + + expect(screen.getAllByText('config-select')).toHaveLength(1) + expect(screen.queryByText('ui-select:__empty__')).not.toBeInTheDocument() + }) + + it('should preserve existing select and file defaults when present', () => { + const selectProps = createBaseProps() + selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: undefined } + selectProps.options = ['alpha', 'beta'] + render() + + expect(screen.getByText('ui-select:__empty__')).toBeInTheDocument() + + const existingFile = { fileId: 'existing-file', type: 'local_file', url: 'https://example.com/existing.png' } + const singleFileProps = createBaseProps() + singleFileProps.tempPayload = { + ...singleFileProps.tempPayload, + type: InputVarType.singleFile, + default: existingFile, + } + render() + + expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('"fileId":"existing-file"') + + const existingFiles = [ + { fileId: 'file-1', type: 'local_file', url: 'https://example.com/1.png' }, + { fileId: 'file-2', type: 'remote_url', url: 'https://example.com/2.png' }, + ] + const multiFileProps = createBaseProps() + multiFileProps.tempPayload = { + ...multiFileProps.tempPayload, + type: InputVarType.multiFiles, + default: existingFiles, + max_length: 2, + } + render() + + expect(screen.getAllByTestId('file-uploader-value')[1]).toHaveTextContent('"fileId":"file-1"') + expect(screen.getAllByTestId('file-uploader-config')[1]).toHaveTextContent('"number_limits":2') + }) + + it('should render empty fallback values for text, paragraph, and number defaults', () => { + const textProps = createBaseProps() + textProps.isStringInput = true + textProps.tempPayload = { ...textProps.tempPayload, type: InputVarType.textInput, default: undefined } + const textView = render() + + expect(screen.getAllByPlaceholderText('variableConfig.inputPlaceholder')[2]).toHaveValue('') + expect(screen.getByText('config-string')).toHaveAttribute('data-max-length', '256') + textView.unmount() + + const paragraphProps = createBaseProps() + paragraphProps.isStringInput = true + paragraphProps.tempPayload = { ...paragraphProps.tempPayload, type: InputVarType.paragraph, default: undefined } + const paragraphView = render() + + expect(screen.getByText('config-string')).toHaveAttribute('data-max-length', 'Infinity') + expect(paragraphView.container.querySelector('textarea')).toHaveValue('') + paragraphView.unmount() + + const numberProps = createBaseProps() + numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: undefined } + render() + + expect(screen.getByRole('spinbutton')).toHaveValue(null) + }) }) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx index d7e09f9578..d32bcec755 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx @@ -44,7 +44,7 @@ vi.mock('../form-fields', () => ({ > invalid-name-change - + diff --git a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx index 6726ba0583..91fe47d83d 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx @@ -10,6 +10,72 @@ vi.mock('@/next/navigation', () => ({ usePathname: () => '/test', })) +vi.mock('@langgenius/dify-ui/popover', async () => { + const React = await import('react') + const PopoverContext = React.createContext({ + open: false, + setOpen: (_open: boolean) => {}, + }) + + const Popover = ({ + children, + open: controlledOpen, + onOpenChange, + }: { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + }) => { + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false) + const isControlled = controlledOpen !== undefined + const open = isControlled ? !!controlledOpen : uncontrolledOpen + const setOpen = (nextOpen: boolean) => { + if (!isControlled) + setUncontrolledOpen(nextOpen) + onOpenChange?.(nextOpen) + } + + return ( + + {children} + + ) + } + + const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { + const { open, setOpen } = React.useContext(PopoverContext) + return ( +
setOpen(!open)} + > + {render} +
+ ) + } + + const PopoverContent = ({ + children, + ...props + }: React.HTMLAttributes & { children?: React.ReactNode }) => { + const { open } = React.useContext(PopoverContext) + if (!open) + return null + + return ( +
+ {children} +
+ ) + } + + return { + Popover, + PopoverTrigger, + PopoverContent, + } +}) + type PortalToFollowElemProps = { children: React.ReactNode open?: boolean @@ -209,20 +275,17 @@ describe('ContextVar', () => { // Act render() - const triggers = screen.getAllByTestId('portal-trigger') - const varPickerTrigger = triggers[triggers.length - 1] + const varPickerTrigger = screen.getByTestId('popover-trigger') await user.click(varPickerTrigger!) - expect(screen.getByTestId('portal-content'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() // Select a different option - const options = screen.getAllByText('var2') - expect(options.length).toBeGreaterThan(0) - await user.click(options[0]!) + await user.click(screen.getByText('var2')) // Assert expect(onChange).toHaveBeenCalledWith('var2') - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() }) it('should toggle dropdown when clicking the trigger button', async () => { @@ -233,16 +296,15 @@ describe('ContextVar', () => { // Act render() - const triggers = screen.getAllByTestId('portal-trigger') - const varPickerTrigger = triggers[triggers.length - 1] + const varPickerTrigger = screen.getByTestId('popover-trigger') // Open dropdown await user.click(varPickerTrigger!) - expect(screen.getByTestId('portal-content'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() // Close dropdown await user.click(varPickerTrigger!) - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx index 1d81a31091..7890343720 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx @@ -18,18 +18,21 @@ type PortalToFollowElemProps = { type PortalToFollowElemTriggerProps = React.HTMLAttributes & { children?: React.ReactNode, asChild?: boolean } type PortalToFollowElemContentProps = React.HTMLAttributes & { children?: React.ReactNode } -vi.mock('@/app/components/base/portal-to-follow-elem', () => { - const PortalContext = React.createContext({ open: false }) +vi.mock('@langgenius/dify-ui/popover', () => { + const PortalContext = React.createContext({ + open: false, + onOpenChange: undefined as ((open: boolean) => void) | undefined, + }) - const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => { + const Popover = ({ children, open, onOpenChange }: PortalToFollowElemProps) => { return ( - +
{children}
) } - const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => { + const PopoverContent = ({ children, ...props }: PortalToFollowElemContentProps) => { const { open } = React.useContext(PortalContext) if (!open) return null @@ -40,24 +43,41 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => { ) } - const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => { + const PopoverTrigger = ({ children, asChild, render, ...props }: PortalToFollowElemTriggerProps & { render?: React.ReactNode }) => { + const { open, onOpenChange } = React.useContext(PortalContext) + const content = render ?? children + const handleClick = (e: React.MouseEvent) => { + props.onClick?.(e) + if (!props.onClick) + onOpenChange?.(!open) + } + + if (React.isValidElement(content)) { + return React.cloneElement(content, { + ...props, + 'onClick': handleClick, + 'data-testid': 'portal-trigger', + } as React.HTMLAttributes) + } + if (asChild && React.isValidElement(children)) { return React.cloneElement(children, { ...props, + 'onClick': handleClick, 'data-testid': 'portal-trigger', } as React.HTMLAttributes) } return ( -
- {children} +
+ {content}
) } return { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, + Popover, + PopoverContent, + PopoverTrigger, } }) diff --git a/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx b/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx index d29b2e34df..9bac1c7a41 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx @@ -3,15 +3,15 @@ import type { FC } from 'react' import type { IInputTypeIconProps } from '@/app/components/app/configuration/config-var/input-type-icon' import { ChevronDownIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import IconTypeIcon from '@/app/components/app/configuration/config-var/input-type-icon' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' type Option = { name: string, value: string, type: string } export type Props = { @@ -33,6 +33,7 @@ const VarItem: FC<{ item: Option }> = ({ item }) => (
) + const VarPicker: FC = ({ triggerClassName, className, @@ -45,47 +46,51 @@ const VarPicker: FC = ({ const [open, setOpen] = useState(false) const currItem = options.find(item => item.value === value) const notSetVar = !currItem + return ( - - setOpen(v => !v)}> -
-
- {value - ? ( - - ) - : ( -
- {notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })} -
- )} + + +
+
+ {currItem + ? ( + + ) + : ( +
+ {notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })} +
+ )} +
+ +
- -
-
- + )} + /> + {options.length > 0 ? (
- {options.map(({ name, value, type }, index) => ( + {options.map(({ name, value, type }) => (
{ onChange(value) @@ -103,9 +108,9 @@ const VarPicker: FC = ({
{t('feature.dataSet.queryVariable.noVarTip', { ns: 'appDebug' })}
)} - - - + + ) } + export default React.memo(VarPicker) diff --git a/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx b/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx index 39db0da1ec..9fb79076d2 100644 --- a/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx @@ -40,28 +40,49 @@ vi.mock('@/app/components/base/input', () => ({ ), })) -vi.mock('@/app/components/base/select', () => ({ - default: ({ defaultValue, onSelect, items, disabled, className }: { - defaultValue: string - onSelect: (item: { value: string }) => void - items: { name: string, value: string }[] - allowSearch?: boolean +vi.mock('@langgenius/dify-ui/select', async () => { + const React = await import('react') + const SelectContext = React.createContext<{ disabled?: boolean - className?: string - }) => ( - - ), -})) + onValueChange?: (value: string) => void + }>({}) + + return { + Select: ({ children, disabled, onValueChange }: { + children: React.ReactNode + disabled?: boolean + onValueChange?: (value: string) => void + }) => ( + +
{children}
+
+ ), + SelectTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => { + const context = React.useContext(SelectContext) + return ( +
+ + +
+ ) + }, + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + const context = React.useContext(SelectContext) + return ( + + ) + }, + SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItemIndicator: () => null, + } +}) vi.mock('@/app/components/base/textarea', () => ({ default: ({ value, onChange, placeholder, readOnly, className }: { @@ -410,11 +431,24 @@ describe('ChatUserInput', () => { })) render() - fireEvent.change(screen.getByTestId('select-input'), { target: { value: 'B' } }) + fireEvent.click(screen.getByTestId('select-B')) expect(mockSetInputs).toHaveBeenCalledWith({ choice: 'B' }) }) + it('should ignore empty select updates', () => { + mockUseContext.mockReturnValue(createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B', 'C'] }), + ]), + })) + + render() + fireEvent.click(screen.getByTestId('select-empty')) + + expect(mockSetInputs).not.toHaveBeenCalled() + }) + it('should call setInputs when number input changes', () => { mockUseContext.mockReturnValue(createContextValue({ modelConfig: createModelConfig([ @@ -443,20 +477,30 @@ describe('ChatUserInput', () => { }) it('should not call setInputs for unknown keys', () => { + const filteredPromptVariables = { + length: 1, + forEach: vi.fn(), + map: (callback: (value: ExtendedPromptVariable, index: number) => unknown) => [ + callback(createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), 0), + ], + } mockUseContext.mockReturnValue(createContextValue({ - modelConfig: createModelConfig([ - createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), - ]), + modelConfig: { + ...createModelConfig(), + configs: { + prompt_template: '', + prompt_variables: { + filter: () => filteredPromptVariables, + } as unknown as PromptVariable[], + }, + }, })) render() - // The component filters by promptVariableObj, so unknown keys won't trigger updates - // This is tested indirectly - only valid keys should trigger setInputs fireEvent.change(screen.getByTestId('input-Name'), { target: { value: 'Valid' } }) - expect(mockSetInputs).toHaveBeenCalledTimes(1) - expect(mockSetInputs).toHaveBeenCalledWith({ name: 'Valid' }) + expect(mockSetInputs).not.toHaveBeenCalled() }) }) @@ -652,7 +696,7 @@ describe('ChatUserInput', () => { render() const select = screen.getByTestId('select-input') expect(select).toBeInTheDocument() - expect(select.children).toHaveLength(0) + expect(screen.queryAllByRole('option')).toHaveLength(0) }) it('should handle select with undefined options', () => { diff --git a/web/app/components/app/configuration/debug/chat-user-input.tsx b/web/app/components/app/configuration/debug/chat-user-input.tsx index b1285b712c..2eff7ac3ca 100644 --- a/web/app/components/app/configuration/debug/chat-user-input.tsx +++ b/web/app/components/app/configuration/debug/chat-user-input.tsx @@ -1,11 +1,11 @@ import type { Inputs } from '@/models/debug' import { cn } from '@langgenius/dify-ui/cn' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import * as React from 'react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import Input from '@/app/components/base/input' -import Select from '@/app/components/base/select' import Textarea from '@/app/components/base/textarea' import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' import ConfigContext from '@/context/debug-configuration' @@ -102,13 +102,26 @@ const ChatUserInput = ({ )} {type === 'select' && ( )} {type === 'number' && ( ({ + Button: ({ + children, + onClick, + disabled, + className, + }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + className?: string + }) => ( + + ), +})) + vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => selector({ setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal, @@ -24,15 +47,51 @@ vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({ ), })) -vi.mock('@/app/components/base/select', () => ({ - default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => ( - - ), -})) +vi.mock('@langgenius/dify-ui/select', async () => { + const React = await import('react') + const SelectContext = React.createContext<{ + onValueChange?: (value: string) => void + }>({}) + + return { + Select: ({ children, onValueChange }: { + children: React.ReactNode + onValueChange?: (value: string) => void + }) => ( + +
{children}
+
+ ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => { + const context = React.useContext(SelectContext) + return ( +
+ + +
+ ) + }, + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + const context = React.useContext(SelectContext) + return ( + + ) + }, + SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItemIndicator: () => null, + } +}) vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({ - default: ({ onChange }: { onChange: (value: boolean) => void }) => ( - + default: ({ name, onChange }: { name: string, onChange: (value: boolean) => void }) => ( + ), })) @@ -121,7 +180,7 @@ describe('PromptValuePanel', () => { }) const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' }) - expect(runButton).not.toBeDisabled() + expect(runButton).toHaveAttribute('data-disabled', 'false') fireEvent.click(runButton) await waitFor(() => expect(mockOnSend).toHaveBeenCalledTimes(1)) }) @@ -137,9 +196,22 @@ describe('PromptValuePanel', () => { }) const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' }) - expect(runButton).toBeDisabled() - fireEvent.click(runButton) - expect(mockOnSend).not.toHaveBeenCalled() + expect(runButton).toHaveAttribute('data-disabled', 'true') + }) + + it('invokes the tooltip-branch run handler when the click callback is triggered', () => { + renderPanel({ + context: { + mode: AppModeEnum.CHAT, + }, + props: { + appType: AppModeEnum.CHAT, + }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'appDebug.inputs.run' })) + + expect(mockOnSend).toHaveBeenCalledTimes(1) }) it('hydrates default values, supports advanced prompt gating, and toggles the feature panel', () => { @@ -163,12 +235,33 @@ describe('PromptValuePanel', () => { }) expect(mockSetInputs).toHaveBeenCalledWith({ textVar: 'default text' }) - expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true') fireEvent.click(screen.getByText('feature bar')) expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalled() }) + it('disables run for advanced completion mode when the completion prompt is empty', () => { + renderPanel({ + context: { + isAdvancedMode: true, + modelModeType: ModelModeType.completion, + completionPromptConfig: { + prompt: { text: '' }, + conversation_histories_role: { user_prefix: 'user', assistant_prefix: 'assistant' }, + }, + modelConfig: { + configs: { + prompt_template: '', + prompt_variables: [], + }, + }, + }, + }) + + expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true') + }) + it('renders paragraph, select, number, checkbox, and vision inputs', () => { const onVisionFilesChange = vi.fn() renderPanel({ @@ -203,13 +296,13 @@ describe('PromptValuePanel', () => { }) fireEvent.change(screen.getByPlaceholderText('Paragraph Var'), { target: { value: 'updated paragraph' } }) - fireEvent.click(screen.getByText('select-input')) + fireEvent.click(screen.getByText('b')) fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } }) fireEvent.click(screen.getByText('bool-input')) fireEvent.click(screen.getByText('image-uploader')) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ paragraphVar: 'updated paragraph' })) - expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ selectVar: 'selected-option' })) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ selectVar: 'b' })) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ numberVar: '2' })) expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ boolVar: true })) expect(onVisionFilesChange).toHaveBeenCalledWith([ @@ -222,6 +315,127 @@ describe('PromptValuePanel', () => { ]) }) + it('ignores empty select values when choosing prompt options', () => { + renderPanel({ + context: { + modelConfig: { + configs: { + prompt_template: 'prompt template', + prompt_variables: [ + { key: 'selectVar', name: 'Select Var', type: 'select', options: ['a', 'b'], required: false }, + ], + }, + }, + }, + props: { + inputs: { + selectVar: 'a', + }, + }, + }) + + fireEvent.click(screen.getByTestId('select-empty')) + + expect(mockSetInputs).not.toHaveBeenCalled() + }) + + it('ignores updates when the rendered field is not tracked in the prompt variable lookup', () => { + const filteredPromptVariables = { + length: 1, + forEach: vi.fn(), + map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [ + callback({ key: 'textVar', name: 'Text Var', type: 'string', required: true }, 0), + ], + } + + renderPanel({ + context: { + modelConfig: { + configs: { + prompt_template: 'prompt template', + prompt_variables: { + filter: () => filteredPromptVariables, + }, + }, + }, + }, + props: { + inputs: { textVar: '' }, + }, + }) + + fireEvent.change(screen.getByPlaceholderText('Text Var'), { target: { value: 'ignored' } }) + + expect(mockSetInputs).not.toHaveBeenCalled() + }) + + it('renders empty select and number placeholders when no value is provided', () => { + renderPanel({ + context: { + modelConfig: { + configs: { + prompt_template: 'prompt template', + prompt_variables: [ + { key: 'selectVar', name: 'Select Var', type: 'select', required: false }, + { key: 'numberVar', name: 'Number Var', type: 'number', required: true }, + ], + }, + }, + }, + props: { + inputs: { + selectVar: '', + numberVar: '', + }, + }, + }) + + expect(screen.getByText('common.placeholder.select')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Number Var')).toHaveValue(null) + expect(screen.queryAllByRole('option')).toHaveLength(0) + }) + + it('falls back to the checkbox key when the label is missing from the rendered collection', () => { + const filteredPromptVariables = { + length: 1, + forEach: vi.fn(), + map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [ + callback({ key: 'boolVar', name: '', type: 'checkbox', required: false }, 0), + ], + } + + renderPanel({ + context: { + modelConfig: { + configs: { + prompt_template: 'prompt template', + prompt_variables: { + filter: () => filteredPromptVariables, + }, + }, + }, + }, + props: { + inputs: { + boolVar: false, + }, + }, + }) + + expect(screen.getByTestId('bool-input-boolVar')).toBeInTheDocument() + }) + + it('marks actions as disabled when readonly even if the prompt is runnable', () => { + renderPanel({ + context: { + readonly: true, + }, + }) + + expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute('data-disabled', 'true') + expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true') + }) + it('collapses the user input panel and hides the clear and run actions', () => { renderPanel() diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index 94bc48da29..c3ba69bf34 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -4,6 +4,7 @@ import type { Inputs } from '@/models/debug' import type { VisionFile, VisionSettings } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { RiArrowDownSLine, RiArrowRightSLine, @@ -17,7 +18,6 @@ import { useStore as useAppStore } from '@/app/components/app/store' import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar' import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader' import Input from '@/app/components/base/input' -import Select from '@/app/components/base/select' import Textarea from '@/app/components/base/textarea' import Tooltip from '@/app/components/base/tooltip' import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' @@ -156,14 +156,26 @@ const PromptValuePanel: FC = ({ )} {type === 'select' && ( )} {type === 'number' && ( { />, ) - expect(screen.getByText('importFromDSL'))!.toBeInTheDocument() + expect(screen.getByText('importApp'))!.toBeInTheDocument() await waitFor(() => { expect(screen.getByText('demo.yml'))!.toBeInTheDocument() @@ -161,7 +161,7 @@ describe('CreateFromDSLModal', () => { }) expect(screen.getByPlaceholderText('importFromDSLUrlPlaceholder'))!.toBeInTheDocument() - const closeTrigger = screen.getByText('importFromDSL').parentElement?.querySelector('.cursor-pointer.items-center') as HTMLElement + const closeTrigger = screen.getByText('importApp').parentElement?.querySelector('.cursor-pointer.items-center') as HTMLElement fireEvent.click(closeTrigger) expect(handleClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index 4f99fe9027..bc5f352634 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -225,7 +225,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS onClose={noop} >
- {t('importFromDSL', { ns: 'app' })} + {t('importApp', { ns: 'app' })}
onClose()} diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index b8ac9af607..3a3eea4d5c 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -5,6 +5,7 @@ import type { AppDetailResponse } from '@/models/app' import type { AppIconType, AppSSO, Language } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react' @@ -19,7 +20,6 @@ import { SparklesSoft } from '@/app/components/base/icons/src/public/common' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' import PremiumBadge from '@/app/components/base/premium-badge' -import { SimpleSelect } from '@/app/components/base/select' import Textarea from '@/app/components/base/textarea' import Tooltip from '@/app/components/base/tooltip' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' @@ -57,6 +57,10 @@ export type ConfigParams = { } const prefixSettings = 'overview.appInfo.settings' +type SelectOption = { + value: string + name: string +} const SettingsModal: FC = ({ isChat, @@ -110,6 +114,8 @@ const SettingsModal: FC = ({ const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext() const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() const isFreePlan = plan.type === 'sandbox' + const languageOptions: SelectOption[] = languages.filter(item => item.supported) + const selectedLanguage = languageOptions.find(item => item.value === language) const handlePlanClick = useCallback(() => { if (isFreePlan) setShowPricingModal() @@ -303,13 +309,26 @@ const SettingsModal: FC = ({ {/* language */}
{t(`${prefixSettings}.language`, { ns: 'appOverview' })}
- item.supported)} - defaultValue={language} - onSelect={item => setLanguage(item.value as Language)} - notClearable - /> +
{/* theme color */} {isChat && ( diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx index 2e0d1bcc84..94fa9f3484 100644 --- a/web/app/components/apps/__tests__/index.spec.tsx +++ b/web/app/components/apps/__tests__/index.spec.tsx @@ -7,9 +7,21 @@ import { useContextSelector } from 'use-context-selector' import AppListContext from '@/context/app-list-context' import { fetchAppDetail } from '@/service/explore' import { AppModeEnum } from '@/types/app' - import Apps from '../index' +vi.mock('@/next/dynamic', () => ({ + default: (loader: () => Promise<{ default: React.ComponentType }>) => { + const LazyComp = React.lazy(loader) + return function DynamicWrapper(props: Record) { + return React.createElement( + React.Suspense, + { fallback: null }, + React.createElement(LazyComp, props), + ) + } + }, +})) + let documentTitleCalls: string[] = [] let educationInitCalls: number = 0 const mockHandleImportDSL = vi.fn() @@ -65,6 +77,16 @@ vi.mock('@/hooks/use-import-dsl', () => ({ }), })) +const mockReplace = vi.fn() +let mockSearchParams = new URLSearchParams() + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + }), + useSearchParams: () => mockSearchParams, +})) + vi.mock('../list', () => { const MockList = () => { const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) @@ -129,6 +151,16 @@ vi.mock('../../app/create-from-dsl-modal/dsl-confirm-modal', () => ({ ), })) +vi.mock('../import-from-marketplace-template-modal', () => ({ + default: ({ templateId, onClose, onConfirm }: { templateId: string, onClose: () => void, onConfirm: (dsl: string) => void }) => ( +
+ {templateId} + + +
+ ), +})) + vi.mock('@/service/explore', () => ({ fetchAppDetail: vi.fn(), })) @@ -161,6 +193,8 @@ describe('Apps', () => { vi.clearAllMocks() documentTitleCalls = [] educationInitCalls = 0 + mockSearchParams = new URLSearchParams() + mockReplace.mockClear() mockFetchAppDetail.mockResolvedValue({ id: 'template-1', name: 'Sample App', @@ -304,6 +338,66 @@ describe('Apps', () => { }) }) + describe('Marketplace Template', () => { + it('should render the template modal when template-id is in search params', async () => { + mockSearchParams = new URLSearchParams('template-id=tpl-42') + renderWithClient() + + expect(await screen.findByTestId('marketplace-template-modal')).toBeInTheDocument() + expect(screen.getByTestId('template-id')).toHaveTextContent('tpl-42') + }) + + it('should not render the template modal when no template-id is present', () => { + renderWithClient() + + expect(screen.queryByTestId('marketplace-template-modal')).not.toBeInTheDocument() + }) + + it('should close the template modal and remove template-id from URL', async () => { + mockSearchParams = new URLSearchParams('template-id=tpl-42') + renderWithClient() + + fireEvent.click(await screen.findByTestId('close-template')) + + expect(mockReplace).toHaveBeenCalledTimes(1) + const replaceArg = mockReplace.mock.calls[0]![0] as string + expect(replaceArg).not.toContain('template-id') + }) + + it('should import DSL from marketplace template on confirm', async () => { + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + mockSearchParams = new URLSearchParams('template-id=tpl-42') + renderWithClient() + + fireEvent.click(await screen.findByTestId('confirm-template')) + + await waitFor(() => { + expect(mockHandleImportDSL).toHaveBeenCalledWith( + { mode: 'yaml-content', yaml_content: 'yaml-dsl-content' }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ) + expect(mockReplace).toHaveBeenCalled() + }) + }) + + it('should show DSL confirm modal when marketplace import is pending', async () => { + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { + options.onPending?.() + }) + mockSearchParams = new URLSearchParams('template-id=tpl-42') + renderWithClient() + + fireEvent.click(await screen.findByTestId('confirm-template')) + + await waitFor(() => { + expect(screen.getByTestId('dsl-confirm-modal')).toBeInTheDocument() + expect(mockReplace).toHaveBeenCalled() + }) + }) + }) + describe('Styling', () => { it('should have overflow-y-auto class', () => { const { container } = renderWithClient() diff --git a/web/app/components/apps/import-from-marketplace-template-modal.tsx b/web/app/components/apps/import-from-marketplace-template-modal.tsx new file mode 100644 index 0000000000..a6a3dee8e4 --- /dev/null +++ b/web/app/components/apps/import-from-marketplace-template-modal.tsx @@ -0,0 +1,182 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { toast } from '@langgenius/dify-ui/toast' +import { RiCloseLine } from '@remixicon/react' +import { useCallback, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { + fetchMarketplaceTemplateDSL, + useMarketplaceTemplateDetail, +} from '@/service/marketplace-templates' + +type ImportFromMarketplaceTemplateModalProps = { + templateId: string + onClose: () => void + onConfirm: (dslContent: string) => void +} + +const ImportFromMarketplaceTemplateModal = ({ + templateId, + onClose, + onConfirm, +}: ImportFromMarketplaceTemplateModalProps) => { + const { t } = useTranslation() + const { data, isLoading, isError } = useMarketplaceTemplateDetail(templateId) + const template = data?.data + const [importing, setImporting] = useState(false) + const isImportingRef = useRef(false) + + const CATEGORY_I18N_MAP: Record = useMemo(() => ({ + marketing: t('marketplace.template.category.marketing', { ns: 'app' }), + sales: t('marketplace.template.category.sales', { ns: 'app' }), + support: t('marketplace.template.category.support', { ns: 'app' }), + operations: t('marketplace.template.category.operations', { ns: 'app' }), + it: t('marketplace.template.category.it', { ns: 'app' }), + knowledge: t('marketplace.template.category.knowledge', { ns: 'app' }), + design: t('marketplace.template.category.design', { ns: 'app' }), + }), [t]) + + const translateCategory = useCallback((slug: string) => { + return CATEGORY_I18N_MAP[slug] ?? slug + }, [CATEGORY_I18N_MAP]) + + const handleConfirm = useCallback(async () => { + if (isImportingRef.current) + return + isImportingRef.current = true + setImporting(true) + try { + const dsl = await fetchMarketplaceTemplateDSL(templateId) + onConfirm(dsl) + } + catch { + toast.error(t('marketplace.template.importFailed', { ns: 'app' })) + } + finally { + setImporting(false) + isImportingRef.current = false + } + }, [templateId, onConfirm, t]) + + return ( + { + if (!open) + onClose() + }} + > + +
+ {t('marketplace.template.modalTitle', { ns: 'app' })} +
+ +
+
+ +
+ {isLoading && ( +
+
Loading...
+
+ )} + + {isError && ( +
+
+ {t('marketplace.template.fetchFailed', { ns: 'app' })} +
+
+ )} + + {template && ( +
+
+ {template.icon_file_key + ? ( + {template.template_name} + ) + : ( +
+ {template.icon || '📄'} +
+ )} +
+
{template.template_name}
+
+ + {t('marketplace.template.publishedBy', { ns: 'app' })} + {' '} + {template.publisher_unique_handle} + + · + + {t('marketplace.template.usageCount', { ns: 'app' })} + {' '} + {template.usage_count} + +
+
+
+ + {template.overview && ( +
+
+ {t('marketplace.template.overview', { ns: 'app' })} +
+
+ {template.overview} +
+
+ )} + + {template.categories.length > 0 && ( +
+ {template.categories.map(cat => ( + + {translateCategory(cat)} + + ))} +
+ )} +
+ )} +
+ +
+ + +
+
+
+ ) +} + +export default ImportFromMarketplaceTemplateModal diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index 9f23e42bb9..9623299336 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -9,6 +9,7 @@ import useDocumentTitle from '@/hooks/use-document-title' import { useImportDSL } from '@/hooks/use-import-dsl' import { DSLImportMode } from '@/models/app' import dynamic from '@/next/dynamic' +import { useRouter, useSearchParams } from '@/next/navigation' import { fetchAppDetail } from '@/service/explore' import { trackCreateApp } from '@/utils/create-app-tracking' import List from './list' @@ -22,11 +23,16 @@ type AppsProps = { const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false }) const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false }) const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false }) +const ImportFromMarketplaceTemplateModal = dynamic(() => import('./import-from-marketplace-template-modal'), { ssr: false }) const Apps = ({ pageType = 'apps', }: AppsProps) => { const { t } = useTranslation() + const searchParams = useSearchParams() + const { replace } = useRouter() + const templateId = searchParams.get('template-id') + const templateDismissedRef = useRef(false) useDocumentTitle(pageType === 'apps' ? t('menus.apps', { ns: 'common' }) @@ -68,6 +74,14 @@ const Apps = ({ const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) + const handleCloseTemplateModal = useCallback(() => { + templateDismissedRef.current = true + const params = new URLSearchParams(searchParams.toString()) + params.delete('template-id') + const query = params.toString() + replace(query ? `?${query}` : window.location.pathname, { scroll: false }) + }, [searchParams, replace]) + const { handleImportDSL, handleImportDSLConfirm, @@ -84,6 +98,22 @@ const Apps = ({ }) }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) + const handleMarketplaceTemplateConfirm = useCallback(async (dslContent: string) => { + await handleImportDSL({ + mode: DSLImportMode.YAML_CONTENT, + yaml_content: dslContent, + }, { + onSuccess: () => { + handleCloseTemplateModal() + onSuccess() + }, + onPending: () => { + handleCloseTemplateModal() + setShowDSLConfirmModal(true) + }, + }) + }, [handleImportDSL, handleCloseTemplateModal, onSuccess]) + const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, @@ -162,6 +192,14 @@ const Apps = ({ onHide={() => setIsShowCreateModal(false)} /> )} + + {templateId && !templateDismissedRef.current && ( + + )}
) diff --git a/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx b/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx index 07dd809f41..7f452e64e9 100644 --- a/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from 'react' import type { Area } from 'react-easy-crop' import type { ImageFile } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -122,11 +123,11 @@ describe('AppIconPicker', () => { }) } - const renderPicker = () => { + const renderPicker = (props: Partial> = {}) => { const onSelect = vi.fn() const onClose = vi.fn() - const { container } = render() + const { container } = render() return { onSelect, onClose, container } } @@ -220,6 +221,20 @@ describe('AppIconPicker', () => { expect(onSelect).not.toHaveBeenCalled() }) + + it('should submit the initial emoji when provided', async () => { + const { onSelect } = renderPicker({ initialEmoji: { icon: 'rabbit', background: '#E4FBCC' } }) + + await userEvent.click(screen.getByText(/ok/i)) + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith({ + type: 'emoji', + icon: 'rabbit', + background: '#E4FBCC', + }) + }) + }) }) describe('Image Upload', () => { diff --git a/web/app/components/base/app-icon-picker/index.tsx b/web/app/components/base/app-icon-picker/index.tsx index 77bc0cd434..64a88f16e1 100644 --- a/web/app/components/base/app-icon-picker/index.tsx +++ b/web/app/components/base/app-icon-picker/index.tsx @@ -34,12 +34,17 @@ export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection type AppIconPickerProps = { onSelect?: (payload: AppIconSelection) => void onClose?: () => void + initialEmoji?: { + icon: string + background?: string | null + } className?: string } const AppIconPicker: FC = ({ onSelect, onClose, + initialEmoji, className, }) => { const { t } = useTranslation() @@ -138,7 +143,14 @@ const AppIconPicker: FC = ({
)} - {activeTab === 'emoji' && } + {activeTab === 'emoji' && ( + + )} {activeTab === 'image' && } diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx index 6081024490..126ee77ae5 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx @@ -270,7 +270,7 @@ describe('InputsFormContent', () => { renderWithContext(, context) const selNodes = screen.getAllByText('Sel') expect(selNodes.length).toBeGreaterThan(0) - expect(screen.queryByText('existing')).toBeNull() + expect(screen.getByText('existing')).toBeInTheDocument() }) it('handles select input empty branches (no current value -> show placeholder)', () => { diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx index 4baa46744d..380b914492 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx @@ -1,9 +1,9 @@ +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import * as React from 'react' import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' import Input from '@/app/components/base/input' -import { PortalSelect } from '@/app/components/base/select' import Textarea from '@/app/components/base/textarea' import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' @@ -85,13 +85,22 @@ const InputsFormContent = ({ showTip }: Props) => { /> )} {form.type === InputVarType.select && ( - ({ value: option, name: option }))} - onSelect={item => handleFormChange(form.variable, item.value as string)} - placeholder={form.label} - /> + )} {form.type === InputVarType.singleFile && ( { const { t } = useTranslation() const [open, setOpen] = useState(false) return ( - - setOpen(v => !v)} + + + + )} + /> + - - - - -
@@ -39,8 +39,8 @@ const ViewFormDropdown = () => {
-
-
+ + ) } diff --git a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx index 3a2cec9820..1b8518e869 100644 --- a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx +++ b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx @@ -6,6 +6,8 @@ import { useDocumentDownload } from '@/service/knowledge/use-document' import { downloadUrl } from '@/utils/download' import Popup from '../popup' +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) + vi.mock('@/service/knowledge/use-document', () => ({ useDocumentDownload: vi.fn(), })) diff --git a/web/app/components/base/chat/chat/citation/popup.tsx b/web/app/components/base/chat/chat/citation/popup.tsx index 2b4070b69a..96b5dac596 100644 --- a/web/app/components/base/chat/chat/citation/popup.tsx +++ b/web/app/components/base/chat/chat/citation/popup.tsx @@ -1,13 +1,9 @@ import type { FC, MouseEvent } from 'react' import type { Resources } from './index' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Fragment, useState } from 'react' import { useTranslation } from 'react-i18next' import FileIcon from '@/app/components/base/file-icon' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import Link from '@/next/link' import { useDocumentDownload } from '@/service/knowledge/use-document' import { downloadUrl } from '@/utils/download' @@ -47,22 +43,25 @@ const Popup: FC = ({ } return ( - - setOpen(v => !v)}> -
- -
{data.documentName}
-
-
- + + +
{data.documentName}
+
+ )} + /> +
@@ -156,8 +155,8 @@ const Popup: FC = ({
-
-
+ + ) } diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx index 733e3b1101..a6d49c4e61 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx @@ -1,9 +1,9 @@ +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import * as React from 'react' import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' import Input from '@/app/components/base/input' -import { PortalSelect } from '@/app/components/base/select' import Textarea from '@/app/components/base/textarea' import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' @@ -85,13 +85,22 @@ const InputsFormContent = ({ showTip }: Props) => { /> )} {form.type === InputVarType.select && ( - ({ value: option, name: option }))} - onSelect={item => handleFormChange(form.variable, item.value as string)} - placeholder={form.label} - /> + )} {form.type === InputVarType.singleFile && ( - setOpen(v => !v)}> - -
- - - + +
+ + )} + /> +
-
- + + ) } diff --git a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx index 8cc22693b6..83e873ab89 100644 --- a/web/app/components/base/copy-feedback/__tests__/index.spec.tsx +++ b/web/app/components/base/copy-feedback/__tests__/index.spec.tsx @@ -40,11 +40,11 @@ describe('CopyFeedback', () => { expect(mockCopy).toHaveBeenCalledWith('test content') }) - it('calls reset on mouse leave', () => { + it('does not reset on mouse leave (relies on hook timeout)', () => { render() const button = screen.getByRole('button') fireEvent.mouseLeave(button.firstChild as Element) - expect(mockReset).toHaveBeenCalledTimes(1) + expect(mockReset).not.toHaveBeenCalled() }) }) }) @@ -88,11 +88,11 @@ describe('CopyFeedbackNew', () => { expect(mockCopy).toHaveBeenCalledWith('test content') }) - it('calls reset on mouse leave', () => { + it('does not reset on mouse leave (relies on hook timeout)', () => { const { container } = render() const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement fireEvent.mouseLeave(clickableArea) - expect(mockReset).toHaveBeenCalledTimes(1) + expect(mockReset).not.toHaveBeenCalled() }) }) }) diff --git a/web/app/components/base/copy-feedback/index.tsx b/web/app/components/base/copy-feedback/index.tsx index 5210066670..431b697a6a 100644 --- a/web/app/components/base/copy-feedback/index.tsx +++ b/web/app/components/base/copy-feedback/index.tsx @@ -19,7 +19,10 @@ const prefixEmbedded = 'overview.appInfo.embedded' const CopyFeedback = ({ content }: Props) => { const { t } = useTranslation() - const { copied, copy, reset } = useClipboard() + // Rely on useClipboard's own timer to flip `copied` back to false so the + // "Copied" tooltip stays visible long enough to be read, matching the + // KeyValueItem pattern. Do NOT reset on mouse leave. + const { copied, copy } = useClipboard({ timeout: 2000 }) const tooltipText = copied ? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' }) @@ -36,10 +39,7 @@ const CopyFeedback = ({ content }: Props) => { popupContent={safeText} > -
+
{copied && } {!copied && }
@@ -52,7 +52,7 @@ export default CopyFeedback export const CopyFeedbackNew = ({ content, className }: Pick) => { const { t } = useTranslation() - const { copied, copy, reset } = useClipboard() + const { copied, copy } = useClipboard({ timeout: 2000 }) const tooltipText = copied ? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' }) @@ -73,7 +73,6 @@ export const CopyFeedbackNew = ({ content, className }: Pick
diff --git a/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx b/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx index ea4f1bb928..d37647f358 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx @@ -3,6 +3,20 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react' import dayjs from '../../utils/dayjs' import DatePicker from '../index' +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) +vi.mock('@langgenius/dify-ui/button', () => ({ + Button: ({ children, onClick, disabled, className }: { + children?: React.ReactNode + onClick?: () => void + disabled?: boolean + className?: string + }) => ( + + ), +})) + // Mock scrollIntoView beforeAll(() => { Element.prototype.scrollIntoView = vi.fn() @@ -113,14 +127,13 @@ describe('DatePicker', () => { render() openPicker() + expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true') - // Simulate a mousedown event outside the container act(() => { document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) }) - // The picker should now be closed - input shows its value - // The picker should now be closed - input shows its value + expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false') expect(screen.getByRole('textbox'))!.toBeInTheDocument() }) }) diff --git a/web/app/components/base/date-and-time-picker/date-picker/index.tsx b/web/app/components/base/date-and-time-picker/date-picker/index.tsx index 9c84e4c096..7858fa2fbe 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/index.tsx @@ -1,14 +1,10 @@ import type { Dayjs } from 'dayjs' import type { DatePickerProps, Period } from '../types' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import Calendar from '../calendar' import TimePickerHeader from '../time-picker/header' import TimePickerOptions from '../time-picker/options' @@ -35,15 +31,14 @@ const DatePicker = ({ needTimePicker = true, renderTrigger, triggerWrapClassName, - popupZIndexClassname = 'z-11', + popupZIndexClassname, noConfirm, getIsDateDisabled, }: DatePickerProps) => { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) const [view, setView] = useState(ViewType.date) - const containerRef = useRef(null) - const isInitial = useRef(true) + const isInitialRef = useRef(true) // Normalize the value to ensure that all subsequent uses are Day.js objects. const normalizedValue = useMemo(() => { @@ -62,46 +57,41 @@ const DatePicker = ({ const [selectedYear, setSelectedYear] = useState(() => (inputValue || defaultValue).year()) useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false) - setView(ViewType.date) - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) - - useEffect(() => { - if (isInitial.current) { - isInitial.current = false + if (isInitialRef.current) { + isInitialRef.current = false return } clearMonthMapCache() if (normalizedValue) { const newValue = getDateWithTimezone({ date: normalizedValue, timezone }) + // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state. setCurrentDate(newValue) + // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value. setSelectedDate(newValue) onChange(newValue) } else { + // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state. setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone })) + // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value. setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined) } + // eslint-disable-next-line react/exhaustive-deps -- this effect intentionally runs only when timezone changes. }, [timezone]) - const handleClickTrigger = (e: React.MouseEvent) => { - e.stopPropagation() - if (isOpen) { - setIsOpen(false) - return - } + const handleOpenChange = useCallback((nextOpen: boolean) => { + setIsOpen(nextOpen) setView(ViewType.date) - setIsOpen(true) - if (normalizedValue) { + if (nextOpen && normalizedValue) { setCurrentDate(normalizedValue) setSelectedDate(normalizedValue) } + }, [normalizedValue]) + + const handleClickTrigger = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + handleOpenChange(!isOpen) } const handleClear = (e: React.MouseEvent) => { @@ -210,21 +200,21 @@ const DatePicker = ({ const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('defaultPlaceholder', { ns: 'time' })) return ( - - - {renderTrigger - ? ( - renderTrigger({ - value: normalizedValue, - selectedDate, - isOpen, - handleClear, - handleClickTrigger, - })) +
)} - - + /> +
{/* Header */} {view === ViewType.date @@ -319,8 +314,8 @@ const DatePicker = ({ ) }
-
- + + ) } diff --git a/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx b/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx index 7fbed3a736..0d02b3b5d5 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx @@ -3,6 +3,20 @@ import { fireEvent, render, screen, within } from '@testing-library/react' import dayjs, { isDayjsObject } from '../../utils/dayjs' import TimePicker from '../index' +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) +vi.mock('@langgenius/dify-ui/button', () => ({ + Button: ({ children, onClick, disabled, className }: { + children?: React.ReactNode + onClick?: () => void + disabled?: boolean + className?: string + }) => ( + + ), +})) + // Mock scrollIntoView since the test DOM runtime doesn't implement it beforeAll(() => { Element.prototype.scrollIntoView = vi.fn() @@ -106,7 +120,7 @@ describe('TimePicker', () => { expect(input)!.toHaveValue('') fireEvent.mouseDown(document.body) - expect(input)!.toHaveValue('') + expect(input)!.toHaveValue('10:00 AM') }) it('should call onClear when clear is clicked while picker is closed', () => { diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx index e07ea177a5..34ec21f0a5 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx @@ -1,14 +1,10 @@ import type { Dayjs } from 'dayjs' import type { TimePickerProps } from '../types' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import TimezoneLabel from '@/app/components/base/timezone-label' import { Period } from '../types' import dayjs, { @@ -43,31 +39,20 @@ const TimePicker = ({ }: TimePickerProps) => { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) - const containerRef = useRef(null) - const isInitial = useRef(true) + const isInitialRef = useRef(true) // Initialize selectedTime const [selectedTime, setSelectedTime] = useState(() => { return toDayjs(value, { timezone }) }) - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - /* v8 ignore next 2 -- outside-click closing is handled by PortalToFollowElem; this local ref guard is a defensive fallback. */ - if (containerRef.current && !containerRef.current.contains(event.target as Node)) - setIsOpen(false) - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) - // Track previous values to avoid unnecessary updates const prevValueRef = useRef(value) const prevTimezoneRef = useRef(timezone) useEffect(() => { - if (isInitial.current) { - isInitial.current = false + if (isInitialRef.current) { + isInitialRef.current = false // Save initial values on first render prevValueRef.current = value prevTimezoneRef.current = timezone @@ -91,6 +76,7 @@ const TimePicker = ({ if (!dayjsValue) return + // eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time. setSelectedTime(dayjsValue) if (timezoneChanged && !valueChanged) @@ -98,6 +84,7 @@ const TimePicker = ({ return } + // eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time. setSelectedTime((prev) => { if (!isDayjsObject(prev)) return undefined @@ -105,24 +92,30 @@ const TimePicker = ({ }) }, [timezone, value, onChange]) - const handleClickTrigger = (e: React.MouseEvent) => { - e.stopPropagation() - if (isOpen) { - setIsOpen(false) + const syncSelectedTimeFromValue = useCallback(() => { + if (!value) return - } - setIsOpen(true) - if (value) { - const dayjsValue = toDayjs(value, { timezone }) - const needsUpdate = dayjsValue && ( - !selectedTime - || !isDayjsObject(selectedTime) - || !dayjsValue.isSame(selectedTime, 'minute') - ) - if (needsUpdate) - setSelectedTime(dayjsValue) - } + const dayjsValue = toDayjs(value, { timezone }) + const needsUpdate = dayjsValue && ( + !selectedTime + || !isDayjsObject(selectedTime) + || !dayjsValue.isSame(selectedTime, 'minute') + ) + if (needsUpdate) + setSelectedTime(dayjsValue) + }, [selectedTime, timezone, value]) + + const handleOpenChange = useCallback((nextOpen: boolean) => { + setIsOpen(nextOpen) + if (nextOpen) + syncSelectedTimeFromValue() + }, [syncSelectedTimeFromValue]) + + const handleClickTrigger = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + handleOpenChange(!isOpen) } const handleClear = (e: React.MouseEvent) => { @@ -132,7 +125,7 @@ const TimePicker = ({ onClear() } - const handleTimeSelect = (hour: string, minute: string, period: Period) => { + const handleTimeSelect = useCallback((hour: string, minute: string, period: Period) => { const periodAdjustedHour = to24Hour(hour, period) const nextMinute = Number.parseInt(minute, 10) setSelectedTime((prev) => { @@ -145,7 +138,7 @@ const TimePicker = ({ .set('second', 0) .set('millisecond', 0) }) - } + }, [timezone]) const getSafeTimeObject = useCallback(() => { if (isDayjsObject(selectedTime)) @@ -156,17 +149,17 @@ const TimePicker = ({ const handleSelectHour = useCallback((hour: string) => { const time = getSafeTimeObject() handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period) - }, [getSafeTimeObject]) + }, [getSafeTimeObject, handleTimeSelect]) const handleSelectMinute = useCallback((minute: string) => { const time = getSafeTimeObject() handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period) - }, [getSafeTimeObject]) + }, [getSafeTimeObject, handleTimeSelect]) const handleSelectPeriod = useCallback((period: Period) => { const time = getSafeTimeObject() handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period) - }, [getSafeTimeObject]) + }, [getSafeTimeObject, handleTimeSelect]) const handleSelectCurrentTime = useCallback(() => { const newDate = getDateWithTimezone({ timezone }) @@ -207,18 +200,19 @@ const TimePicker = ({ /> ) return ( - - - {renderTrigger - ? (renderTrigger({ +
)} -
- + /> +
{/* Header */}
@@ -258,8 +257,8 @@ const TimePicker = ({ />
-
- + + ) } diff --git a/web/app/components/base/date-and-time-picker/types.ts b/web/app/components/base/date-and-time-picker/types.ts index 0068ec22ac..2773fb7bc7 100644 --- a/web/app/components/base/date-and-time-picker/types.ts +++ b/web/app/components/base/date-and-time-picker/types.ts @@ -28,7 +28,7 @@ export type DatePickerProps = { onChange: (date: Dayjs | undefined) => void onClear: () => void triggerWrapClassName?: string - renderTrigger?: (props: TriggerProps) => React.ReactNode + renderTrigger?: (props: TriggerProps) => React.ReactElement minuteFilter?: (minutes: string[]) => string[] popupZIndexClassname?: string noConfirm?: boolean @@ -62,7 +62,7 @@ export type TimePickerProps = { placeholder?: string onChange: (date: Dayjs | undefined) => void onClear: () => void - renderTrigger?: (props: TriggerParams) => React.ReactNode + renderTrigger?: (props: TriggerParams) => React.ReactElement title?: string minuteFilter?: (minutes: string[]) => string[] popupClassName?: string diff --git a/web/app/components/base/emoji-picker/Inner.tsx b/web/app/components/base/emoji-picker/Inner.tsx index e2595c5efb..36a98f7dd1 100644 --- a/web/app/components/base/emoji-picker/Inner.tsx +++ b/web/app/components/base/emoji-picker/Inner.tsx @@ -45,20 +45,21 @@ type IEmojiPickerInnerProps = { } const EmojiPickerInner: FC = ({ + emoji, + background, onSelect, className, }) => { const { categories } = data as EmojiMartData - const [selectedEmoji, setSelectedEmoji] = useState('') - const [selectedBackground, setSelectedBackground] = useState(backgroundColors[0]) - const [showStyleColors, setShowStyleColors] = useState(false) + const [selectedEmoji, setSelectedEmoji] = useState(emoji || '') + const [selectedBackground, setSelectedBackground] = useState(background || backgroundColors[0]) + const [showStyleColors, setShowStyleColors] = useState(!!emoji) const [searchedEmojis, setSearchedEmojis] = useState([]) const [isSearching, setIsSearching] = useState(false) React.useEffect(() => { if (selectedEmoji) { - setShowStyleColors(true) /* v8 ignore next 2 - @preserve */ if (selectedBackground) onSelect?.(selectedEmoji, selectedBackground) @@ -105,6 +106,7 @@ const EmojiPickerInner: FC = ({ className="inline-flex h-10 w-10 items-center justify-center rounded-lg" onClick={() => { setSelectedEmoji(emoji) + setShowStyleColors(true) }} >
@@ -130,6 +132,7 @@ const EmojiPickerInner: FC = ({ className="inline-flex h-10 w-10 items-center justify-center rounded-lg" onClick={() => { setSelectedEmoji(emoji) + setShowStyleColors(true) }} >
diff --git a/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx b/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx index f0cf3091d7..41683d7af3 100644 --- a/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx +++ b/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx @@ -45,6 +45,15 @@ describe('EmojiPickerInner', () => { expect(screen.getByText('food'))!.toBeInTheDocument() expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument() }) + + it('initializes selected emoji and background when provided', async () => { + render() + + expect(screen.getByText('Choose Style'))!.toBeInTheDocument() + await waitFor(() => { + expect(mockOnSelect).toHaveBeenCalledWith('rabbit', '#E4FBCC') + }) + }) }) describe('User Interactions', () => { diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx index dc111a680b..6259c7cb4f 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx @@ -61,7 +61,7 @@ describe('FileUploadSettings (setting-modal)', () => { }) }) - it('should call onOpen with toggle function when trigger is clicked', () => { + it('should call onOpen with true when trigger is clicked', () => { const onOpen = vi.fn() renderWithProvider( @@ -71,12 +71,7 @@ describe('FileUploadSettings (setting-modal)', () => { fireEvent.click(screen.getByText('Upload Settings')) - expect(onOpen).toHaveBeenCalled() - // The toggle function should flip the open state - const toggleFn = onOpen.mock.calls[0]![0] - expect(typeof toggleFn).toBe('function') - expect(toggleFn(false)).toBe(true) - expect(toggleFn(true)).toBe(false) + expect(onOpen).toHaveBeenCalledWith(true) }) it('should not call onOpen when disabled', () => { diff --git a/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx b/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx index 2a09f63bee..a1c6bffbe0 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx @@ -1,16 +1,16 @@ 'use client' import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { memo } from 'react' import SettingContent from '@/app/components/base/features/new-feature-panel/file-upload/setting-content' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' type FileUploadSettingsProps = { open: boolean - onOpen: (state: any) => void + onOpen: (state: boolean) => void onChange?: OnFeaturesChange disabled?: boolean children?: React.ReactNode @@ -25,18 +25,27 @@ const FileUploadSettings = ({ imageUpload, }: FileUploadSettingsProps) => { return ( - { + if (disabled) + return + onOpen(nextOpen) }} > - !disabled && onOpen((open: boolean) => !open)}> - {children} - - + + {children} +
+ )} + /> +
- - +
+ ) } export default memo(FileUploadSettings) diff --git a/web/app/components/base/features/new-feature-panel/moderation/__tests__/form-generation.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/__tests__/form-generation.spec.tsx index e5176e2066..a131180be0 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/__tests__/form-generation.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/__tests__/form-generation.spec.tsx @@ -132,8 +132,8 @@ describe('FormGeneration', () => { }) render() - fireEvent.click(screen.getByText(/placeholder\.select/)) - fireEvent.click(screen.getByText('GPT-4')) + fireEvent.click(screen.getByRole('combobox')) + fireEvent.click(screen.getByRole('option', { name: 'GPT-4' })) expect(onChange).toHaveBeenCalledWith({ model: 'gpt-4' }) }) @@ -152,7 +152,7 @@ describe('FormGeneration', () => { render() expect(screen.getByText('模型')).toBeInTheDocument() - fireEvent.click(screen.getByText(/placeholder\.select/)) - expect(screen.getByText('智谱-4')).toBeInTheDocument() + fireEvent.click(screen.getByRole('combobox')) + expect(screen.getByRole('option', { name: '智谱-4' })).toBeInTheDocument() }) }) diff --git a/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx b/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx index 57b579b431..0392aa2b55 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' import type { CodeBasedExtensionForm } from '@/models/common' import type { ModerationConfig } from '@/models/debug' -import { PortalSelect } from '@/app/components/base/select' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import Textarea from '@/app/components/base/textarea' import { useLocale } from '@/context/i18n' @@ -24,53 +24,65 @@ const FormGeneration: FC = ({ return ( <> { - forms.map((form, index) => ( -
-
- {locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']} -
- { - form.type === 'text-input' && ( - handleFormChange(form.variable, e.target.value)} - /> - ) - } - { - form.type === 'paragraph' && ( -
-