diff --git a/.github/workflows/cli-edge.yml b/.github/workflows/cli-edge.yml new file mode 100644 index 00000000000..159f3545e13 --- /dev/null +++ b/.github/workflows/cli-edge.yml @@ -0,0 +1,74 @@ +name: CLI Edge Publish + +on: + push: + branches: [main] + paths: + - 'cli/**' + - 'packages/contracts/generated/api/openapi/**' + workflow_dispatch: + +concurrency: + group: difyctl-edge-publish + cancel-in-progress: false + +jobs: + publish: + name: build + publish edge to R2 + runs-on: ${{ github.repository == 'langgenius/dify' && 'depot-ubuntu-24.04' || 'ubuntu-24.04' }} + if: vars.DIFYCTL_R2_BUCKET != '' + defaults: + run: + shell: bash + working-directory: ./cli + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Enable cross-arch native prebuilds + working-directory: ./ + run: cat cli/scripts/cross-arch.pnpm.yaml >> pnpm-workspace.yaml + + - name: Setup web environment + uses: ./.github/actions/setup-web + + - name: Setup Bun + uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2 + with: + bun-version-file: cli/.bun-version + + - name: Compute edge version + id: ver + run: echo "version=$(node scripts/release-naming.mjs edge-version "$(git rev-parse --short HEAD)")" >> "$GITHUB_OUTPUT" + + - name: Compile standalone binaries (all targets, all-or-nothing) + run: | + CLI_VERSION="${{ steps.ver.outputs.version }}" \ + DIFYCTL_CHANNEL=edge \ + DIFYCTL_COMMIT="$(git rev-parse HEAD)" \ + DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \ + pnpm build:bin + + - name: Generate sha256 checksums + run: CLI_VERSION="${{ steps.ver.outputs.version }}" scripts/release-write-checksums.sh + + - name: Smoke the runner-arch binary + run: ./dist/bin/difyctl-v${{ steps.ver.outputs.version }}-linux-x64 version + + - name: Publish to R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.DIFYCTL_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFYCTL_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED + AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED + DIFYCTL_R2_S3_ENDPOINT: ${{ vars.DIFYCTL_R2_S3_ENDPOINT }} + DIFYCTL_R2_BUCKET: ${{ vars.DIFYCTL_R2_BUCKET }} + DIFYCTL_R2_PUBLIC_BASE: ${{ vars.DIFYCTL_R2_PUBLIC_BASE }} + DIFYCTL_COMMIT: ${{ github.sha }} + run: | + DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \ + scripts/release-r2-publish.sh edge "${{ steps.ver.outputs.version }}" diff --git a/.github/workflows/deploy-agent.yml b/.github/workflows/deploy-agent.yml new file mode 100644 index 00000000000..e244bb3f949 --- /dev/null +++ b/.github/workflows/deploy-agent.yml @@ -0,0 +1,28 @@ +name: Deploy Agent + +permissions: + contents: read + +on: + workflow_run: + workflows: ["Build and Push API & Web"] + branches: + - "deploy/agent" + types: + - completed + +jobs: + deploy: + runs-on: depot-ubuntu-24.04 + if: | + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'deploy/agent' + steps: + - name: Deploy to server + uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5 + with: + host: ${{ secrets.AGENT_SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + ${{ vars.SSH_SCRIPT_AGENT || secrets.SSH_SCRIPT_AGENT }} diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 29503d7b6b0..56cf8d5fca4 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -113,7 +113,7 @@ jobs: run: vp exec playwright install --with-deps chromium - name: Run dify-ui tests - run: vp test run --coverage --silent=passed-only + run: vp test run --project unit --coverage --silent=passed-only - name: Report coverage if: ${{ env.CODECOV_TOKEN != '' }} @@ -123,3 +123,26 @@ jobs: flags: dify-ui env: CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }} + + dify-ui-storybook-test: + name: dify-ui Storybook Tests + runs-on: depot-ubuntu-24.04-4 + defaults: + run: + shell: bash + working-directory: ./packages/dify-ui + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup web environment + uses: ./.github/actions/setup-web + + - name: Install Chromium for Browser Mode + run: vp exec playwright install --with-deps chromium + + - name: Run dify-ui Storybook tests + run: vp run test:storybook diff --git a/Makefile b/Makefile index be665e71231..801c59cc281 100644 --- a/Makefile +++ b/Makefile @@ -157,7 +157,7 @@ build-web: build-api: @echo "Building API Docker image: $(API_IMAGE):$(VERSION)..." - docker build -t $(API_IMAGE):$(VERSION) ./api + docker build -t $(API_IMAGE):$(VERSION) -f api/Dockerfile . @echo "API Docker image built successfully: $(API_IMAGE):$(VERSION)" # Push Docker images diff --git a/api/.env.example b/api/.env.example index f645ba7bf02..8a2af53c6e7 100644 --- a/api/.env.example +++ b/api/.env.example @@ -774,3 +774,11 @@ EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000 ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true # Human input timeout check interval in minutes HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1 + +# Nacos remote settings source HTTP timeouts (seconds). +# Bound how long requests to the Nacos endpoint wait before failing, so a slow or +# unresponsive Nacos server cannot stall API startup or token refresh. +# Read timeout for Nacos requests (default: 10.0) +DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0 +# Connect timeout for Nacos requests (default: 3.0) +DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0 diff --git a/api/Dockerfile.dockerignore b/api/Dockerfile.dockerignore index 8ec1323c3f6..1ce0ddb0aea 100644 --- a/api/Dockerfile.dockerignore +++ b/api/Dockerfile.dockerignore @@ -8,18 +8,30 @@ !dify-agent/src/ !dify-agent/src/** -api/.venv -api/.venv/** -api/.env -api/*.env.* -api/.idea -api/.mypy_cache -api/.ruff_cache -api/storage/generate_files/* -api/storage/privkeys/* -api/storage/tools/* -api/storage/upload_files/* -api/logs -api/*.log* +# Environment configuration and example +.env +*.env.* + +# Python related files **/__pycache__ **/*.pyc +**/.venv/ +**/.mypy_cache/ +**/.ruff_cache/ +**/.import_linter_cache/ +**/.pytest_cache/ +**/.hypothesis/ + + +# Upload files and logs +api/storage/** +api/logs/ +api/*.log* + +# Tests +api/tests + + +# Editor configuration +**/.vscode/ +**/.idea/ diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 0915c127eba..55944929ddc 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -19,6 +19,7 @@ from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers import ExitIntent from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID +from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig from dify_agent.layers.dify_plugin import ( DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, @@ -38,6 +39,7 @@ from dify_agent.protocol import ( DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID, CreateRunRequest, + DeferredToolResultsPayload, LayerExitSignals, RunComposition, RunLayerSpec, @@ -53,6 +55,7 @@ AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt" DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context" DIFY_DRIVE_LAYER_ID = "drive" DIFY_PLUGIN_TOOLS_LAYER_ID = "tools" +DIFY_ASK_HUMAN_LAYER_ID = "ask_human" DIFY_SHELL_LAYER_ID = "shell" @@ -139,11 +142,18 @@ class AgentBackendWorkflowNodeRunInput(BaseModel): # Drive Skills & Files declaration (dify.drive) — an index the agent pulls # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. drive_config: DifyDriveLayerConfig | None = None + # Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when + # the Agent Soul configures human involvement; a deferred call ends the run and + # the workflow pauses via the existing HITL form mechanism (ENG-635). + ask_human_config: DifyAskHumanLayerConfig | None = None # Inject the sandboxed shell layer (dify.shell). Requires the agent backend # to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED. include_shell: bool = False shell_config: DifyShellLayerConfig | None = None session_snapshot: CompositorSessionSnapshot | None = None + # Human tool results fed back into a continuation run after a HITL submission + # (ENG-638). Keyed by the original deferred tool_call_id. + deferred_tool_results: DeferredToolResultsPayload | None = None include_history: bool = True suspend_on_exit: bool = True metadata: dict[str, JsonValue] = Field(default_factory=dict) @@ -178,11 +188,17 @@ class AgentBackendAgentAppRunInput(BaseModel): # Drive Skills & Files declaration (dify.drive) — an index the agent pulls # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. drive_config: DifyDriveLayerConfig | None = None + # Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when + # the Agent Soul configures human involvement (ENG-635). + ask_human_config: DifyAskHumanLayerConfig | None = None # Inject the sandboxed shell layer (dify.shell). Requires the agent backend # to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED. include_shell: bool = False shell_config: DifyShellLayerConfig | None = None session_snapshot: CompositorSessionSnapshot | None = None + # Human tool results fed back into a continuation run after a HITL submission + # (ENG-638). Keyed by the original deferred tool_call_id. + deferred_tool_results: DeferredToolResultsPayload | None = None include_history: bool = True suspend_on_exit: bool = True metadata: dict[str, JsonValue] = Field(default_factory=dict) @@ -284,6 +300,19 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.ask_human_config is not None: + # Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends + # the run with a deferred_tool_call; the caller pauses (workflow HITL) and + # later resumes with deferred_tool_results. Needs the history layer above. + layers.append( + RunLayerSpec( + name=DIFY_ASK_HUMAN_LAYER_ID, + type=DIFY_ASK_HUMAN_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=run_input.ask_human_config, + ) + ) + if run_input.include_shell: # Sandboxed bash workspace (dify.shell). Depends on execution_context so # the agent server can mint per-command Agent Stub env (back proxy); @@ -318,6 +347,7 @@ class AgentBackendRunRequestBuilder: idempotency_key=run_input.idempotency_key, metadata=run_input.metadata, session_snapshot=run_input.session_snapshot, + deferred_tool_results=run_input.deferred_tool_results, on_exit=LayerExitSignals( default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE, ), @@ -453,6 +483,19 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.ask_human_config is not None: + # Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends + # the run with a deferred_tool_call; the caller pauses (workflow HITL) and + # later resumes with deferred_tool_results. Needs the history layer above. + layers.append( + RunLayerSpec( + name=DIFY_ASK_HUMAN_LAYER_ID, + type=DIFY_ASK_HUMAN_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=run_input.ask_human_config, + ) + ) + if run_input.include_shell: # Sandboxed bash workspace (dify.shell). Depends on execution_context so # the agent server can mint per-command Agent Stub env (back proxy); @@ -487,6 +530,7 @@ class AgentBackendRunRequestBuilder: idempotency_key=run_input.idempotency_key, metadata=run_input.metadata, session_snapshot=run_input.session_snapshot, + deferred_tool_results=run_input.deferred_tool_results, on_exit=LayerExitSignals( default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE, ), diff --git a/api/commands/__init__.py b/api/commands/__init__.py index 86f6faa78c2..ea4c5aaa2a8 100644 --- a/api/commands/__init__.py +++ b/api/commands/__init__.py @@ -11,6 +11,7 @@ from .data_migration import ( migration_data_wizard, ) from .plugin import ( + backfill_plugin_auto_upgrade, extract_plugins, extract_unique_plugins, install_plugins, @@ -49,6 +50,7 @@ from .vector import ( __all__ = [ "add_qdrant_index", "archive_workflow_runs", + "backfill_plugin_auto_upgrade", "clean_expired_messages", "clean_workflow_runs", "cleanup_orphaned_draft_variables", diff --git a/api/commands/plugin.py b/api/commands/plugin.py index e1b3cf0fa1e..71c19f842fb 100644 --- a/api/commands/plugin.py +++ b/api/commands/plugin.py @@ -1,10 +1,11 @@ import json import logging +import time from typing import Any, cast import click from pydantic import TypeAdapter -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.engine import CursorResult from configs import dify_config @@ -15,11 +16,13 @@ from core.plugin.plugin_service import PluginService from core.tools.utils.system_encryption import encrypt_system_params from extensions.ext_database import db from models import Tenant +from models.account import TenantPluginAutoUpgradeStrategy from models.oauth import DatasourceOauthParamConfig, DatasourceProvider from models.provider_ids import DatasourceProviderID, ToolProviderID from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding from models.tools import ToolOAuthSystemClient from services.plugin.data_migration import PluginDataMigration +from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService from services.plugin.plugin_migration import PluginMigration logger = logging.getLogger(__name__) @@ -402,6 +405,110 @@ def migrate_data_for_plugin(): click.echo(click.style("Migrate data for plugin completed.", fg="green")) +def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None): + category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory) + stmt = ( + select(TenantPluginAutoUpgradeStrategy.tenant_id) + .group_by(TenantPluginAutoUpgradeStrategy.tenant_id) + .having(func.count(func.distinct(TenantPluginAutoUpgradeStrategy.category)) < category_count) + .order_by(TenantPluginAutoUpgradeStrategy.tenant_id) + ) + + if limit is not None: + stmt = stmt.limit(limit) + + return stmt + + +def _count_auto_upgrade_strategy_tenant_ids(limit: int | None) -> int: + candidate_stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).subquery() + return db.session.scalar(select(func.count()).select_from(candidate_stmt)) or 0 + + +def _iter_auto_upgrade_strategy_tenant_ids(limit: int | None): + stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).execution_options(yield_per=1000) + yield from db.session.scalars(stmt) + + +@click.command( + "backfill-plugin-auto-upgrade", + help="Backfill category-scoped plugin auto-upgrade strategies and normalize plugin lists.", +) +@click.option("--tenant-id", multiple=True, help="Tenant ID to backfill. Can be passed multiple times.") +@click.option("--limit", type=int, default=None, help="Maximum number of candidate tenants to process.") +@click.option("--batch-size", type=int, default=500, show_default=True, help="Progress reporting batch size.") +@click.option("--dry-run", is_flag=True, help="Only print candidate tenant count.") +def backfill_plugin_auto_upgrade( + tenant_id: tuple[str, ...], + limit: int | None, + batch_size: int, + dry_run: bool, +): + """ + Backfill historical auto-upgrade strategies after the category column exists. + + Missing category rows are created from the tenant's tool/default row. Pure default + strategies become latest for model plugins and fix-only for all other categories. + Tenants with include/exclude plugin IDs are split + by installed plugin category using plugin daemon metadata. + """ + start_at = time.perf_counter() + candidate_count = len(tenant_id) if tenant_id else _count_auto_upgrade_strategy_tenant_ids(limit) + click.echo(click.style(f"Found {candidate_count} candidate tenants.", fg="yellow")) + + if dry_run: + elapsed = time.perf_counter() - start_at + click.echo(click.style(f"Dry run completed. elapsed={elapsed:.2f}s", fg="green")) + return + + tenant_ids = list(tenant_id) if tenant_id else _iter_auto_upgrade_strategy_tenant_ids(limit) + + backfilled_count = 0 + created_count = 0 + normalized_count = 0 + skipped_count = 0 + failed_count = 0 + for index, current_tenant_id in enumerate(tenant_ids, start=1): + try: + result = PluginAutoUpgradeService.backfill_strategy_categories( + current_tenant_id, + ) + except Exception as e: + failed_count += 1 + click.echo(click.style(f"Failed tenant {current_tenant_id}: {str(e)}", fg="red")) + continue + + if result.created_count > 0: + backfilled_count += 1 + created_count += result.created_count + elif not result.normalized: + skipped_count += 1 + if result.normalized: + normalized_count += 1 + + if batch_size > 0 and index % batch_size == 0: + click.echo( + click.style( + f"Processed {index}/{candidate_count} tenants. " + f"backfilled={backfilled_count}, created_rows={created_count}, " + f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, " + f"elapsed={time.perf_counter() - start_at:.2f}s", + fg="yellow", + ) + ) + + elapsed = time.perf_counter() - start_at + click.echo( + click.style( + f"Backfill plugin auto-upgrade strategy categories completed. " + f"backfilled={backfilled_count}, created_rows={created_count}, " + f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, " + f"elapsed={elapsed:.2f}s", + fg="green", + ) + ) + + @click.command("extract-plugins", help="Extract plugins.") @click.option("--output_file", prompt=True, help="The file to store the extracted plugins.", default="plugins.jsonl") @click.option("--workers", prompt=True, help="The number of workers to extract plugins.", default=10) diff --git a/api/configs/enterprise/__init__.py b/api/configs/enterprise/__init__.py index b3a93d97736..705ea67bcbc 100644 --- a/api/configs/enterprise/__init__.py +++ b/api/configs/enterprise/__init__.py @@ -16,7 +16,7 @@ class EnterpriseFeatureConfig(BaseSettings): CAN_REPLACE_LOGO: bool = Field( description="Allow customization of the enterprise logo.", - default=False, + default=True, ) ENTERPRISE_REQUEST_TIMEOUT: int = Field( diff --git a/api/configs/remote_settings_sources/nacos/http_request.py b/api/configs/remote_settings_sources/nacos/http_request.py index 1a0744a21bd..a15b6aeaae5 100644 --- a/api/configs/remote_settings_sources/nacos/http_request.py +++ b/api/configs/remote_settings_sources/nacos/http_request.py @@ -20,6 +20,12 @@ class NacosHttpClient: self.token: str | None = None self.token_ttl = 18000 self.token_expire_time: float = 0 + # Bounded timeouts so a slow or unresponsive Nacos server cannot hang the API + # service indefinitely during startup or token refresh. + self.timeout = httpx.Timeout( + float(os.getenv("DIFY_ENV_NACOS_REQUEST_TIMEOUT", "10.0")), + connect=float(os.getenv("DIFY_ENV_NACOS_CONNECT_TIMEOUT", "3.0")), + ) def http_request( self, url: str, method: str = "GET", headers: dict[str, str] | None = None, params: dict[str, str] | None = None @@ -28,12 +34,17 @@ class NacosHttpClient: headers = {} if params is None: params = {} + full_url = "http://" + self.server + url try: self._inject_auth_info(headers, params) - response = httpx.request(method, url="http://" + self.server + url, headers=headers, params=params) + response = httpx.request(method, url=full_url, headers=headers, params=params, timeout=self.timeout) response.raise_for_status() return response.text + except httpx.TimeoutException as e: + logger.warning("Request to Nacos timed out (url=%s, timeout=%s): %s", full_url, self.timeout, e) + return f"Request to Nacos timed out: {e}" except httpx.RequestError as e: + logger.warning("Request to Nacos failed (url=%s): %s", full_url, e) return f"Request to Nacos failed: {e}" def _inject_auth_info(self, headers: dict[str, str], params: dict[str, str], module: str = "config") -> None: @@ -78,13 +89,16 @@ class NacosHttpClient: params = {"username": self.username, "password": self.password} url = "http://" + self.server + "/nacos/v1/auth/login" try: - resp = httpx.request("POST", url, headers=None, params=params) + resp = httpx.request("POST", url, headers=None, params=params, timeout=self.timeout) resp.raise_for_status() response_data = resp.json() self.token = response_data.get("accessToken") self.token_ttl = response_data.get("tokenTtl", 18000) self.token_expire_time = current_time + self.token_ttl - 10 return self.token + except httpx.TimeoutException: + logger.exception("[get-access-token] request to Nacos timed out (url=%s, timeout=%s)", url, self.timeout) + raise except Exception: logger.exception("[get-access-token] exception occur") raise diff --git a/api/controllers/console/agent/app_helpers.py b/api/controllers/console/agent/app_helpers.py new file mode 100644 index 00000000000..51adc1e136e --- /dev/null +++ b/api/controllers/console/agent/app_helpers.py @@ -0,0 +1,10 @@ +from uuid import UUID + +from extensions.ext_database import db +from models.model import App +from services.agent.roster_service import AgentRosterService + + +def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App: + """Resolve the hidden Agent App backing an Agent Console resource.""" + return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id)) diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 5e778f67f91..6915a54db93 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -1,7 +1,10 @@ +from uuid import UUID + from flask_restx import Resource from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( account_initialization_required, @@ -35,6 +38,10 @@ register_response_schema_models( ) +def _resolve_agent_app_id(*, tenant_id: str, agent_id: UUID) -> str: + return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id).id + + @console_ns.route("/apps//workflows/draft/nodes//agent-composer") class WorkflowAgentComposerApi(Resource): @console_ns.response( @@ -176,18 +183,18 @@ class WorkflowAgentComposerSaveToRosterApi(Resource): ) -@console_ns.route("/apps//agent-composer") -class AgentAppComposerApi(Resource): +@console_ns.route("/agent//composer") +class AgentComposerApi(Resource): @console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__]) @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): + def get(self, tenant_id: str, agent_id: UUID): + app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) return dump_response( AgentAppComposerResponse, - AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id), + AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id), ) @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) @@ -196,24 +203,24 @@ class AgentAppComposerApi(Resource): @login_required @account_initialization_required @edit_permission_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_user_id @with_current_tenant_id - def put(self, tenant_id: str, account_id: str, app_model: App): + def put(self, tenant_id: str, account_id: str, agent_id: UUID): + app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) payload = ComposerSavePayload.model_validate(console_ns.payload or {}) return dump_response( AgentAppComposerResponse, AgentComposerService.save_agent_app_composer( tenant_id=tenant_id, - app_id=app_model.id, + app_id=app_id, account_id=account_id, payload=payload, ), ) -@console_ns.route("/apps//agent-composer/validate") -class AgentAppComposerValidateApi(Resource): +@console_ns.route("/agent//composer/validate") +class AgentComposerValidateApi(Resource): @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) @console_ns.response( 200, "Agent app composer validation result", console_ns.models[AgentComposerValidateResponse.__name__] @@ -221,36 +228,36 @@ class AgentAppComposerValidateApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_tenant_id - def post(self, tenant_id: str, app_model: App): + def post(self, tenant_id: str, agent_id: UUID): + _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) payload = ComposerSavePayload.model_validate(console_ns.payload or {}) ComposerConfigValidator.validate_save_payload(payload) findings = AgentComposerService.collect_validation_findings( tenant_id=tenant_id, payload=payload, - agent_id=AgentComposerService.resolve_bound_agent_id(tenant_id=tenant_id, app_id=app_model.id), + agent_id=str(agent_id), ) return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings}) -@console_ns.route("/apps//agent-composer/candidates") -class AgentAppComposerCandidatesApi(Resource): +@console_ns.route("/agent//composer/candidates") +class AgentComposerCandidatesApi(Resource): @console_ns.response( 200, "Agent app composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__] ) @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_user_id @with_current_tenant_id - def get(self, tenant_id: str, current_user_id: str, app_model: App): + def get(self, tenant_id: str, current_user_id: str, agent_id: UUID): + app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) return dump_response( AgentComposerCandidatesResponse, AgentComposerService.get_agent_app_candidates( tenant_id=tenant_id, - app_id=app_model.id, + app_id=app_id, user_id=current_user_id, ), ) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 735e5fe88b9..faa97ada0db 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -6,10 +6,21 @@ from pydantic import BaseModel, Field from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns +from controllers.console.app.app import ( + AppDetailWithSite, + AppListQuery, + AppPagination, + UpdateAppPayload, + _normalize_app_list_query_args, +) from controllers.console.wraps import ( account_initialization_required, + cloud_edition_billing_resource_check, + edit_permission_required, + enterprise_license_required, setup_required, with_current_tenant_id, + with_current_user, ) from extensions.ext_database import db from fields.agent_fields import ( @@ -18,12 +29,17 @@ from fields.agent_fields import ( AgentInviteOptionsResponse, AgentPublishedReferenceResponse, AgentRosterListResponse, - AgentRosterResponse, ) from libs.helper import dump_response from libs.login import login_required +from models import Account +from models.model import IconType +from services.agent.errors import AgentNotFoundError from services.agent.roster_service import AgentRosterService +from services.app_service import AppListParams, AppService, CreateAppParams +from services.enterprise.enterprise_service import EnterpriseService from services.entities.agent_entities import RosterListQuery +from services.feature_service import FeatureService class AgentInviteOptionsQuery(RosterListQuery): @@ -34,20 +50,38 @@ class AgentIdPath(BaseModel): agent_id: str +class AgentAppCreatePayload(BaseModel): + name: str = Field(..., min_length=1, description="Agent name") + description: str | None = Field(default=None, description="Agent description (max 400 chars)", max_length=400) + role: str = Field(default="", description="Agent role", max_length=255) + icon_type: IconType | None = Field(default=None, description="Icon type") + icon: str | None = Field(default=None, description="Icon") + icon_background: str | None = Field(default=None, description="Icon background color") + + +class AgentAppUpdatePayload(UpdateAppPayload): + role: str | None = Field(default=None, description="Agent role", max_length=255) + + register_schema_models( console_ns, + AgentAppCreatePayload, + AgentAppUpdatePayload, AgentInviteOptionsQuery, AgentIdPath, + AppListQuery, + UpdateAppPayload, RosterListQuery, ) register_response_schema_models( console_ns, + AppDetailWithSite, + AppPagination, AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentInviteOptionsResponse, AgentPublishedReferenceResponse, AgentRosterListResponse, - AgentRosterResponse, ) @@ -55,25 +89,163 @@ def _agent_roster_service() -> AgentRosterService: return AgentRosterService(db.session) -@console_ns.route("/agents") -class AgentRosterListApi(Resource): - @console_ns.doc(params=query_params_from_model(RosterListQuery)) - @console_ns.response(200, "Agent roster list", console_ns.models[AgentRosterListResponse.__name__]) +def _serialize_agent_app_detail(app_model) -> dict: + app_model = AppService().get_app(app_model) + if FeatureService.get_system_features().webapp_auth.enabled: + app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id)) + app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined] + + roster_service = _agent_roster_service() + agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=app_model.id) + if not agent: + raise AgentNotFoundError() + payload = AppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json") + payload.pop("bound_agent_id", None) + payload["app_id"] = str(app_model.id) + payload["id"] = agent.id + payload["role"] = agent.role or "" + payload["active_config_is_published"] = roster_service.active_config_is_published( + tenant_id=app_model.tenant_id, + agent=agent, + ) + return payload + + +def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: + app_ids = [str(app.id) for app in app_pagination.items] + roster_service = _agent_roster_service() + agents_by_app_id = roster_service.load_app_backing_agents_by_app_id( + tenant_id=tenant_id, + app_ids=app_ids, + ) + active_config_is_published_by_agent_id = roster_service.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=list(agents_by_app_id.values()), + ) + payload = AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json") + for item in payload["data"]: + app_id = item["id"] + item.pop("bound_agent_id", None) + agent = agents_by_app_id.get(app_id) + if agent: + item["app_id"] = app_id + item["id"] = agent.id + item["role"] = agent.role or "" + item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False) + return payload + + +def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID): + return _agent_roster_service().get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id)) + + +@console_ns.route("/agent") +class AgentAppListApi(Resource): + @console_ns.doc(params=query_params_from_model(AppListQuery)) + @console_ns.response(200, "Agent app list", console_ns.models[AppPagination.__name__]) @setup_required @login_required @account_initialization_required + @with_current_user @with_current_tenant_id - def get(self, tenant_id: str): - query = RosterListQuery.model_validate(request.args.to_dict(flat=True)) - return dump_response( - AgentRosterListResponse, - _agent_roster_service().list_roster_agents( - tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword - ), + def get(self, current_tenant_id: str, current_user: Account): + args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args)) + params = AppListParams( + page=args.page, + limit=args.limit, + mode="agent", + name=args.name, + tag_ids=args.tag_ids, + creator_ids=args.creator_ids, + is_created_by_me=args.is_created_by_me, + status="normal", ) + app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params) + if app_pagination is None: + empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) + return empty.model_dump(mode="json") -@console_ns.route("/agents/invite-options") + return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id) + + @console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__]) + @console_ns.response(201, "Agent app created successfully", console_ns.models[AppDetailWithSite.__name__]) + @console_ns.response(403, "Insufficient permissions") + @console_ns.response(400, "Invalid request parameters") + @setup_required + @login_required + @account_initialization_required + @cloud_edition_billing_resource_check("apps") + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user: Account): + args = AgentAppCreatePayload.model_validate(console_ns.payload) + params = CreateAppParams( + name=args.name, + description=args.description, + mode="agent", + agent_role=args.role, + icon_type=args.icon_type, + icon=args.icon, + icon_background=args.icon_background, + ) + + app = AppService().create_app(current_tenant_id, params, current_user) + return _serialize_agent_app_detail(app), 201 + + +@console_ns.route("/agent/") +class AgentAppApi(Resource): + @console_ns.response(200, "Agent app detail", console_ns.models[AppDetailWithSite.__name__]) + @setup_required + @login_required + @account_initialization_required + @enterprise_license_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _serialize_agent_app_detail(app_model) + + @console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__]) + @console_ns.response(200, "Agent app updated successfully", console_ns.models[AppDetailWithSite.__name__]) + @console_ns.response(403, "Insufficient permissions") + @console_ns.response(400, "Invalid request parameters") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_tenant_id + def put(self, tenant_id: str, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + args = AgentAppUpdatePayload.model_validate(console_ns.payload) + args_dict: AppService.ArgsDict = { + "name": args.name, + "description": args.description or "", + "icon_type": args.icon_type, + "icon": args.icon or "", + "icon_background": args.icon_background or "", + "use_icon_as_answer_icon": args.use_icon_as_answer_icon or False, + "max_active_requests": args.max_active_requests or 0, + "role": args.role, + } + updated = AppService().update_app(app_model, args_dict) + return _serialize_agent_app_detail(updated) + + @console_ns.response(204, "Agent app deleted successfully") + @console_ns.response(403, "Insufficient permissions") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_tenant_id + def delete(self, tenant_id: str, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + AppService().delete_app(app_model) + return "", 204 + + +@console_ns.route("/agent/invite-options") class AgentInviteOptionsApi(Resource): @console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery)) @console_ns.response(200, "Agent invite options", console_ns.models[AgentInviteOptionsResponse.__name__]) @@ -95,21 +267,7 @@ class AgentInviteOptionsApi(Resource): ) -@console_ns.route("/agents/") -class AgentRosterDetailApi(Resource): - @console_ns.response(200, "Agent detail", console_ns.models[AgentRosterResponse.__name__]) - @setup_required - @login_required - @account_initialization_required - @with_current_tenant_id - def get(self, tenant_id: str, agent_id: UUID): - return dump_response( - AgentRosterResponse, - _agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id)), - ) - - -@console_ns.route("/agents//versions") +@console_ns.route("/agent//versions") class AgentRosterVersionsApi(Resource): @console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__]) @setup_required @@ -123,7 +281,7 @@ class AgentRosterVersionsApi(Resource): ) -@console_ns.route("/agents//versions/") +@console_ns.route("/agent//versions/") class AgentRosterVersionDetailApi(Resource): @console_ns.response(200, "Agent version detail", console_ns.models[AgentConfigSnapshotDetailResponse.__name__]) @setup_required diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index 929aa1d1fff..23ccd28ad6f 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -1,5 +1,6 @@ import logging from typing import Any +from uuid import UUID from flask import request from flask_restx import Resource @@ -13,8 +14,14 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.console import console_ns +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required, with_current_user +from controllers.console.wraps import ( + account_initialization_required, + setup_required, + with_current_tenant_id, + with_current_user, +) from extensions.ext_database import db from fields.base import ResponseModel from libs.helper import uuid_value @@ -42,7 +49,7 @@ from services.file_service import FileService logger = logging.getLogger(__name__) -_AGENT_DRIVE_APP_MODES = [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] +_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] class AgentLogQuery(BaseModel): @@ -72,6 +79,10 @@ class AgentDriveDeleteFileQuery(AgentDriveMutationQuery): key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf") +class AgentDriveDeleteFileByAgentQuery(BaseModel): + key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf") + + class AgentLogMetaResponse(ResponseModel): status: str executor: str @@ -138,7 +149,7 @@ class AgentDriveDeleteResponse(ResponseModel): config_version_id: str | None = None -register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload) +register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery) register_response_schema_models( console_ns, AgentDriveDeleteResponse, @@ -152,7 +163,7 @@ register_response_schema_models( def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None: - if node_id: + if node_id and app_model.mode != AppMode.AGENT: return AgentComposerService.resolve_workflow_node_agent_id( tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id ) @@ -163,6 +174,192 @@ def _agent_not_bound() -> tuple[dict[str, str], int]: return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400 +def _upload_skill_for_app(*, current_user: Account): + if "file" not in request.files: + return {"code": "no_file", "message": "no skill file uploaded"}, 400 + if len(request.files) > 1: + return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 + + upload = request.files["file"] + content = upload.stream.read() + try: + manifest = SkillPackageService().validate_and_extract(content=content, filename=upload.filename or "") + except SkillPackageError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + + upload_file = FileService(db.engine).upload_file( + filename=upload.filename or "skill.zip", + content=content, + mimetype=upload.mimetype or "application/zip", + user=current_user, + ) + skill_ref = manifest.to_skill_ref(file_id=upload_file.id) + return {"skill": skill_ref.model_dump(exclude_none=True), "manifest": manifest.model_dump()}, 201 + + +def _standardize_skill_for_app(*, current_user: Account, app_model: App): + query = query_params_from_request(AgentDriveMutationQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + if "file" not in request.files: + return {"code": "no_file", "message": "no skill file uploaded"}, 400 + if len(request.files) > 1: + return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 + + upload = request.files["file"] + content = upload.stream.read() + try: + result = SkillStandardizeService().standardize( + content=content, + filename=upload.filename or "", + tenant_id=app_model.tenant_id, + user_id=current_user.id, + agent_id=agent_id, + ) + except (SkillPackageError, AgentDriveError) as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + return result, 201 + + +def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True): + query = query_params_from_request(AgentDriveMutationQuery) + node_id = query.node_id if allow_node_id else None + agent_id = _resolve_agent_id(app_model, node_id) + if not agent_id: + return _agent_not_bound() + payload = AgentDriveFilePayload.model_validate(console_ns.payload or {}) + + upload_file = db.session.scalar( + select(UploadFile).where( + UploadFile.id == payload.upload_file_id, + UploadFile.tenant_id == app_model.tenant_id, + ) + ) + if upload_file is None: + return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404 + + try: + key = normalize_drive_key(f"files/{upload_file.name}") + committed = AgentDriveService().commit( + tenant_id=app_model.tenant_id, + user_id=current_user.id, + agent_id=agent_id, + items=[ + DriveCommitItem( + key=key, + file_ref=DriveFileRef(kind="upload_file", id=upload_file.id), + # ADD FILE uploads exist solely to live in the drive, so the + # drive owns (and physically cleans) the value on delete. + value_owned_by_drive=True, + ) + ], + ) + except AgentDriveError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + + row = committed[0] + file_ref = AgentFileRefConfig.model_validate( + { + "id": row["key"], + "name": upload_file.name, + "file_id": upload_file.id, + "drive_key": row["key"], + "type": row.get("mime_type"), + "size": row.get("size"), + } + ) + config_version_id = AgentComposerService.add_drive_file_ref( + tenant_id=app_model.tenant_id, + agent_id=agent_id, + account_id=current_user.id, + file_ref=file_ref, + app_id=app_model.id, + node_id=node_id, + ) + return { + "file": { + "name": upload_file.name, + "drive_key": row["key"], + "file_id": upload_file.id, + "size": row.get("size"), + "mime_type": row.get("mime_type"), + }, + "config_version_id": config_version_id, + }, 201 + + +def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True): + query = query_params_from_request(AgentDriveDeleteFileQuery) + node_id = query.node_id if allow_node_id else None + agent_id = _resolve_agent_id(app_model, node_id) + if not agent_id: + return _agent_not_bound() + try: + key = normalize_drive_key(query.key) + except AgentDriveError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + + config_version_id = AgentComposerService.remove_drive_refs( + tenant_id=app_model.tenant_id, + agent_id=agent_id, + account_id=current_user.id, + file_key=key, + app_id=app_model.id, + node_id=node_id, + ) + removed_keys: list[str] = [] + try: + removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key) + except AgentDriveError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + except Exception: + # Soul-first ordering: the ref is already gone; orphan KV rows are + # harmless and an idempotent DELETE retry cleans them. + logger.exception("agent drive delete failed for key %s (soul already updated)", key) + return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id} + + +def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True): + query = query_params_from_request(AgentDriveMutationQuery) + node_id = query.node_id if allow_node_id else None + agent_id = _resolve_agent_id(app_model, node_id) + if not agent_id: + return _agent_not_bound() + if "/" in slug or not slug.strip(): + return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400 + + config_version_id = AgentComposerService.remove_drive_refs( + tenant_id=app_model.tenant_id, + agent_id=agent_id, + account_id=current_user.id, + skill_slug=slug, + app_id=app_model.id, + node_id=node_id, + ) + removed_keys: list[str] = [] + try: + removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/") + except AgentDriveError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + except Exception: + logger.exception("agent drive delete failed for skill %s (soul already updated)", slug) + return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id} + + +def _infer_skill_tools_for_app(*, app_model: App, slug: str): + query = query_params_from_request(AgentDriveMutationQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + if "/" in slug or not slug.strip(): + return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400 + try: + return SkillToolInferenceService().infer(tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug) + except SkillToolInferenceError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + + @console_ns.route("/apps//agent/logs") class AgentLogApi(Resource): @console_ns.doc("get_agent_logs") @@ -182,6 +379,23 @@ class AgentLogApi(Resource): return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id) +@console_ns.route("/agent//skills/upload") +class AgentSkillUploadByAgentApi(Resource): + @console_ns.doc("upload_agent_skill_by_agent") + @console_ns.doc(description="Upload + validate a Skill package for an Agent App") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.response(201, "Skill validated", console_ns.models[AgentSkillUploadResponse.__name__]) + @console_ns.response(400, "Invalid skill package") + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _upload_skill_for_app(current_user=current_user) + + @console_ns.route("/apps//agent/skills/upload") class AgentSkillUploadApi(Resource): @console_ns.doc("upload_agent_skill") @@ -192,7 +406,7 @@ class AgentSkillUploadApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=_AGENT_DRIVE_APP_MODES) + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) @with_current_user def post(self, current_user: Account, app_model: App): """Validate an uploaded Skill package and persist the archive. @@ -200,26 +414,28 @@ class AgentSkillUploadApi(Resource): Returns a validated skill ref (to bind into the Agent soul config on save) plus its manifest. Standardizing into the agent drive is ENG-594. """ - if "file" not in request.files: - return {"code": "no_file", "message": "no skill file uploaded"}, 400 - if len(request.files) > 1: - return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 + return _upload_skill_for_app(current_user=current_user) - upload = request.files["file"] - content = upload.stream.read() - try: - manifest = SkillPackageService().validate_and_extract(content=content, filename=upload.filename or "") - except SkillPackageError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - upload_file = FileService(db.engine).upload_file( - filename=upload.filename or "skill.zip", - content=content, - mimetype=upload.mimetype or "application/zip", - user=current_user, - ) - skill_ref = manifest.to_skill_ref(file_id=upload_file.id) - return {"skill": skill_ref.model_dump(exclude_none=True), "manifest": manifest.model_dump()}, 201 +@console_ns.route("/agent//skills/standardize") +class AgentSkillStandardizeByAgentApi(Resource): + @console_ns.doc("standardize_agent_skill_by_agent") + @console_ns.doc(description="Validate + standardize a Skill into an Agent App drive") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.response( + 201, + "Skill standardized into drive", + console_ns.models[AgentSkillStandardizeResponse.__name__], + ) + @console_ns.response(400, "Invalid skill package or no bound agent") + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _standardize_skill_for_app(current_user=current_user, app_model=app_model) @console_ns.route("/apps//agent/skills/standardize") @@ -236,32 +452,43 @@ class AgentSkillStandardizeApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=_AGENT_DRIVE_APP_MODES) + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) @with_current_user def post(self, current_user: Account, app_model: App): """Upload a Skill, validate it, and standardize it into the app agent's drive.""" - query = query_params_from_request(AgentDriveMutationQuery) - agent_id = _resolve_agent_id(app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - if "file" not in request.files: - return {"code": "no_file", "message": "no skill file uploaded"}, 400 - if len(request.files) > 1: - return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 + return _standardize_skill_for_app(current_user=current_user, app_model=app_model) - upload = request.files["file"] - content = upload.stream.read() - try: - result = SkillStandardizeService().standardize( - content=content, - filename=upload.filename or "", - tenant_id=app_model.tenant_id, - user_id=current_user.id, - agent_id=agent_id, - ) - except (SkillPackageError, AgentDriveError) as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - return result, 201 + +@console_ns.route("/agent//files") +class AgentDriveFilesByAgentApi(Resource): + @console_ns.doc("commit_agent_drive_file_by_agent") + @console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__]) + @console_ns.response( + 201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__] + ) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False) + + @console_ns.doc("delete_agent_drive_file_by_agent") + @console_ns.doc(description="Delete one Agent App drive file by key") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)}) + @console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def delete(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False) @console_ns.route("/apps//agent/files") @@ -276,73 +503,11 @@ class AgentDriveFilesApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=_AGENT_DRIVE_APP_MODES) + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) @with_current_user def post(self, current_user: Account, app_model: App): """ADD FILE: commit one uploaded file into the bound agent's drive.""" - query = query_params_from_request(AgentDriveMutationQuery) - agent_id = _resolve_agent_id(app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - payload = AgentDriveFilePayload.model_validate(console_ns.payload or {}) - - upload_file = db.session.scalar( - select(UploadFile).where( - UploadFile.id == payload.upload_file_id, - UploadFile.tenant_id == app_model.tenant_id, - ) - ) - if upload_file is None: - return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404 - - try: - key = normalize_drive_key(f"files/{upload_file.name}") - committed = AgentDriveService().commit( - tenant_id=app_model.tenant_id, - user_id=current_user.id, - agent_id=agent_id, - items=[ - DriveCommitItem( - key=key, - file_ref=DriveFileRef(kind="upload_file", id=upload_file.id), - # ADD FILE uploads exist solely to live in the drive, so the - # drive owns (and physically cleans) the value on delete. - value_owned_by_drive=True, - ) - ], - ) - except AgentDriveError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - - row = committed[0] - file_ref = AgentFileRefConfig.model_validate( - { - "id": row["key"], - "name": upload_file.name, - "file_id": upload_file.id, - "drive_key": row["key"], - "type": row.get("mime_type"), - "size": row.get("size"), - } - ) - config_version_id = AgentComposerService.add_drive_file_ref( - tenant_id=app_model.tenant_id, - agent_id=agent_id, - account_id=current_user.id, - file_ref=file_ref, - app_id=app_model.id, - node_id=query.node_id, - ) - return { - "file": { - "name": upload_file.name, - "drive_key": row["key"], - "file_id": upload_file.id, - "size": row.get("size"), - "mime_type": row.get("mime_type"), - }, - "config_version_id": config_version_id, - }, 201 + return _commit_drive_file_for_app(current_user=current_user, app_model=app_model) @console_ns.doc("delete_agent_drive_file") @console_ns.doc(description="Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)") @@ -351,36 +516,26 @@ class AgentDriveFilesApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=_AGENT_DRIVE_APP_MODES) + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) @with_current_user def delete(self, current_user: Account, app_model: App): - query = query_params_from_request(AgentDriveDeleteFileQuery) - agent_id = _resolve_agent_id(app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - try: - key = normalize_drive_key(query.key) - except AgentDriveError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code + return _delete_drive_file_for_app(current_user=current_user, app_model=app_model) - config_version_id = AgentComposerService.remove_drive_refs( - tenant_id=app_model.tenant_id, - agent_id=agent_id, - account_id=current_user.id, - file_key=key, - app_id=app_model.id, - node_id=query.node_id, - ) - removed_keys: list[str] = [] - try: - removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key) - except AgentDriveError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - except Exception: - # Soul-first ordering: the ref is already gone; orphan KV rows are - # harmless and an idempotent DELETE retry cleans them. - logger.exception("agent drive delete failed for key %s (soul already updated)", key) - return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id} + +@console_ns.route("/agent//skills/") +class AgentSkillByAgentApi(Resource): + @console_ns.doc("delete_agent_skill_by_agent") + @console_ns.doc(description="Delete a standardized skill from an Agent App drive") + @console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"}) + @console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False) @console_ns.route("/apps//agent/skills/") @@ -400,34 +555,29 @@ class AgentSkillApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=_AGENT_DRIVE_APP_MODES) + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) @with_current_user def delete(self, current_user: Account, app_model: App, slug: str): - query = query_params_from_request(AgentDriveMutationQuery) - agent_id = _resolve_agent_id(app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - if "/" in slug or not slug.strip(): - return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400 + return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug) - config_version_id = AgentComposerService.remove_drive_refs( - tenant_id=app_model.tenant_id, - agent_id=agent_id, - account_id=current_user.id, - skill_slug=slug, - app_id=app_model.id, - node_id=query.node_id, - ) - removed_keys: list[str] = [] - try: - removed_keys = AgentDriveService().delete( - tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/" - ) - except AgentDriveError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - except Exception: - logger.exception("agent drive delete failed for skill %s (soul already updated)", slug) - return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id} + +@console_ns.route("/agent//skills//infer-tools") +class AgentSkillInferToolsByAgentApi(Resource): + @console_ns.doc("infer_agent_skill_tools_by_agent") + @console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill") + @console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"}) + @console_ns.response( + 200, + "Inference result (draft suggestions, nothing persisted)", + console_ns.models[SkillToolInferenceResult.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def post(self, tenant_id: str, agent_id: UUID, slug: str): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + return _infer_skill_tools_for_app(app_model=app_model, slug=slug) @console_ns.route("/apps//agent/skills//infer-tools") @@ -451,16 +601,7 @@ class AgentSkillInferToolsApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=_AGENT_DRIVE_APP_MODES) + @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) def post(self, app_model: App, slug: str): """Suggest CLI tools/env for a skill. Saving still goes through composer validation.""" - query = query_params_from_request(AgentDriveMutationQuery) - agent_id = _resolve_agent_id(app_model, query.node_id) - if not agent_id: - return _agent_not_bound() - if "/" in slug or not slug.strip(): - return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400 - try: - return SkillToolInferenceService().infer(tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug) - except SkillToolInferenceError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code + return _infer_skill_tools_for_app(app_model=app_model, slug=slug) diff --git a/api/controllers/console/app/agent_app_access.py b/api/controllers/console/app/agent_app_access.py index 97ff1344901..4e79beac594 100644 --- a/api/controllers/console/app/agent_app_access.py +++ b/api/controllers/console/app/agent_app_access.py @@ -5,25 +5,31 @@ reference. This exposes the read-only "Workflow access" surface from the PRD: which workflow apps use this Agent, without leaking the workflows' internals. """ +from uuid import UUID + from flask_restx import Resource from pydantic import Field from controllers.common.schema import register_response_schema_models from controllers.console import console_ns -from controllers.console.app.wraps import get_app_model +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id from extensions.ext_database import db from fields.base import ResponseModel from libs.login import login_required -from models.model import App, AppMode from services.agent.roster_service import AgentRosterService class AgentReferencingWorkflowResponse(ResponseModel): app_id: str app_name: str + app_icon_type: str | None = None + app_icon: str | None = None + app_icon_background: str | None = None app_mode: str + app_updated_at: int | None = None workflow_id: str + workflow_version: str node_ids: list[str] = Field(default_factory=list) @@ -34,23 +40,23 @@ class AgentReferencingWorkflowsResponse(ResponseModel): register_response_schema_models(console_ns, AgentReferencingWorkflowsResponse) -@console_ns.route("/apps//agent-referencing-workflows") +@console_ns.route("/agent//referencing-workflows") class AgentAppReferencingWorkflowsResource(Resource): @console_ns.doc("list_agent_app_referencing_workflows") @console_ns.doc(description="List workflow apps that reference this Agent App's bound Agent (read-only)") - @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.doc(params={"agent_id": "Agent ID"}) @console_ns.response( 200, "Referencing workflows listed successfully", console_ns.models[AgentReferencingWorkflowsResponse.__name__], ) - @console_ns.response(404, "App not found") + @console_ns.response(404, "Agent not found") @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): + def get(self, tenant_id: str, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) workflows = AgentRosterService(db.session).list_workflows_referencing_app_agent( tenant_id=tenant_id, app_id=app_model.id ) diff --git a/api/controllers/console/app/agent_app_feature.py b/api/controllers/console/app/agent_app_feature.py index 104fc391d51..79d7589873c 100644 --- a/api/controllers/console/app/agent_app_feature.py +++ b/api/controllers/console/app/agent_app_feature.py @@ -9,17 +9,20 @@ persists them onto the app's ``app_model_config`` without touching anything the Soul owns. """ +from uuid import UUID + from flask_restx import Resource from pydantic import BaseModel, Field from controllers.common.fields import SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.app.wraps import get_app_model +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.wraps import ( account_initialization_required, edit_permission_required, setup_required, + with_current_tenant_id, with_current_user, ) from events.app_event import app_model_config_was_updated @@ -32,7 +35,6 @@ from models.agent_config_entities import ( AgentSuggestedQuestionsAfterAnswerFeatureConfig, AgentTextToSpeechFeatureConfig, ) -from models.model import App, AppMode from services.agent_app_feature_service import AgentAppFeatureConfigService @@ -64,22 +66,23 @@ register_schema_models(console_ns, AgentAppFeaturesPayload) register_response_schema_models(console_ns, SimpleResultResponse) -@console_ns.route("/apps//agent-features") +@console_ns.route("/agent//features") class AgentAppFeatureConfigResource(Resource): @console_ns.doc("update_agent_app_features") @console_ns.doc(description="Update an Agent App's presentation features (opener, follow-up, citations, ...)") - @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.doc(params={"agent_id": "Agent ID"}) @console_ns.expect(console_ns.models[AgentAppFeaturesPayload.__name__]) @console_ns.response(200, "Features updated successfully", console_ns.models[SimpleResultResponse.__name__]) @console_ns.response(400, "Invalid configuration") - @console_ns.response(404, "App not found") + @console_ns.response(404, "Agent not found") @setup_required @login_required @edit_permission_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_user - def post(self, current_user: Account, app_model: App): + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {}) new_app_model_config = AgentAppFeatureConfigService.update_features( diff --git a/api/controllers/console/app/agent_app_sandbox.py b/api/controllers/console/app/agent_app_sandbox.py index b59d66c7439..f9bda13c63a 100644 --- a/api/controllers/console/app/agent_app_sandbox.py +++ b/api/controllers/console/app/agent_app_sandbox.py @@ -22,6 +22,7 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.console import console_ns +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id from fields.base import ResponseModel @@ -132,18 +133,18 @@ def _handle(exc: Exception) -> tuple[dict[str, object], int]: raise exc -@console_ns.route("/apps//agent-sandbox/files") +@console_ns.route("/agent//sandbox/files") class AgentAppSandboxListResource(Resource): @console_ns.doc("list_agent_app_sandbox_files") @console_ns.doc(description="List a directory in an Agent App conversation sandbox") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentSandboxListQuery)}) + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxListQuery)}) @console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__]) @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): + def get(self, tenant_id: str, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) query = query_params_from_request(AgentSandboxListQuery) try: result = AgentAppSandboxService().list_files( @@ -157,18 +158,18 @@ class AgentAppSandboxListResource(Resource): return result.model_dump() -@console_ns.route("/apps//agent-sandbox/files/read") +@console_ns.route("/agent//sandbox/files/read") class AgentAppSandboxReadResource(Resource): @console_ns.doc("read_agent_app_sandbox_file") @console_ns.doc(description="Read a text/binary preview file in an Agent App conversation sandbox") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentSandboxFileQuery)}) + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxFileQuery)}) @console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__]) @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_tenant_id - def get(self, tenant_id: str, app_model: App): + def get(self, tenant_id: str, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) query = query_params_from_request(AgentSandboxFileQuery) try: result = AgentAppSandboxService().read_file( @@ -182,7 +183,7 @@ class AgentAppSandboxReadResource(Resource): return result.model_dump() -@console_ns.route("/apps//agent-sandbox/files/upload") +@console_ns.route("/agent//sandbox/files/upload") class AgentAppSandboxUploadResource(Resource): @console_ns.doc("upload_agent_app_sandbox_file") @console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping") @@ -191,9 +192,9 @@ class AgentAppSandboxUploadResource(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) @with_current_tenant_id - def post(self, tenant_id: str, app_model: App): + def post(self, tenant_id: str, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {}) try: result = AgentAppSandboxService().upload_file( diff --git a/api/controllers/console/app/agent_drive_inspector.py b/api/controllers/console/app/agent_drive_inspector.py index ff5b87fc28f..b8d1d487808 100644 --- a/api/controllers/console/app/agent_drive_inspector.py +++ b/api/controllers/console/app/agent_drive_inspector.py @@ -10,6 +10,8 @@ backend — drive data lives in the API's own DB/storage, served straight from from __future__ import annotations +from uuid import UUID + from flask_restx import Resource from pydantic import BaseModel, Field @@ -19,8 +21,9 @@ from controllers.common.schema import ( register_response_schema_models, ) from controllers.console import console_ns +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required +from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id from fields.base import ResponseModel from libs.login import login_required from models.model import App, AppMode @@ -33,11 +36,19 @@ class AgentDriveListQuery(BaseModel): node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") +class AgentDriveListByAgentQuery(BaseModel): + prefix: str = Field(default="", description="Key prefix filter: '/' for one skill, 'files/' for files") + + class AgentDriveFileQuery(BaseModel): key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md") node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") +class AgentDriveFileByAgentQuery(BaseModel): + key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md") + + class AgentDriveItemResponse(ResponseModel): key: str size: int | None = None @@ -85,7 +96,66 @@ def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]: return {"code": exc.code, "message": exc.message}, exc.status_code -_APP_MODES = [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] +_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] + + +@console_ns.route("/agent//drive/files") +class AgentDriveListByAgentApi(Resource): + @console_ns.doc("list_agent_drive_files_by_agent") + @console_ns.doc(description="List agent drive entries for an Agent App") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)}) + @console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + query = query_params_from_request(AgentDriveListByAgentQuery) + resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + try: + items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix) + except AgentDriveError as exc: + return _handle(exc) + return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]} + + +@console_ns.route("/agent//drive/files/preview") +class AgentDrivePreviewByAgentApi(Resource): + @console_ns.doc("preview_agent_drive_file_by_agent") + @console_ns.doc(description="Truncated text preview of one Agent App drive value") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)}) + @console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + query = query_params_from_request(AgentDriveFileByAgentQuery) + resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + try: + return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) + except AgentDriveError as exc: + return _handle(exc) + + +@console_ns.route("/agent//drive/files/download") +class AgentDriveDownloadByAgentApi(Resource): + @console_ns.doc("download_agent_drive_file_by_agent") + @console_ns.doc(description="Time-limited external signed URL for one Agent App drive value") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)}) + @console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + query = query_params_from_request(AgentDriveFileByAgentQuery) + resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + try: + url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key) + except AgentDriveError as exc: + return _handle(exc) + return {"url": url} @console_ns.route("/apps//agent/drive/files") @@ -97,7 +167,7 @@ class AgentDriveListApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=_APP_MODES) + @get_app_model(mode=_WORKFLOW_APP_MODES) def get(self, app_model: App): query = query_params_from_request(AgentDriveListQuery) agent_id = _resolve_agent_id(app_model, query.node_id) @@ -121,7 +191,7 @@ class AgentDrivePreviewApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=_APP_MODES) + @get_app_model(mode=_WORKFLOW_APP_MODES) def get(self, app_model: App): query = query_params_from_request(AgentDriveFileQuery) agent_id = _resolve_agent_id(app_model, query.node_id) @@ -142,7 +212,7 @@ class AgentDriveDownloadApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=_APP_MODES) + @get_app_model(mode=_WORKFLOW_APP_MODES) def get(self, app_model: App): query = query_params_from_request(AgentDriveFileQuery) agent_id = _resolve_agent_id(app_model, query.node_id) @@ -157,6 +227,9 @@ class AgentDriveDownloadApi(Resource): __all__ = [ "AgentDriveDownloadApi", + "AgentDriveDownloadByAgentApi", "AgentDriveListApi", + "AgentDriveListByAgentApi", "AgentDrivePreviewApi", + "AgentDrivePreviewByAgentApi", ] diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index f6bba27d564..2fb3a402962 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,6 +1,7 @@ import logging import re import uuid +from collections.abc import Sequence from datetime import datetime from typing import Any, Literal, cast @@ -41,12 +42,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES from extensions.ext_database import db from fields.base import ResponseModel from graphon.enums import WorkflowExecutionStatus -from libs.helper import build_icon_url, to_timestamp +from libs.helper import build_icon_url, dump_response, to_timestamp from libs.login import login_required from models import Account, App, DatasetPermissionEnum, Workflow from models.model import IconType from services.app_dsl_service import AppDslService -from services.app_service import AppListParams, AppService, CreateAppParams +from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import ImportMode, ImportStatus from services.entities.knowledge_entities.knowledge_entities import ( @@ -63,7 +64,7 @@ from services.entities.knowledge_entities.knowledge_entities import ( ) from services.feature_service import FeatureService -ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] +ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] register_enum_models(console_ns, IconType) @@ -73,10 +74,14 @@ _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$") AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] -class AppListQuery(BaseModel): +class AppListBaseQuery(BaseModel): page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)") limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)") mode: AppListMode = Field(default=cast(AppListMode, "all"), description="App mode filter") + sort_by: AppListSortBy = Field( + default="last_modified", + description="Sort apps by last modified, recently created, or earliest created", + ) name: str | None = Field(default=None, description="Filter by app name") tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs") creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs") @@ -119,6 +124,14 @@ class AppListQuery(BaseModel): raise ValueError("Invalid UUID format in creator_ids.") from exc +class AppListQuery(AppListBaseQuery): + pass + + +class StarredAppListQuery(AppListBaseQuery): + pass + + def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]: normalized: dict[str, str | list[str]] = {} indexed_tag_ids: list[tuple[int, str]] = [] @@ -150,9 +163,7 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, class CreateAppPayload(BaseModel): name: str = Field(..., min_length=1, description="App name") description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400) - mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] = Field( - ..., description="App mode" - ) + mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = Field(..., description="App mode") icon_type: IconType | None = Field(default=None, description="Icon type") icon: str | None = Field(default=None, description="Icon") icon_background: str | None = Field(default=None, description="Icon background color") @@ -387,6 +398,13 @@ class AppPartial(ResponseModel): create_user_name: str | None = None author_name: str | None = None has_draft_trigger: bool | None = None + # For Agent App type: the roster Agent backing this app (None otherwise). + bound_agent_id: str | None = None + # For Agent App responses exposed through /agent. + app_id: str | None = None + role: str | None = None + active_config_is_published: bool = False + is_starred: bool = False @computed_field(return_type=str | None) # type: ignore @property @@ -437,6 +455,10 @@ class AppDetailWithSite(AppDetail): site: Site | None = None # For Agent App type: the roster Agent backing this app (None otherwise). bound_agent_id: str | None = None + # For Agent App responses exposed through /agent. + app_id: str | None = None + role: str | None = None + active_config_is_published: bool = False @computed_field(return_type=str | None) # type: ignore @property @@ -456,12 +478,54 @@ class AppExportResponse(ResponseModel): data: str +def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None: + if FeatureService.get_system_features().webapp_auth.enabled: + app_ids = [str(app.id) for app in apps] + res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids) + if len(res) != len(app_ids): + raise BadRequest("Invalid app id in webapp auth") + + for app in apps: + if str(app.id) in res: + app.access_mode = res[str(app.id)].access_mode + + workflow_capable_app_ids = [str(app.id) for app in apps if app.mode in {"workflow", "advanced-chat"}] + draft_trigger_app_ids: set[str] = set() + if workflow_capable_app_ids: + draft_workflows = ( + session.execute( + select(Workflow).where( + Workflow.version == Workflow.VERSION_DRAFT, + Workflow.app_id.in_(workflow_capable_app_ids), + Workflow.tenant_id == tenant_id, + ) + ) + .scalars() + .all() + ) + trigger_node_types = TRIGGER_NODE_TYPES + for workflow in draft_workflows: + node_id = None + try: + for node_id, node_data in workflow.walk_nodes(): + if node_data.get("type") in trigger_node_types: + draft_trigger_app_ids.add(str(workflow.app_id)) + break + except Exception: + _logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id) + continue + + for app in apps: + app.has_draft_trigger = str(app.id) in draft_trigger_app_ids + + register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum) register_response_schema_models(console_ns, AppTraceResponse, RedirectUrlResponse, SimpleResultResponse) register_schema_models( console_ns, AppListQuery, + StarredAppListQuery, CreateAppPayload, UpdateAppPayload, CopyAppPayload, @@ -521,6 +585,7 @@ class AppListApi(Resource): page=args.page, limit=args.limit, mode=args.mode, + sort_by=args.sort_by, name=args.name, tag_ids=args.tag_ids, creator_ids=args.creator_ids, @@ -534,46 +599,7 @@ class AppListApi(Resource): empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) return empty.model_dump(mode="json"), 200 - if FeatureService.get_system_features().webapp_auth.enabled: - app_ids = [str(app.id) for app in app_pagination.items] - res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids) - if len(res) != len(app_ids): - raise BadRequest("Invalid app id in webapp auth") - - for app in app_pagination.items: - if str(app.id) in res: - app.access_mode = res[str(app.id)].access_mode - - workflow_capable_app_ids = [ - str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"} - ] - draft_trigger_app_ids: set[str] = set() - if workflow_capable_app_ids: - draft_workflows = ( - session.execute( - select(Workflow).where( - Workflow.version == Workflow.VERSION_DRAFT, - Workflow.app_id.in_(workflow_capable_app_ids), - Workflow.tenant_id == current_tenant_id, - ) - ) - .scalars() - .all() - ) - trigger_node_types = TRIGGER_NODE_TYPES - for workflow in draft_workflows: - node_id = None - try: - for node_id, node_data in workflow.walk_nodes(): - if node_data.get("type") in trigger_node_types: - draft_trigger_app_ids.add(str(workflow.app_id)) - break - except Exception: - _logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id) - continue - - for app in app_pagination.items: - app.has_draft_trigger = str(app.id) in draft_trigger_app_ids + _enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id) pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True) return pagination_model.model_dump(mode="json"), 200 @@ -609,6 +635,78 @@ class AppListApi(Resource): return app_detail.model_dump(mode="json"), 201 +@console_ns.route("/apps/starred") +class StarredAppListApi(Resource): + @console_ns.doc("list_starred_apps") + @console_ns.doc(description="Get applications starred by the current account") + @console_ns.doc(params=query_params_from_model(StarredAppListQuery)) + @console_ns.response(200, "Success", console_ns.models[AppPagination.__name__]) + @setup_required + @login_required + @account_initialization_required + @enterprise_license_required + @with_session(write=False) + @with_current_user_id + @with_current_tenant_id + def get(self, current_tenant_id: str, current_user_id: str, session: Session): + args = StarredAppListQuery.model_validate(_normalize_app_list_query_args(request.args)) + params = StarredAppListParams( + page=args.page, + limit=args.limit, + mode=args.mode, + sort_by=args.sort_by, + name=args.name, + tag_ids=args.tag_ids, + creator_ids=args.creator_ids, + is_created_by_me=args.is_created_by_me, + ) + + app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params) + if not app_pagination: + empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) + return empty.model_dump(mode="json"), 200 + + _enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id) + + pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True) + return pagination_model.model_dump(mode="json"), 200 + + +@console_ns.route("/apps//star") +class AppStarApi(Resource): + @console_ns.doc("star_app") + @console_ns.doc(description="Star an application for the current account") + @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(404, "App not found") + @setup_required + @login_required + @account_initialization_required + @enterprise_license_required + @with_current_user_id + @with_session + @get_app_model(mode=None) + def post(self, session: Session, current_user_id: str, app_model: App): + AppService.star_app(session, app=app_model, account_id=current_user_id) + return dump_response(SimpleResultResponse, {"result": "success"}) + + @console_ns.doc("unstar_app") + @console_ns.doc(description="Remove the current account's star from an application") + @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(404, "App not found") + @setup_required + @login_required + @account_initialization_required + @enterprise_license_required + @with_current_user_id + @with_session + @get_app_model(mode=None) + def delete(self, session: Session, current_user_id: str, app_model: App): + AppService.unstar_app(session, app=app_model, account_id=current_user_id) + return dump_response(SimpleResultResponse, {"result": "success"}) + + @console_ns.route("/apps/") class AppApi(Resource): @console_ns.doc("get_app_detail") @@ -628,7 +726,7 @@ class AppApi(Resource): if FeatureService.get_system_features().webapp_auth.enabled: app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id)) - app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined] + app_model.access_mode = app_setting.access_mode response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True) return response_model.model_dump(mode="json") diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 452d80bd5f2..853f7b023ff 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -1,5 +1,6 @@ import logging from typing import Any, Literal +from uuid import UUID from flask import request from flask_restx import Resource @@ -10,6 +11,7 @@ import services from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.error import ( AppUnavailableError, CompletionRequestError, @@ -23,6 +25,7 @@ from controllers.console.wraps import ( account_initialization_required, edit_permission_required, setup_required, + with_current_tenant_id, with_current_user, with_current_user_id, ) @@ -186,51 +189,27 @@ class ChatMessageApi(Resource): @edit_permission_required @with_current_user def post(self, current_user: Account, app_model: App): - raw_payload = console_ns.payload or {} - args_model = ChatMessagePayload.model_validate(raw_payload) - args = args_model.model_dump(exclude_none=True, by_alias=True) + return _create_chat_message(current_user=current_user, app_model=app_model) - streaming = _resolve_debugger_chat_streaming( - app_mode=AppMode.value_of(app_model.mode), - response_mode=args_model.response_mode, - response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload, - ) - if AppMode.value_of(app_model.mode) == AppMode.AGENT: - args["response_mode"] = "streaming" - args["auto_generate_name"] = False - external_trace_id = get_external_trace_id(request) - if external_trace_id: - args["external_trace_id"] = external_trace_id - - try: - response = AppGenerateService.generate( - app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming - ) - - return helper.compact_generate_response(response) - except services.errors.conversation.ConversationNotExistsError: - raise NotFound("Conversation Not Exists.") - except services.errors.conversation.ConversationCompletedError: - raise ConversationCompletedError() - except services.errors.app_model_config.AppModelConfigBrokenError: - logger.exception("App model config broken.") - raise AppUnavailableError() - except ProviderTokenNotInitError as ex: - raise ProviderNotInitializeError(ex.description) - except QuotaExceededError: - raise ProviderQuotaExceededError() - except ModelCurrentlyNotSupportError: - raise ProviderModelCurrentlyNotSupportError() - except InvokeRateLimitError as ex: - raise InvokeRateLimitHttpError(ex.description) - except InvokeError as e: - raise CompletionRequestError(e.description) - except ValueError as e: - raise e - except Exception as e: - logger.exception("internal server error.") - raise InternalServerError() +@console_ns.route("/agent//chat-messages") +class AgentChatMessageApi(Resource): + @console_ns.doc("create_agent_chat_message") + @console_ns.doc(description="Generate an Agent App chat message for debugging") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.expect(console_ns.models[ChatMessagePayload.__name__]) + @console_ns.response(200, "Chat message generated successfully", console_ns.models[GeneratedAppResponse.__name__]) + @console_ns.response(400, "Invalid request parameters") + @console_ns.response(404, "Agent or conversation not found") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _create_chat_message(current_user=current_user, app_model=app_model) @console_ns.route("/apps//chat-messages//stop") @@ -245,12 +224,79 @@ class ChatMessageStopApi(Resource): @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @with_current_user_id def post(self, current_user_id: str, app_model: App, task_id: str): + return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id) - AppTaskService.stop_task( - task_id=task_id, - invoke_from=InvokeFrom.DEBUGGER, - user_id=current_user_id, - app_mode=AppMode.value_of(app_model.mode), + +@console_ns.route("/agent//chat-messages//stop") +class AgentChatMessageStopApi(Resource): + @console_ns.doc("stop_agent_chat_message") + @console_ns.doc(description="Stop a running Agent App chat message generation") + @console_ns.doc(params={"agent_id": "Agent ID", "task_id": "Task ID to stop"}) + @console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user_id + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id) + + +def _create_chat_message(*, current_user: Account, app_model: App): + raw_payload = console_ns.payload or {} + args_model = ChatMessagePayload.model_validate(raw_payload) + args = args_model.model_dump(exclude_none=True, by_alias=True) + + streaming = _resolve_debugger_chat_streaming( + app_mode=AppMode.value_of(app_model.mode), + response_mode=args_model.response_mode, + response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload, + ) + if AppMode.value_of(app_model.mode) == AppMode.AGENT: + args["response_mode"] = "streaming" + args["auto_generate_name"] = False + + external_trace_id = get_external_trace_id(request) + if external_trace_id: + args["external_trace_id"] = external_trace_id + + try: + response = AppGenerateService.generate( + app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming ) - return {"result": "success"}, 200 + return helper.compact_generate_response(response) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.conversation.ConversationCompletedError: + raise ConversationCompletedError() + except services.errors.app_model_config.AppModelConfigBrokenError: + logger.exception("App model config broken.") + raise AppUnavailableError() + except ProviderTokenNotInitError as ex: + raise ProviderNotInitializeError(ex.description) + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except InvokeRateLimitError as ex: + raise InvokeRateLimitHttpError(ex.description) + except InvokeError as e: + raise CompletionRequestError(e.description) + except ValueError as e: + raise e + except Exception as e: + logger.exception("internal server error.") + raise InternalServerError() + + +def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str): + AppTaskService.stop_task( + task_id=task_id, + invoke_from=InvokeFrom.DEBUGGER, + user_id=current_user_id, + app_mode=AppMode.value_of(app_model.mode), + ) + + return {"result": "success"}, 200 diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index cbab951bf6a..ef112b1b1e4 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -13,6 +13,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes from controllers.common.fields import SimpleResultResponse, TextFileResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns +from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.error import ( CompletionRequestError, ProviderModelCurrentlyNotSupportError, @@ -25,6 +26,7 @@ from controllers.console.wraps import ( account_initialization_required, edit_permission_required, setup_required, + with_current_tenant_id, with_current_user, ) from core.app.entities.app_invoke_entities import InvokeFrom @@ -183,67 +185,25 @@ class ChatMessageListApi(Resource): @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @edit_permission_required def get(self, app_model: App): - args = ChatMessagesQuery.model_validate(request.args.to_dict()) + return _list_chat_messages(app_model=app_model) - conversation = db.session.scalar( - select(Conversation) - .where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id) - .limit(1) - ) - if not conversation: - raise NotFound("Conversation Not Exists.") - - if args.first_id: - first_message = db.session.scalar( - select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1) - ) - - if not first_message: - raise NotFound("First message not found") - - history_messages = db.session.scalars( - select(Message) - .where( - Message.conversation_id == conversation.id, - Message.created_at < first_message.created_at, - Message.id != first_message.id, - ) - .order_by(Message.created_at.desc()) - .limit(args.limit) - ).all() - else: - history_messages = db.session.scalars( - select(Message) - .where(Message.conversation_id == conversation.id) - .order_by(Message.created_at.desc()) - .limit(args.limit) - ).all() - - # Initialize has_more based on whether we have a full page - if len(history_messages) == args.limit: - current_page_first_message = history_messages[-1] - # Check if there are more messages before the current page - has_more = db.session.scalar( - select( - exists().where( - Message.conversation_id == conversation.id, - Message.created_at < current_page_first_message.created_at, - Message.id != current_page_first_message.id, - ) - ) - ) - else: - # If we don't have a full page, there are no more messages - has_more = False - - history_messages = list(reversed(history_messages)) - attach_message_extra_contents(history_messages) - - return MessageInfiniteScrollPaginationResponse.model_validate( - InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more), - from_attributes=True, - ).model_dump(mode="json") +@console_ns.route("/agent//chat-messages") +class AgentChatMessageListApi(Resource): + @console_ns.doc("list_agent_chat_messages") + @console_ns.doc(description="Get Agent App chat messages for a conversation with pagination") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.doc(params=query_params_from_model(ChatMessagesQuery)) + @console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__]) + @console_ns.response(404, "Agent or conversation not found") + @login_required + @account_initialization_required + @setup_required + @edit_permission_required + @with_current_tenant_id + def get(self, current_tenant_id: str, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _list_chat_messages(app_model=app_model) @console_ns.route("/apps//feedbacks") @@ -261,44 +221,25 @@ class MessageFeedbackApi(Resource): @account_initialization_required @with_current_user def post(self, current_user: Account, app_model: App): - args = MessageFeedbackPayload.model_validate(console_ns.payload) + return _update_message_feedback(current_user=current_user, app_model=app_model) - message_id = str(args.message_id) - message = db.session.scalar( - select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1) - ) - - if not message: - raise NotFound("Message Not Exists.") - - feedback = message.admin_feedback - - if not args.rating and feedback: - db.session.delete(feedback) - elif args.rating and feedback: - feedback.rating = FeedbackRating(args.rating) - feedback.content = args.content - elif not args.rating and not feedback: - raise ValueError("rating cannot be None when feedback not exists") - else: - rating_value = args.rating - if rating_value is None: - raise ValueError("rating is required to create feedback") - feedback = MessageFeedback( - app_id=app_model.id, - conversation_id=message.conversation_id, - message_id=message.id, - rating=FeedbackRating(rating_value), - content=args.content, - from_source=FeedbackFromSource.ADMIN, - from_account_id=current_user.id, - ) - db.session.add(feedback) - - db.session.commit() - - return {"result": "success"} +@console_ns.route("/agent//feedbacks") +class AgentMessageFeedbackApi(Resource): + @console_ns.doc("create_agent_message_feedback") + @console_ns.doc(description="Create or update Agent App message feedback") + @console_ns.doc(params={"agent_id": "Agent ID"}) + @console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__]) + @console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__]) + @console_ns.response(404, "Agent or message not found") + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _update_message_feedback(current_user=current_user, app_model=app_model) @console_ns.route("/apps//annotations/count") @@ -340,31 +281,28 @@ class MessageSuggestedQuestionApi(Resource): @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @with_current_user def get(self, current_user: Account, app_model: App, message_id: UUID): - message_id_str = str(message_id) + return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id) - try: - questions = MessageService.get_suggested_questions_after_answer( - app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER - ) - except MessageNotExistsError: - raise NotFound("Message not found") - except ConversationNotExistsError: - raise NotFound("Conversation not found") - except ProviderTokenNotInitError as ex: - raise ProviderNotInitializeError(ex.description) - except QuotaExceededError: - raise ProviderQuotaExceededError() - except ModelCurrentlyNotSupportError: - raise ProviderModelCurrentlyNotSupportError() - except InvokeError as e: - raise CompletionRequestError(e.description) - except SuggestedQuestionsAfterAnswerDisabledError: - raise AppSuggestedQuestionsAfterAnswerDisabledError() - except Exception: - logger.exception("internal server error.") - raise InternalServerError() - return {"data": questions} +@console_ns.route("/agent//chat-messages//suggested-questions") +class AgentMessageSuggestedQuestionApi(Resource): + @console_ns.doc("get_agent_message_suggested_questions") + @console_ns.doc(description="Get suggested questions for an Agent App message") + @console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"}) + @console_ns.response( + 200, + "Suggested questions retrieved successfully", + console_ns.models[SuggestedQuestionsResponse.__name__], + ) + @console_ns.response(404, "Agent, message, or conversation not found") + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id) @console_ns.route("/apps//feedbacks/export") @@ -423,14 +361,167 @@ class MessageApi(Resource): @login_required @account_initialization_required def get(self, app_model: App, message_id: UUID): - message_id_str = str(message_id) + return _get_message_detail(app_model=app_model, message_id=message_id) - message = db.session.scalar( - select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1) + +@console_ns.route("/agent//messages/") +class AgentMessageApi(Resource): + @console_ns.doc("get_agent_message") + @console_ns.doc(description="Get Agent App message details by ID") + @console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"}) + @console_ns.response(200, "Message retrieved successfully", console_ns.models[MessageDetailResponse.__name__]) + @console_ns.response(404, "Agent or message not found") + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID): + app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) + return _get_message_detail(app_model=app_model, message_id=message_id) + + +def _list_chat_messages(*, app_model: App): + args = ChatMessagesQuery.model_validate(request.args.to_dict()) + + conversation = db.session.scalar( + select(Conversation) + .where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id) + .limit(1) + ) + + if not conversation: + raise NotFound("Conversation Not Exists.") + + if args.first_id: + first_message = db.session.scalar( + select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1) ) - if not message: - raise NotFound("Message Not Exists.") + if not first_message: + raise NotFound("First message not found") - attach_message_extra_contents([message]) - return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json") + history_messages = db.session.scalars( + select(Message) + .where( + Message.conversation_id == conversation.id, + Message.created_at < first_message.created_at, + Message.id != first_message.id, + ) + .order_by(Message.created_at.desc()) + .limit(args.limit) + ).all() + else: + history_messages = db.session.scalars( + select(Message) + .where(Message.conversation_id == conversation.id) + .order_by(Message.created_at.desc()) + .limit(args.limit) + ).all() + + # Initialize has_more based on whether we have a full page + if len(history_messages) == args.limit: + current_page_first_message = history_messages[-1] + # Check if there are more messages before the current page + has_more = db.session.scalar( + select( + exists().where( + Message.conversation_id == conversation.id, + Message.created_at < current_page_first_message.created_at, + Message.id != current_page_first_message.id, + ) + ) + ) + else: + # If we don't have a full page, there are no more messages + has_more = False + + history_messages = list(reversed(history_messages)) + attach_message_extra_contents(history_messages) + + return MessageInfiniteScrollPaginationResponse.model_validate( + InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more), + from_attributes=True, + ).model_dump(mode="json") + + +def _update_message_feedback(*, current_user: Account, app_model: App): + args = MessageFeedbackPayload.model_validate(console_ns.payload) + + message_id = str(args.message_id) + + message = db.session.scalar( + select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1) + ) + + if not message: + raise NotFound("Message Not Exists.") + + feedback = message.admin_feedback + + if not args.rating and feedback: + db.session.delete(feedback) + elif args.rating and feedback: + feedback.rating = FeedbackRating(args.rating) + feedback.content = args.content + elif not args.rating and not feedback: + raise ValueError("rating cannot be None when feedback not exists") + else: + rating_value = args.rating + if rating_value is None: + raise ValueError("rating is required to create feedback") + feedback = MessageFeedback( + app_id=app_model.id, + conversation_id=message.conversation_id, + message_id=message.id, + rating=FeedbackRating(rating_value), + content=args.content, + from_source=FeedbackFromSource.ADMIN, + from_account_id=current_user.id, + ) + db.session.add(feedback) + + db.session.commit() + + return {"result": "success"} + + +def _get_message_suggested_questions(*, current_user: Account, app_model: App, message_id: UUID): + message_id_str = str(message_id) + + try: + questions = MessageService.get_suggested_questions_after_answer( + app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER + ) + except MessageNotExistsError: + raise NotFound("Message not found") + except ConversationNotExistsError: + raise NotFound("Conversation not found") + except ProviderTokenNotInitError as ex: + raise ProviderNotInitializeError(ex.description) + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except InvokeError as e: + raise CompletionRequestError(e.description) + except SuggestedQuestionsAfterAnswerDisabledError: + raise AppSuggestedQuestionsAfterAnswerDisabledError() + except Exception: + logger.exception("internal server error.") + raise InternalServerError() + + return {"data": questions} + + +def _get_message_detail(*, app_model: App, message_id: UUID): + message_id_str = str(message_id) + + message = db.session.scalar( + select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1) + ) + + if not message: + raise NotFound("Message Not Exists.") + + attach_message_extra_contents([message]) + return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json") diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 8600f6cbf51..a8969f4d5ec 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -2,12 +2,12 @@ import json import logging from collections.abc import Sequence from datetime import datetime -from typing import Any, NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict, cast from flask import abort, request from flask_restx import Resource, fields from pydantic import AliasChoices, BaseModel, Field, RootModel, ValidationError, field_validator -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services @@ -449,8 +449,16 @@ class DraftWorkflowApi(Resource): if not workflow: raise DraftWorkflowNotExist() - # return workflow, if not found, return 404 - return dump_response(WorkflowResponse, workflow) + from services.agent.workflow_publish_service import WorkflowAgentPublishService + + # Return workflow with response-only Agent node job projection so the + # front-end can treat draft graph node data as the editing source. + response = WorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph( + session=cast(Session, db.session), + draft_workflow=workflow, + ) + return response @setup_required @login_required diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index e9d26974c19..7c941e14368 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -994,7 +994,7 @@ class RagPipelineTransformApi(Resource): dataset_id_str = str(dataset_id) rag_pipeline_transform_service = RagPipelineTransformService() - result = rag_pipeline_transform_service.transform_dataset(dataset_id_str) + result = rag_pipeline_transform_service.transform_dataset(dataset_id_str, db.session) return result diff --git a/api/controllers/console/explore/recommended_app.py b/api/controllers/console/explore/recommended_app.py index 1b532264408..72f797eeb36 100644 --- a/api/controllers/console/explore/recommended_app.py +++ b/api/controllers/console/explore/recommended_app.py @@ -66,6 +66,10 @@ class RecommendedAppListResponse(ResponseModel): categories: list[str] +class LearnDifyAppListResponse(ResponseModel): + recommended_apps: list[RecommendedAppResponse] + + class RecommendedAppDetailResponse(RootModel[dict[str, Any]]): root: dict[str, Any] @@ -76,10 +80,19 @@ register_schema_models( RecommendedAppInfoResponse, RecommendedAppResponse, RecommendedAppListResponse, + LearnDifyAppListResponse, ) register_response_schema_models(console_ns, RecommendedAppDetailResponse) +def _resolve_language(language: str | None, user: Account) -> str: + if language and language in languages: + return language + if user.interface_language: + return user.interface_language + return languages[0] + + @console_ns.route("/explore/apps") class RecommendedAppListApi(Resource): @console_ns.doc(params=query_params_from_model(RecommendedAppsQuery)) @@ -90,13 +103,7 @@ class RecommendedAppListApi(Resource): def get(self, current_user: Account): # language args args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True)) - language = args.language - if language and language in languages: - language_prefix = language - elif current_user.interface_language: - language_prefix = current_user.interface_language - else: - language_prefix = languages[0] + language_prefix = _resolve_language(args.language, current_user) return RecommendedAppListResponse.model_validate( RecommendedAppService.get_recommended_apps_and_categories(db.session, language_prefix), @@ -104,6 +111,23 @@ class RecommendedAppListApi(Resource): ).model_dump(mode="json") +@console_ns.route("/explore/apps/learn-dify") +class LearnDifyAppListApi(Resource): + @console_ns.doc(params=query_params_from_model(RecommendedAppsQuery)) + @console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__]) + @login_required + @account_initialization_required + @with_current_user + def get(self, current_user: Account): + args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True)) + language_prefix = _resolve_language(args.language, current_user) + + return LearnDifyAppListResponse.model_validate( + RecommendedAppService.get_learn_dify_apps(db.session, language_prefix), + from_attributes=True, + ).model_dump(mode="json") + + @console_ns.route("/explore/apps/") class RecommendedAppApi(Resource): @console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__]) diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index be5bef0efad..94979e25b36 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -1,10 +1,11 @@ import io from collections.abc import Mapping -from typing import Any, Literal +from datetime import datetime +from typing import Any, Literal, TypedDict from flask import request, send_file from flask_restx import Resource -from pydantic import BaseModel, Field, RootModel +from pydantic import BaseModel, ConfigDict, Field, RootModel from werkzeug.datastructures import FileStorage from werkzeug.exceptions import Forbidden @@ -26,15 +27,33 @@ from controllers.console.wraps import ( with_current_user, with_current_user_id, ) +from core.helper.position_helper import is_filtered +from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource from core.plugin.impl.exc import PluginDaemonClientSideError from core.plugin.plugin_service import PluginService +from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort +from core.tools.entities.common_entities import I18nObject +from core.tools.entities.tool_entities import ToolProviderType +from core.tools.tool_manager import ToolManager from fields.base import ResponseModel from graphon.model_runtime.utils.encoders import jsonable_encoder +from libs.helper import dump_response from libs.login import login_required from models.account import Account, TenantPluginAutoUpgradeStrategy, TenantPluginPermission +from models.provider_ids import ToolProviderID +from services.entities.model_provider_entities import ProviderEntityResponse from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService from services.plugin.plugin_parameter_service import PluginParameterService from services.plugin.plugin_permission_service import PluginPermissionService +from services.tools.tools_transform_service import ToolTransformService + + +class AutoUpgradeSettingsResponse(TypedDict): + strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting + upgrade_time_of_day: int + upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode + exclude_plugins: list[str] + include_plugins: list[str] class ParserList(BaseModel): @@ -42,6 +61,11 @@ class ParserList(BaseModel): page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)") +class PluginCategoryListQuery(BaseModel): + page: int = Field(default=1, ge=1, description="Page number") + page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)") + + class ParserLatest(BaseModel): plugin_ids: list[str] @@ -100,8 +124,8 @@ class ParserUninstall(BaseModel): class ParserPermissionChange(BaseModel): - install_permission: TenantPluginPermission.InstallPermission - debug_permission: TenantPluginPermission.DebugPermission + install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE + debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE class ParserDynamicOptions(BaseModel): @@ -137,13 +161,64 @@ class PluginAutoUpgradeSettingsPayload(BaseModel): include_plugins: list[str] = Field(default_factory=list) -class ParserPreferencesChange(BaseModel): - permission: PluginPermissionSettingsPayload +class PluginAutoUpgradeChangeResponse(ResponseModel): + success: bool + message: str | None = None + + +class PluginAutoUpgradeSettingsResponseModel(ResponseModel): + strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting + upgrade_time_of_day: int + upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode + exclude_plugins: list[str] + include_plugins: list[str] + + +class PluginAutoUpgradeFetchResponse(ResponseModel): + category: TenantPluginAutoUpgradeStrategy.PluginCategory + auto_upgrade: PluginAutoUpgradeSettingsResponseModel + + +class PluginDeclarationResponse(ResponseModel): + version: str + author: str | None + name: str + description: I18nObject + icon: str + icon_dark: str | None = None + label: I18nObject + category: PluginCategory + created_at: datetime + resource: Mapping[str, Any] + plugins: Mapping[str, list[str] | None] + tags: list[str] = Field(default_factory=list) + repo: str | None = None + verified: bool = False + tool: Mapping[str, Any] | None = None + model: ProviderEntityResponse | None = None + endpoint: Mapping[str, Any] | None = None + agent_strategy: Mapping[str, Any] | None = None + datasource: Mapping[str, Any] | None = None + trigger: Mapping[str, Any] | None = None + meta: Mapping[str, Any] + + +class ParserAutoUpgradeChange(BaseModel): + model_config = ConfigDict(extra="forbid") + + category: TenantPluginAutoUpgradeStrategy.PluginCategory auto_upgrade: PluginAutoUpgradeSettingsPayload +class ParserAutoUpgradeFetch(BaseModel): + category: TenantPluginAutoUpgradeStrategy.PluginCategory + + class ParserExcludePlugin(BaseModel): + model_config = ConfigDict(extra="forbid") + plugin_id: str + category: TenantPluginAutoUpgradeStrategy.PluginCategory class ParserReadme(BaseModel): @@ -157,6 +232,63 @@ class PluginDebuggingKeyResponse(ResponseModel): port: int +class PluginCategoryInstalledPluginResponse(ResponseModel): + id: str + name: str + tenant_id: str + plugin_id: str + plugin_unique_identifier: str + endpoints_active: int + endpoints_setups: int + installation_id: str + declaration: PluginDeclarationResponse + runtime_type: str + version: str + created_at: datetime + updated_at: datetime + source: PluginInstallationSource + checksum: str + meta: Mapping[str, Any] + + +class PluginCategoryBuiltinToolResponse(ResponseModel): + model_config = ConfigDict(extra="allow") + + author: str + name: str + label: I18nObject + description: I18nObject + parameters: list[Mapping[str, Any]] | None = None + labels: list[str] + output_schema: Mapping[str, object] + + +class PluginCategoryBuiltinToolProviderResponse(ResponseModel): + model_config = ConfigDict(extra="allow") + + id: str + author: str + name: str + plugin_id: str | None + plugin_unique_identifier: str | None + description: I18nObject + icon: str | Mapping[str, str] + icon_dark: str | Mapping[str, str] | None + label: I18nObject + type: ToolProviderType + team_credentials: Mapping[str, object] + is_team_authorization: bool + allow_delete: bool + tools: list[PluginCategoryBuiltinToolResponse] + labels: list[str] + + +class PluginCategoryListResponse(ResponseModel): + plugins: list[PluginCategoryInstalledPluginResponse] + builtin_tools: list[PluginCategoryBuiltinToolProviderResponse] + has_more: bool + + class PluginDaemonOperationResponse(RootModel[Any]): root: Any @@ -200,11 +332,6 @@ class PluginOperationSuccessResponse(ResponseModel): message: str | None = None -class PluginPreferencesResponse(ResponseModel): - permission: PluginPermissionSettingsPayload - auto_upgrade: PluginAutoUpgradeSettingsPayload - - class PluginReadmeResponse(ResponseModel): readme: str @@ -212,6 +339,7 @@ class PluginReadmeResponse(ResponseModel): register_schema_models( console_ns, ParserList, + PluginCategoryListQuery, PluginAutoUpgradeSettingsPayload, PluginPermissionSettingsPayload, ParserLatest, @@ -228,13 +356,21 @@ register_schema_models( ParserPermissionChange, ParserDynamicOptions, ParserDynamicOptionsWithCredentials, - ParserPreferencesChange, + ParserAutoUpgradeChange, + ParserAutoUpgradeFetch, ParserExcludePlugin, ParserReadme, ) register_response_schema_models( console_ns, + PluginAutoUpgradeChangeResponse, + PluginAutoUpgradeFetchResponse, + PluginAutoUpgradeSettingsResponseModel, BinaryFileResponse, + PluginCategoryBuiltinToolProviderResponse, + PluginCategoryBuiltinToolResponse, + PluginCategoryInstalledPluginResponse, + PluginCategoryListResponse, PluginDaemonOperationResponse, PluginDebuggingKeyResponse, PluginDynamicOptionsResponse, @@ -243,7 +379,6 @@ register_response_schema_models( PluginManifestResponse, PluginOperationSuccessResponse, PluginPermissionResponse, - PluginPreferencesResponse, PluginReadmeResponse, PluginTaskResponse, PluginTasksResponse, @@ -254,12 +389,36 @@ register_response_schema_models( register_enum_models( console_ns, TenantPluginPermission.DebugPermission, + TenantPluginAutoUpgradeStrategy.PluginCategory, TenantPluginAutoUpgradeStrategy.UpgradeMode, TenantPluginAutoUpgradeStrategy.StrategySetting, TenantPluginPermission.InstallPermission, ) +def _default_auto_upgrade_settings( + tenant_id: str, + category: TenantPluginAutoUpgradeStrategy.PluginCategory, +) -> AutoUpgradeSettingsResponse: + return { + "strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category), + "upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id), + "upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + "exclude_plugins": [], + "include_plugins": [], + } + + +def _auto_upgrade_settings_to_dict(strategy: TenantPluginAutoUpgradeStrategy) -> AutoUpgradeSettingsResponse: + return { + "strategy_setting": strategy.strategy_setting, + "upgrade_time_of_day": strategy.upgrade_time_of_day, + "upgrade_mode": strategy.upgrade_mode, + "exclude_plugins": strategy.exclude_plugins, + "include_plugins": strategy.include_plugins, + } + + def _read_upload_content(file: FileStorage, max_size: int) -> bytes: """ Read the uploaded file and validate its actual size before delegating to the plugin service. @@ -274,6 +433,33 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes: return content +def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]: + db_builtin_providers = { + str(ToolProviderID(provider.provider)): provider + for provider in ToolManager.list_default_builtin_providers(tenant_id) + } + builtin_providers = [] + + for provider in ToolManager.list_hardcoded_providers(): + if is_filtered( + include_set=dify_config.POSITION_TOOL_INCLUDES_SET, + exclude_set=dify_config.POSITION_TOOL_EXCLUDES_SET, + data=provider, + name_func=lambda provider_controller: provider_controller.entity.identity.name, + ): + continue + + user_provider = ToolTransformService.builtin_provider_to_user_provider( + provider_controller=provider, + db_provider=db_builtin_providers.get(provider.entity.identity.name), + decrypt_credentials=False, + ) + ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider) + builtin_providers.append(user_provider) + + return [provider.to_dict() for provider in BuiltinToolProviderSort.sort(builtin_providers)] + + @console_ns.route("/workspaces/current/plugin/debugging-key") class PluginDebuggingKeyApi(Resource): @console_ns.response(200, "Success", console_ns.models[PluginDebuggingKeyResponse.__name__]) @@ -312,6 +498,41 @@ class PluginListApi(Resource): return jsonable_encoder({"plugins": plugins_with_total.list, "total": plugins_with_total.total}) +@console_ns.route("/workspaces/current/plugin//list") +class PluginCategoryListApi(Resource): + @console_ns.doc(params=query_params_from_model(PluginCategoryListQuery)) + @console_ns.response(200, "Success", console_ns.models[PluginCategoryListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, category: str): + args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True)) + + try: + plugin_category = PluginCategory(category) + except ValueError: + return {"code": "invalid_param", "message": "invalid plugin category"}, 400 + + try: + plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size) + except PluginDaemonClientSideError as e: + return {"code": "plugin_error", "message": e.description}, 400 + + builtin_tools = [] + if plugin_category == PluginCategory.Tool: + builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id) + + return dump_response( + PluginCategoryListResponse, + { + "plugins": jsonable_encoder(plugins.list), + "builtin_tools": builtin_tools, + "has_more": plugins.has_more, + }, + ) + + @console_ns.route("/workspaces/current/plugin/list/latest-versions") class PluginListLatestVersionsApi(Resource): @console_ns.expect(console_ns.models[ParserLatest.__name__]) @@ -713,11 +934,13 @@ class PluginChangePermissionApi(Resource): args = ParserPermissionChange.model_validate(console_ns.payload) - return { - "success": PluginPermissionService.change_permission( - tenant_id, args.install_permission, args.debug_permission - ) - } + set_permission_result = PluginPermissionService.change_permission( + tenant_id, args.install_permission, args.debug_permission + ) + if not set_permission_result: + return jsonable_encoder({"success": False, "message": "Failed to set permission"}) + + return jsonable_encoder({"success": True}) @console_ns.route("/workspaces/current/plugin/permission/fetch") @@ -806,10 +1029,10 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource): return jsonable_encoder({"options": options}) -@console_ns.route("/workspaces/current/plugin/preferences/change") -class PluginChangePreferencesApi(Resource): - @console_ns.expect(console_ns.models[ParserPreferencesChange.__name__]) - @console_ns.response(200, "Success", console_ns.models[PluginOperationSuccessResponse.__name__]) +@console_ns.route("/workspaces/current/plugin/auto-upgrade/change") +class PluginChangeAutoUpgradeApi(Resource): + @console_ns.expect(console_ns.models[ParserAutoUpgradeChange.__name__]) + @console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeChangeResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -819,38 +1042,17 @@ class PluginChangePreferencesApi(Resource): if not user.is_admin_or_owner: raise Forbidden() - args = ParserPreferencesChange.model_validate(console_ns.payload) - - permission = args.permission - - install_permission = permission.install_permission - debug_permission = permission.debug_permission + args = ParserAutoUpgradeChange.model_validate(console_ns.payload) auto_upgrade = args.auto_upgrade - - strategy_setting = auto_upgrade.strategy_setting - upgrade_time_of_day = auto_upgrade.upgrade_time_of_day - upgrade_mode = auto_upgrade.upgrade_mode - exclude_plugins = auto_upgrade.exclude_plugins - include_plugins = auto_upgrade.include_plugins - - # set permission - set_permission_result = PluginPermissionService.change_permission( - tenant_id, - install_permission, - debug_permission, - ) - if not set_permission_result: - return jsonable_encoder({"success": False, "message": "Failed to set permission"}) - - # set auto upgrade strategy set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy( tenant_id, - strategy_setting, - upgrade_time_of_day, - upgrade_mode, - exclude_plugins, - include_plugins, + auto_upgrade.strategy_setting, + auto_upgrade.upgrade_time_of_day, + auto_upgrade.upgrade_mode, + auto_upgrade.exclude_plugins, + auto_upgrade.include_plugins, + category=args.category, ) if not set_auto_upgrade_strategy_result: return jsonable_encoder({"success": False, "message": "Failed to set auto upgrade strategy"}) @@ -858,49 +1060,35 @@ class PluginChangePreferencesApi(Resource): return jsonable_encoder({"success": True}) -@console_ns.route("/workspaces/current/plugin/preferences/fetch") -class PluginFetchPreferencesApi(Resource): - @console_ns.response(200, "Success", console_ns.models[PluginPreferencesResponse.__name__]) +@console_ns.route("/workspaces/current/plugin/auto-upgrade/fetch") +class PluginFetchAutoUpgradeApi(Resource): + @console_ns.doc(params=query_params_from_model(ParserAutoUpgradeFetch)) + @console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeFetchResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str): - permission = PluginPermissionService.get_permission(tenant_id) - permission_dict = { - "install_permission": TenantPluginPermission.InstallPermission.EVERYONE, - "debug_permission": TenantPluginPermission.DebugPermission.EVERYONE, - } + args = ParserAutoUpgradeFetch.model_validate(request.args.to_dict(flat=True)) + auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, args.category) + auto_upgrade_dict = ( + _auto_upgrade_settings_to_dict(auto_upgrade) + if auto_upgrade + else _default_auto_upgrade_settings(tenant_id, args.category) + ) - if permission: - permission_dict["install_permission"] = permission.install_permission - permission_dict["debug_permission"] = permission.debug_permission - - auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id) - auto_upgrade_dict = { - "strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED, - "upgrade_time_of_day": 0, - "upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, - "exclude_plugins": [], - "include_plugins": [], - } - - if auto_upgrade: - auto_upgrade_dict = { - "strategy_setting": auto_upgrade.strategy_setting, - "upgrade_time_of_day": auto_upgrade.upgrade_time_of_day, - "upgrade_mode": auto_upgrade.upgrade_mode, - "exclude_plugins": auto_upgrade.exclude_plugins, - "include_plugins": auto_upgrade.include_plugins, + return jsonable_encoder( + { + "category": args.category, + "auto_upgrade": auto_upgrade_dict, } - - return jsonable_encoder({"permission": permission_dict, "auto_upgrade": auto_upgrade_dict}) + ) -@console_ns.route("/workspaces/current/plugin/preferences/autoupgrade/exclude") +@console_ns.route("/workspaces/current/plugin/auto-upgrade/exclude") class PluginAutoUpgradeExcludePluginApi(Resource): @console_ns.expect(console_ns.models[ParserExcludePlugin.__name__]) - @console_ns.response(200, "Success", console_ns.models[PluginOperationSuccessResponse.__name__]) + @console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -909,7 +1097,9 @@ class PluginAutoUpgradeExcludePluginApi(Resource): # exclude one single plugin args = ParserExcludePlugin.model_validate(console_ns.payload) - return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id)}) + return jsonable_encoder( + {"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id, args.category)} + ) @console_ns.route("/workspaces/current/plugin/readme") diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py index 7cf88e44534..59a33fe0385 100644 --- a/api/controllers/console/workspace/workspace.py +++ b/api/controllers/console/workspace/workspace.py @@ -31,9 +31,9 @@ from controllers.console.wraps import ( from enums.cloud_plan import CloudPlan from extensions.ext_database import db from fields.base import ResponseModel -from libs.helper import TimestampField, dump_response, to_timestamp +from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp from libs.login import login_required -from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus +from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus from services.account_service import TenantService from services.billing_service import BillingService, SubscriptionPlan from services.enterprise.enterprise_service import EnterpriseService @@ -219,6 +219,7 @@ tenants_fields = { "plan": fields.String, "status": fields.String, "created_at": TimestampField, + "last_opened_at": OptionalTimestampField, "current": fields.Boolean, } @@ -234,7 +235,12 @@ class TenantListApi(Resource): @with_current_user @with_current_tenant_id def get(self, current_tenant_id: str, current_user: Account): - tenants = TenantService.get_join_tenants(current_user) + tenant_rows: list[tuple[Tenant, TenantAccountJoin]] = [ + (tenant, membership) + for tenant, membership in TenantService.get_workspaces_for_account(db.session, current_user.id) + if tenant.status == TenantStatus.NORMAL + ] + tenants = [tenant for tenant, _ in tenant_rows] tenant_dicts = [] is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED @@ -247,7 +253,7 @@ class TenantListApi(Resource): if not tenant_plans: logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path") - for tenant in tenants: + for tenant, membership in tenant_rows: plan: str = CloudPlan.SANDBOX if is_saas: tenant_plan = tenant_plans.get(tenant.id) @@ -266,6 +272,7 @@ class TenantListApi(Resource): "name": tenant.name, "status": tenant.status, "created_at": tenant.created_at, + "last_opened_at": membership.last_opened_at, "plan": plan, "current": tenant.id == current_tenant_id if current_tenant_id else False, } 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 6f8117ee590..d8ca19b5fc3 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -1,6 +1,5 @@ import json import logging -import re import time from collections.abc import Callable, Generator, Mapping from contextlib import contextmanager @@ -1010,11 +1009,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): if message.status == MessageStatus.PAUSED: message.status = MessageStatus.NORMAL - # If there are assistant files, remove markdown image links from answer answer_text = self._task_state.answer - if self._recorded_files: - # Remove markdown image links since we're storing files separately - answer_text = re.sub(r"!\[.*?\]\(.*?\)", "", answer_text).strip() message.answer = answer_text message.updated_at = naive_utc_now() diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 467afb891db..32ceecddf59 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -158,6 +158,110 @@ class AgentAppGenerator(MessageBasedAppGenerator): ) return AgentAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from) + def resume_after_form_submission( + self, + *, + app_model: App, + user: Account | EndUser, + conversation_id: str, + invoke_from: InvokeFrom, + ) -> None: + """Resume an Agent App conversation after a submitted ask_human HITL form. + + ENG-635: triggered by a background task (not an HTTP request). Runs one + blocking turn with no user query; the runner threads the human's reply + into the agent run as deferred_tool_results and the assistant answer is + persisted to the conversation. Live streaming to a reconnected client is + out of scope here — the message is persisted and can be re-fetched. + """ + agent, snapshot, agent_soul = self._resolve_agent(app_model) + conversation = ConversationService.get_conversation( + app_model=app_model, conversation_id=conversation_id, user=user + ) + + app_config = AgentAppConfigManager.get_app_config( + app_model=app_model, + agent_soul=agent_soul, + app_model_config=app_model.app_model_config, + conversation=conversation, + ) + model_conf = ModelConfigConverter.convert(app_config) + trace_manager = TraceQueueManager(app_model.id, user.id if isinstance(user, Account) else user.session_id) + + # ENG-638: the agent backend requires the resume composition's layer + # names to match the suspended snapshot, which includes the per-turn + # user-prompt layer. So re-send the original user message (the paused + # turn's query); the continuation is driven by deferred_tool_results and + # the restored snapshot, not by re-processing this prompt. A blank prompt + # would drop the user-prompt layer and fail the snapshot match. + paused_message = db.session.scalar( + select(Message) + .where(Message.conversation_id == conversation.id, Message.query != "") + .order_by(Message.created_at.desc()) + .limit(1) + ) + resume_query = paused_message.query if paused_message and paused_message.query else "(resumed)" + + application_generate_entity = AgentAppGenerateEntity( + task_id=str(uuid.uuid4()), + app_config=app_config, + model_conf=model_conf, + conversation_id=conversation.id, + # A resume carries no new user inputs; the human's answer is the + # submitted form, threaded in by the runner as deferred_tool_results. + # The query re-sends the paused turn's message (see above). + inputs={}, + query=resume_query, + files=[], + parent_message_id=UUID_NIL, + user_id=user.id, + stream=False, + invoke_from=invoke_from, + extras={"auto_generate_conversation_name": False}, + call_depth=0, + trace_manager=trace_manager, + agent_id=agent.id, + agent_config_snapshot_id=snapshot.id, + ) + + conversation, message = self._init_generate_records(application_generate_entity, conversation) + + queue_manager = MessageBasedAppQueueManager( + task_id=application_generate_entity.task_id, + user_id=application_generate_entity.user_id, + invoke_from=application_generate_entity.invoke_from, + conversation_id=conversation.id, + app_mode=conversation.mode, + message_id=message.id, + ) + + context = contextvars.copy_context() + worker_thread = threading.Thread( + target=self._generate_worker, + kwargs={ + "flask_app": current_app._get_current_object(), # type: ignore + "context": context, + "application_generate_entity": application_generate_entity, + "queue_manager": queue_manager, + "conversation_id": conversation.id, + "message_id": message.id, + "user_from": UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER, + # Resume continues a paused agent run; skip input guards (see _generate_worker). + "is_resume": True, + }, + ) + worker_thread.start() + + # Blocking: drive the chat task pipeline to persist the assistant answer. + self._handle_response( + application_generate_entity=application_generate_entity, + queue_manager=queue_manager, + conversation=conversation, + message=message, + user=user, + stream=False, + ) + def _generate_worker( self, *, @@ -168,6 +272,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): conversation_id: str, message_id: str, user_from: UserFrom, + is_resume: bool = False, ) -> None: from libs.flask_utils import preserve_flask_contexts @@ -177,20 +282,30 @@ class AgentAppGenerator(MessageBasedAppGenerator): message = self._get_message(message_id) app_config = application_generate_entity.app_config - # Apply app-level input guards (content moderation + annotation - # reply) before reaching the Agent backend, mirroring the EasyUI - # chat / agent-chat runners. These can short-circuit the turn. - app_model = db.session.get(App, app_config.app_id) - if app_model is None: - raise AgentAppGeneratorError("App not found") - handled, query = self._run_input_guards( - application_generate_entity=application_generate_entity, - app_model=app_model, - message=message, - queue_manager=queue_manager, - ) - if handled: - return + if is_resume: + # ENG-638: a resume continues a paused agent run; the human's + # reply is threaded in by the runner as deferred_tool_results. + # The query is the replayed paused-turn message, kept only to + # match the suspended snapshot's layers — it is NOT new + # end-user input, so input guards must NOT run. Moderation or an + # annotation match on the replayed query would short-circuit the + # turn and drop the human reply, stranding the ask_human session. + query = application_generate_entity.query or "" + else: + # Apply app-level input guards (content moderation + annotation + # reply) before reaching the Agent backend, mirroring the EasyUI + # chat / agent-chat runners. These can short-circuit the turn. + app_model = db.session.get(App, app_config.app_id) + if app_model is None: + raise AgentAppGeneratorError("App not found") + handled, query = self._run_input_guards( + application_generate_entity=application_generate_entity, + app_model=app_model, + message=message, + queue_manager=queue_manager, + ) + if handled: + return dify_context = DifyRunContext( tenant_id=app_config.tenant_id, @@ -258,7 +373,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): app_config = application_generate_entity.app_config model_name = application_generate_entity.model_conf.model - query = application_generate_entity.query + query = application_generate_entity.query or "" # content moderation (sensitive_word_avoidance); a blocked input yields a # preset answer, an "overridden" action returns a sanitized query. @@ -273,7 +388,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): trace_manager=application_generate_entity.trace_manager, ) except ModerationError as e: - publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e)) + publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e), user_query=query) return True, query # annotation reply: a matching annotation answers the turn deterministically. @@ -290,7 +405,12 @@ class AgentAppGenerator(MessageBasedAppGenerator): QueueAnnotationReplyEvent(message_annotation_id=annotation_reply.id), PublishFrom.APPLICATION_MANAGER, ) - publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=annotation_reply.content) + publish_text_answer( + queue_manager=queue_manager, + model_name=model_name, + answer=annotation_reply.content, + user_query=query, + ) return True, query return False, query diff --git a/api/core/app/apps/agent_app/app_runner.py b/api/core/app/apps/agent_app/app_runner.py index 7b767e50881..03c6f3e410c 100644 --- a/api/core/app/apps/agent_app/app_runner.py +++ b/api/core/app/apps/agent_app/app_runner.py @@ -14,9 +14,12 @@ import json import logging from typing import Any +from dify_agent.layers.ask_human import AskHumanToolArgs +from dify_agent.protocol import DeferredToolResultsPayload from pydantic import JsonValue from clients.agent_backend import ( + AgentBackendDeferredToolCallInternalEvent, AgentBackendError, AgentBackendInternalEventType, AgentBackendRunClient, @@ -27,21 +30,41 @@ from clients.agent_backend import ( ) from core.app.apps.agent_app.runtime_request_builder import ( AgentAppRuntimeBuildContext, + AgentAppRuntimeRequest, AgentAppRuntimeRequestBuilder, ) -from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope +from core.app.apps.agent_app.session_store import ( + AgentAppRuntimeSessionStore, + AgentAppSessionScope, + StoredAgentAppSession, +) from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import DifyRunContext from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent +from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl +from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form +from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage -from graphon.model_runtime.entities.message_entities import AssistantPromptMessage +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage, UserPromptMessage from models.agent_config_entities import AgentSoulConfig logger = logging.getLogger(__name__) -def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answer: str) -> None: +def _prompt_messages_from_query(user_query: str | None) -> list[PromptMessage]: + if not user_query: + return [] + return [UserPromptMessage(content=user_query)] + + +def publish_text_answer( + *, + queue_manager: AppQueueManager, + model_name: str, + answer: str, + user_query: str | None = None, +) -> None: """Publish a complete assistant answer as one chunk + message-end. The EasyUI chat task pipeline consumes a QueueLLMChunkEvent stream followed @@ -49,9 +72,10 @@ def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answ both the backend-produced answer and short-circuited answers (moderation / annotation reply) share the exact same persistence + SSE path. """ + prompt_messages = _prompt_messages_from_query(user_query) chunk = LLMResultChunk( model=model_name, - prompt_messages=[], + prompt_messages=prompt_messages, delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=answer)), ) queue_manager.publish(QueueLLMChunkEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER) @@ -59,7 +83,7 @@ def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answ QueueMessageEndEvent( llm_result=LLMResult( model=model_name, - prompt_messages=[], + prompt_messages=prompt_messages, message=AssistantPromptMessage(content=answer), usage=LLMUsage.empty_usage(), ), @@ -104,7 +128,13 @@ class AgentAppRunner: agent_id=agent_id, agent_config_snapshot_id=agent_config_snapshot_id, ) - session_snapshot = self._session_store.load_active_snapshot(scope) + # ENG-638: if a prior turn paused on ask_human and the form is now answered, + # resume by threading the human's reply into this run as deferred_tool_results. + stored = self._session_store.load_active_session(scope) + session_snapshot = stored.session_snapshot if stored is not None else None + deferred_tool_results = self._resolve_pending_ask_human( + stored=stored, dify_context=dify_context, message_id=message_id + ) runtime = self._request_builder.build( AgentAppRuntimeBuildContext( @@ -116,18 +146,36 @@ class AgentAppRunner: user_query=query, idempotency_key=message_id, session_snapshot=session_snapshot, + deferred_tool_results=deferred_tool_results, ) ) create_response = self._agent_backend_client.create_run(runtime.request) terminal = self._consume_stream(create_response.run_id, queue_manager=queue_manager) + if isinstance(terminal, AgentBackendDeferredToolCallInternalEvent): + # ENG-635: the agent asked a human. End this turn with the question and + # a conversation-owned HITL form; a form submission resumes the run. + self._pause_for_ask_human( + terminal=terminal, + scope=scope, + dify_context=dify_context, + agent_soul=agent_soul, + conversation_id=conversation_id, + message_id=message_id, + model_name=model_name, + runtime=runtime, + queue_manager=queue_manager, + query=query, + ) + return + if not isinstance(terminal, AgentBackendRunSucceededInternalEvent): error = getattr(terminal, "error", None) or "Agent backend run did not complete successfully." raise AgentBackendError(str(error)) answer = self._extract_answer(terminal.output) - self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer) + self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, query=query) self._save_session( scope=scope, backend_run_id=terminal.run_id, @@ -135,6 +183,95 @@ class AgentAppRunner: runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition), ) + def _pause_for_ask_human( + self, + *, + terminal: AgentBackendDeferredToolCallInternalEvent, + scope: AgentAppSessionScope, + dify_context: DifyRunContext, + agent_soul: AgentSoulConfig, + conversation_id: str, + message_id: str, + model_name: str, + runtime: AgentAppRuntimeRequest, + queue_manager: AppQueueManager, + query: str, + ) -> None: + """End the chat turn on a dify.ask_human call: create a conversation-owned + HITL form, persist the pause correlation, and surface the question.""" + try: + created = create_ask_human_form( + deferred_tool_call=terminal.deferred_tool_call, + # Chat forms have no workflow node; key by the turn's message id. + node_id=message_id, + default_node_title="Agent", + contacts=agent_soul.human.contacts, + repository=self._build_form_repository(dify_context), + conversation_id=conversation_id, + ) + except AskHumanFormBuildError as error: + raise AgentBackendError(f"Failed to build ask_human form for Agent App chat: {error}") from error + + # Persist the snapshot + correlation so a form submission can start the + # second run with the human's answer (ENG-637/638 columns, conversation owner). + self._save_session( + scope=scope, + backend_run_id=terminal.run_id, + snapshot=terminal.session_snapshot, + runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition), + pending_form_id=created.form_id, + pending_tool_call_id=terminal.deferred_tool_call.tool_call_id, + ) + + # The structured form is delivered via the HITL surface(s); the chat turn + # ends by echoing the agent's question so the conversation reflects the ask. + self._publish_answer( + queue_manager=queue_manager, + model_name=model_name, + answer=self._ask_human_message(created.args), + query=query, + ) + + def _resolve_pending_ask_human( + self, + *, + stored: StoredAgentAppSession | None, + dify_context: DifyRunContext, + message_id: str, + ) -> DeferredToolResultsPayload | None: + """Build deferred_tool_results when a pending ask_human form is answered.""" + if stored is None or stored.pending_form_id is None or stored.pending_tool_call_id is None: + return None + outcome = resolve_ask_human_form( + form_id=stored.pending_form_id, + tenant_id=dify_context.tenant_id, + node_id=message_id, + ) + if outcome is None or outcome.deferred_result is None: + # Form missing or still waiting — run a normal turn, no resume. + return None + return build_deferred_tool_results( + tool_call_id=stored.pending_tool_call_id, + result=outcome.deferred_result, + ) + + def _build_form_repository(self, dify_context: DifyRunContext) -> HumanInputFormRepository: + invoke_source = dify_context.invoke_from.value + return HumanInputFormRepositoryImpl( + tenant_id=dify_context.tenant_id, + app_id=dify_context.app_id, + workflow_execution_id=None, + invoke_source=invoke_source, + submission_actor_id=dify_context.user_id if invoke_source in {"debugger", "explore"} else None, + ) + + @staticmethod + def _ask_human_message(args: AskHumanToolArgs) -> str: + parts = [args.question] + if args.markdown: + parts.append(args.markdown) + return "\n\n".join(parts) + def _consume_stream(self, run_id: str, *, queue_manager: AppQueueManager): terminal = None for public_event in self._agent_backend_client.stream_events(run_id): @@ -166,10 +303,12 @@ class AgentAppRunner: except Exception: logger.warning("Failed to cancel stopped Agent App backend run: run_id=%s", run_id, exc_info=True) - def _publish_answer(self, *, queue_manager: AppQueueManager, model_name: str, answer: str) -> None: + def _publish_answer( + self, *, queue_manager: AppQueueManager, model_name: str, answer: str, query: str | None + ) -> None: # MVP: emit the full answer as a single chunk + message-end. The chat # task pipeline streams the chunk over SSE and persists the message. - publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer) + publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, user_query=query) def _save_session( self, @@ -178,6 +317,8 @@ class AgentAppRunner: backend_run_id: str, snapshot: Any, runtime_layer_specs: Any, + pending_form_id: str | None = None, + pending_tool_call_id: str | None = None, ) -> None: try: self._session_store.save_active_snapshot( @@ -185,6 +326,8 @@ class AgentAppRunner: backend_run_id=backend_run_id, snapshot=snapshot, runtime_layer_specs=runtime_layer_specs, + pending_form_id=pending_form_id, + pending_tool_call_id=pending_tool_call_id, ) except Exception: logger.warning( diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index 73d7fdedb8c..71cc0385f97 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -18,7 +18,7 @@ from dify_agent.layers.execution_context import ( DifyExecutionContextLayerConfig, DifyExecutionContextUserFrom, ) -from dify_agent.protocol import CreateRunRequest +from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload from clients.agent_backend import ( AgentBackendAgentAppRunInput, @@ -34,6 +34,7 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import ( ) from core.workflow.nodes.agent_v2.runtime_request_builder import ( append_runtime_warnings, + build_ask_human_layer_config, build_drive_layer_config, build_shell_layer_config, ) @@ -64,6 +65,8 @@ class AgentAppRuntimeBuildContext: user_query: str idempotency_key: str session_snapshot: CompositorSessionSnapshot | None = None + # ENG-638: set when resuming a chat turn after a submitted ask_human form. + deferred_tool_results: DeferredToolResultsPayload | None = None @dataclass(frozen=True, slots=True) @@ -154,9 +157,11 @@ class AgentAppRuntimeRequestBuilder: user_prompt=context.user_query, tools=tools_layer, drive_config=drive_config, + ask_human_config=build_ask_human_layer_config(agent_soul), include_shell=dify_config.AGENT_SHELL_ENABLED, shell_config=build_shell_layer_config(agent_soul), session_snapshot=context.session_snapshot, + deferred_tool_results=context.deferred_tool_results, idempotency_key=context.idempotency_key, metadata=metadata, ) diff --git a/api/core/app/apps/agent_app/session_store.py b/api/core/app/apps/agent_app/session_store.py index 22af2f2068f..8c68e218d1f 100644 --- a/api/core/app/apps/agent_app/session_store.py +++ b/api/core/app/apps/agent_app/session_store.py @@ -56,6 +56,10 @@ class StoredAgentAppSession: session_snapshot: CompositorSessionSnapshot backend_run_id: str | None runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list) + # ENG-635: set while the conversation turn is paused on a dify.ask_human + # deferred call, awaiting a HITL form submission. + pending_form_id: str | None = None + pending_tool_call_id: str | None = None class AgentAppRuntimeSessionStore: @@ -75,6 +79,8 @@ class AgentAppRuntimeSessionStore: session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), backend_run_id=row.backend_run_id, runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs), + pending_form_id=row.pending_form_id, + pending_tool_call_id=row.pending_tool_call_id, ) def load_active_session_for_conversation( @@ -125,6 +131,8 @@ class AgentAppRuntimeSessionStore: backend_run_id: str, snapshot: CompositorSessionSnapshot | None, runtime_layer_specs: list[RuntimeLayerSpec], + pending_form_id: str | None = None, + pending_tool_call_id: str | None = None, ) -> None: if snapshot is None: return @@ -144,6 +152,8 @@ class AgentAppRuntimeSessionStore: session_snapshot=snapshot_json, composition_layer_specs=runtime_layer_specs_json, status=AgentRuntimeSessionStatus.ACTIVE, + pending_form_id=pending_form_id, + pending_tool_call_id=pending_tool_call_id, ) session.add(row) else: @@ -152,6 +162,9 @@ class AgentAppRuntimeSessionStore: row.composition_layer_specs = runtime_layer_specs_json row.status = AgentRuntimeSessionStatus.ACTIVE row.cleaned_at = None + # Set (or clear, when omitted) the ask_human pause correlation. + row.pending_form_id = pending_form_id + row.pending_tool_call_id = pending_tool_call_id session.flush() other_rows = session.scalars( select(AgentRuntimeSession).where( diff --git a/api/core/helper/marketplace.py b/api/core/helper/marketplace.py index d7b6e820625..2c7d520250c 100644 --- a/api/core/helper/marketplace.py +++ b/api/core/helper/marketplace.py @@ -12,6 +12,8 @@ from extensions.ext_redis import redis_client marketplace_api_url = URL(str(dify_config.MARKETPLACE_API_URL)) logger = logging.getLogger(__name__) +MARKETPLACE_TIMEOUT = 30 + def get_plugin_pkg_url(plugin_unique_identifier: str) -> str: return str((marketplace_api_url / "api/v1/plugins/download").with_query(unique_identifier=plugin_unique_identifier)) @@ -26,7 +28,12 @@ def batch_fetch_plugin_manifests(plugin_ids: list[str]) -> Sequence[MarketplaceP return [] url = str(marketplace_api_url / "api/v1/plugins/batch") - response = httpx.post(url, json={"plugin_ids": plugin_ids}, headers={"X-Dify-Version": dify_config.project.version}) + response = httpx.post( + url, + json={"plugin_ids": plugin_ids}, + headers={"X-Dify-Version": dify_config.project.version}, + timeout=MARKETPLACE_TIMEOUT, + ) response.raise_for_status() return [MarketplacePluginDeclaration.model_validate(plugin) for plugin in response.json()["data"]["plugins"]] @@ -37,7 +44,12 @@ def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]: return [] url = str(marketplace_api_url / "api/v1/plugins/batch") - response = httpx.post(url, json={"plugin_ids": plugin_ids}, headers={"X-Dify-Version": dify_config.project.version}) + response = httpx.post( + url, + json={"plugin_ids": plugin_ids}, + headers={"X-Dify-Version": dify_config.project.version}, + timeout=MARKETPLACE_TIMEOUT, + ) response.raise_for_status() data = response.json() @@ -46,7 +58,7 @@ def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]: def record_install_plugin_event(plugin_unique_identifier: str): url = str(marketplace_api_url / "api/v1/stats/plugins/install_count") - response = httpx.post(url, json={"unique_identifier": plugin_unique_identifier}) + response = httpx.post(url, json={"unique_identifier": plugin_unique_identifier}, timeout=MARKETPLACE_TIMEOUT) response.raise_for_status() @@ -64,7 +76,7 @@ def fetch_global_plugin_manifest(cache_key_prefix: str, cache_ttl: int) -> None: Exception: If any other error occurs during fetching or caching """ url = str(marketplace_api_url / "api/v1/dist/plugins/manifest.json") - response = httpx.get(url, headers={"X-Dify-Version": dify_config.project.version}, timeout=30) + response = httpx.get(url, headers={"X-Dify-Version": dify_config.project.version}, timeout=MARKETPLACE_TIMEOUT) response.raise_for_status() raw_json = response.json() diff --git a/api/core/plugin/entities/plugin_daemon.py b/api/core/plugin/entities/plugin_daemon.py index 257638ad77c..507a6ea5cd3 100644 --- a/api/core/plugin/entities/plugin_daemon.py +++ b/api/core/plugin/entities/plugin_daemon.py @@ -168,6 +168,7 @@ class PluginInstallTask(BasePluginEntity): class PluginInstallTaskStartResponse(BaseModel): all_installed: bool = Field(description="Whether all plugins are installed.") task_id: str = Field(description="The ID of the install task.") + task: PluginInstallTask | None = Field(default=None, description="The install task.") class PluginVerification(BaseModel): @@ -206,6 +207,11 @@ class PluginListResponse(BaseModel): total: int +class PluginListWithoutTotalResponse(BaseModel): + list: list[PluginEntity] + has_more: bool + + class PluginDynamicSelectOptionsResponse(BaseModel): options: Sequence[PluginParameterOption] = Field(description="The options of the dynamic select.") diff --git a/api/core/plugin/impl/plugin.py b/api/core/plugin/impl/plugin.py index 8a7175bb51f..34e8d315d86 100644 --- a/api/core/plugin/impl/plugin.py +++ b/api/core/plugin/impl/plugin.py @@ -6,6 +6,7 @@ from requests import HTTPError from core.plugin.entities.bundle import PluginBundleDependency from core.plugin.entities.plugin import ( MissingPluginDependency, + PluginCategory, PluginDeclaration, PluginEntity, PluginInstallation, @@ -16,6 +17,7 @@ from core.plugin.entities.plugin_daemon import ( PluginInstallTask, PluginInstallTaskStartResponse, PluginListResponse, + PluginListWithoutTotalResponse, PluginReadmeResponse, ) from core.plugin.impl.base import BasePluginClient @@ -74,6 +76,16 @@ class PluginInstaller(BasePluginClient): params={"page": page, "page_size": page_size, "response_type": "paged"}, ) + def list_plugins_by_category( + self, tenant_id: str, category: PluginCategory, page: int, page_size: int + ) -> PluginListWithoutTotalResponse: + return self._request_with_plugin_daemon_response( + "GET", + f"plugin/{tenant_id}/management/{category.value}/list", + PluginListWithoutTotalResponse, + params={"page": page, "page_size": page_size, "response_type": "paged"}, + ) + def upload_pkg( self, tenant_id: str, diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index 79c372690e6..2ab3f87db72 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -31,6 +31,7 @@ from core.helper.marketplace import download_plugin_pkg from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType from core.plugin.entities.bundle import PluginBundleDependency from core.plugin.entities.plugin import ( + PluginCategory, PluginDeclaration, PluginEntity, PluginInstallation, @@ -41,6 +42,7 @@ from core.plugin.entities.plugin_daemon import ( PluginInstallTask, PluginInstallTaskStatus, PluginListResponse, + PluginListWithoutTotalResponse, PluginModelProviderEntity, PluginVerification, ) @@ -437,6 +439,19 @@ class PluginService: PluginService._reconcile_endpoint_counts(tenant_id, user_id, plugins.list) return plugins + @staticmethod + def list_by_category( + tenant_id: str, category: PluginCategory, page: int, page_size: int + ) -> PluginListWithoutTotalResponse: + """ + List plugins in one category with a has-more cursor signal and without calculating total. + + The daemon scans tenant installations in the existing list order and stops once it finds one extra match. + This keeps pagination usable before category is persisted on installation rows. + """ + manager = PluginInstaller() + return manager.list_plugins_by_category(tenant_id, category, page, page_size) + @staticmethod def _normalize_endpoint_count(value: object) -> int: """Convert daemon endpoint counters to safe non-negative integers. diff --git a/api/core/plugin/utils/converter.py b/api/core/plugin/utils/converter.py index 12d8e282b2a..1cd1745f92d 100644 --- a/api/core/plugin/utils/converter.py +++ b/api/core/plugin/utils/converter.py @@ -6,16 +6,13 @@ from graphon.file import File def convert_parameters_to_plugin_format(parameters: dict[str, Any]) -> dict[str, Any]: for parameter_name, parameter in parameters.items(): - if isinstance(parameter, File): - parameters[parameter_name] = parameter.to_plugin_parameter() - elif isinstance(parameter, list) and all(isinstance(p, File) for p in parameter): - parameters[parameter_name] = [] - for p in parameter: - parameters[parameter_name].append(p.to_plugin_parameter()) - elif isinstance(parameter, ToolSelector): - parameters[parameter_name] = parameter.to_plugin_parameter() - elif isinstance(parameter, list) and all(isinstance(p, ToolSelector) for p in parameter): - parameters[parameter_name] = [] - for p in parameter: - parameters[parameter_name].append(p.to_plugin_parameter()) + match parameter: + case File(): + parameters[parameter_name] = parameter.to_plugin_parameter() + case [*items] if all(isinstance(p, File) for p in items): + parameters[parameter_name] = [p.to_plugin_parameter() for p in items] + case ToolSelector(): + parameters[parameter_name] = parameter.to_plugin_parameter() + case [*items] if all(isinstance(p, ToolSelector) for p in items): + parameters[parameter_name] = [p.to_plugin_parameter() for p in items] return parameters diff --git a/api/core/rag/extractor/watercrawl/client.py b/api/core/rag/extractor/watercrawl/client.py index d1ce142dbdd..1f4adc0d418 100644 --- a/api/core/rag/extractor/watercrawl/client.py +++ b/api/core/rag/extractor/watercrawl/client.py @@ -118,16 +118,18 @@ class WaterCrawlAPIClient(BaseAPIClient): response.raise_for_status() if response.status_code == 204: return None - if response.headers.get("Content-Type") == "application/json": + content_type = response.headers.get("Content-Type", "") + media_type = content_type.split(";", 1)[0].strip().lower() + if media_type == "application/json": return response.json() or {} - if response.headers.get("Content-Type") == "application/octet-stream": + if media_type == "application/octet-stream": return response.content - if response.headers.get("Content-Type") == "text/event-stream": + if media_type == "text/event-stream": return self.process_eventstream(response) - raise Exception(f"Unknown response type: {response.headers.get('Content-Type')}") + raise Exception(f"Unknown response type: {content_type}") def get_crawl_requests_list(self, page: int | None = None, page_size: int | None = None): query_params = {"page": page or 1, "page_size": page_size or 10} @@ -217,7 +219,7 @@ class WaterCrawlAPIClient(BaseAPIClient): return event_data["data"] def download_result(self, result_object: dict[str, Any]): - response = httpx.get(result_object["result"], timeout=None) + response = httpx.get(result_object["result"], timeout=30) try: response.raise_for_status() result_object["result"] = response.json() diff --git a/api/core/rag/index_processor/processor/paragraph_index_processor.py b/api/core/rag/index_processor/processor/paragraph_index_processor.py index c4f28ae2164..f68e5a4e6b3 100644 --- a/api/core/rag/index_processor/processor/paragraph_index_processor.py +++ b/api/core/rag/index_processor/processor/paragraph_index_processor.py @@ -5,6 +5,8 @@ import re import uuid from typing import Any, TypedDict, cast, override +from sqlalchemy.orm import scoped_session + logger = logging.getLogger(__name__) from sqlalchemy import select @@ -411,11 +413,13 @@ class ParagraphIndexProcessor(BaseIndexProcessor): if supports_vision: # First, try to get images from SegmentAttachmentBinding (preferred method) if segment_id: - image_files = ParagraphIndexProcessor._extract_images_from_segment_attachments(tenant_id, segment_id) + image_files = ParagraphIndexProcessor._extract_images_from_segment_attachments( + tenant_id, segment_id, db.session + ) # If no images from attachments, fall back to extracting from text if not image_files: - image_files = ParagraphIndexProcessor._extract_images_from_text(tenant_id, text) + image_files = ParagraphIndexProcessor._extract_images_from_text(tenant_id, text, db.session) # Build prompt messages prompt_messages = [] @@ -469,7 +473,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor): return summary_content, usage @staticmethod - def _extract_images_from_text(tenant_id: str, text: str) -> list[File]: + def _extract_images_from_text(tenant_id: str, text: str, session: scoped_session) -> list[File]: """ Extract images from markdown text and convert them to File objects. @@ -518,7 +522,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor): # Get unique IDs for database query unique_upload_file_ids = list(set(upload_file_id_list)) - upload_files = db.session.scalars( + upload_files = session.scalars( select(UploadFile).where(UploadFile.id.in_(unique_upload_file_ids), UploadFile.tenant_id == tenant_id) ).all() @@ -549,7 +553,9 @@ class ParagraphIndexProcessor(BaseIndexProcessor): return file_objects @staticmethod - def _extract_images_from_segment_attachments(tenant_id: str, segment_id: str) -> list[File]: + def _extract_images_from_segment_attachments( + tenant_id: str, segment_id: str, session: scoped_session + ) -> list[File]: """ Extract images from SegmentAttachmentBinding table (preferred method). This matches how DatasetRetrieval gets segment attachments. @@ -564,7 +570,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor): from sqlalchemy import select # Query attachments from SegmentAttachmentBinding table - attachments_with_bindings = db.session.execute( + attachments_with_bindings = session.execute( select(SegmentAttachmentBinding, UploadFile) .join(UploadFile, UploadFile.id == SegmentAttachmentBinding.attachment_id) .where( diff --git a/api/core/repositories/human_input_repository.py b/api/core/repositories/human_input_repository.py index 4d1a3ef0063..599cc643ea4 100644 --- a/api/core/repositories/human_input_repository.py +++ b/api/core/repositories/human_input_repository.py @@ -63,6 +63,10 @@ class FormCreateParams: display_in_ui: bool resolved_default_values: Mapping[str, Any] form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME + # ENG-635: the conversation this form belongs to. Set together with + # workflow_execution_id for chatflow runs; set alone (workflow_execution_id None) + # for Agent v2 chat ask_human forms, which have no workflow run. + conversation_id: str | None = None class HumanInputFormRecipientEntity(Protocol): @@ -217,6 +221,9 @@ class HumanInputFormRecord: recipient_id: str | None recipient_type: RecipientType | None access_token: str | None + # ENG-635: Agent v2 chat owner (NULL for workflow-owned forms). Trailing + + # defaulted so existing record constructions stay source-compatible. + conversation_id: str | None = None @property def submitted(self) -> bool: @@ -232,6 +239,7 @@ class HumanInputFormRecord: return cls( form_id=form_model.id, workflow_run_id=form_model.workflow_run_id, + conversation_id=form_model.conversation_id, node_id=form_model.node_id, tenant_id=form_model.tenant_id, app_id=form_model.app_id, @@ -433,8 +441,15 @@ class HumanInputFormRepositoryImpl: if not app_id: raise ValueError("app_id is required to create a human input form") workflow_execution_id = params.workflow_execution_id or self._workflow_execution_id - if params.form_kind == HumanInputFormKind.RUNTIME and workflow_execution_id is None: - raise ValueError("workflow_execution_id is required for runtime human input forms") + # A RUNTIME form must be owned by at least one of: a workflow run (workflow / + # Human-Input / agent node) or a conversation turn (ENG-635: Agent v2 chat + # ask_human; chatflow runs set both — workflow_run_id and conversation_id). + if ( + params.form_kind == HumanInputFormKind.RUNTIME + and workflow_execution_id is None + and params.conversation_id is None + ): + raise ValueError("a runtime human input form requires a workflow_execution_id or conversation_id") with session_factory.create_session() as session, session.begin(): # Generate unique form ID @@ -456,6 +471,7 @@ class HumanInputFormRepositoryImpl: tenant_id=self._tenant_id, app_id=app_id, workflow_run_id=workflow_execution_id, + conversation_id=params.conversation_id, form_kind=params.form_kind, node_id=params.node_id, form_definition=form_definition.model_dump_json(), diff --git a/api/core/tools/__base/tool.py b/api/core/tools/__base/tool.py index 4d784b5f232..b219ba49575 100644 --- a/api/core/tools/__base/tool.py +++ b/api/core/tools/__base/tool.py @@ -66,20 +66,21 @@ class Tool(ABC): message_id=message_id, ) - if isinstance(result, ToolInvokeMessage): + match result: + case ToolInvokeMessage(): - def single_generator() -> Generator[ToolInvokeMessage, None, None]: - yield result + def single_generator() -> Generator[ToolInvokeMessage, None, None]: + yield result - return single_generator() - elif isinstance(result, list): + return single_generator() + case list(): - def generator() -> Generator[ToolInvokeMessage, None, None]: - yield from result + def generator() -> Generator[ToolInvokeMessage, None, None]: + yield from result - return generator() - else: - return result + return generator() + case _: + return result def _transform_tool_parameters_type(self, tool_parameters: dict[str, Any]) -> dict[str, Any]: """ diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index 99be960a200..850571c3f19 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -398,6 +398,8 @@ class ToolManager: user_id: str | None = None, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, variable_pool: "VariablePool | None" = None, + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> Tool: """ get the agent tool runtime @@ -415,7 +417,12 @@ class ToolManager: runtime_parameters: dict[str, Any] = {} parameters = tool_entity.get_merged_runtime_parameters() runtime_parameters = cls._convert_tool_parameters_type( - parameters, variable_pool, agent_tool.tool_parameters, typ="agent" + parameters, + variable_pool, + agent_tool.tool_parameters, + typ="agent", + allow_file_parameters=allow_file_parameters, + use_default_for_missing_form_parameters=use_default_for_missing_form_parameters, ) # decrypt runtime parameters encryption_manager = ToolParameterConfigurationManager( @@ -1063,6 +1070,8 @@ class ToolManager: variable_pool: "VariablePool | None", tool_configurations: Mapping[str, Any], typ: Literal["agent", "workflow", "tool"] = "workflow", + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> dict[str, Any]: """ Convert tool parameters type @@ -1081,6 +1090,7 @@ class ToolManager: } and parameter.required and typ == "agent" + and not allow_file_parameters ): raise ValueError(f"file type parameter {parameter.name} not supported in agent") # save tool parameter to tool entity memory @@ -1117,7 +1127,19 @@ class ToolManager: runtime_parameters[parameter.name] = parameter_value else: - value = parameter.init_frontend_parameter(tool_configurations.get(parameter.name)) + parameter_value = tool_configurations.get(parameter.name) + if use_default_for_missing_form_parameters and parameter_value is None: + if parameter.default is not None: + parameter_value = parameter.default + elif ( + parameter.required + and parameter.type == ToolParameter.ToolParameterType.SELECT + and parameter.options + ): + parameter_value = parameter.options[0].value + else: + continue + value = parameter.init_frontend_parameter(parameter_value) runtime_parameters[parameter.name] = value return runtime_parameters diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index bf85fc19188..54c6c55949e 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -334,6 +334,7 @@ class DifyNodeFactory(NodeFactory): self.graph_runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID, ), + conversation_id_getter=self._conversation_id, ) self._tool_runtime = DifyToolNodeRuntime(self._dify_context) self._http_request_file_manager = file_manager diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index 8c7d5c157ca..4eced02cd10 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -710,10 +710,12 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): run_context: Mapping[str, Any] | DifyRunContext, *, workflow_execution_id_getter: Callable[[], str | None] | None = None, + conversation_id_getter: Callable[[], str | None] | None = None, form_repository: HumanInputFormRepository | None = None, ) -> None: self._run_context = resolve_dify_run_context(run_context) self._workflow_execution_id_getter = workflow_execution_id_getter + self._conversation_id_getter = conversation_id_getter self._form_repository = form_repository self._file_reference_factory = DifyFileReferenceFactory(self._run_context) @@ -762,6 +764,7 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): return DifyHumanInputNodeRuntime( self._run_context, workflow_execution_id_getter=self._workflow_execution_id_getter, + conversation_id_getter=self._conversation_id_getter, form_repository=form_repository, ) @@ -799,6 +802,9 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): repo = self.build_form_repository() params = FormCreateParams( workflow_execution_id=self._workflow_execution_id_getter() if self._workflow_execution_id_getter else None, + # A chatflow (advanced-chat) run carries a conversation; tag the form with + # it too so it is queryable per conversation. None for a pure workflow run. + conversation_id=self._conversation_id_getter() if self._conversation_id_getter else None, node_id=node_id, form_config=node_data, rendered_content=rendered_content, diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py index 1e22a35fc97..8adb27240c7 100644 --- a/api/core/workflow/nodes/agent_v2/agent_node.py +++ b/api/core/workflow/nodes/agent_v2/agent_node.py @@ -24,13 +24,16 @@ from clients.agent_backend import ( extract_runtime_layer_specs, ) from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext +from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl from core.workflow.system_variables import SystemVariableKey, get_system_text -from graphon.entities.pause_reason import SchedulingPause +from graphon.entities.pause_reason import HumanInputRequired, SchedulingPause from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEvent, StreamCompletedEvent from graphon.nodes.base.node import Node -from models.agent_config_entities import WorkflowNodeJobConfig +from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig +from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason +from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form from .binding_resolver import WorkflowAgentBindingError, WorkflowAgentBindingResolver from .entities import DifyAgentNodeData from .output_adapter import WorkflowAgentOutputAdapter @@ -117,6 +120,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]): self.graph_runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID, ) + # Set on chatflow (advanced-chat) runs; None for a pure workflow run. Lets an + # ask_human form be tagged with its conversation in addition to workflow_run_id. + conversation_id = get_system_text( + self.graph_runtime_state.variable_pool, + SystemVariableKey.CONVERSATION_ID, + ) inputs: dict[str, Any] = {} process_data: dict[str, Any] = {} metadata: dict[str, Any] = { @@ -168,6 +177,33 @@ class DifyAgentNode(Node[DifyAgentNodeData]): ) outputs_by_name = {o.name: o for o in effective_outputs} + # ──── ENG-638: resume after a submitted/timed-out ask_human form ──── + # graphon re-executes this _run when the outer workflow resumes. If a + # pending ask_human form is now terminal, thread the human's answer into + # the second Agent run as deferred_tool_results; if it is somehow still + # waiting, re-emit the same pause defensively. + deferred_tool_results = None + if self._session_store is not None: + stored_session = self._session_store.load_active_session(session_scope) + if stored_session is not None and stored_session.pending_form_id is not None: + resume_outcome = resolve_ask_human_form( + form_id=stored_session.pending_form_id, + tenant_id=dify_ctx.tenant_id, + node_id=self._node_id, + ) + if resume_outcome is not None and resume_outcome.repause is not None: + yield PauseRequestedEvent(reason=resume_outcome.repause) + return + if ( + resume_outcome is not None + and resume_outcome.deferred_result is not None + and stored_session.pending_tool_call_id is not None + ): + deferred_tool_results = build_deferred_tool_results( + tool_call_id=stored_session.pending_tool_call_id, + result=resume_outcome.deferred_result, + ) + # ──── Retry loop (Stage 4 §7) ──── attempt = 0 while True: @@ -188,6 +224,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): snapshot=bundle.snapshot, attempt=attempt, session_snapshot=session_snapshot, + deferred_tool_results=deferred_tool_results, ) ) except WorkflowAgentRuntimeRequestBuildError as error: @@ -251,19 +288,56 @@ class DifyAgentNode(Node[DifyAgentNodeData]): return if isinstance(terminal_event, AgentBackendDeferredToolCallInternalEvent): + # ENG-636: a dify.ask_human deferred call pauses the *outer* + # workflow through the existing HITL form path. Any other deferred + # tool (none today) falls back to a generic scheduling pause. The + # form is built *before* the snapshot is saved so its id can be + # persisted as the pause correlation (ENG-637). + try: + pause_reason: HumanInputRequired | SchedulingPause | None = build_ask_human_pause_reason( + deferred_tool_call=terminal_event.deferred_tool_call, + node_id=self._node_id, + default_node_title=bundle.agent.name or self._node_id, + workflow_run_id=workflow_run_id, + conversation_id=conversation_id, + contacts=AgentSoulConfig.model_validate(bundle.snapshot.config_snapshot_dict).human.contacts, + repository=self._build_human_input_form_repository( + dify_ctx=dify_ctx, workflow_run_id=workflow_run_id + ), + ) + except AskHumanFormBuildError as error: + yield self._failure_event( + inputs=inputs, + process_data=process_data, + metadata=metadata, + error=str(error), + error_type="agent_ask_human_form_build_error", + ) + return + + # ENG-637: persist the awaiting-form id + deferred tool_call id + # next to the snapshot so the resumed node can rebuild + # deferred_tool_results from the submitted form. + pending_form_id: str | None = None + pending_tool_call_id: str | None = None + if isinstance(pause_reason, HumanInputRequired): + pending_form_id = pause_reason.form_id + pending_tool_call_id = terminal_event.deferred_tool_call.tool_call_id + else: + pause_reason = SchedulingPause( + message=terminal_event.message + or "Agent backend run requested workflow pause for external input." + ) self._save_session_snapshot( session_scope=session_scope, backend_run_id=terminal_event.run_id, snapshot=terminal_event.session_snapshot, runtime_layer_specs=extract_runtime_layer_specs(runtime_request.request.composition), metadata=metadata, + pending_form_id=pending_form_id, + pending_tool_call_id=pending_tool_call_id, ) - yield PauseRequestedEvent( - reason=SchedulingPause( - message=terminal_event.message - or "Agent backend run requested workflow pause for external input." - ) - ) + yield PauseRequestedEvent(reason=pause_reason) return # Non-success terminal (failed / cancelled) skips per-output @@ -448,6 +522,27 @@ class DifyAgentNode(Node[DifyAgentNodeData]): ], } + def _build_human_input_form_repository( + self, + *, + dify_ctx: DifyRunContext, + workflow_run_id: str | None, + ) -> HumanInputFormRepository: + """Construct the existing HITL form repository for ask_human form creation. + + Mirrors the Human Input node's repository wiring (``node_runtime``) so the + ask_human form shares the same delivery/debug/console behavior: a + submission actor is only attributed for debugger/explore surfaces. + """ + invoke_source = dify_ctx.invoke_from.value + return HumanInputFormRepositoryImpl( + tenant_id=dify_ctx.tenant_id, + app_id=dify_ctx.app_id, + workflow_execution_id=workflow_run_id, + invoke_source=invoke_source, + submission_actor_id=dify_ctx.user_id if invoke_source in {"debugger", "explore"} else None, + ) + def _save_session_snapshot( self, *, @@ -456,6 +551,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]): snapshot: CompositorSessionSnapshot | None, runtime_layer_specs: list[RuntimeLayerSpec], metadata: dict[str, Any], + pending_form_id: str | None = None, + pending_tool_call_id: str | None = None, ) -> None: if self._session_store is None: return @@ -465,6 +562,8 @@ class DifyAgentNode(Node[DifyAgentNodeData]): backend_run_id=backend_run_id, snapshot=snapshot, runtime_layer_specs=runtime_layer_specs, + pending_form_id=pending_form_id, + pending_tool_call_id=pending_tool_call_id, ) agent_backend = dict(metadata.get("agent_backend") or {}) agent_backend["session_snapshot_persisted"] = snapshot is not None diff --git a/api/core/workflow/nodes/agent_v2/ask_human_hitl.py b/api/core/workflow/nodes/agent_v2/ask_human_hitl.py new file mode 100644 index 00000000000..0b535cf4f10 --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/ask_human_hitl.py @@ -0,0 +1,354 @@ +"""Map a ``dify.ask_human`` deferred tool call onto the existing HITL form path. + +ENG-636. When an Agent backend run ends with a deferred ``dify.ask_human`` tool +call, the workflow Agent node pauses the *outer* workflow through the very same +``HumanInputRequired`` form mechanism the Human Input node uses — reusing the +form repository, delivery channels, and submission endpoints unchanged. This +module is a pure translation layer: model-facing ask_human tool args become +graphon form entities (``HumanInputNodeData`` / ``FormInputConfig`` / +``UserActionConfig``) plus Dify delivery configs. It adds no new HITL behavior. + +The agent-side ``dify.ask_human`` contract is richer than the workflow form +schema in two places, handled here without widening the form vocabulary: + +* ask_human fields carry a human label; graphon ``FormInputConfig`` does not. + Labels are rendered into ``form_content`` next to a ``{{#$output.#}}`` + marker — exactly how the Human Input node positions labelled inputs today. +* ask_human action ids are valid identifiers of any length; graphon + ``UserActionConfig.id`` caps ids at 20 chars. Over-long ids are clamped + deterministically (stable + collision-resistant) so the form always builds; + the human-visible action *label* is always preserved verbatim. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any, assert_never + +from dify_agent.layers.ask_human import ( + AskHumanAction, + AskHumanField, + AskHumanFileField, + AskHumanFileListField, + AskHumanParagraphField, + AskHumanSelectField, + AskHumanToolArgs, +) +from dify_agent.protocol import DeferredToolCallPayload +from pydantic import ValidationError + +from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepository +from core.workflow.human_input_adapter import ( + DeliveryChannelConfig, + EmailDeliveryConfig, + EmailDeliveryMethod, + EmailRecipients, + ExternalRecipient, + InteractiveSurfaceDeliveryMethod, +) +from graphon.entities.pause_reason import HumanInputRequired +from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + FormInputConfig, + HumanInputNodeData, + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + StringSource, + UserActionConfig, +) +from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType +from models.agent_config_entities import AgentHumanContactConfig + +# Default ask_human tool name (see ``DifyAskHumanLayerConfig.tool_name``). The +# Agent node only knows how to translate this one deferred tool into a form. +DEFAULT_ASK_HUMAN_TOOL_NAME = "ask_human" + +# Graphon ``UserActionConfig.id`` hard-caps identifiers at 20 chars. +_MAX_ACTION_ID_LEN = 20 +# No timeout lives in the ask_human contract; reuse the Human Input node default. +_DEFAULT_TIMEOUT_HOURS = 36 +_EMAIL_SUBJECT_MAX_LEN = 120 + +# ``ButtonStyle`` has no "destructive"; map the ask_human destructive intent onto +# the closest existing form style rather than extending the HITL vocabulary. +_ACTION_STYLE_TO_BUTTON: dict[str, ButtonStyle] = { + "default": ButtonStyle.DEFAULT, + "primary": ButtonStyle.PRIMARY, + "destructive": ButtonStyle.ACCENT, +} + +# ask_human permits zero actions (a plain acknowledgement); the form surface +# needs at least one submit affordance. +_DEFAULT_SUBMIT_ACTION = UserActionConfig(id="submit", title="Submit", button_style=ButtonStyle.PRIMARY) + + +class AskHumanFormBuildError(ValueError): + """Raised when ask_human tool args cannot be mapped to a HITL form.""" + + +def parse_ask_human_args(raw_args: object) -> AskHumanToolArgs: + """Validate the raw deferred-tool ``args`` payload into ``AskHumanToolArgs``.""" + if isinstance(raw_args, AskHumanToolArgs): + return raw_args + if isinstance(raw_args, Mapping): + try: + return AskHumanToolArgs.model_validate(dict(raw_args)) + except ValidationError as error: + raise AskHumanFormBuildError(f"invalid ask_human args: {error}") from error + raise AskHumanFormBuildError(f"ask_human args must be a mapping, got {type(raw_args).__name__}") + + +def _clamp_action_id(action_id: str) -> str: + """Return a stable graphon-safe (<= 20 char) action id. + + ask_human ids are already valid identifiers; ids within the limit pass + through untouched so the resume round-trip returns the model's exact id. + Over-long ids are truncated with a short content hash to stay deterministic + and collision-resistant while remaining a valid identifier. + """ + if len(action_id) <= _MAX_ACTION_ID_LEN: + return action_id + digest = hashlib.blake2b(action_id.encode("utf-8"), digest_size=3).hexdigest() + prefix = action_id[: _MAX_ACTION_ID_LEN - len(digest) - 1] + return f"{prefix}_{digest}" + + +def _to_form_input(field: AskHumanField) -> FormInputConfig: + match field: + case AskHumanParagraphField(): + default = ( + StringSource(type=ValueSourceType.CONSTANT, value=field.default) if field.default is not None else None + ) + return ParagraphInputConfig(output_variable_name=field.name, default=default) + case AskHumanSelectField(): + return SelectInputConfig( + output_variable_name=field.name, + option_source=StringListSource( + type=ValueSourceType.CONSTANT, + value=[option.value for option in field.options], + ), + ) + case AskHumanFileField(): + return FileInputConfig(output_variable_name=field.name) + case AskHumanFileListField(): + return FileListInputConfig(output_variable_name=field.name, number_limits=field.max_files or 0) + case _: # pragma: no cover - exhaustive over the discriminated union + assert_never(field) + + +def _to_user_actions(actions: Sequence[AskHumanAction]) -> list[UserActionConfig]: + if not actions: + return [_DEFAULT_SUBMIT_ACTION] + return [ + UserActionConfig( + id=_clamp_action_id(action.id), + title=action.label, + button_style=_ACTION_STYLE_TO_BUTTON.get(action.style, ButtonStyle.DEFAULT), + ) + for action in actions + ] + + +def _render_form_content(args: AskHumanToolArgs) -> str: + """Compose the markdown body, positioning each field's label + input marker. + + Graphon ``FormInputConfig`` carries no label, so the field label is written + into the content next to the ``{{#$output.#}}`` marker that the form + surface replaces with the live input — identical to the Human Input node. + """ + blocks: list[str] = [] + if args.title: + blocks.append(f"## {args.title}") + blocks.append(args.question) + if args.markdown: + blocks.append(args.markdown) + for field in args.fields: + label = f"{field.label} *" if field.required else field.label + blocks.append(f"**{label}**\n\n{{{{#$output.{field.name}#}}}}") + return "\n\n".join(blocks) + + +def _resolved_default_values(args: AskHumanToolArgs) -> dict[str, Any]: + """Pre-fill map the form surface reads, keyed by output variable name. + + The graphon select input has no default field, so a select default can only + be conveyed here; paragraph defaults are included for a uniform pre-fill. + """ + defaults: dict[str, Any] = {} + for field in args.fields: + if isinstance(field, AskHumanParagraphField | AskHumanSelectField) and field.default is not None: + defaults[field.name] = field.default + return defaults + + +def ask_human_args_to_node_data(args: AskHumanToolArgs, *, node_title: str) -> HumanInputNodeData: + """Translate validated ask_human args into a synthetic Human Input node config.""" + return HumanInputNodeData( + title=node_title, + form_content=_render_form_content(args), + inputs=[_to_form_input(field) for field in args.fields], + user_actions=_to_user_actions(args.actions), + timeout=_DEFAULT_TIMEOUT_HOURS, + timeout_unit=TimeoutUnit.HOUR, + ) + + +def build_delivery_methods( + contacts: Sequence[AgentHumanContactConfig], + *, + args: AskHumanToolArgs, +) -> list[DeliveryChannelConfig]: + """Build form delivery channels: always the interactive surface, plus email to + the configured human contacts (the recipients chosen in Agent Soul).""" + methods: list[DeliveryChannelConfig] = [InteractiveSurfaceDeliveryMethod()] + + seen: set[str] = set() + emails: list[str] = [] + for contact in contacts: + email = (contact.email or "").strip() + if email and email not in seen: + seen.add(email) + emails.append(email) + + if emails: + subject = (args.title or args.question).strip()[:_EMAIL_SUBJECT_MAX_LEN] + if args.urgency == "high": + subject = f"[Action needed] {subject}" + body = f"{args.question}\n\nOpen the request: {EmailDeliveryConfig.URL_PLACEHOLDER}" + methods.append( + EmailDeliveryMethod( + config=EmailDeliveryConfig( + recipients=EmailRecipients(items=[ExternalRecipient(email=email) for email in emails]), + subject=subject, + body=body, + ) + ) + ) + return methods + + +@dataclass(frozen=True, slots=True) +class AskHumanFormCreated: + """A created ask_human HITL form, owner-agnostic (workflow run or conversation).""" + + form_id: str + args: AskHumanToolArgs + node_data: HumanInputNodeData + node_title: str + resolved_default_values: dict[str, Any] + + +def create_ask_human_form( + *, + deferred_tool_call: DeferredToolCallPayload, + node_id: str, + default_node_title: str, + contacts: Sequence[AgentHumanContactConfig], + repository: HumanInputFormRepository, + workflow_run_id: str | None = None, + conversation_id: str | None = None, + display_in_ui: bool = True, +) -> AskHumanFormCreated: + """Create a HITL form from an ask_human deferred call (caller verified tool_name). + + The form is owned by exactly one of ``workflow_run_id`` (workflow Agent node) + or ``conversation_id`` (Agent v2 chat). Raises ``AskHumanFormBuildError`` on + invalid args, a missing owner, or a repository failure. + """ + if not workflow_run_id and not conversation_id: + raise AskHumanFormBuildError("an ask_human HITL form requires a workflow_run_id or conversation_id") + + args = parse_ask_human_args(deferred_tool_call.args) + node_title = args.title or default_node_title + node_data = ask_human_args_to_node_data(args, node_title=node_title) + resolved_default_values = _resolved_default_values(args) + + try: + form = repository.create_form( + FormCreateParams( + workflow_execution_id=workflow_run_id, + conversation_id=conversation_id, + node_id=node_id, + form_config=node_data, + # No workflow-variable placeholders to resolve — the content is + # fully model-authored, so rendered == template. + rendered_content=node_data.form_content, + delivery_methods=build_delivery_methods(contacts, args=args), + display_in_ui=display_in_ui, + resolved_default_values=resolved_default_values, + ) + ) + except ValueError as error: + raise AskHumanFormBuildError(f"failed to create ask_human HITL form: {error}") from error + + return AskHumanFormCreated( + form_id=form.id, + args=args, + node_data=node_data, + node_title=node_title, + resolved_default_values=resolved_default_values, + ) + + +def build_ask_human_pause_reason( + *, + deferred_tool_call: DeferredToolCallPayload, + node_id: str, + default_node_title: str, + workflow_run_id: str | None, + contacts: Sequence[AgentHumanContactConfig], + repository: HumanInputFormRepository, + conversation_id: str | None = None, + expected_tool_name: str = DEFAULT_ASK_HUMAN_TOOL_NAME, + display_in_ui: bool = True, +) -> HumanInputRequired | None: + """Create a workflow HITL form for an ask_human call and return its pause reason. + + Returns ``None`` when the deferred call is *not* the ask_human tool, letting + the caller fall back to a generic scheduling pause. Raises + ``AskHumanFormBuildError`` when the call is ask_human but its args or the form + cannot be built — the caller should surface that as a node failure rather + than a silent, unresumable pause. + """ + if deferred_tool_call.tool_name != expected_tool_name: + return None + if not workflow_run_id: + raise AskHumanFormBuildError("workflow_run_id is required to create an ask_human HITL form") + + created = create_ask_human_form( + deferred_tool_call=deferred_tool_call, + node_id=node_id, + default_node_title=default_node_title, + contacts=contacts, + repository=repository, + workflow_run_id=workflow_run_id, + # A chatflow agent node also belongs to a conversation; tag the form so it is + # queryable per conversation. None for a pure workflow run (workflow_run_id only). + conversation_id=conversation_id, + display_in_ui=display_in_ui, + ) + return HumanInputRequired( + form_id=created.form_id, + form_content=created.node_data.form_content, + inputs=list(created.node_data.inputs), + actions=list(created.node_data.user_actions), + node_id=node_id, + node_title=created.node_title, + resolved_default_values=created.resolved_default_values, + ) + + +__all__ = [ + "DEFAULT_ASK_HUMAN_TOOL_NAME", + "AskHumanFormBuildError", + "AskHumanFormCreated", + "ask_human_args_to_node_data", + "build_ask_human_pause_reason", + "build_delivery_methods", + "create_ask_human_form", + "parse_ask_human_args", +] diff --git a/api/core/workflow/nodes/agent_v2/ask_human_resume.py b/api/core/workflow/nodes/agent_v2/ask_human_resume.py new file mode 100644 index 00000000000..13c9602722b --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/ask_human_resume.py @@ -0,0 +1,159 @@ +"""Resume an Agent run after a dify.ask_human HITL form reaches a terminal state. + +ENG-638. When the outer workflow resumes (the human submitted the form, or it +timed out), graphon re-executes the Agent node's ``_run``. This module reads the +correlated HITL form (by ``pending_form_id``) and maps it back into the +agent-side ``dify.ask_human`` contract so the node can start a *second* Agent run +that carries the human's answer: + +* submitted -> AskHumanToolResult(status="submitted", action, values) +* timeout / expired -> AskHumanToolResult(status="timeout") +* still waiting (defensive: the host resumed us early) -> re-emit the same + HumanInputRequired pause rebuilt from the stored form definition. + +It only *reads* existing HITL form state — it never mutates the form or the HITL +submission flow. The DB read (``resolve_ask_human_form``) is kept thin so the +mapping (``map_form_to_outcome``) stays pure and unit-testable. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from dify_agent.layers.ask_human import ( + AskHumanResultStatus, + AskHumanSelectedAction, + AskHumanToolResult, +) +from dify_agent.protocol import DeferredToolResultsPayload +from pydantic import JsonValue +from sqlalchemy import select + +from core.db.session_factory import session_factory +from graphon.entities.pause_reason import HumanInputRequired +from graphon.nodes.human_input.entities import FormDefinition +from graphon.nodes.human_input.enums import HumanInputFormStatus +from models.human_input import HumanInputForm + +# A WAITING form has not been answered yet; the other terminal states map onto +# the agent-facing result status. EXPIRED (global timeout) and TIMEOUT +# (node-level) both surface to the model as "timeout" so it can react. +_FORM_STATUS_TO_RESULT: dict[HumanInputFormStatus, AskHumanResultStatus] = { + HumanInputFormStatus.SUBMITTED: "submitted", + HumanInputFormStatus.TIMEOUT: "timeout", + HumanInputFormStatus.EXPIRED: "timeout", +} + + +@dataclass(frozen=True, slots=True) +class AskHumanResumeOutcome: + """Result of inspecting a correlated ask_human form on workflow resume. + + Exactly one of ``deferred_result`` / ``repause`` is set: + * ``deferred_result`` — the form reached a terminal state; start the second run. + * ``repause`` — the form is still waiting; re-emit this pause defensively. + """ + + deferred_result: AskHumanToolResult | None = None + repause: HumanInputRequired | None = None + + +def resolve_ask_human_form(*, form_id: str, tenant_id: str, node_id: str) -> AskHumanResumeOutcome | None: + """Load the correlated form and map it to a resume outcome. + + Returns ``None`` when the form no longer exists (correlation lost) — the + caller should fall back to a normal (non-resume) Agent run. + """ + with session_factory.create_session() as session: + form = session.scalar( + select(HumanInputForm).where( + HumanInputForm.id == form_id, + HumanInputForm.tenant_id == tenant_id, + ) + ) + if form is None: + return None + return map_form_to_outcome( + status=form.status, + selected_action_id=form.selected_action_id, + submitted_data=form.submitted_data, + rendered_content=form.rendered_content, + form_definition=form.form_definition, + form_id=form.id, + node_id=node_id, + ) + + +def map_form_to_outcome( + *, + status: HumanInputFormStatus, + selected_action_id: str | None, + submitted_data: str | None, + rendered_content: str, + form_definition: str, + form_id: str, + node_id: str, +) -> AskHumanResumeOutcome: + """Map a terminal (or still-waiting) HITL form to a resume outcome. Pure.""" + definition = FormDefinition.model_validate_json(form_definition) + if status == HumanInputFormStatus.WAITING: + return AskHumanResumeOutcome(repause=_rebuild_pause(definition=definition, form_id=form_id, node_id=node_id)) + + result_status = _FORM_STATUS_TO_RESULT.get(status, "unavailable") + if result_status != "submitted": + return AskHumanResumeOutcome(deferred_result=AskHumanToolResult(status=result_status)) + return AskHumanResumeOutcome( + deferred_result=AskHumanToolResult( + status="submitted", + action=_selected_action(selected_action_id=selected_action_id, definition=definition), + values=_submitted_values(submitted_data), + rendered_content=rendered_content, + ) + ) + + +def build_deferred_tool_results(*, tool_call_id: str, result: AskHumanToolResult) -> DeferredToolResultsPayload: + """Wrap an ask_human result as the deferred-tool-results payload for resume.""" + return DeferredToolResultsPayload(calls={tool_call_id: result.model_dump(mode="json")}) + + +def _submitted_values(submitted_data: str | None) -> dict[str, JsonValue]: + if not submitted_data: + return {} + parsed = json.loads(submitted_data) + if not isinstance(parsed, dict): + return {} + return {str(key): value for key, value in parsed.items()} + + +def _selected_action(*, selected_action_id: str | None, definition: FormDefinition) -> AskHumanSelectedAction | None: + if selected_action_id is None: + return None + # The form's user_action title is the verbatim ask_human action label set at + # form-build time; fall back to the id only if the action is somehow missing. + label = next( + (action.title for action in definition.user_actions if action.id == selected_action_id), + selected_action_id, + ) + return AskHumanSelectedAction(id=selected_action_id, label=label) + + +def _rebuild_pause(*, definition: FormDefinition, form_id: str, node_id: str) -> HumanInputRequired: + return HumanInputRequired( + form_id=form_id, + form_content=definition.rendered_content or definition.form_content, + inputs=list(definition.inputs), + actions=list(definition.user_actions), + node_id=node_id, + node_title=definition.node_title or node_id, + resolved_default_values=dict(definition.default_values), + ) + + +__all__ = [ + "AskHumanResumeOutcome", + "build_deferred_tool_results", + "map_form_to_outcome", + "resolve_ask_human_form", +] diff --git a/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py b/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py index ccf8f9fa171..80366cc5193 100644 --- a/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py +++ b/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py @@ -42,6 +42,8 @@ class AgentToolRuntimeProvider(Protocol): user_id: str | None = None, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, variable_pool: Any | None = None, + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> Tool: ... @@ -176,6 +178,8 @@ class WorkflowAgentPluginToolsBuilder: user_id=user_id, invoke_from=invoke_from, variable_pool=None, + allow_file_parameters=True, + use_default_for_missing_form_parameters=True, ) except ToolProviderNotFoundError as exc: raise WorkflowAgentPluginToolsBuildError( diff --git a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py index 35da898f1ee..8e0578d1a15 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py +++ b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py @@ -18,13 +18,15 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( # ENG-623: exposed at runtime as the dify.drive declaration layer # (an index the agent pulls through the back proxy). "skills_files", + # ENG-635: human involvement is exposed at runtime as the dify.ask_human + # deferred tool; a call pauses via the existing HITL form mechanism. + "human", } ) RESERVED_AGENT_BACKEND_FEATURES = frozenset( { "knowledge", - "human", "memory", } ) @@ -85,6 +87,7 @@ def build_runtime_feature_manifest( reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap" reserved_status["env"] = "supported_by_shell_bootstrap" reserved_status["sandbox"] = "forwarded_to_shell_layer_config" + reserved_status["human"] = "supported_by_ask_human_hitl" if agent_soul.human.contacts else "not_configured" return { "supported": sorted(SUPPORTED_AGENT_BACKEND_FEATURES), diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 984a0a20698..53c657e8ef9 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Any, Literal, Protocol, assert_never, cast from agenton.compositor import CompositorSessionSnapshot +from dify_agent.layers.ask_human import DifyAskHumanLayerConfig from dify_agent.layers.drive import ( DifyDriveFileConfig, DifyDriveLayerConfig, @@ -22,7 +23,7 @@ from dify_agent.layers.shell import ( DifyShellSandboxConfig, DifyShellSecretRefConfig, ) -from dify_agent.protocol import CreateRunRequest +from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload from pydantic import BaseModel from clients.agent_backend import ( @@ -41,6 +42,7 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( AgentSoulConfig, DeclaredArrayItem, + DeclaredOutputChildConfig, DeclaredOutputConfig, DeclaredOutputType, WorkflowNodeJobConfig, @@ -103,6 +105,9 @@ class WorkflowAgentRuntimeBuildContext: # idempotency key so the backend treats each retry as a fresh request. attempt: int = 0 session_snapshot: CompositorSessionSnapshot | None = None + # ENG-638: set when resuming after a submitted ask_human HITL form; threads + # the human's answer back into the second Agent run keyed by tool_call_id. + deferred_tool_results: DeferredToolResultsPayload | None = None @dataclass(frozen=True, slots=True) @@ -217,9 +222,11 @@ class WorkflowAgentRuntimeRequestBuilder: output=self._build_output_config(node_job.declared_outputs), tools=tools_layer, drive_config=drive_config, + ask_human_config=build_ask_human_layer_config(agent_soul), include_shell=dify_config.AGENT_SHELL_ENABLED, shell_config=build_shell_layer_config(agent_soul), session_snapshot=context.session_snapshot, + deferred_tool_results=context.deferred_tool_results, idempotency_key=self._idempotency_key(context), metadata=metadata, ) @@ -389,7 +396,11 @@ class WorkflowAgentRuntimeRequestBuilder: @staticmethod def _schema_for_declared_output(output: DeclaredOutputConfig) -> dict[str, Any]: - schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(output.type, array_item=output.array_item) + schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type( + output.type, + array_item=output.array_item, + children=output.children, + ) if output.description: schema["description"] = output.description return schema @@ -399,6 +410,7 @@ class WorkflowAgentRuntimeRequestBuilder: output_type: DeclaredOutputType, *, array_item: DeclaredArrayItem | None = None, + children: Sequence[DeclaredOutputChildConfig] | None = None, ) -> dict[str, Any]: match output_type: case DeclaredOutputType.STRING: @@ -408,18 +420,23 @@ class WorkflowAgentRuntimeRequestBuilder: case DeclaredOutputType.BOOLEAN: return {"type": "boolean"} case DeclaredOutputType.OBJECT: - return {"type": "object"} + object_schema: dict[str, Any] = {"type": "object"} + WorkflowAgentRuntimeRequestBuilder._apply_child_properties(object_schema, children or []) + return object_schema case DeclaredOutputType.ARRAY: # Stage 4 §4.2: items shape mirrors the declared array_item. # Validator guarantees array_item is set when type is array. item_type = array_item.type if array_item else DeclaredOutputType.OBJECT - schema: dict[str, Any] = { + array_schema: dict[str, Any] = { "type": "array", - "items": WorkflowAgentRuntimeRequestBuilder._schema_for_type(item_type), + "items": WorkflowAgentRuntimeRequestBuilder._schema_for_type( + item_type, + children=array_item.children if array_item else None, + ), } if array_item is not None and array_item.description: - schema["items"]["description"] = array_item.description - return schema + array_schema["items"]["description"] = array_item.description + return array_schema case DeclaredOutputType.FILE: return { "oneOf": [ @@ -463,6 +480,27 @@ class WorkflowAgentRuntimeRequestBuilder: } assert_never(output_type) + @staticmethod + def _apply_child_properties(schema: dict[str, Any], children: Sequence[DeclaredOutputChildConfig]) -> None: + if not children: + return + properties: dict[str, Any] = {} + required: list[str] = [] + for child in children: + child_schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type( + child.type, + array_item=child.array_item, + children=child.children, + ) + if child.description: + child_schema["description"] = child.description + properties[child.name] = child_schema + if child.required: + required.append(child.name) + schema["properties"] = properties + if required: + schema["required"] = required + @staticmethod def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]: normalized: dict[str, str | int | float | bool | None] = {} @@ -496,6 +534,20 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi ) +def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None: + """Enable the dify.ask_human deferred tool when the soul configures human involvement. + + HITL is opt-in: only when at least one human contact is configured does the + model get the ``ask_human`` tool (recipients for the resulting form come from + those contacts, ENG-635). Returns ``None`` to leave the tool off entirely. + The tool/field guardrails use the layer defaults; ``human.tools`` semantics are + out of scope this round. + """ + if not agent_soul.human.contacts: + return None + return DifyAskHumanLayerConfig() + + def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, str]]) -> None: """Merge build-time warnings into the metadata runtime-support manifest.""" if not warnings: @@ -649,7 +701,13 @@ def _shell_secret_ref(item: object) -> DifyShellSecretRefConfig | None: name = _name_from_mapping(data) if name is None: return None - ref = data.get("ref") or data.get("id") or data.get("credential_id") or data.get("provider_credential_id") + ref = ( + data.get("ref") + or data.get("value") + or data.get("id") + or data.get("credential_id") + or data.get("provider_credential_id") + ) return DifyShellSecretRefConfig(name=name, ref=str(ref) if ref is not None else None) diff --git a/api/core/workflow/nodes/agent_v2/session_store.py b/api/core/workflow/nodes/agent_v2/session_store.py index 08a83ad5319..215fa67b5eb 100644 --- a/api/core/workflow/nodes/agent_v2/session_store.py +++ b/api/core/workflow/nodes/agent_v2/session_store.py @@ -47,12 +47,20 @@ class StoredWorkflowAgentSession: session_snapshot: CompositorSessionSnapshot backend_run_id: str | None runtime_layer_specs: list[RuntimeLayerSpec] = field(default_factory=list) + # ENG-637: set while the session is paused on a dify.ask_human deferred call. + pending_form_id: str | None = None + pending_tool_call_id: str | None = None class WorkflowAgentRuntimeSessionStore: """Stores Agent backend session snapshots for workflow Agent node re-entry.""" def load_active_snapshot(self, scope: WorkflowAgentSessionScope) -> CompositorSessionSnapshot | None: + stored = self.load_active_session(scope) + return stored.session_snapshot if stored is not None else None + + def load_active_session(self, scope: WorkflowAgentSessionScope) -> StoredWorkflowAgentSession | None: + """Load the active session row including any pending ask_human correlation.""" if scope.workflow_run_id is None: return None @@ -69,7 +77,14 @@ class WorkflowAgentRuntimeSessionStore: ) if row is None: return None - return CompositorSessionSnapshot.model_validate_json(row.session_snapshot) + return StoredWorkflowAgentSession( + scope=scope, + session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), + backend_run_id=row.backend_run_id, + runtime_layer_specs=_deserialize_specs(row.composition_layer_specs), + pending_form_id=row.pending_form_id, + pending_tool_call_id=row.pending_tool_call_id, + ) def list_active_sessions(self, *, workflow_run_id: str) -> list[StoredWorkflowAgentSession]: with session_factory.create_session() as session: @@ -109,6 +124,8 @@ class WorkflowAgentRuntimeSessionStore: backend_run_id: str, snapshot: CompositorSessionSnapshot | None, runtime_layer_specs: list[RuntimeLayerSpec], + pending_form_id: str | None = None, + pending_tool_call_id: str | None = None, ) -> None: if scope.workflow_run_id is None or snapshot is None: return @@ -141,6 +158,8 @@ class WorkflowAgentRuntimeSessionStore: session_snapshot=snapshot_json, composition_layer_specs=specs_json, status=WorkflowAgentRuntimeSessionStatus.ACTIVE, + pending_form_id=pending_form_id, + pending_tool_call_id=pending_tool_call_id, ) session.add(row) else: @@ -151,6 +170,9 @@ class WorkflowAgentRuntimeSessionStore: row.composition_layer_specs = specs_json row.status = WorkflowAgentRuntimeSessionStatus.ACTIVE row.cleaned_at = None + # Set (or clear, when omitted) the ask_human pause correlation. + row.pending_form_id = pending_form_id + row.pending_tool_call_id = pending_tool_call_id session.commit() def mark_cleaned(self, *, scope: WorkflowAgentSessionScope, backend_run_id: str | None = None) -> None: diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index feb3bc7a4cb..42c83b30f2b 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -153,6 +153,7 @@ def init_app(app: DifyApp) -> Celery: "tasks.trigger_processing_tasks", # async trigger processing "tasks.generate_summary_index_task", # summary index generation "tasks.regenerate_summary_index_task", # summary index regeneration + "tasks.app_generate.resume_agent_app_task", # ENG-635: Agent v2 chat ask_human resume ] day = dify_config.CELERY_BEAT_SCHEDULER_TIME diff --git a/api/extensions/ext_commands.py b/api/extensions/ext_commands.py index 5d91d79f26a..4d60bdb5f65 100644 --- a/api/extensions/ext_commands.py +++ b/api/extensions/ext_commands.py @@ -5,6 +5,7 @@ def init_app(app: DifyApp): from commands import ( add_qdrant_index, archive_workflow_runs, + backfill_plugin_auto_upgrade, clean_expired_messages, clean_workflow_runs, cleanup_orphaned_draft_variables, @@ -53,6 +54,7 @@ def init_app(app: DifyApp): upgrade_db, fix_app_site_missing, migrate_data_for_plugin, + backfill_plugin_auto_upgrade, extract_plugins, extract_unique_plugins, install_plugins, diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index ce4854ce360..36d96231987 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -44,7 +44,11 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel): class AgentPublishedReferenceResponse(ResponseModel): app_id: str app_name: str + app_icon_type: str | None = None + app_icon: str | None = None + app_icon_background: str | None = None app_mode: str + app_updated_at: int | None = None workflow_id: str workflow_version: str node_ids: list[str] = Field(default_factory=list) @@ -66,6 +70,7 @@ class AgentRosterResponse(ResponseModel): workflow_node_id: str | None = None active_config_snapshot_id: str | None = None active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None + active_config_is_published: bool = False status: AgentStatus created_by: str | None = None updated_by: str | None = None diff --git a/api/migrations/versions/2026_06_15_1052-c167a72a00eb_add_ask_human_pause_correlation_to_.py b/api/migrations/versions/2026_06_15_1052-c167a72a00eb_add_ask_human_pause_correlation_to_.py new file mode 100644 index 00000000000..6bf0bae4bb5 --- /dev/null +++ b/api/migrations/versions/2026_06_15_1052-c167a72a00eb_add_ask_human_pause_correlation_to_.py @@ -0,0 +1,32 @@ +"""add ask_human pause correlation to agent_runtime_sessions + +Revision ID: c167a72a00eb +Revises: c4d5e6f7a8b9 +Create Date: 2026-06-15 10:52:15.736666 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'c167a72a00eb' +down_revision = 'c4d5e6f7a8b9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ENG-637: correlate a paused dify.ask_human session to its awaiting HITL + # form and the deferred tool_call id, so the resumed Agent node can rebuild + # deferred_tool_results from the submitted form. Both columns are nullable + # and NULL whenever the session is not paused on human input. + with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op: + batch_op.add_column(sa.Column('pending_form_id', models.types.StringUUID(), nullable=True)) + batch_op.add_column(sa.Column('pending_tool_call_id', sa.String(length=255), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('agent_runtime_sessions', schema=None) as batch_op: + batch_op.drop_column('pending_tool_call_id') + batch_op.drop_column('pending_form_id') diff --git a/api/migrations/versions/2026_06_15_1100-b7c2d9e8a1f4_add_tenant_last_opened_at.py b/api/migrations/versions/2026_06_15_1100-b7c2d9e8a1f4_add_tenant_last_opened_at.py new file mode 100644 index 00000000000..ce2fd2b79ca --- /dev/null +++ b/api/migrations/versions/2026_06_15_1100-b7c2d9e8a1f4_add_tenant_last_opened_at.py @@ -0,0 +1,26 @@ +"""add tenant account join last opened at + +Revision ID: b7c2d9e8a1f4 +Revises: 9f4b7c2d1a80 +Create Date: 2026-06-05 11:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b7c2d9e8a1f4" +down_revision = "9f4b7c2d1a80" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op: + batch_op.add_column(sa.Column("last_opened_at", sa.DateTime(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op: + batch_op.drop_column("last_opened_at") diff --git a/api/migrations/versions/2026_06_15_1110-d2f1a4b8c3e0_add_conversation_id_to_human_input_forms.py b/api/migrations/versions/2026_06_15_1110-d2f1a4b8c3e0_add_conversation_id_to_human_input_forms.py new file mode 100644 index 00000000000..3af00e3926f --- /dev/null +++ b/api/migrations/versions/2026_06_15_1110-d2f1a4b8c3e0_add_conversation_id_to_human_input_forms.py @@ -0,0 +1,29 @@ +"""add conversation_id to human_input_forms for agent v2 chat hitl + +Revision ID: d2f1a4b8c3e0 +Revises: c167a72a00eb +Create Date: 2026-06-15 11:10:00.000000 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'd2f1a4b8c3e0' +down_revision = 'c167a72a00eb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ENG-635: Agent v2 chat ask_human forms are owned by a conversation turn + # instead of a workflow run (the new Agent App has no workflow_run_id). + # Nullable; existing workflow-owned forms keep conversation_id NULL. + with op.batch_alter_table('human_input_forms', schema=None) as batch_op: + batch_op.add_column(sa.Column('conversation_id', models.types.StringUUID(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('human_input_forms', schema=None) as batch_op: + batch_op.drop_column('conversation_id') diff --git a/api/migrations/versions/2026_06_15_1200-f6a7b8c9d012_add_plugin_auto_upgrade_category.py b/api/migrations/versions/2026_06_15_1200-f6a7b8c9d012_add_plugin_auto_upgrade_category.py new file mode 100644 index 00000000000..491d8c201fa --- /dev/null +++ b/api/migrations/versions/2026_06_15_1200-f6a7b8c9d012_add_plugin_auto_upgrade_category.py @@ -0,0 +1,42 @@ +"""add plugin auto upgrade category + +Revision ID: f6a7b8c9d012 +Revises: b7c2d9e8a1f4 +Create Date: 2026-05-15 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f6a7b8c9d012" +down_revision = "b7c2d9e8a1f4" +branch_labels = None +depends_on = None + + +LEGACY_CATEGORY = "tool" +UNIQUE_CONSTRAINT_NAME = "unique_tenant_plugin_auto_upgrade_strategy" +UPGRADE_TIME_INDEX_NAME = "idx_tenant_plugin_auto_upgrade_strategy_time" +STRATEGY_TABLE_NAME = "tenant_plugin_auto_upgrade_strategies" + + +def upgrade(): + with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op: + batch_op.add_column( + sa.Column("category", sa.String(length=32), server_default=LEGACY_CATEGORY, nullable=False) + ) + batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique") + batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id", "category"]) + batch_op.create_index(UPGRADE_TIME_INDEX_NAME, ["upgrade_time_of_day"]) + + +def downgrade(): + op.execute(sa.text(f"DELETE FROM {STRATEGY_TABLE_NAME} WHERE category != '{LEGACY_CATEGORY}'")) + + with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op: + batch_op.drop_index(UPGRADE_TIME_INDEX_NAME) + batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique") + batch_op.drop_column("category") + batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id"]) diff --git a/api/migrations/versions/2026_06_15_1300-f5e8a9c0d2b3_add_learn_dify_flag_to_recommended_apps.py b/api/migrations/versions/2026_06_15_1300-f5e8a9c0d2b3_add_learn_dify_flag_to_recommended_apps.py new file mode 100644 index 00000000000..f0af2ddcaab --- /dev/null +++ b/api/migrations/versions/2026_06_15_1300-f5e8a9c0d2b3_add_learn_dify_flag_to_recommended_apps.py @@ -0,0 +1,26 @@ +"""add learn dify flag to recommended apps + +Revision ID: f5e8a9c0d2b3 +Revises: f6a7b8c9d012 +Create Date: 2026-05-18 15:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f5e8a9c0d2b3" +down_revision = "f6a7b8c9d012" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.add_column(sa.Column("is_learn_dify", sa.Boolean(), server_default=sa.text("false"), nullable=False)) + + +def downgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.drop_column("is_learn_dify") diff --git a/api/migrations/versions/2026_06_15_1400-c4d5e6f7a8b9_add_app_stars.py b/api/migrations/versions/2026_06_15_1400-c4d5e6f7a8b9_add_app_stars.py new file mode 100644 index 00000000000..780d94be9ab --- /dev/null +++ b/api/migrations/versions/2026_06_15_1400-c4d5e6f7a8b9_add_app_stars.py @@ -0,0 +1,38 @@ +"""add app stars + +Revision ID: c4d5e6f7a8b9 +Revises: f5e8a9c0d2b3 +Create Date: 2026-06-08 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models.types + +# revision identifiers, used by Alembic. +revision = "c4d5e6f7a8b9" +down_revision = "f5e8a9c0d2b3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "app_stars", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("account_id", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name="app_star_pkey"), + sa.UniqueConstraint("tenant_id", "account_id", "app_id", name="app_star_tenant_account_app_unique"), + ) + with op.batch_alter_table("app_stars", schema=None) as batch_op: + batch_op.create_index("app_star_tenant_account_idx", ["tenant_id", "account_id"], unique=False) + batch_op.create_index("app_star_app_idx", ["app_id"], unique=False) + + +def downgrade() -> None: + op.drop_table("app_stars") diff --git a/api/models/__init__.py b/api/models/__init__.py index 55bc642566b..78ca43fa374 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -73,6 +73,7 @@ from .model import ( AppMCPServer, AppMode, AppModelConfig, + AppStar, Conversation, DatasetRetrieverResource, DifySetup, @@ -175,6 +176,7 @@ __all__ = [ "AppMCPServer", "AppMode", "AppModelConfig", + "AppStar", "AppTrigger", "AppTriggerStatus", "AppTriggerType", diff --git a/api/models/account.py b/api/models/account.py index a3074c6f637..66766693a5b 100644 --- a/api/models/account.py +++ b/api/models/account.py @@ -301,6 +301,7 @@ class TenantAccountJoin(TypeBase): updated_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.current_timestamp(), nullable=False, init=False, onupdate=func.current_timestamp() ) + last_opened_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) class AccountIntegrate(TypeBase): @@ -389,6 +390,14 @@ class TenantPluginPermission(TypeBase): class TenantPluginAutoUpgradeStrategy(TypeBase): + class PluginCategory(enum.StrEnum): + TOOL = "tool" + MODEL = "model" + EXTENSION = "extension" + AGENT_STRATEGY = "agent-strategy" + DATASOURCE = "datasource" + TRIGGER = "trigger" + class StrategySetting(enum.StrEnum): DISABLED = "disabled" FIX_ONLY = "fix_only" @@ -402,13 +411,20 @@ class TenantPluginAutoUpgradeStrategy(TypeBase): __tablename__ = "tenant_plugin_auto_upgrade_strategies" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="tenant_plugin_auto_upgrade_strategy_pkey"), - sa.UniqueConstraint("tenant_id", name="unique_tenant_plugin_auto_upgrade_strategy"), + sa.UniqueConstraint("tenant_id", "category", name="unique_tenant_plugin_auto_upgrade_strategy"), + sa.Index("idx_tenant_plugin_auto_upgrade_strategy_time", "upgrade_time_of_day"), ) id: Mapped[str] = mapped_column( StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False ) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + category: Mapped[PluginCategory] = mapped_column( + EnumText(PluginCategory, length=32), + nullable=False, + server_default="tool", + default=PluginCategory.TOOL, + ) strategy_setting: Mapped[StrategySetting] = mapped_column( EnumText(StrategySetting, length=16), nullable=False, diff --git a/api/models/agent.py b/api/models/agent.py index e60e09de815..649835f5220 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -398,6 +398,12 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base): default=AgentRuntimeSessionStatus.ACTIVE, ) cleaned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + # ENG-637: when a run pauses for a dify.ask_human deferred call, these link + # the session to the awaiting HITL form and the deferred tool_call_id, so a + # resumed node can map the submitted form back into deferred_tool_results. + # Both NULL whenever the session is not paused on human input. + pending_form_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + pending_tool_call_id: Mapped[str | None] = mapped_column(String(255), nullable=True) # Back-compat alias for the shipped workflow lifecycle code (PR #36724). diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 1f50924681f..76108f271d4 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -2,9 +2,9 @@ from __future__ import annotations import re from enum import StrEnum -from typing import Any, Final, Literal +from typing import Annotated, Any, Final, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator from core.workflow.file_reference import is_canonical_file_reference from graphon.file import FileTransferMethod @@ -29,6 +29,44 @@ class DeclaredOutputType(StrEnum): FILE = "file" +_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "name": {"type": "string"}, + "type": { + "type": "string", + "enum": [item.value for item in DeclaredOutputType], + }, + "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "required": {"type": "boolean"}, + "file": {"type": "object", "additionalProperties": True}, + "array_item": { + "type": "object", + "additionalProperties": True, + "properties": { + "type": { + "type": "string", + "enum": [item.value for item in DeclaredOutputType], + }, + "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + }, + }, + "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + }, + "required": ["name", "type"], + }, +} + +DeclaredOutputChildren = Annotated[ + list["DeclaredOutputChildConfig"], + WithJsonSchema(_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA), +] + + class AgentCliToolAuthorizationStatus(StrEnum): """Authorization state for Agent-scoped CLI tools. @@ -148,6 +186,9 @@ class AgentSecretRefConfig(AgentFlexibleConfig): env_name: str | None = Field(default=None, max_length=255) variable: str | None = Field(default=None, max_length=255) type: str | None = Field(default=None, max_length=64) + # UI-facing selected secret reference. This is a credential/ref id, not the + # plaintext secret value; runtime maps it to the shell-layer ``ref``. + value: str | None = Field(default=None, max_length=255) id: str | None = Field(default=None, max_length=255) ref: str | None = Field(default=None, max_length=255) credential_id: str | None = Field(default=None, max_length=255) @@ -507,11 +548,55 @@ class DeclaredArrayItem(BaseModel): type: DeclaredOutputType description: str | None = None + children: DeclaredOutputChildren = Field(default_factory=list) @model_validator(mode="after") def _reject_nested_array(self) -> DeclaredArrayItem: if self.type == DeclaredOutputType.ARRAY: raise ValueError("nested arrays are not supported as array_item.type") + if self.children and self.type != DeclaredOutputType.OBJECT: + raise ValueError("array_item.children is only allowed when array_item.type is object") + return self + + +class DeclaredOutputChildConfig(BaseModel): + """Nested field under an object-shaped declared output. + + The first backend version keeps child fields lightweight: they describe the + variable-picker/schema tree but do not own independent retry/check behavior. + """ + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + type: DeclaredOutputType + description: str | None = None + required: bool = True + file: DeclaredOutputFileConfig | None = None + array_item: DeclaredArrayItem | None = None + children: DeclaredOutputChildren = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_shape(self) -> DeclaredOutputChildConfig: + if not _OUTPUT_NAME_PATTERN.fullmatch(self.name): + raise ValueError( + f"output child name {self.name!r} must match {_OUTPUT_NAME_PATTERN.pattern} " + "(JSON-schema-friendly identifier)" + ) + if self.type == DeclaredOutputType.FILE: + if self.file is None: + self.file = DeclaredOutputFileConfig() + elif self.file is not None: + raise ValueError("file metadata is only allowed for file output children") + + if self.type == DeclaredOutputType.ARRAY: + if self.array_item is None: + self.array_item = DeclaredArrayItem(type=DeclaredOutputType.OBJECT) + elif self.array_item is not None: + raise ValueError("array_item is only allowed when child type is array") + + if self.children and self.type != DeclaredOutputType.OBJECT: + raise ValueError("children is only allowed for object output children") return self @@ -592,6 +677,7 @@ class DeclaredOutputConfig(BaseModel): required: bool = True file: DeclaredOutputFileConfig | None = None array_item: DeclaredArrayItem | None = None + children: DeclaredOutputChildren = Field(default_factory=list) check: DeclaredOutputCheckConfig | None = None failure_strategy: DeclaredOutputFailureStrategy = Field(default_factory=DeclaredOutputFailureStrategy) @@ -625,6 +711,9 @@ class DeclaredOutputConfig(BaseModel): elif self.array_item is not None: raise ValueError("array_item is only allowed when type is array") + if self.children and self.type != DeclaredOutputType.OBJECT: + raise ValueError("children is only allowed for object outputs") + # Per PRD §OUTPUT 配置框: output check is file-only. if self.check is not None and self.check.enabled and self.type != DeclaredOutputType.FILE: raise ValueError("output check is only allowed for file outputs") diff --git a/api/models/human_input.py b/api/models/human_input.py index 7b02e8d29d5..d11274bc921 100644 --- a/api/models/human_input.py +++ b/api/models/human_input.py @@ -40,6 +40,13 @@ class HumanInputForm(DefaultFieldsMixin, Base): tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + # ENG-635: a RUNTIME form is tagged with its owning workflow run and/or its + # conversation. Workflow / Human-Input / agent-node forms always set + # workflow_run_id, and ALSO set conversation_id when the run has a conversation + # (chatflow / advanced-chat). Agent v2 chat ask_human forms set only + # conversation_id (the new Agent App has no workflow_run_id). At least one is set; + # resume routing prefers workflow_run_id when both are present. + conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) form_kind: Mapped[HumanInputFormKind] = mapped_column( EnumText(HumanInputFormKind), nullable=False, diff --git a/api/models/model.py b/api/models/model.py index 540cf0eeede..09809b85f6b 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -397,6 +397,12 @@ class App(Base): __tablename__ = "apps" __table_args__ = (sa.PrimaryKeyConstraint("id", name="app_pkey"), sa.Index("app_tenant_id_idx", "tenant_id")) + if TYPE_CHECKING: + # Response-only attributes attached by app list/detail enrichers. + access_mode: str | None + has_draft_trigger: bool + is_starred: bool + id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4())) tenant_id: Mapped[str] = mapped_column(StringUUID) name: Mapped[str] = mapped_column(String(255)) @@ -654,6 +660,28 @@ class App(Base): return None +class AppStar(Base): + """Account-scoped star marker for apps in a workspace.""" + + __tablename__ = "app_stars" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="app_star_pkey"), + sa.UniqueConstraint("tenant_id", "account_id", "app_id", name="app_star_tenant_account_app_unique"), + sa.Index("app_star_tenant_account_idx", "tenant_id", "account_id"), + sa.Index("app_star_app_idx", "app_id"), + ) + + id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + account_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + + @override + def __repr__(self) -> str: + return f"" + + class AppModelConfig(TypeBase): __tablename__ = "app_model_configs" __table_args__ = (sa.PrimaryKeyConstraint("id", name="app_model_config_pkey"), sa.Index("app_app_id_idx", "app_id")) @@ -907,6 +935,9 @@ class RecommendedApp(TypeBase): custom_disclaimer: Mapped[str] = mapped_column(LongText, default="") position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True) + is_learn_dify: Mapped[bool] = mapped_column( + sa.Boolean, nullable=False, server_default=sa.text("false"), default=False + ) install_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) language: Mapped[str] = mapped_column( String(255), diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index cf63b9de26b..2f2e8ae15bd 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -293,22 +293,42 @@ Check if activation token is valid | ---- | ----------- | ------ | | 200 | Success | **application/json**: [ActivationCheckResponse](#activationcheckresponse)
| -### [GET] /agents +### [GET] /agent #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| keyword | query | | No | string | -| limit | query | | No | integer,
**Default:** 20 | -| page | query | | No | integer,
**Default:** 1 | +| creator_ids | query | Filter by creator account IDs | No | [ string ] | +| is_created_by_me | query | Filter by creator | No | boolean | +| limit | query | Page size (1-100) | No | integer,
**Default:** 20 | +| mode | query | App mode filter | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | +| name | query | Filter by app name | No | string | +| page | query | Page number (1-99999) | No | integer,
**Default:** 1 | +| sort_by | query | Sort apps by last modified, recently created, or earliest created | No | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | +| tag_ids | query | Filter by tag IDs | No | [ string ] | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent roster list | **application/json**: [AgentRosterListResponse](#agentrosterlistresponse)
| +| 200 | Agent app list | **application/json**: [AppPagination](#apppagination)
| -### [GET] /agents/invite-options +### [POST] /agent +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentAppCreatePayload](#agentappcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Agent app created successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| +| 400 | Invalid request parameters | | +| 403 | Insufficient permissions | | + +### [GET] /agent/invite-options #### Parameters | Name | Located in | Description | Required | Schema | @@ -324,7 +344,21 @@ Check if activation token is valid | ---- | ----------- | ------ | | 200 | Agent invite options | **application/json**: [AgentInviteOptionsResponse](#agentinviteoptionsresponse)
| -### [GET] /agents/{agent_id} +### [DELETE] /agent/{agent_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Agent app deleted successfully | +| 403 | Insufficient permissions | + +### [GET] /agent/{agent_id} #### Parameters | Name | Located in | Description | Required | Schema | @@ -335,9 +369,428 @@ Check if activation token is valid | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent detail | **application/json**: [AgentRosterResponse](#agentrosterresponse)
| +| 200 | Agent app detail | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| -### [GET] /agents/{agent_id}/versions +### [PUT] /agent/{agent_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentAppUpdatePayload](#agentappupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent app updated successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| +| 400 | Invalid request parameters | | +| 403 | Insufficient permissions | | + +### [GET] /agent/{agent_id}/chat-messages +Get Agent App chat messages for a conversation with pagination + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| conversation_id | query | Conversation ID | Yes | string | +| first_id | query | First message ID for pagination | No | string | +| limit | query | Number of messages to return (1-100) | No | integer,
**Default:** 20 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MessageInfiniteScrollPaginationResponse](#messageinfinitescrollpaginationresponse)
| +| 404 | Agent or conversation not found | | + +### [GET] /agent/{agent_id}/chat-messages/{message_id}/suggested-questions +Get suggested questions for an Agent App message + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| message_id | path | Message ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Suggested questions retrieved successfully | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)
| +| 404 | Agent, message, or conversation not found | | + +### [POST] /agent/{agent_id}/chat-messages/{task_id}/stop +Stop a running Agent App chat message generation + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| task_id | path | Task ID to stop | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| + +### [GET] /agent/{agent_id}/composer +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent app composer state | **application/json**: [AgentAppComposerResponse](#agentappcomposerresponse)
| + +### [PUT] /agent/{agent_id}/composer +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent app composer saved | **application/json**: [AgentAppComposerResponse](#agentappcomposerresponse)
| + +### [GET] /agent/{agent_id}/composer/candidates +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent app composer candidates | **application/json**: [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse)
| + +### [POST] /agent/{agent_id}/composer/validate +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent app composer validation result | **application/json**: [AgentComposerValidateResponse](#agentcomposervalidateresponse)
| + +### [GET] /agent/{agent_id}/drive/files +List agent drive entries for an Agent App + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)
| + +### [GET] /agent/{agent_id}/drive/files/download +Time-limited external signed URL for one Agent App drive value + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)
| + +### [GET] /agent/{agent_id}/drive/files/preview +Truncated text preview of one Agent App drive value + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)
| + +### [POST] /agent/{agent_id}/features +Update an Agent App's presentation features (opener, follow-up, citations, ...) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentAppFeaturesPayload](#agentappfeaturespayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Features updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | Invalid configuration | | +| 404 | Agent not found | | + +### [POST] /agent/{agent_id}/feedbacks +Create or update Agent App message feedback + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MessageFeedbackPayload](#messagefeedbackpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Feedback updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 404 | Agent or message not found | | + +### [DELETE] /agent/{agent_id}/files +Delete one Agent App drive file by key + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| key | query | Drive key, e.g. files/sample.pdf | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| + +### [POST] /agent/{agent_id}/files +Commit an uploaded file into the Agent App drive under files/ + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)
| + +### [GET] /agent/{agent_id}/messages/{message_id} +Get Agent App message details by ID + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| message_id | path | Message ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Message retrieved successfully | **application/json**: [MessageDetailResponse](#messagedetailresponse)
| +| 404 | Agent or message not found | | + +### [GET] /agent/{agent_id}/referencing-workflows +List workflow apps that reference this Agent App's bound Agent (read-only) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Referencing workflows listed successfully | **application/json**: [AgentReferencingWorkflowsResponse](#agentreferencingworkflowsresponse)
| +| 404 | Agent not found | | + +### [GET] /agent/{agent_id}/sandbox/files +List a directory in an Agent App conversation sandbox + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| conversation_id | query | Agent App conversation ID | Yes | string | +| path | query | Directory path relative to the sandbox workspace | No | string,
**Default:** . | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Listing returned | **application/json**: [SandboxListResponse](#sandboxlistresponse)
| + +### [GET] /agent/{agent_id}/sandbox/files/read +Read a text/binary preview file in an Agent App conversation sandbox + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| conversation_id | query | Agent App conversation ID | Yes | string | +| path | query | File path relative to the sandbox workspace | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)
| + +### [POST] /agent/{agent_id}/sandbox/files/upload +Upload one Agent App sandbox file as a Dify ToolFile mapping + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentSandboxUploadPayload](#agentsandboxuploadpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)
| + +### [POST] /agent/{agent_id}/skills/standardize +Validate + standardize a Skill into an Agent App drive + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Skill standardized into drive | **application/json**: [AgentSkillStandardizeResponse](#agentskillstandardizeresponse)
| +| 400 | Invalid skill package or no bound agent | | + +### [POST] /agent/{agent_id}/skills/upload +Upload + validate a Skill package for an Agent App + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Skill validated | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
| +| 400 | Invalid skill package | | + +### [DELETE] /agent/{agent_id}/skills/{slug} +Delete a standardized skill from an Agent App drive + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| slug | path | Skill slug (single path segment) | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| + +### [POST] /agent/{agent_id}/skills/{slug}/infer-tools +Infer CLI tool + ENV suggestions from a standardized Agent App skill + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string | +| slug | path | Skill slug (single path segment) | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)
| + +### [GET] /agent/{agent_id}/versions #### Parameters | Name | Located in | Description | Required | Schema | @@ -350,7 +803,7 @@ Check if activation token is valid | ---- | ----------- | ------ | | 200 | Agent versions | **application/json**: [AgentConfigSnapshotListResponse](#agentconfigsnapshotlistresponse)
| -### [GET] /agents/{agent_id}/versions/{version_id} +### [GET] /agent/{agent_id}/versions/{version_id} #### Parameters | Name | Located in | Description | Required | Schema | @@ -531,6 +984,7 @@ Get list of applications with pagination and filtering | mode | query | App mode filter | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | | name | query | Filter by app name | No | string | | page | query | Page number (1-99999) | No | integer,
**Default:** 1 | +| sort_by | query | Sort apps by last modified, recently created, or earliest created | No | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | | tag_ids | query | Filter by tag IDs | No | [ string ] | #### Responses @@ -600,6 +1054,28 @@ Create a new application | 200 | Import confirmed | **application/json**: [Import](#import)
| | 400 | Import failed | **application/json**: [Import](#import)
| +### [GET] /apps/starred +Get applications starred by the current account + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| creator_ids | query | Filter by creator account IDs | No | [ string ] | +| is_created_by_me | query | Filter by creator | No | boolean | +| limit | query | Page size (1-100) | No | integer,
**Default:** 20 | +| mode | query | App mode filter | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | +| name | query | Filter by app name | No | string | +| page | query | Page number (1-99999) | No | integer,
**Default:** 1 | +| sort_by | query | Sort apps by last modified, recently created, or earliest created | No | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | +| tag_ids | query | Filter by tag IDs | No | [ string ] | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AppPagination](#apppagination)
| + ### [POST] /apps/workflows/online-users Get workflow online users @@ -839,164 +1315,6 @@ Run draft workflow for advanced chat application | 400 | Invalid request parameters | | | 403 | Permission denied | | -### [GET] /apps/{app_id}/agent-composer -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Agent app composer state | **application/json**: [AgentAppComposerResponse](#agentappcomposerresponse)
| - -### [PUT] /apps/{app_id}/agent-composer -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Agent app composer saved | **application/json**: [AgentAppComposerResponse](#agentappcomposerresponse)
| - -### [GET] /apps/{app_id}/agent-composer/candidates -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Agent app composer candidates | **application/json**: [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse)
| - -### [POST] /apps/{app_id}/agent-composer/validate -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [ComposerSavePayload](#composersavepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Agent app composer validation result | **application/json**: [AgentComposerValidateResponse](#agentcomposervalidateresponse)
| - -### [POST] /apps/{app_id}/agent-features -Update an Agent App's presentation features (opener, follow-up, citations, ...) - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [AgentAppFeaturesPayload](#agentappfeaturespayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Features updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -| 400 | Invalid configuration | | -| 404 | App not found | | - -### [GET] /apps/{app_id}/agent-referencing-workflows -List workflow apps that reference this Agent App's bound Agent (read-only) - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Referencing workflows listed successfully | **application/json**: [AgentReferencingWorkflowsResponse](#agentreferencingworkflowsresponse)
| -| 404 | App not found | | - -### [GET] /apps/{app_id}/agent-sandbox/files -List a directory in an Agent App conversation sandbox - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| conversation_id | query | Agent App conversation ID | Yes | string | -| path | query | Directory path relative to the sandbox workspace | No | string,
**Default:** . | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Listing returned | **application/json**: [SandboxListResponse](#sandboxlistresponse)
| - -### [GET] /apps/{app_id}/agent-sandbox/files/read -Read a text/binary preview file in an Agent App conversation sandbox - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| conversation_id | query | Agent App conversation ID | Yes | string | -| path | query | File path relative to the sandbox workspace | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)
| - -### [POST] /apps/{app_id}/agent-sandbox/files/upload -Upload one Agent App sandbox file as a Dify ToolFile mapping - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [AgentSandboxUploadPayload](#agentsandboxuploadpayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)
| - ### [GET] /apps/{app_id}/agent/drive/files List agent drive entries (read-only inspector; one endpoint for both tabs) @@ -2045,6 +2363,38 @@ Reset access token for application site | 403 | Insufficient permissions (admin/owner required) | | | 404 | App or site not found | | +### [DELETE] /apps/{app_id}/star +Remove the current account's star from an application + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 404 | App not found | | + +### [POST] /apps/{app_id}/star +Star an application for the current account + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 404 | App not found | | + ### [GET] /apps/{app_id}/statistics/average-response-time Get average response time statistics for an application @@ -5573,6 +5923,19 @@ Check if dataset is in use | ---- | ----------- | ------ | | 200 | Success | **application/json**: [RecommendedAppListResponse](#recommendedapplistresponse)
| +### [GET] /explore/apps/learn-dify +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| language | query | Language code for recommended app localization | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [LearnDifyAppListResponse](#learndifyapplistresponse)
| + ### [GET] /explore/apps/{app_id} #### Parameters @@ -9391,6 +9754,45 @@ Returns permission flags that control workspace features like member invitations | ---- | ----------- | ------ | | 200 | Success | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| +### [POST] /workspaces/current/plugin/auto-upgrade/change +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserAutoUpgradeChange](#parserautoupgradechange)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginAutoUpgradeChangeResponse](#pluginautoupgradechangeresponse)
| + +### [POST] /workspaces/current/plugin/auto-upgrade/exclude +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ParserExcludePlugin](#parserexcludeplugin)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SuccessResponse](#successresponse)
| + +### [GET] /workspaces/current/plugin/auto-upgrade/fetch +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| category | query | | Yes | string,
**Available values:** "agent-strategy", "datasource", "extension", "model", "tool", "trigger" | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginAutoUpgradeFetchResponse](#pluginautoupgradefetchresponse)
| + ### [GET] /workspaces/current/plugin/debugging-key #### Responses @@ -9570,39 +9972,6 @@ Returns permission flags that control workspace features like member invitations | ---- | ----------- | ------ | | 200 | Success | **application/json**: [PluginPermissionResponse](#pluginpermissionresponse)
| -### [POST] /workspaces/current/plugin/preferences/autoupgrade/exclude -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [ParserExcludePlugin](#parserexcludeplugin)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginOperationSuccessResponse](#pluginoperationsuccessresponse)
| - -### [POST] /workspaces/current/plugin/preferences/change -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [ParserPreferencesChange](#parserpreferenceschange)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginOperationSuccessResponse](#pluginoperationsuccessresponse)
| - -### [GET] /workspaces/current/plugin/preferences/fetch -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [PluginPreferencesResponse](#pluginpreferencesresponse)
| - ### [GET] /workspaces/current/plugin/readme #### Parameters @@ -9744,6 +10113,21 @@ Returns permission flags that control workspace features like member invitations | ---- | ----------- | ------ | | 200 | Success | **application/json**: [PluginDaemonOperationResponse](#plugindaemonoperationresponse)
| +### [GET] /workspaces/current/plugin/{category}/list +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| page | query | Page number | No | integer,
**Default:** 1 | +| page_size | query | Page size (1-256) | No | integer,
**Default:** 256 | +| category | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PluginCategoryListResponse](#plugincategorylistresponse)
| + ### [GET] /workspaces/current/tool-labels #### Responses @@ -10641,7 +11025,7 @@ Default namespace | deprecated | boolean | | No | | features | [ [ModelFeature](#modelfeature) ] | | No | | fetch_from | [FetchFrom](#fetchfrom) | | Yes | -| label | [I18nObject](#i18nobject) | | Yes | +| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | | model | string | | Yes | | model_properties | object | | Yes | | model_type | [ModelType](#modeltype) | | Yes | @@ -10893,6 +11277,17 @@ Default namespace | validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No | | variant | string | | Yes | +#### AgentAppCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Agent description (max 400 chars) | No | +| icon | string | Icon | No | +| icon_background | string | Icon background color | No | +| icon_type | [IconType](#icontype) | Icon type | No | +| name | string | Agent name | Yes | +| role | string | Agent role | No | + #### AgentAppFeaturesPayload Presentation features configurable on an Agent App. @@ -10910,6 +11305,19 @@ default (the config form sends the full desired feature state on save). | suggested_questions_after_answer | [AgentSuggestedQuestionsAfterAnswerFeatureConfig](#agentsuggestedquestionsafteranswerfeatureconfig) | Follow-up suggestions config, e.g. {'enabled': true} | No | | text_to_speech | [AgentTextToSpeechFeatureConfig](#agenttexttospeechfeatureconfig) | Text-to-speech config | No | +#### AgentAppUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | App description (max 400 chars) | No | +| icon | string | Icon | No | +| icon_background | string | Icon background color | No | +| icon_type | [IconType](#icontype) | Icon type | No | +| max_active_requests | integer | Maximum active requests | No | +| name | string | App name | Yes | +| role | string | Agent role | No | +| use_icon_as_answer_icon | boolean | Use icon as answer icon | No | + #### AgentCliToolAuthorizationStatus Authorization state for Agent-scoped CLI tools. @@ -11150,6 +11558,12 @@ Audit operation recorded for Agent Soul version/revision changes. | version | integer | | Yes | | version_note | string | | No | +#### AgentDriveDeleteFileByAgentQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| key | string | Drive key, e.g. files/sample.pdf | Yes | + #### AgentDriveDeleteResponse | Name | Type | Description | Required | @@ -11289,6 +11703,7 @@ Supported icon storage formats for Agent roster entries. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| active_config_is_published | boolean | | No | | active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No | | active_config_snapshot_id | string | | No | | agent_kind | [AgentKind](#agentkind) | | Yes | @@ -11464,9 +11879,13 @@ the current roster/workflow APIs scoped to Dify Agent. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| app_icon | string | | No | +| app_icon_background | string | | No | +| app_icon_type | string | | No | | app_id | string | | Yes | | app_mode | string | | Yes | | app_name | string | | Yes | +| app_updated_at | integer | | No | | node_ids | [ string ] | | No | | workflow_id | string | | Yes | | workflow_version | string | | Yes | @@ -11475,11 +11894,16 @@ the current roster/workflow APIs scoped to Dify Agent. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| app_icon | string | | No | +| app_icon_background | string | | No | +| app_icon_type | string | | No | | app_id | string | | Yes | | app_mode | string | | Yes | | app_name | string | | Yes | +| app_updated_at | integer | | No | | node_ids | [ string ] | | No | | workflow_id | string | | Yes | +| workflow_version | string | | Yes | #### AgentReferencingWorkflowsResponse @@ -11501,6 +11925,7 @@ the current roster/workflow APIs scoped to Dify Agent. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| active_config_is_published | boolean | | No | | active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No | | active_config_snapshot_id | string | | No | | agent_kind | [AgentKind](#agentkind) | | Yes | @@ -11566,6 +11991,7 @@ Visibility and lifecycle scope of an Agent record. | provider_credential_id | string | | No | | ref | string | | No | | type | string | | No | +| value | string | | No | | variable | string | | No | #### AgentSensitiveWordAvoidanceFeatureConfig @@ -12092,8 +12518,9 @@ Enum class for api provider schema type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | access_mode | string | | No | +| active_config_is_published | boolean | | No | | api_base_url | string | | No | -| app_model_config | [ModelConfig](#modelconfig) | | No | +| app_id | string | | No | | bound_agent_id | string | | No | | created_at | integer | | No | | created_by | string | | No | @@ -12104,10 +12531,13 @@ Enum class for api provider schema type. | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | +| icon_url | string | | Yes | | id | string | | Yes | | max_active_requests | integer | | No | -| mode_compatible_with_agent | string | | Yes | +| mode | string | | Yes | +| model_config | [ModelConfig](#modelconfig) | | No | | name | string | | Yes | +| role | string | | No | | site | [Site](#site) | | No | | tags | [ [Tag](#tag) ] | | No | | tracing | [JSONValue](#jsonvalue) | | No | @@ -12167,6 +12597,7 @@ Enum class for api provider schema type. | mode | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | App mode filter
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No | | name | string | Filter by app name | No | | page | integer,
**Default:** 1 | Page number (1-99999) | No | +| sort_by | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | Sort apps by last modified, recently created, or earliest created
*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No | | tag_ids | [ string ] | Filter by tag IDs | No | #### AppMCPServerResponse @@ -12200,10 +12631,10 @@ AppMCPServer Status Enum | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| has_next | boolean | | Yes | -| items | [ [AppPartial](#apppartial) ] | | Yes | +| data | [ [AppPartial](#apppartial) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | | page | integer | | Yes | -| per_page | integer | | Yes | | total | integer | | Yes | #### AppPartial @@ -12211,20 +12642,26 @@ AppMCPServer Status Enum | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | access_mode | string | | No | -| app_model_config | [ModelConfigPartial](#modelconfigpartial) | | No | +| active_config_is_published | boolean | | No | +| app_id | string | | No | | author_name | string | | No | +| bound_agent_id | string | | No | | create_user_name | string | | No | | created_at | integer | | No | | created_by | string | | No | -| desc_or_prompt | string | | No | +| description | string | | No | | has_draft_trigger | boolean | | No | | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | +| icon_url | string | | Yes | | id | string | | Yes | +| is_starred | boolean | | No | | max_active_requests | integer | | No | -| mode_compatible_with_agent | string | | Yes | +| mode | string | | Yes | +| model_config | [ModelConfigPartial](#modelconfigpartial) | | No | | name | string | | Yes | +| role | string | | No | | tags | [ [Tag](#tag) ] | | No | | updated_at | integer | | No | | updated_by | string | | No | @@ -13018,7 +13455,7 @@ Enum class for configurate method of provider model. | icon | string | Icon | No | | icon_background | string | Icon background color | No | | icon_type | [IconType](#icontype) | Icon type | No | -| mode | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "chat", "completion", "workflow" | App mode
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes | +| mode | string,
**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | App mode
*Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes | | name | string | App name | Yes | #### CreateSnippetPayload @@ -13050,10 +13487,10 @@ Model class for credential form schema. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | default | string | | No | -| label | [I18nObject](#i18nobject) | | Yes | +| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | | max_length | integer | | No | | options | [ [FormOption](#formoption) ] | | No | -| placeholder | [I18nObject](#i18nobject) | | No | +| placeholder | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | | required | boolean,
**Default:** true | | No | | show_on | [ [FormShowOnObject](#formshowonobject) ],
**Default:** | | No | | type | [FormType](#formtype) | | Yes | @@ -13769,6 +14206,7 @@ about. Stage 4 §4.2. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | type | [DeclaredOutputType](#declaredoutputtype) | | Yes | @@ -13797,6 +14235,7 @@ code can call ``output.failure_strategy.on_failure`` without None-guards. | ---- | ---- | ----------- | -------- | | array_item | [DeclaredArrayItem](#declaredarrayitem) | | No | | check | [DeclaredOutputCheckConfig](#declaredoutputcheckconfig) | | No | +| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | failure_strategy | [DeclaredOutputFailureStrategy](#declaredoutputfailurestrategy) | | No | | file | [DeclaredOutputFileConfig](#declaredoutputfileconfig) | | No | @@ -14473,8 +14912,8 @@ Enum class for fetch from. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| label | [I18nObject](#i18nobject) | | Yes | -| placeholder | [I18nObject](#i18nobject) | | No | +| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| placeholder | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | #### FileInfo @@ -14605,7 +15044,7 @@ Model class for form option. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| label | [I18nObject](#i18nobject) | | Yes | +| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | | show_on | [ [FormShowOnObject](#formshowonobject) ],
**Default:** | | No | | value | string | | Yes | @@ -14837,6 +15276,8 @@ Model class for i18n object. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | en_US | string | | Yes | +| ja_JP | string | | No | +| pt_BR | string | | No | | zh_Hans | string | | No | #### IconInfo @@ -15098,6 +15539,12 @@ Enum class for large language model mode. | ---- | ---- | ----------- | -------- | | LLMMode | string | Enum class for large language model mode. | | +#### LearnDifyAppListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| recommended_apps | [ [RecommendedAppResponse](#recommendedappresponse) ] | | Yes | + #### LegacyEndpointUpdatePayload | Name | Type | Description | Required | @@ -15463,7 +15910,7 @@ Metadata operation data | ---- | ---- | ----------- | -------- | | created_at | integer | | No | | created_by | string | | No | -| model_dict | [JSONValue](#jsonvalue) | | No | +| model | [JSONValue](#jsonvalue) | | No | | pre_prompt | string | | No | | updated_at | integer | | No | | updated_by | string | | No | @@ -15932,8 +16379,8 @@ Model class for parameter rule. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | default | | | No | -| help | [I18nObject](#i18nobject) | | No | -| label | [I18nObject](#i18nobject) | | Yes | +| help | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | | max | number | | No | | min | number | | No | | name | string | | Yes | @@ -15983,6 +16430,19 @@ Enum class for parameter type. | file_name | string | | Yes | | plugin_unique_identifier | string | | Yes | +#### ParserAutoUpgradeChange + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes | +| category | [PluginCategory](#plugincategory) | | Yes | + +#### ParserAutoUpgradeFetch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| category | [PluginCategory](#plugincategory) | | Yes | + #### ParserCreateCredential | Name | Type | Description | Required | @@ -16079,6 +16539,7 @@ Enum class for parameter type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| category | [PluginCategory](#plugincategory) | | Yes | | plugin_id | string | | Yes | #### ParserGetCredentials @@ -16166,8 +16627,8 @@ Enum class for parameter type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| debug_permission | [DebugPermission](#debugpermission) | | Yes | -| install_permission | [InstallPermission](#installpermission) | | Yes | +| debug_permission | [DebugPermission](#debugpermission) | | No | +| install_permission | [InstallPermission](#installpermission) | | No | #### ParserPluginIdentifierQuery @@ -16197,13 +16658,6 @@ Enum class for parameter type. | model | string | | Yes | | model_type | [ModelType](#modeltype) | | Yes | -#### ParserPreferencesChange - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes | -| permission | [PluginPermissionSettingsPayload](#pluginpermissionsettingspayload) | | Yes | - #### ParserPreferredProviderType | Name | Type | Description | Required | @@ -16348,6 +16802,20 @@ Shared permission levels for resources (datasets, credentials, etc.) | unit | string | | No | | variable | string | | Yes | +#### PluginAutoUpgradeChangeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message | string | | No | +| success | boolean | | Yes | + +#### PluginAutoUpgradeFetchResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_upgrade | [PluginAutoUpgradeSettingsResponseModel](#pluginautoupgradesettingsresponsemodel) | | Yes | +| category | [PluginCategory](#plugincategory) | | Yes | + #### PluginAutoUpgradeSettingsPayload | Name | Type | Description | Required | @@ -16358,6 +16826,90 @@ Shared permission levels for resources (datasets, credentials, etc.) | upgrade_mode | [UpgradeMode](#upgrademode) | | No | | upgrade_time_of_day | integer | | No | +#### PluginAutoUpgradeSettingsResponseModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| exclude_plugins | [ string ] | | Yes | +| include_plugins | [ string ] | | Yes | +| strategy_setting | [StrategySetting](#strategysetting) | | Yes | +| upgrade_mode | [UpgradeMode](#upgrademode) | | Yes | +| upgrade_time_of_day | integer | | Yes | + +#### PluginCategory + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| PluginCategory | string | | | + +#### PluginCategoryBuiltinToolProviderResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allow_delete | boolean | | Yes | +| author | string | | Yes | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| icon | string
object | | Yes | +| icon_dark | string
object | | Yes | +| id | string | | Yes | +| is_team_authorization | boolean | | Yes | +| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| labels | [ string ] | | Yes | +| name | string | | Yes | +| plugin_id | string | | Yes | +| plugin_unique_identifier | string | | Yes | +| team_credentials | object | | Yes | +| tools | [ [PluginCategoryBuiltinToolResponse](#plugincategorybuiltintoolresponse) ] | | Yes | +| type | [ToolProviderType](#toolprovidertype) | | Yes | + +#### PluginCategoryBuiltinToolResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | | Yes | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| labels | [ string ] | | Yes | +| name | string | | Yes | +| output_schema | object | | Yes | +| parameters | [ object ] | | No | + +#### PluginCategoryInstalledPluginResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| checksum | string | | Yes | +| created_at | dateTime | | Yes | +| declaration | [PluginDeclarationResponse](#plugindeclarationresponse) | | Yes | +| endpoints_active | integer | | Yes | +| endpoints_setups | integer | | Yes | +| id | string | | Yes | +| installation_id | string | | Yes | +| meta | object | | Yes | +| name | string | | Yes | +| plugin_id | string | | Yes | +| plugin_unique_identifier | string | | Yes | +| runtime_type | string | | Yes | +| source | [PluginInstallationSource](#plugininstallationsource) | | Yes | +| tenant_id | string | | Yes | +| updated_at | dateTime | | Yes | +| version | string | | Yes | + +#### PluginCategoryListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| page | integer,
**Default:** 1 | Page number | No | +| page_size | integer,
**Default:** 256 | Page size (1-256) | No | + +#### PluginCategoryListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| builtin_tools | [ [PluginCategoryBuiltinToolProviderResponse](#plugincategorybuiltintoolproviderresponse) ] | | Yes | +| has_more | boolean | | Yes | +| plugins | [ [PluginCategoryInstalledPluginResponse](#plugincategoryinstalledpluginresponse) ] | | Yes | + #### PluginDaemonOperationResponse | Name | Type | Description | Required | @@ -16372,6 +16924,32 @@ Shared permission levels for resources (datasets, credentials, etc.) | key | string | | Yes | | port | integer | | Yes | +#### PluginDeclarationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_strategy | object | | No | +| author | string | | Yes | +| category | [PluginCategory](#plugincategory) | | Yes | +| created_at | dateTime | | Yes | +| datasource | object | | No | +| description | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| endpoint | object | | No | +| icon | string | | Yes | +| icon_dark | string | | No | +| label | [core__tools__entities__common_entities__I18nObject](#core__tools__entities__common_entities__i18nobject) | | Yes | +| meta | object | | Yes | +| model | [ProviderEntityResponse](#providerentityresponse) | | No | +| name | string | | Yes | +| plugins | object | | Yes | +| repo | string | | No | +| resource | object | | Yes | +| tags | [ string ] | | No | +| tool | object | | No | +| trigger | object | | No | +| verified | boolean | | No | +| version | string | | Yes | + #### PluginDependency | Name | Type | Description | Required | @@ -16405,6 +16983,12 @@ Shared permission levels for resources (datasets, credentials, etc.) | ---- | ---- | ----------- | -------- | | PluginInstallationScope | string | | | +#### PluginInstallationSource + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| PluginInstallationSource | string | | | + #### PluginInstallationsResponse | Name | Type | Description | Required | @@ -16457,13 +17041,6 @@ Shared permission levels for resources (datasets, credentials, etc.) | debug_permission | [DebugPermission](#debugpermission) | | No | | install_permission | [InstallPermission](#installpermission) | | No | -#### PluginPreferencesResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes | -| permission | [PluginPermissionSettingsPayload](#pluginpermissionsettingspayload) | | Yes | - #### PluginReadmeResponse | Name | Type | Description | Required | @@ -16550,14 +17127,35 @@ Model class for provider credential schema. | error | string | | No | | result | string,
**Available values:** "error", "success" | *Enum:* `"error"`, `"success"` | Yes | +#### ProviderEntityResponse + +Runtime provider response with codegen-safe model pricing schemas. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| background | string | | No | +| configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes | +| description | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| help | [ProviderHelpEntity](#providerhelpentity) | | No | +| icon_small | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| icon_small_dark | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | No | +| label | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| model_credential_schema | [ModelCredentialSchema](#modelcredentialschema) | | No | +| models | [ [AIModelEntityResponse](#aimodelentityresponse) ],
**Default:** | | No | +| position | object | | No | +| provider | string | | Yes | +| provider_credential_schema | [ProviderCredentialSchema](#providercredentialschema) | | No | +| provider_name | string | | No | +| supported_model_types | [ [ModelType](#modeltype) ] | | Yes | + #### ProviderHelpEntity Model class for provider help. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| title | [I18nObject](#i18nobject) | | Yes | -| url | [I18nObject](#i18nobject) | | Yes | +| title | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | +| url | [graphon__model_runtime__entities__common_entities__I18nObject](#graphon__model_runtime__entities__common_entities__i18nobject) | | Yes | #### ProviderModelWithStatusEntity @@ -17473,6 +18071,19 @@ Query parameters for listing snippet published workflows. | updated_by | [SimpleAccount](#simpleaccount) | | No | | version | string | | Yes | +#### StarredAppListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| creator_ids | [ string ] | Filter by creator account IDs | No | +| is_created_by_me | boolean | Filter by creator | No | +| limit | integer,
**Default:** 20 | Page size (1-100) | No | +| mode | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | App mode filter
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No | +| name | string | Filter by app name | No | +| page | integer,
**Default:** 1 | Page number (1-99999) | No | +| sort_by | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | Sort apps by last modified, recently created, or earliest created
*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No | +| tag_ids | [ string ] | Filter by tag IDs | No | + #### StatisticTimeRangeQuery | Name | Type | Description | Required | @@ -17817,6 +18428,14 @@ Tag type | ---- | ---- | ----------- | -------- | | ToolProviderOpaqueResponse | | | | +#### ToolProviderType + +Enum class for tool provider + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolProviderType | string | Enum class for tool provider | | + #### TraceAppConfigResponse | Name | Type | Description | Required | @@ -19236,6 +19855,26 @@ Workflow tool configuration | use_count | integer | | No | | version | integer | | No | +#### core__tools__entities__common_entities__I18nObject + +Model class for i18n object. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| en_US | string | | Yes | +| ja_JP | string | | No | +| pt_BR | string | | No | +| zh_Hans | string | | No | + +#### graphon__model_runtime__entities__common_entities__I18nObject + +Model class for i18n object. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| en_US | string | | Yes | +| zh_Hans | string | | No | + ## FastOpenAPI Preview (OpenAPI 3.1) ### Dify API (FastOpenAPI PoC) diff --git a/api/schedule/check_upgradable_plugin_task.py b/api/schedule/check_upgradable_plugin_task.py index cf223f6e9e9..f19a2bf18d8 100644 --- a/api/schedule/check_upgradable_plugin_task.py +++ b/api/schedule/check_upgradable_plugin_task.py @@ -73,6 +73,7 @@ def check_upgradable_plugin_task(): strategy.upgrade_mode, strategy.exclude_plugins, strategy.include_plugins, + strategy.category, ) # Only sleep if batch_interval_time > 0.0001 AND current batch is not the last one diff --git a/api/services/account_service.py b/api/services/account_service.py index 1ab5fbd4507..e39d13a3929 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -70,6 +70,7 @@ from services.errors.account import ( ) from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError from services.feature_service import FeatureService +from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService from tasks.delete_account_task import delete_account_task from tasks.mail_account_deletion_task import send_account_deletion_verification_code from tasks.mail_change_mail_task import ( @@ -263,6 +264,7 @@ class AccountService: account.set_tenant_id(available_ta.tenant_id) available_ta.current = True + available_ta.last_opened_at = naive_utc_now() db.session.commit() AccountService._refresh_account_last_active(account) @@ -1167,15 +1169,17 @@ class TenantService: db.session.add(tenant) db.session.commit() - plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy( - tenant_id=tenant.id, - strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, - upgrade_time_of_day=0, - upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, - exclude_plugins=[], - include_plugins=[], - ) - db.session.add(plugin_upgrade_strategy) + for category in TenantPluginAutoUpgradeStrategy.PluginCategory: + plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy( + tenant_id=tenant.id, + category=category, + strategy_setting=PluginAutoUpgradeService.default_strategy_setting_for_category(category), + upgrade_time_of_day=PluginAutoUpgradeService.default_upgrade_time_of_day(tenant.id), + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + exclude_plugins=[], + include_plugins=[], + ) + db.session.add(plugin_upgrade_strategy) db.session.commit() tenant.encrypt_public_key = generate_key_pair(tenant.id) @@ -1447,6 +1451,7 @@ class TenantService: .values(current=False) ) tenant_account_join.current = True + tenant_account_join.last_opened_at = naive_utc_now() # Set the current tenant for the account account.set_tenant_id(tenant_account_join.tenant_id) db.session.commit() diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 56f5b9f60e6..69a2306cc8a 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -1,6 +1,6 @@ from typing import Any, TypedDict -from sqlalchemy import func, select +from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError from libs.datetime_utils import naive_utc_now @@ -19,7 +19,7 @@ from models.agent import ( ) from models.agent_config_entities import AgentSoulConfig from models.enums import AppStatus -from models.model import App +from models.model import App, AppMode from models.workflow import Workflow from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_validator import ComposerConfigValidator @@ -37,7 +37,11 @@ class AgentReferencingWorkflow(TypedDict): app_id: str app_name: str + app_icon_type: str | None + app_icon: str | None + app_icon_background: str | None app_mode: str + app_updated_at: int | None workflow_id: str workflow_version: str node_ids: list[str] @@ -52,6 +56,7 @@ class AgentRosterService: agent: Agent, active_version: AgentConfigSnapshot | None = None, published_references: list[AgentReferencingWorkflow] | None = None, + active_config_is_published: bool = False, ) -> dict[str, Any]: published_references = published_references or [] return { @@ -70,6 +75,7 @@ class AgentRosterService: "workflow_node_id": agent.workflow_node_id, "active_config_snapshot_id": agent.active_config_snapshot_id, "active_config_snapshot": AgentRosterService.serialize_version(active_version) if active_version else None, + "active_config_is_published": active_config_is_published, "status": agent.status.value, "created_by": agent.created_by, "updated_by": agent.updated_by, @@ -124,6 +130,10 @@ class AgentRosterService: tenant_id=tenant_id, agent_ids=[agent.id for agent in agents], ) + active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=agents, + ) data = [] for agent in agents: @@ -135,6 +145,7 @@ class AgentRosterService: agent, active_version, published_references_by_agent_id.get(agent.id, []), + active_config_is_published_by_agent_id.get(agent.id, False), ) ) @@ -161,11 +172,16 @@ class AgentRosterService: tenant_id=tenant_id, agent_ids=[agent.id for agent in agents], ) + active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=agents, + ) data = [ self.serialize_agent( agent, versions_by_id.get(agent.active_config_snapshot_id) if agent.active_config_snapshot_id else None, published_references_by_agent_id.get(agent.id, []), + active_config_is_published_by_agent_id.get(agent.id, False), ) for agent in agents ] @@ -278,6 +294,7 @@ class AgentRosterService: app_id: str, name: str, description: str = "", + role: str = "", icon_type: Any = None, icon: str | None = None, icon_background: str | None = None, @@ -294,7 +311,7 @@ class AgentRosterService: tenant_id=tenant_id, name=name, description=description, - role="", + role=role, icon_type=icon_type, icon=icon, icon_background=icon_background, @@ -337,6 +354,21 @@ class AgentRosterService: self._session.flush() return agent + def load_app_backing_agents_by_app_id(self, *, tenant_id: str, app_ids: list[str]) -> dict[str, Agent]: + """Return active app-backed Agents keyed by Agent App id.""" + if not app_ids: + return {} + agents = self._session.scalars( + select(Agent).where( + Agent.tenant_id == tenant_id, + Agent.app_id.in_(app_ids), + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.status == AgentStatus.ACTIVE, + ) + ).all() + return {agent.app_id: agent for agent in agents if agent.app_id} + def get_app_backing_agent(self, *, tenant_id: str, app_id: str) -> Agent | None: """Return the roster Agent that backs the given Agent App, if any.""" return self._session.scalar( @@ -349,6 +381,43 @@ class AgentRosterService: ) ) + def get_agent_app_model(self, *, tenant_id: str, agent_id: str) -> App: + """Resolve the Agent App hidden behind an app-backed Agent id. + + The public /agent route uses Agent ids, while the runtime and legacy app + APIs still operate on App ids internally. Only app-backed roster Agents + are accepted here; workflow-only Agents and historical standalone roster + Agents are not Agent App resources. + """ + agent = self._session.scalar( + select(Agent) + .where( + Agent.tenant_id == tenant_id, + Agent.id == agent_id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.app_id.is_not(None), + Agent.status == AgentStatus.ACTIVE, + ) + .limit(1) + ) + if agent is None or agent.app_id is None: + raise AgentNotFoundError() + + app = self._session.scalar( + select(App) + .where( + App.tenant_id == tenant_id, + App.id == agent.app_id, + App.mode == AppMode.AGENT, + App.status == AppStatus.NORMAL, + ) + .limit(1) + ) + if app is None: + raise AgentNotFoundError() + return app + def list_workflows_referencing_app_agent(self, *, tenant_id: str, app_id: str) -> list[AgentReferencingWorkflow]: """List the workflow apps that reference this Agent App's bound Agent. @@ -372,7 +441,16 @@ class AgentRosterService: tenant_id=tenant_id, agent_ids=[agent.id], ) - return self.serialize_agent(agent, active_version, published_references_by_agent_id.get(agent.id, [])) + active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=[agent], + ) + return self.serialize_agent( + agent, + active_version, + published_references_by_agent_id.get(agent.id, []), + active_config_is_published_by_agent_id.get(agent.id, False), + ) def update_roster_agent( self, *, tenant_id: str, agent_id: str, account_id: str, payload: RosterAgentUpdatePayload @@ -403,12 +481,48 @@ class AgentRosterService: agent.updated_by = account_id self._session.commit() + @staticmethod + def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]: + if agent.source == AgentSource.AGENT_APP: + return {AgentConfigRevisionOperation.SAVE_NEW_VERSION} + return { + AgentConfigRevisionOperation.CREATE_VERSION, + AgentConfigRevisionOperation.SAVE_NEW_VERSION, + AgentConfigRevisionOperation.SAVE_NEW_AGENT, + AgentConfigRevisionOperation.SAVE_TO_ROSTER, + } + + def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool: + """Return whether the Agent's current active snapshot is a visible published version.""" + return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get( + agent.id, + False, + ) + + def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]: + """Return publish-state flags for the active config snapshots of the given Agents.""" + published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents) + return {agent.id: agent.id in published_agent_ids for agent in agents} + def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]: - self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + visible_version_ids = ( + select(AgentConfigRevision.current_snapshot_id) + .where( + AgentConfigRevision.tenant_id == tenant_id, + AgentConfigRevision.agent_id == agent_id, + AgentConfigRevision.operation.in_(self._visible_version_operations(agent)), + ) + .subquery() + ) versions = list( self._session.scalars( select(AgentConfigSnapshot) - .where(AgentConfigSnapshot.tenant_id == tenant_id, AgentConfigSnapshot.agent_id == agent_id) + .where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id.in_(select(visible_version_ids.c.current_snapshot_id)), + ) .order_by(AgentConfigSnapshot.version.desc()) ).all() ) @@ -419,7 +533,19 @@ class AgentRosterService: ] def get_agent_version_detail(self, *, tenant_id: str, agent_id: str, version_id: str) -> dict[str, Any]: - self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + visible_revision_id = self._session.scalar( + select(AgentConfigRevision.id) + .where( + AgentConfigRevision.tenant_id == tenant_id, + AgentConfigRevision.agent_id == agent_id, + AgentConfigRevision.current_snapshot_id == version_id, + AgentConfigRevision.operation.in_(self._visible_version_operations(agent)), + ) + .limit(1) + ) + if not visible_revision_id: + raise AgentVersionNotFoundError() version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id) revisions = list( self._session.scalars( @@ -475,6 +601,29 @@ class AgentRosterService: raise AgentVersionNotFoundError() return version + def _load_published_active_snapshot_agent_ids(self, *, tenant_id: str, agents: list[Agent]) -> set[str]: + predicates = [ + and_( + AgentConfigRevision.agent_id == agent.id, + AgentConfigRevision.current_snapshot_id == agent.active_config_snapshot_id, + AgentConfigRevision.operation.in_(self._visible_version_operations(agent)), + ) + for agent in agents + if agent.active_config_snapshot_id + ] + if not predicates: + return set() + + agent_ids = self._session.scalars( + select(AgentConfigRevision.agent_id) + .where( + AgentConfigRevision.tenant_id == tenant_id, + or_(*predicates), + ) + .distinct() + ).all() + return set(agent_ids) + def _load_published_references_by_agent_id( self, *, tenant_id: str, agent_ids: list[str] ) -> dict[str, list[AgentReferencingWorkflow]]: @@ -520,7 +669,11 @@ class AgentRosterService: AgentReferencingWorkflow( app_id=binding.app_id, app_name=app.name, + app_icon_type=(icon_type.value if (icon_type := getattr(app, "icon_type", None)) else None), + app_icon=getattr(app, "icon", None), + app_icon_background=getattr(app, "icon_background", None), app_mode=str(app.mode), + app_updated_at=to_timestamp(getattr(app, "updated_at", None)), workflow_id=binding.workflow_id, workflow_version=binding.workflow_version, node_ids=[], @@ -533,7 +686,7 @@ class AgentRosterService: references = list(by_workflow.values()) for reference in references: reference["node_ids"] = sorted(set(reference["node_ids"])) - references.sort(key=lambda item: (item["app_name"].lower(), item["workflow_id"])) + references.sort(key=lambda item: (-(item["app_updated_at"] or 0), item["app_name"].lower())) result[agent_id] = references return result diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index 13927026c48..3d39419d794 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -1,14 +1,23 @@ from __future__ import annotations +import copy from collections.abc import Mapping -from typing import Any +from typing import Any, cast +from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.orm import Session from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator -from models.agent import Agent, AgentScope, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding -from models.agent_config_entities import WorkflowNodeJobConfig +from models.agent import ( + Agent, + AgentConfigSnapshot, + AgentScope, + AgentStatus, + WorkflowAgentBindingType, + WorkflowAgentNodeBinding, +) +from models.agent_config_entities import DeclaredOutputConfig, WorkflowNodeJobConfig from models.workflow import Workflow @@ -17,6 +26,43 @@ class WorkflowAgentPublishService: _DRAFT_WORKFLOW_VERSION = Workflow.VERSION_DRAFT _AGENT_BINDING_KEY = "agent_binding" + _AGENT_TASK_KEY = "agent_task" + _AGENT_DECLARED_OUTPUTS_KEY = "agent_declared_outputs" + + @classmethod + def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]: + """Return draft graph with persisted Agent node job config projected into node data. + + Workflow draft graph is the front-end's editing source of truth, while + runtime/publish reads WorkflowAgentNodeBinding.node_job_config. This + response-only projection keeps reads aligned without writing binding + details back into the stored graph JSON. + """ + graph = cast(dict[str, Any], copy.deepcopy(draft_workflow.graph_dict)) + agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph)) + if not agent_nodes: + return graph + + bindings = session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id, + WorkflowAgentNodeBinding.app_id == draft_workflow.app_id, + WorkflowAgentNodeBinding.workflow_id == draft_workflow.id, + WorkflowAgentNodeBinding.workflow_version == cls._DRAFT_WORKFLOW_VERSION, + WorkflowAgentNodeBinding.node_id.in_(list(agent_nodes.keys())), + ) + ).all() + for binding in bindings: + node_data = agent_nodes.get(binding.node_id) + if not isinstance(node_data, dict): + continue + node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict) + if node_job.workflow_prompt is not None: + node_data[cls._AGENT_TASK_KEY] = node_job.workflow_prompt + node_data[cls._AGENT_DECLARED_OUTPUTS_KEY] = [ + output.model_dump(mode="json") for output in node_job.declared_outputs + ] + return graph @classmethod def validate_agent_nodes_for_publish(cls, *, session: Session, draft_workflow: Workflow) -> None: @@ -27,7 +73,7 @@ class WorkflowAgentPublishService: WorkflowAgentNodeValidator.validate_draft_workflow(session=session, workflow=draft_workflow) @classmethod - def sync_roster_agent_bindings_for_draft( + def sync_agent_bindings_for_draft( cls, *, session: Session, @@ -57,10 +103,11 @@ class WorkflowAgentPublishService: continue if not isinstance(binding_payload, Mapping): raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.") - cls._sync_roster_agent_binding_for_node( + cls._sync_agent_binding_for_node( session=session, draft_workflow=draft_workflow, node_id=node_id, + node_data=node_data, node_binding=binding_payload, existing_binding=existing_by_node_id.get(node_id), account_id=account_id, @@ -68,23 +115,93 @@ class WorkflowAgentPublishService: session.flush() @classmethod - def _sync_roster_agent_binding_for_node( + def sync_roster_agent_bindings_for_draft( + cls, + *, + session: Session, + draft_workflow: Workflow, + account_id: str, + ) -> None: + cls.sync_agent_bindings_for_draft( + session=session, + draft_workflow=draft_workflow, + account_id=account_id, + ) + + @classmethod + def _sync_agent_binding_for_node( cls, *, session: Session, draft_workflow: Workflow, node_id: str, + node_data: Mapping[str, Any], node_binding: Mapping[str, Any], existing_binding: WorkflowAgentNodeBinding | None, account_id: str, ) -> None: binding_type = node_binding.get("binding_type") - if binding_type != WorkflowAgentBindingType.ROSTER_AGENT.value: - raise ValueError(f"Workflow Agent node {node_id} only supports roster_agent graph binding.") agent_id = node_binding.get("agent_id") if not isinstance(agent_id, str) or not agent_id: - raise ValueError(f"Workflow Agent node {node_id} roster_agent binding requires agent_id.") + raise ValueError(f"Workflow Agent node {node_id} agent binding requires agent_id.") + if binding_type == WorkflowAgentBindingType.ROSTER_AGENT.value: + agent, current_snapshot_id = cls._resolve_roster_agent_graph_binding( + session=session, + draft_workflow=draft_workflow, + node_id=node_id, + agent_id=agent_id, + ) + resolved_binding_type = WorkflowAgentBindingType.ROSTER_AGENT + elif binding_type == WorkflowAgentBindingType.INLINE_AGENT.value: + raw_current_snapshot_id = node_binding.get("current_snapshot_id") + if not isinstance(raw_current_snapshot_id, str) or not raw_current_snapshot_id: + raise ValueError(f"Workflow Agent node {node_id} inline_agent binding requires current_snapshot_id.") + current_snapshot_id = raw_current_snapshot_id + agent = cls._resolve_inline_agent_graph_binding( + session=session, + draft_workflow=draft_workflow, + node_id=node_id, + agent_id=agent_id, + current_snapshot_id=current_snapshot_id, + ) + resolved_binding_type = WorkflowAgentBindingType.INLINE_AGENT + else: + raise ValueError(f"Workflow Agent node {node_id} has unsupported agent_binding type.") + + binding = existing_binding + node_job_config = cls._node_job_config_from_node_data( + existing_binding=existing_binding, + node_data=node_data, + ) + if binding is None: + binding = WorkflowAgentNodeBinding( + tenant_id=draft_workflow.tenant_id, + app_id=draft_workflow.app_id, + workflow_id=draft_workflow.id, + workflow_version=cls._DRAFT_WORKFLOW_VERSION, + node_id=node_id, + node_job_config=node_job_config, + created_by=account_id, + ) + session.add(binding) + else: + binding.node_job_config = node_job_config + + binding.binding_type = resolved_binding_type + binding.agent_id = agent.id + binding.current_snapshot_id = current_snapshot_id + binding.updated_by = account_id + + @classmethod + def _resolve_roster_agent_graph_binding( + cls, + *, + session: Session, + draft_workflow: Workflow, + node_id: str, + agent_id: str, + ) -> tuple[Agent, str]: agent = session.scalar( select(Agent) .where( @@ -97,28 +214,86 @@ class WorkflowAgentPublishService: ) if agent is None: raise ValueError(f"Workflow Agent node {node_id} references an unavailable roster agent.") + if agent.scope != AgentScope.ROSTER: + raise ValueError(f"Workflow Agent node {node_id} roster_agent binding must reference a roster agent.") if not agent.active_config_snapshot_id: raise ValueError(f"Workflow Agent node {node_id} roster agent has no active config snapshot.") + return agent, agent.active_config_snapshot_id - binding = existing_binding - if binding is None: - binding = WorkflowAgentNodeBinding( - tenant_id=draft_workflow.tenant_id, - app_id=draft_workflow.app_id, - workflow_id=draft_workflow.id, - workflow_version=cls._DRAFT_WORKFLOW_VERSION, - node_id=node_id, - node_job_config=WorkflowNodeJobConfig(), - created_by=account_id, + @classmethod + def _resolve_inline_agent_graph_binding( + cls, + *, + session: Session, + draft_workflow: Workflow, + node_id: str, + agent_id: str, + current_snapshot_id: str, + ) -> Agent: + agent = session.scalar( + select(Agent) + .where( + Agent.tenant_id == draft_workflow.tenant_id, + Agent.id == agent_id, + Agent.scope == AgentScope.WORKFLOW_ONLY, + Agent.app_id == draft_workflow.app_id, + Agent.workflow_id == draft_workflow.id, + Agent.workflow_node_id == node_id, + Agent.status == AgentStatus.ACTIVE, ) - session.add(binding) - elif not binding.node_job_config: - binding.node_job_config = WorkflowNodeJobConfig() + .limit(1) + ) + if agent is None: + raise ValueError(f"Workflow Agent node {node_id} references an unavailable inline agent.") + if ( + agent.scope != AgentScope.WORKFLOW_ONLY + or agent.app_id != draft_workflow.app_id + or agent.workflow_id != draft_workflow.id + or agent.workflow_node_id != node_id + ): + raise ValueError(f"Workflow Agent node {node_id} inline_agent binding does not belong to this node.") - binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT - binding.agent_id = agent.id - binding.current_snapshot_id = agent.active_config_snapshot_id - binding.updated_by = account_id + snapshot = session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == draft_workflow.tenant_id, + AgentConfigSnapshot.agent_id == agent.id, + AgentConfigSnapshot.id == current_snapshot_id, + ) + .limit(1) + ) + if snapshot is None or snapshot.agent_id != agent.id: + raise ValueError(f"Workflow Agent node {node_id} references a missing inline agent config snapshot.") + return agent + + @classmethod + def _node_job_config_from_node_data( + cls, + *, + existing_binding: WorkflowAgentNodeBinding | None, + node_data: Mapping[str, Any], + ) -> WorkflowNodeJobConfig: + if existing_binding and existing_binding.node_job_config: + node_job = WorkflowNodeJobConfig.model_validate(existing_binding.node_job_config_dict) + else: + node_job = WorkflowNodeJobConfig() + + agent_task = node_data.get(cls._AGENT_TASK_KEY) + if isinstance(agent_task, str): + node_job.workflow_prompt = agent_task + + declared_outputs_payload = node_data.get(cls._AGENT_DECLARED_OUTPUTS_KEY) + if declared_outputs_payload is not None: + if not isinstance(declared_outputs_payload, list): + raise ValueError("Workflow Agent node agent_declared_outputs must be a list.") + try: + node_job.declared_outputs = [ + DeclaredOutputConfig.model_validate(output) for output in declared_outputs_payload + ] + except ValidationError as exc: + raise ValueError("Workflow Agent node has invalid agent_declared_outputs.") from exc + + return node_job @classmethod def copy_agent_node_bindings_to_published( diff --git a/api/services/app_service.py b/api/services/app_service.py index 837e1a1449c..c435a672520 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -2,7 +2,7 @@ import json import logging from collections.abc import Sequence from datetime import datetime -from typing import Any, Literal, TypedDict, cast, override +from typing import Any, Literal, NotRequired, TypedDict, cast, override import sqlalchemy as sa from flask_sqlalchemy.pagination import Pagination @@ -23,7 +23,7 @@ from graphon.model_runtime.entities.model_entities import ModelPropertyKey, Mode from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from libs.datetime_utils import naive_utc_now from libs.login import current_user -from models import Account +from models import Account, AppStar from models.agent import Agent, AgentIconType, AgentScope, AgentSource, AgentStatus from models.model import App, AppMode, AppModelConfig, IconType, Site from models.tools import ApiToolProvider @@ -36,23 +36,34 @@ from tasks.remove_app_and_related_data_task import remove_app_and_related_data_t logger = logging.getLogger(__name__) +AppListSortBy = Literal["last_modified", "recently_created", "earliest_created"] -class AppListParams(BaseModel): + +class AppListBaseParams(BaseModel): page: int = Field(default=1, ge=1) limit: int = Field(default=20, ge=1, le=100) mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] = "all" + sort_by: AppListSortBy = "last_modified" name: str | None = None tag_ids: list[str] | None = None creator_ids: list[str] | None = None is_created_by_me: bool | None = None + + +class AppListParams(AppListBaseParams): status: str | None = None openapi_visible: bool = False +class StarredAppListParams(AppListBaseParams): + pass + + class CreateAppParams(BaseModel): name: str = Field(min_length=1) description: str | None = None mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] + agent_role: str = Field(default="", max_length=255) icon_type: str | None = None icon: str | None = None icon_background: str | None = None @@ -62,6 +73,85 @@ class CreateAppParams(BaseModel): class AppService: + @staticmethod + def _build_app_list_filters( + user_id: str, tenant_id: str, params: AppListBaseParams + ) -> list[sa.ColumnElement[bool]]: + filters = [App.tenant_id == tenant_id, App.is_universal == False] + + if params.mode == "workflow": + filters.append(App.mode == AppMode.WORKFLOW) + elif params.mode == "completion": + filters.append(App.mode == AppMode.COMPLETION) + elif params.mode == "chat": + filters.append(App.mode == AppMode.CHAT) + elif params.mode == "advanced-chat": + filters.append(App.mode == AppMode.ADVANCED_CHAT) + elif params.mode == "agent-chat": + filters.append(App.mode == AppMode.AGENT_CHAT) + elif params.mode == "agent": + filters.append(App.mode == AppMode.AGENT) + elif params.mode == "all": + filters.append(App.mode != AppMode.AGENT) + + if isinstance(params, AppListParams): + if params.status: + filters.append(App.status == params.status) + # OpenAPI surface visibility gate. Pushed into the query so + # `pagination.total` reflects only apps the openapi caller can + # actually reach; post-filtering by enable_api after the page + # arrives would make `total` page-dependent. + if params.openapi_visible: + filters.append(App.enable_api.is_(True)) + + if params.is_created_by_me: + filters.append(App.created_by == user_id) + if params.creator_ids: + filters.append(App.created_by.in_(params.creator_ids)) + if params.name: + from libs.helper import escape_like_pattern + + name = params.name[:30] + escaped_name = escape_like_pattern(name) + filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\")) + if params.tag_ids and len(params.tag_ids) > 0: + target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids, match_all=True) + if target_ids and len(target_ids) > 0: + filters.append(App.id.in_(target_ids)) + else: + return [] + + return filters + + @staticmethod + def _build_app_list_order_by(sort_by: AppListSortBy) -> sa.ColumnElement[Any]: + return { + "last_modified": App.updated_at.desc(), + "recently_created": App.created_at.desc(), + "earliest_created": App.created_at.asc(), + }[sort_by] + + @staticmethod + def get_starred_app_ids( + session: Session | scoped_session, + *, + tenant_id: str, + account_id: str, + app_ids: Sequence[str], + ) -> set[str]: + """Return app IDs starred by this account within the tenant.""" + if not app_ids: + return set() + + starred_app_ids = session.scalars( + select(AppStar.app_id).where( + AppStar.tenant_id == tenant_id, + AppStar.account_id == account_id, + AppStar.app_id.in_(list(app_ids)), + ) + ).all() + return set(starred_app_ids) + @staticmethod def get_app_by_id( session: Session | scoped_session, @@ -109,61 +199,104 @@ class AppService: def get_paginate_apps(self, user_id: str, tenant_id: str, params: AppListParams) -> Pagination | None: """ - Get app list with pagination + Get app list with pagination, filters, and explicit sort order. :param user_id: user id :param tenant_id: tenant id :param params: query parameters :return: """ - filters = [App.tenant_id == tenant_id, App.is_universal == False] + filters = self._build_app_list_filters(user_id, tenant_id, params) + if not filters: + return None - if params.mode == "workflow": - filters.append(App.mode == AppMode.WORKFLOW) - elif params.mode == "completion": - filters.append(App.mode == AppMode.COMPLETION) - elif params.mode == "chat": - filters.append(App.mode == AppMode.CHAT) - elif params.mode == "advanced-chat": - filters.append(App.mode == AppMode.ADVANCED_CHAT) - elif params.mode == "agent-chat": - filters.append(App.mode == AppMode.AGENT_CHAT) - elif params.mode == "agent": - filters.append(App.mode == AppMode.AGENT) - - if params.status: - filters.append(App.status == params.status) - # OpenAPI surface visibility gate. Pushed into the query so - # `pagination.total` reflects only apps the openapi caller can - # actually reach — post-filtering by enable_api after the page - # arrives would make `total` page-dependent. - if params.openapi_visible: - filters.append(App.enable_api.is_(True)) - if params.is_created_by_me: - filters.append(App.created_by == user_id) - if params.creator_ids: - filters.append(App.created_by.in_(params.creator_ids)) - if params.name: - from libs.helper import escape_like_pattern - - name = params.name[:30] - escaped_name = escape_like_pattern(name) - filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\")) - if params.tag_ids and len(params.tag_ids) > 0: - target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids, match_all=True) - if target_ids and len(target_ids) > 0: - filters.append(App.id.in_(target_ids)) - else: - return None + order_by = self._build_app_list_order_by(params.sort_by) app_models = db.paginate( - sa.select(App).where(*filters).order_by(App.created_at.desc()), + sa.select(App).where(*filters).order_by(order_by), page=params.page, per_page=params.limit, error_out=False, ) + app_ids = [str(app.id) for app in app_models.items] + starred_app_ids = self.get_starred_app_ids( + db.session, + tenant_id=tenant_id, + account_id=user_id, + app_ids=app_ids, + ) + for app in app_models.items: + app.is_starred = str(app.id) in starred_app_ids + return app_models + def get_paginate_starred_apps( + self, user_id: str, tenant_id: str, params: StarredAppListParams + ) -> Pagination | None: + """ + Get apps starred by the current account with pagination, filters, and explicit sort order. + """ + filters = self._build_app_list_filters(user_id, tenant_id, params) + if not filters: + return None + + order_by = self._build_app_list_order_by(params.sort_by) + app_models = db.paginate( + sa.select(App) + .join( + AppStar, + sa.and_( + AppStar.tenant_id == App.tenant_id, + AppStar.app_id == App.id, + AppStar.account_id == user_id, + ), + ) + .where(AppStar.tenant_id == tenant_id, *filters) + .order_by(order_by), + page=params.page, + per_page=params.limit, + error_out=False, + ) + + for app in app_models.items: + app.is_starred = True + + return app_models + + @staticmethod + def star_app(session: Session, *, app: App, account_id: str) -> None: + """Create the account's app star if it does not already exist.""" + existing_star = session.scalar( + select(AppStar) + .where( + AppStar.tenant_id == app.tenant_id, + AppStar.app_id == app.id, + AppStar.account_id == account_id, + ) + .limit(1) + ) + if existing_star: + return + + session.add(AppStar(tenant_id=app.tenant_id, app_id=app.id, account_id=account_id)) + + @staticmethod + def unstar_app(session: Session, *, app: App, account_id: str) -> None: + """Remove the account's app star if present.""" + existing_star = session.scalar( + select(AppStar) + .where( + AppStar.tenant_id == app.tenant_id, + AppStar.app_id == app.id, + AppStar.account_id == account_id, + ) + .limit(1) + ) + if not existing_star: + return + + session.delete(existing_star) + def create_app(self, tenant_id: str, params: CreateAppParams, account: Account) -> App: """ Create app @@ -282,6 +415,7 @@ class AppService: app_id=app.id, name=params.name, description=params.description or "", + role=params.agent_role, icon_type=icon_type, icon=params.icon, icon_background=params.icon_background, @@ -377,6 +511,7 @@ class AppService: icon_background: str use_icon_as_answer_icon: bool max_active_requests: int + role: NotRequired[str | None] @staticmethod def _get_backing_agent_for_update(app: App) -> Agent | None: @@ -408,6 +543,7 @@ class AppService: icon_type: IconType | str | None = None, icon: str | None = None, icon_background: str | None = None, + role: str | None = None, account_id: str | None = None, updated_at: datetime | None = None, ) -> None: @@ -430,6 +566,8 @@ class AppService: agent.icon = icon if icon_background is not None: agent.icon_background = icon_background + if role is not None: + agent.role = role agent.updated_by = account_id if updated_at is not None: agent.updated_at = updated_at @@ -464,6 +602,7 @@ class AppService: icon_type=app.icon_type, icon=app.icon, icon_background=app.icon_background, + role=args.get("role"), account_id=current_user.id, updated_at=app.updated_at, ) diff --git a/api/services/auth/jina.py b/api/services/auth/jina.py index 0b49b88e987..c5e9ac2d1c5 100644 --- a/api/services/auth/jina.py +++ b/api/services/auth/jina.py @@ -43,10 +43,16 @@ class JinaAuth(ApiKeyAuthBase): def _handle_error(self, response): if response.status_code in {402, 409, 500}: - error_message = response.json().get("error", "Unknown error occurred") + try: + error_message = response.json().get("error", "Unknown error occurred") + except ValueError: + error_message = response.text or "Unknown error occurred" raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") else: if response.text: - error_message = json.loads(response.text).get("error", "Unknown error occurred") + try: + error_message = json.loads(response.text).get("error", "Unknown error occurred") + except ValueError: + error_message = response.text raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") raise Exception(f"Unexpected error occurred while trying to authorize. Status code: {response.status_code}") diff --git a/api/services/auth/jina/jina.py b/api/services/auth/jina/jina.py index d8d2fd51c0c..6260b6b48ad 100644 --- a/api/services/auth/jina/jina.py +++ b/api/services/auth/jina/jina.py @@ -43,10 +43,16 @@ class JinaAuth(ApiKeyAuthBase): def _handle_error(self, response): if response.status_code in {402, 409, 500}: - error_message = response.json().get("error", "Unknown error occurred") + try: + error_message = response.json().get("error", "Unknown error occurred") + except ValueError: + error_message = response.text or "Unknown error occurred" raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") else: if response.text: - error_message = json.loads(response.text).get("error", "Unknown error occurred") + try: + error_message = json.loads(response.text).get("error", "Unknown error occurred") + except ValueError: + error_message = response.text raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") raise Exception(f"Unexpected error occurred while trying to authorize. Status code: {response.status_code}") diff --git a/api/services/auth/watercrawl/watercrawl.py b/api/services/auth/watercrawl/watercrawl.py index d07c2cc3189..ac8ec24d893 100644 --- a/api/services/auth/watercrawl/watercrawl.py +++ b/api/services/auth/watercrawl/watercrawl.py @@ -37,10 +37,16 @@ class WatercrawlAuth(ApiKeyAuthBase): def _handle_error(self, response): if response.status_code in {402, 409, 500}: - error_message = response.json().get("error", "Unknown error occurred") + try: + error_message = response.json().get("error", "Unknown error occurred") + except ValueError: + error_message = response.text or "Unknown error occurred" raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") else: if response.text: - error_message = json.loads(response.text).get("error", "Unknown error occurred") + try: + error_message = json.loads(response.text).get("error", "Unknown error occurred") + except ValueError: + error_message = response.text raise Exception(f"Failed to authorize. Status code: {response.status_code}. Error: {error_message}") raise Exception(f"Unexpected error occurred while trying to authorize. Status code: {response.status_code}") diff --git a/api/services/entities/model_provider_entities.py b/api/services/entities/model_provider_entities.py index 7499ebbc18d..020dc4a2ea9 100644 --- a/api/services/entities/model_provider_entities.py +++ b/api/services/entities/model_provider_entities.py @@ -192,6 +192,27 @@ class SimpleProviderEntityResponse(BaseModel): return self +class ProviderEntityResponse(BaseModel): + """Runtime provider response with codegen-safe model pricing schemas.""" + + provider: str + provider_name: str = "" + label: I18nObject + description: I18nObject | None = None + icon_small: I18nObject | None = None + icon_small_dark: I18nObject | None = None + background: str | None = None + help: ProviderHelpEntity | None = None + supported_model_types: Sequence[ModelType] + configurate_methods: list[ConfigurateMethod] + models: list[AIModelEntityResponse] = [] + provider_credential_schema: ProviderCredentialSchema | None = None + model_credential_schema: ModelCredentialSchema | None = None + position: dict[str, list[str]] | None = {} + + model_config = ConfigDict(from_attributes=True, protected_namespaces=()) + + class DefaultModelResponse(BaseModel): """ Default model entity. diff --git a/api/services/human_input_service.py b/api/services/human_input_service.py index dcddfe0f2c6..cf6645d9358 100644 --- a/api/services/human_input_service.py +++ b/api/services/human_input_service.py @@ -223,9 +223,12 @@ class HumanInputService: if result.form_kind != HumanInputFormKind.RUNTIME: return - if result.workflow_run_id is None: - return - self.enqueue_resume(result.workflow_run_id) + # A RUNTIME form is owned by a workflow run (workflow Agent node) or a + # conversation (ENG-635: Agent v2 chat). Route the resume accordingly. + if result.workflow_run_id is not None: + self.enqueue_resume(result.workflow_run_id) + elif result.conversation_id is not None: + self.enqueue_agent_app_resume(conversation_id=result.conversation_id, form_id=result.form_id) def ensure_form_active(self, form: Form) -> None: if form.submitted: @@ -286,6 +289,22 @@ class HumanInputService: logger.warning("App mode %s does not support resume for workflow run %s", app.mode, workflow_run_id) + def enqueue_agent_app_resume(self, *, conversation_id: str, form_id: str) -> None: + """ENG-635: resume an Agent v2 chat after its ask_human form is submitted. + + Enqueues a background turn for the conversation; the Agent App runner + continues the agent run, threading the human's reply into the request as + ``deferred_tool_results``. + """ + from tasks.app_generate.resume_agent_app_task import resume_agent_app_execution + + try: + resume_agent_app_execution.apply_async( + kwargs={"conversation_id": conversation_id, "form_id": form_id}, + ) + except Exception: # pragma: no cover + logger.exception("Failed to enqueue Agent App resume for conversation %s form %s", conversation_id, form_id) + def _load_variable_pool_for_form(self, form: Form) -> ReadOnlyVariablePool | None: workflow_run_id = form.workflow_run_id if workflow_run_id is None: diff --git a/api/services/plugin/plugin_auto_upgrade_service.py b/api/services/plugin/plugin_auto_upgrade_service.py index b96b8140acd..0e13214ee77 100644 --- a/api/services/plugin/plugin_auto_upgrade_service.py +++ b/api/services/plugin/plugin_auto_upgrade_service.py @@ -1,18 +1,295 @@ +"""Manage tenant plugin auto-upgrade strategies. + +The storage is category-scoped: each tenant can have one strategy per plugin +category. Public mutation helpers require an explicit category so callers do +not accidentally overwrite every plugin type with one workspace-level policy. +""" + +import logging +from dataclasses import dataclass +from hashlib import sha256 + from sqlalchemy import select +from sqlalchemy.orm import Session from core.db.session_factory import session_factory +from core.plugin.impl.plugin import PluginInstaller from models.account import TenantPluginAutoUpgradeStrategy +logger = logging.getLogger(__name__) + +PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory +PLUGIN_CATEGORIES = tuple(PluginCategory) +SECONDS_PER_DAY = 24 * 60 * 60 +AUTO_UPGRADE_CHECK_SLOT_SECONDS = 15 * 60 +AUTO_UPGRADE_CHECK_SLOT_COUNT = SECONDS_PER_DAY // AUTO_UPGRADE_CHECK_SLOT_SECONDS + + +@dataclass(frozen=True) +class PluginAutoUpgradeBackfillResult: + created_count: int + normalized: bool + class PluginAutoUpgradeService: @staticmethod - def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None: - with session_factory.create_session() as session: - return session.scalar( - select(TenantPluginAutoUpgradeStrategy) - .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .limit(1) + def default_strategy_setting_for_category( + category: PluginCategory, + ) -> TenantPluginAutoUpgradeStrategy.StrategySetting: + if category == PluginCategory.MODEL: + return TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST + return TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY + + @staticmethod + def default_upgrade_time_of_day(tenant_id: str) -> int: + """Spread default checks across 15-minute aligned slots by tenant.""" + hash_input = tenant_id.encode() + slot = int.from_bytes(sha256(hash_input).digest()[:8], "big") % AUTO_UPGRADE_CHECK_SLOT_COUNT + return slot * AUTO_UPGRADE_CHECK_SLOT_SECONDS + + @staticmethod + def _coerce_category(category: object) -> PluginCategory | None: + """Accept daemon enum/string categories and ignore unknown values.""" + category_value = getattr(category, "value", category) + if category_value is None: + return None + + try: + return PluginCategory(str(category_value)) + except ValueError: + return None + + @staticmethod + def _get_installed_plugin_categories(tenant_id: str) -> dict[str, PluginCategory]: + """Build a plugin_id -> category map for splitting legacy include/exclude lists.""" + installed_plugins = PluginInstaller().list_plugins(tenant_id) + plugin_categories: dict[str, PluginCategory] = {} + + for plugin in installed_plugins: + plugin_category = PluginAutoUpgradeService._coerce_category(plugin.declaration.category) + if plugin_category is not None: + plugin_categories[plugin.plugin_id] = plugin_category + + return plugin_categories + + @staticmethod + def _filter_plugin_ids_for_category( + plugin_ids: list[str], + category: PluginCategory, + plugin_categories: dict[str, PluginCategory], + ) -> list[str]: + return [plugin_id for plugin_id in plugin_ids if plugin_categories.get(plugin_id) == category] + + @staticmethod + def _log_unknown_plugin_ids( + tenant_id: str, + field_name: str, + plugin_ids: list[str], + plugin_categories: dict[str, PluginCategory], + ) -> None: + unknown_plugin_ids = [plugin_id for plugin_id in plugin_ids if plugin_id not in plugin_categories] + if not unknown_plugin_ids: + return + + logger.warning( + "Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: " + "tenant_id=%s, field=%s, plugin_ids=%s", + tenant_id, + field_name, + unknown_plugin_ids, + ) + + @staticmethod + def _has_default_strategy(strategy: TenantPluginAutoUpgradeStrategy) -> bool: + return ( + strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY + and strategy.upgrade_time_of_day == 0 + and strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE + and not strategy.exclude_plugins + and not strategy.include_plugins + ) + + @staticmethod + def _strategy_setting_for_category( + source_strategy: TenantPluginAutoUpgradeStrategy, + category: PluginCategory, + source_has_default_strategy: bool, + ) -> TenantPluginAutoUpgradeStrategy.StrategySetting: + # Only pure legacy defaults adopt the new model=latest default. User-edited + # strategies keep their original setting across all categories. + if source_has_default_strategy: + return PluginAutoUpgradeService.default_strategy_setting_for_category(category) + return source_strategy.strategy_setting + + @staticmethod + def _upgrade_time_of_day_for_category( + tenant_id: str, + source_strategy: TenantPluginAutoUpgradeStrategy, + source_has_default_strategy: bool, + ) -> int: + # Pure legacy defaults are spread by tenant so all default rows do not + # concentrate in the same scheduler window. User-edited schedules keep their time. + if source_has_default_strategy: + return PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id) + return source_strategy.upgrade_time_of_day + + @staticmethod + def backfill_strategy_categories( + tenant_id: str, + ) -> PluginAutoUpgradeBackfillResult: + """Create missing category strategies and split include/exclude lists when needed. + + The historical row is treated as the workspace-level source strategy. + New category rows copy it first, then plugin lists are narrowed by real + plugin category when the source strategy contains include/exclude IDs. + """ + with session_factory.create_session() as session, session.begin(): + strategies = list( + session.scalars( + select(TenantPluginAutoUpgradeStrategy).where( + TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id + ) + ).all() ) + if not strategies: + return PluginAutoUpgradeBackfillResult(created_count=0, normalized=False) + + # Schema migration marks the historical workspace-level row as tool. + source_strategy = next( + (strategy for strategy in strategies if strategy.category == PluginCategory.TOOL), + strategies[0], + ) + source_has_default_strategy = PluginAutoUpgradeService._has_default_strategy(source_strategy) + strategies_by_category = {strategy.category: strategy for strategy in strategies} + exclude_plugins = source_strategy.exclude_plugins + include_plugins = source_strategy.include_plugins + should_split_plugin_lists = bool(exclude_plugins or include_plugins) + # Query daemon only for tenants that actually customized plugin lists. + plugin_categories = ( + PluginAutoUpgradeService._get_installed_plugin_categories(tenant_id) + if should_split_plugin_lists + else {} + ) + if should_split_plugin_lists: + PluginAutoUpgradeService._log_unknown_plugin_ids( + tenant_id, + "exclude_plugins", + exclude_plugins, + plugin_categories, + ) + PluginAutoUpgradeService._log_unknown_plugin_ids( + tenant_id, + "include_plugins", + include_plugins, + plugin_categories, + ) + + created_count = 0 + for category in PLUGIN_CATEGORIES: + strategy = strategies_by_category.get(category) + if strategy is None: + # Start from the legacy workspace-level behavior before narrowing lists. + strategy = TenantPluginAutoUpgradeStrategy( + tenant_id=tenant_id, + category=category, + strategy_setting=PluginAutoUpgradeService._strategy_setting_for_category( + source_strategy, category, source_has_default_strategy + ), + upgrade_time_of_day=PluginAutoUpgradeService._upgrade_time_of_day_for_category( + tenant_id, source_strategy, source_has_default_strategy + ), + upgrade_mode=source_strategy.upgrade_mode, + exclude_plugins=source_strategy.exclude_plugins.copy(), + include_plugins=source_strategy.include_plugins.copy(), + ) + session.add(strategy) + created_count += 1 + elif source_has_default_strategy: + strategy.strategy_setting = PluginAutoUpgradeService.default_strategy_setting_for_category( + strategy.category + ) + strategy.upgrade_time_of_day = PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id) + + if not should_split_plugin_lists: + continue + + # Narrow include/exclude lists to the current category after all rows exist. + strategy.exclude_plugins = PluginAutoUpgradeService._filter_plugin_ids_for_category( + exclude_plugins, + strategy.category, + plugin_categories, + ) + strategy.include_plugins = PluginAutoUpgradeService._filter_plugin_ids_for_category( + include_plugins, + strategy.category, + plugin_categories, + ) + + return PluginAutoUpgradeBackfillResult(created_count=created_count, normalized=should_split_plugin_lists) + + @staticmethod + def _get_strategy( + session: Session, + tenant_id: str, + category: PluginCategory, + ) -> TenantPluginAutoUpgradeStrategy | None: + return session.scalar( + select(TenantPluginAutoUpgradeStrategy) + .where( + TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id, + TenantPluginAutoUpgradeStrategy.category == category, + ) + .limit(1) + ) + + @staticmethod + def get_strategy( + tenant_id: str, + category: PluginCategory, + ) -> TenantPluginAutoUpgradeStrategy | None: + with session_factory.create_session() as session: + return PluginAutoUpgradeService._get_strategy(session, tenant_id, category) + + @staticmethod + def get_strategies(tenant_id: str) -> list[TenantPluginAutoUpgradeStrategy]: + with session_factory.create_session() as session: + return list( + session.scalars( + select(TenantPluginAutoUpgradeStrategy).where( + TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id + ) + ).all() + ) + + @staticmethod + def _change_strategy( + session: Session, + tenant_id: str, + category: PluginCategory, + strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting, + upgrade_time_of_day: int, + upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode, + exclude_plugins: list[str], + include_plugins: list[str], + ) -> None: + exist_strategy = PluginAutoUpgradeService._get_strategy(session, tenant_id, category) + if not exist_strategy: + strategy = TenantPluginAutoUpgradeStrategy( + tenant_id=tenant_id, + category=category, + strategy_setting=strategy_setting, + upgrade_time_of_day=upgrade_time_of_day, + upgrade_mode=upgrade_mode, + exclude_plugins=exclude_plugins, + include_plugins=include_plugins, + ) + session.add(strategy) + else: + exist_strategy.strategy_setting = strategy_setting + exist_strategy.upgrade_time_of_day = upgrade_time_of_day + exist_strategy.upgrade_mode = upgrade_mode + exist_strategy.exclude_plugins = exclude_plugins + exist_strategy.include_plugins = include_plugins @staticmethod def change_strategy( @@ -22,64 +299,72 @@ class PluginAutoUpgradeService: upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode, exclude_plugins: list[str], include_plugins: list[str], + category: PluginCategory, ) -> bool: with session_factory.create_session() as session, session.begin(): - exist_strategy = session.scalar( - select(TenantPluginAutoUpgradeStrategy) - .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .limit(1) + PluginAutoUpgradeService._change_strategy( + session, + tenant_id=tenant_id, + category=category, + strategy_setting=strategy_setting, + upgrade_time_of_day=upgrade_time_of_day, + upgrade_mode=upgrade_mode, + exclude_plugins=exclude_plugins, + include_plugins=include_plugins, ) - if not exist_strategy: - strategy = TenantPluginAutoUpgradeStrategy( - tenant_id=tenant_id, - strategy_setting=strategy_setting, - upgrade_time_of_day=upgrade_time_of_day, - upgrade_mode=upgrade_mode, - exclude_plugins=exclude_plugins, - include_plugins=include_plugins, - ) - session.add(strategy) - else: - exist_strategy.strategy_setting = strategy_setting - exist_strategy.upgrade_time_of_day = upgrade_time_of_day - exist_strategy.upgrade_mode = upgrade_mode - exist_strategy.exclude_plugins = exclude_plugins - exist_strategy.include_plugins = include_plugins return True @staticmethod - def exclude_plugin(tenant_id: str, plugin_id: str) -> bool: - with session_factory.create_session() as session, session.begin(): - exist_strategy = session.scalar( - select(TenantPluginAutoUpgradeStrategy) - .where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id) - .limit(1) + def _exclude_plugin( + session: Session, + tenant_id: str, + category: PluginCategory, + plugin_id: str, + ) -> None: + """Remove one plugin from automatic updates for a single category strategy.""" + exist_strategy = PluginAutoUpgradeService._get_strategy(session, tenant_id, category) + if not exist_strategy: + PluginAutoUpgradeService._change_strategy( + session, + tenant_id, + category, + TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, + 0, + TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + [plugin_id], + [], ) - if not exist_strategy: - # create for this tenant - PluginAutoUpgradeService.change_strategy( - tenant_id, - TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, - 0, - TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, - [plugin_id], - [], - ) - return True - else: - if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE: - if plugin_id not in exist_strategy.exclude_plugins: - new_exclude_plugins = exist_strategy.exclude_plugins.copy() - new_exclude_plugins.append(plugin_id) - exist_strategy.exclude_plugins = new_exclude_plugins - elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL: - if plugin_id in exist_strategy.include_plugins: - new_include_plugins = exist_strategy.include_plugins.copy() - new_include_plugins.remove(plugin_id) - exist_strategy.include_plugins = new_include_plugins - elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL: - exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE - exist_strategy.exclude_plugins = [plugin_id] + else: + if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE: + # In exclude mode, disabling one plugin means adding it to exclude_plugins. + if plugin_id not in exist_strategy.exclude_plugins: + new_exclude_plugins = exist_strategy.exclude_plugins.copy() + new_exclude_plugins.append(plugin_id) + exist_strategy.exclude_plugins = new_exclude_plugins + elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL: + # In partial mode, disabling one plugin means removing it from include_plugins. + if plugin_id in exist_strategy.include_plugins: + new_include_plugins = exist_strategy.include_plugins.copy() + new_include_plugins.remove(plugin_id) + exist_strategy.include_plugins = new_include_plugins + elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL: + # In all mode, switch to exclude mode so only this plugin is skipped. + exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE + exist_strategy.exclude_plugins = [plugin_id] - return True + @staticmethod + def exclude_plugin( + tenant_id: str, + plugin_id: str, + category: PluginCategory, + ) -> bool: + with session_factory.create_session() as session, session.begin(): + PluginAutoUpgradeService._exclude_plugin( + session, + tenant_id, + category, + plugin_id, + ) + + return True diff --git a/api/services/rag_pipeline/rag_pipeline_transform_service.py b/api/services/rag_pipeline/rag_pipeline_transform_service.py index ca755d0b91d..dc3eeae201e 100644 --- a/api/services/rag_pipeline/rag_pipeline_transform_service.py +++ b/api/services/rag_pipeline/rag_pipeline_transform_service.py @@ -8,6 +8,7 @@ from uuid import uuid4 import yaml from flask_login import current_user from sqlalchemy import select +from sqlalchemy.orm import scoped_session from configs import dify_config from constants import DOCUMENT_EXTENSIONS @@ -28,8 +29,8 @@ logger = logging.getLogger(__name__) class RagPipelineTransformService: - def transform_dataset(self, dataset_id: str): - dataset = db.session.get(Dataset, dataset_id) + def transform_dataset(self, dataset_id: str, session: scoped_session): + dataset = session.get(Dataset, dataset_id) if not dataset: raise ValueError("Dataset not found") if dataset.pipeline_id and dataset.runtime_mode == DatasetRuntimeMode.RAG_PIPELINE: @@ -94,9 +95,9 @@ class RagPipelineTransformService: dataset.pipeline_id = pipeline.id # deal document data - self._deal_document_data(dataset) + self._deal_document_data(dataset, session) - db.session.commit() + session.commit() return { "pipeline_id": pipeline.id, "dataset_id": dataset_id, @@ -310,13 +311,13 @@ class RagPipelineTransformService: "status": "success", } - def _deal_document_data(self, dataset: Dataset): + def _deal_document_data(self, dataset: Dataset, session: scoped_session): file_node_id = "1752479895761" notion_node_id = "1752489759475" jina_node_id = "1752491761974" firecrawl_node_id = "1752565402678" - documents = db.session.scalars(select(Document).where(Document.dataset_id == dataset.id)).all() + documents = session.scalars(select(Document).where(Document.dataset_id == dataset.id)).all() for document in documents: data_source_info_dict = document.data_source_info_dict @@ -326,7 +327,7 @@ class RagPipelineTransformService: document.data_source_type = DataSourceType.LOCAL_FILE file_id = data_source_info_dict.get("upload_file_id") if file_id: - file = db.session.get(UploadFile, file_id) + file = session.get(UploadFile, file_id) if file: data_source_info = json.dumps( { @@ -350,8 +351,8 @@ class RagPipelineTransformService: datasource_node_id=file_node_id, ) document_pipeline_execution_log.created_at = document.created_at - db.session.add(document) - db.session.add(document_pipeline_execution_log) + session.add(document) + session.add(document_pipeline_execution_log) elif document.data_source_type == DataSourceType.NOTION_IMPORT: document.data_source_type = DataSourceType.ONLINE_DOCUMENT data_source_info = json.dumps( @@ -378,8 +379,8 @@ class RagPipelineTransformService: datasource_node_id=notion_node_id, ) document_pipeline_execution_log.created_at = document.created_at - db.session.add(document) - db.session.add(document_pipeline_execution_log) + session.add(document) + session.add(document_pipeline_execution_log) elif document.data_source_type == DataSourceType.WEBSITE_CRAWL: data_source_info = json.dumps( { @@ -406,5 +407,5 @@ class RagPipelineTransformService: datasource_node_id=datasource_node_id, ) document_pipeline_execution_log.created_at = document.created_at - db.session.add(document) - db.session.add(document_pipeline_execution_log) + session.add(document) + session.add(document_pipeline_execution_log) diff --git a/api/services/recommend_app/database/database_retrieval.py b/api/services/recommend_app/database/database_retrieval.py index d420b33930b..9d6c28c2117 100644 --- a/api/services/recommend_app/database/database_retrieval.py +++ b/api/services/recommend_app/database/database_retrieval.py @@ -1,4 +1,4 @@ -from typing import Any, TypedDict, override +from typing import Any, NotRequired, TypedDict, override from sqlalchemy import select @@ -22,6 +22,7 @@ class RecommendedAppItemDict(TypedDict): categories: list[str] position: int is_listed: bool + can_trial: NotRequired[bool] class RecommendedAppsResultDict(TypedDict): @@ -64,14 +65,47 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): :param language: language :return: """ - recommended_apps = db.session.scalars( - select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.language == language) - ).all() + recommended_apps = cls._fetch_listed_recommended_apps(language) if len(recommended_apps) == 0: - recommended_apps = db.session.scalars( - select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.language == languages[0]) - ).all() + recommended_apps = cls._fetch_listed_recommended_apps(languages[0]) + + return cls._format_recommended_apps(recommended_apps, language) + + @classmethod + def fetch_learn_dify_apps_from_db(cls, language: str) -> RecommendedAppsResultDict: + """ + Fetch listed recommended apps explicitly marked for the Learn Dify section. + :param language: language + :return: + """ + recommended_apps = cls._fetch_listed_recommended_apps(language, is_learn_dify=True) + + if len(recommended_apps) == 0 and language != languages[0]: + recommended_apps = cls._fetch_listed_recommended_apps(languages[0], is_learn_dify=True) + + return cls._format_recommended_apps(recommended_apps, language) + + @classmethod + def _fetch_listed_recommended_apps( + cls, language: str, *, is_learn_dify: bool | None = None + ) -> list[RecommendedApp]: + filters = [RecommendedApp.is_listed.is_(True), RecommendedApp.language == language] + if is_learn_dify is not None: + filters.append(RecommendedApp.is_learn_dify.is_(is_learn_dify)) + + return list(db.session.scalars(select(RecommendedApp).where(*filters)).all()) + + @classmethod + def _format_recommended_apps( + cls, recommended_apps: list[RecommendedApp], language: str + ) -> RecommendedAppsResultDict: + """ + Serialize DB recommended app rows into the Explore list response shape. + :param recommended_apps: recommended app rows + :param language: language used for category ordering + :return: + """ categories = set() recommended_apps_result: list[RecommendedAppItemDict] = [] diff --git a/api/services/recommended_app_service.py b/api/services/recommended_app_service.py index 00eb5ee2d1b..bc8bb58acba 100644 --- a/api/services/recommended_app_service.py +++ b/api/services/recommended_app_service.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import scoped_session from configs import dify_config from models.model import AccountTrialAppRecord, TrialApp from services.feature_service import FeatureService +from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory @@ -31,13 +32,24 @@ class RecommendedAppService: apps = result["recommended_apps"] for app in apps: app_id = app["app_id"] - trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1)) - if trial_app_model: - app["can_trial"] = True - else: - app["can_trial"] = False + app["can_trial"] = cls._can_trial_app(session, app_id) return result + @classmethod + def get_learn_dify_apps(cls, session: scoped_session, language: str) -> dict[str, Any]: + """ + Get database-backed recommended apps marked as Learn Dify. + :param language: language + :return: + """ + result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language) + + if FeatureService.get_system_features().enable_trial_app: + for app in result["recommended_apps"]: + app["can_trial"] = cls._can_trial_app(session, app["app_id"]) + + return {"recommended_apps": result["recommended_apps"]} + @classmethod def get_recommend_app_detail(cls, session: scoped_session, app_id: str) -> dict[str, Any] | None: """ @@ -52,11 +64,7 @@ class RecommendedAppService: return None if FeatureService.get_system_features().enable_trial_app: app_id = result["id"] - trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1)) - if trial_app_model: - result["can_trial"] = True - else: - result["can_trial"] = False + result["can_trial"] = cls._can_trial_app(session, app_id) return result @classmethod @@ -77,3 +85,8 @@ class RecommendedAppService: else: session.add(AccountTrialAppRecord(app_id=app_id, count=1, account_id=account_id)) session.commit() + + @staticmethod + def _can_trial_app(session: scoped_session, app_id: str) -> bool: + trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1)) + return trial_app_model is not None diff --git a/api/services/snippet_dsl_service.py b/api/services/snippet_dsl_service.py index ad8d76a3878..19e0dabab95 100644 --- a/api/services/snippet_dsl_service.py +++ b/api/services/snippet_dsl_service.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime from enum import StrEnum from urllib.parse import urlparse -import yaml # type: ignore +import yaml from packaging import version from pydantic import BaseModel, Field from sqlalchemy import select @@ -498,7 +498,7 @@ class SnippetDslService: export_data=export_data, snippet=snippet, workflow=workflow, include_secret=include_secret ) - return yaml.dump(export_data, allow_unicode=True) # type: ignore + return yaml.dump(export_data, allow_unicode=True) def _append_workflow_export_data( self, *, export_data: dict, snippet: CustomizedSnippet, workflow: Workflow, include_secret: bool diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 4be120ac782..9f8e4b83093 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -323,7 +323,7 @@ class WorkflowService: from services.agent.workflow_publish_service import WorkflowAgentPublishService db.session.flush() - WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft( + WorkflowAgentPublishService.sync_agent_bindings_for_draft( session=cast(Session, db.session), draft_workflow=workflow, account_id=account.id, diff --git a/api/tasks/app_generate/resume_agent_app_task.py b/api/tasks/app_generate/resume_agent_app_task.py new file mode 100644 index 00000000000..118a476bc8d --- /dev/null +++ b/api/tasks/app_generate/resume_agent_app_task.py @@ -0,0 +1,70 @@ +"""Background resume of an Agent v2 chat after a submitted ask_human HITL form. + +ENG-635. When a human submits a conversation-owned ask_human form (delivered via +email/webapp), ``HumanInputService`` enqueues this task. It reconstructs the +conversation context and runs one blocking Agent App turn; the runner detects the +answered form and continues the agent run with the human's reply +(``deferred_tool_results``), persisting the assistant answer to the conversation. +""" + +from __future__ import annotations + +import logging + +from celery import shared_task + +from core.app.apps.agent_app.app_generator import AgentAppGenerator +from core.app.entities.app_invoke_entities import InvokeFrom +from extensions.ext_database import db +from models.account import Account +from models.human_input import HumanInputForm +from models.model import App, Conversation, EndUser +from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE + +logger = logging.getLogger(__name__) + + +@shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE, name="resume_agent_app_execution") +def resume_agent_app_execution(*, conversation_id: str, form_id: str) -> None: + form = db.session.get(HumanInputForm, form_id) + if form is None or form.conversation_id != conversation_id: + logger.warning("Agent App resume: form %s missing or conversation mismatch", form_id) + return + + app_model = db.session.get(App, form.app_id) + if app_model is None: + logger.warning("Agent App resume: app %s not found for form %s", form.app_id, form_id) + return + + conversation = db.session.get(Conversation, conversation_id) + if conversation is None: + logger.warning("Agent App resume: conversation %s not found", conversation_id) + return + + user = _resolve_conversation_user(app_model=app_model, conversation=conversation) + if user is None: + logger.warning("Agent App resume: no user resolvable for conversation %s", conversation_id) + return + + try: + AgentAppGenerator().resume_after_form_submission( + app_model=app_model, + user=user, + conversation_id=conversation_id, + invoke_from=InvokeFrom.WEB_APP, + ) + except Exception: + logger.exception("Agent App resume failed for conversation %s form %s", conversation_id, form_id) + finally: + db.session.close() + + +def _resolve_conversation_user(*, app_model: App, conversation: Conversation) -> Account | EndUser | None: + if conversation.from_account_id: + account = db.session.get(Account, conversation.from_account_id) + if account is not None: + account.set_tenant_id(app_model.tenant_id) + return account + if conversation.from_end_user_id: + return db.session.get(EndUser, conversation.from_end_user_id) + return None diff --git a/api/tasks/human_input_timeout_tasks.py b/api/tasks/human_input_timeout_tasks.py index fd743205a1b..f670d5a3382 100644 --- a/api/tasks/human_input_timeout_tasks.py +++ b/api/tasks/human_input_timeout_tasks.py @@ -95,16 +95,30 @@ def check_and_handle_human_input_timeouts(limit: int = 100) -> None: timeout_status=HumanInputFormStatus.EXPIRED if is_global else HumanInputFormStatus.TIMEOUT, reason="global_timeout" if is_global else "node_timeout", ) - assert record.workflow_run_id is not None, "workflow_run_id should not be None for non-test form" if is_global: + # Global timeout applies only to workflow-owned forms + # (_is_global_timeout requires a workflow_run_id): end the run. + assert record.workflow_run_id is not None, "global timeout requires a workflow_run_id" _handle_global_timeout( form_id=record.form_id, workflow_run_id=record.workflow_run_id, node_id=record.node_id, session_factory=session_factory, ) - else: + elif record.workflow_run_id is not None: + # Workflow Agent node / Human Input node form: resume the workflow. service.enqueue_resume(record.workflow_run_id) + elif record.conversation_id is not None: + # ENG-635: Agent v2 chat ask_human form is conversation-owned (no + # workflow_run_id). Resume the chat turn so the timeout is threaded + # back to the agent run as the ask_human deferred_tool_result + # (status="timeout"), mirroring HumanInputService.submit_form_by_token. + service.enqueue_agent_app_resume(conversation_id=record.conversation_id, form_id=record.form_id) + else: + logger.warning( + "Timed-out form %s has neither workflow_run_id nor conversation_id; skipping resume", + record.form_id, + ) except Exception: logger.exception( "Failed to handle timeout for form_id=%s workflow_run_id=%s", diff --git a/api/tasks/process_tenant_plugin_autoupgrade_check_task.py b/api/tasks/process_tenant_plugin_autoupgrade_check_task.py index ab54b9e72ed..0840a595b7d 100644 --- a/api/tasks/process_tenant_plugin_autoupgrade_check_task.py +++ b/api/tasks/process_tenant_plugin_autoupgrade_check_task.py @@ -7,7 +7,7 @@ import click from celery import shared_task from core.plugin.entities.marketplace import MarketplacePluginSnapshot -from core.plugin.entities.plugin import PluginInstallationSource +from core.plugin.entities.plugin import PluginInstallation, PluginInstallationSource from core.plugin.impl.plugin import PluginInstaller from core.plugin.plugin_service import PluginService from extensions.ext_redis import redis_client @@ -15,6 +15,7 @@ from models.account import TenantPluginAutoUpgradeStrategy logger = logging.getLogger(__name__) +PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory RETRY_TIMES_OF_ONE_PLUGIN_IN_ONE_TENANT = 3 CACHE_REDIS_KEY_PREFIX = "plugin_autoupgrade_check_task:cached_plugin_snapshot:" CACHE_REDIS_TTL = 60 * 60 # 1 hour @@ -72,6 +73,25 @@ def marketplace_batch_fetch_plugin_manifests( return result +def _normalize_category(category: PluginCategory | str | None) -> str | None: + if category is None: + return None + if isinstance(category, PluginCategory): + return category.value + return str(category) + + +def _plugin_matches_category(plugin: PluginInstallation, category: str | None) -> bool: + """Return whether an installed plugin should be checked by a category strategy.""" + if category is None: + return True + + declaration = getattr(plugin, "declaration", None) + plugin_category = getattr(declaration, "category", None) + plugin_category_value = getattr(plugin_category, "value", plugin_category) + return plugin_category_value == category + + @shared_task(queue="plugin") def process_tenant_plugin_autoupgrade_check_task( tenant_id: str, @@ -80,13 +100,15 @@ def process_tenant_plugin_autoupgrade_check_task( upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode, exclude_plugins: list[str], include_plugins: list[str], + category: PluginCategory | str | None = None, ): try: manager = PluginInstaller() + category_value = _normalize_category(category) click.echo( click.style( - f"Checking upgradable plugin for tenant: {tenant_id}", + f"Checking upgradable plugin for tenant: {tenant_id}, category: {category_value or 'all'}", fg="green", ) ) @@ -102,7 +124,11 @@ def process_tenant_plugin_autoupgrade_check_task( all_plugins = manager.list_plugins(tenant_id) for plugin in all_plugins: - if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id in include_plugins: + if ( + plugin.source == PluginInstallationSource.Marketplace + and plugin.plugin_id in include_plugins + and _plugin_matches_category(plugin, category_value) + ): plugin_ids.append( ( plugin.plugin_id, @@ -117,7 +143,9 @@ def process_tenant_plugin_autoupgrade_check_task( plugin_ids = [ (plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier) for plugin in all_plugins - if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id not in exclude_plugins + if plugin.source == PluginInstallationSource.Marketplace + and plugin.plugin_id not in exclude_plugins + and _plugin_matches_category(plugin, category_value) ] elif upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL: all_plugins = manager.list_plugins(tenant_id) @@ -125,6 +153,7 @@ def process_tenant_plugin_autoupgrade_check_task( (plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier) for plugin in all_plugins if plugin.source == PluginInstallationSource.Marketplace + and _plugin_matches_category(plugin, category_value) ] if not plugin_ids: diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index 5f1f0952af5..d0763a7a1ad 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -22,6 +22,7 @@ from models import ( AppDatasetJoin, AppMCPServer, AppModelConfig, + AppStar, AppTrigger, Conversation, EndUser, @@ -64,6 +65,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): _delete_app_mcp_servers(tenant_id, app_id) _delete_app_api_tokens(tenant_id, app_id) _delete_installed_apps(tenant_id, app_id) + _delete_app_stars(tenant_id, app_id) _delete_recommended_apps(tenant_id, app_id) _delete_app_annotation_data(tenant_id, app_id) _delete_app_dataset_joins(tenant_id, app_id) @@ -173,6 +175,18 @@ def _delete_installed_apps(tenant_id: str, app_id: str): ) +def _delete_app_stars(tenant_id: str, app_id: str): + def del_app_star(session, app_star_id: str): + session.execute(delete(AppStar).where(AppStar.id == app_star_id).execution_options(synchronize_session=False)) + + _delete_records( + """select id from app_stars where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_app_star, + "app star", + ) + + def _delete_recommended_apps(tenant_id: str, app_id: str): def del_recommended_app(session, recommended_app_id: str): session.execute( diff --git a/api/tasks/workflow_execution_tasks.py b/api/tasks/workflow_execution_tasks.py index 5ca04fd7c2e..d1eb24d860d 100644 --- a/api/tasks/workflow_execution_tasks.py +++ b/api/tasks/workflow_execution_tasks.py @@ -101,11 +101,13 @@ def _create_workflow_run_from_execution( workflow_run.triggered_from = triggered_from workflow_run.version = execution.workflow_version json_converter = WorkflowRuntimeTypeConverter() - workflow_run.graph = json.dumps(json_converter.to_json_encodable(execution.graph)) - workflow_run.inputs = json.dumps(json_converter.to_json_encodable(execution.inputs)) + workflow_run.graph = json.dumps(json_converter.to_json_encodable(execution.graph), ensure_ascii=False) + workflow_run.inputs = json.dumps(json_converter.to_json_encodable(execution.inputs), ensure_ascii=False) workflow_run.status = execution.status workflow_run.outputs = ( - json.dumps(json_converter.to_json_encodable(execution.outputs)) if execution.outputs else "{}" + json.dumps(json_converter.to_json_encodable(execution.outputs), ensure_ascii=False) + if execution.outputs + else "{}" ) workflow_run.error = execution.error_message workflow_run.elapsed_time = execution.elapsed_time diff --git a/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py b/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py index 0a19debc392..fc76129e3c7 100644 --- a/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py +++ b/api/tests/integration_tests/services/plugin/test_plugin_lifecycle.py @@ -7,6 +7,8 @@ from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermissi from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService from services.plugin.plugin_permission_service import PluginPermissionService +PLUGIN_CATEGORY = TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL + @pytest.fixture def tenant(flask_req_ctx): @@ -71,7 +73,7 @@ class TestPluginPermissionLifecycle: class TestPluginAutoUpgradeLifecycle: def test_get_returns_none_for_new_tenant(self, tenant): - assert PluginAutoUpgradeService.get_strategy(tenant) is None + assert PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) is None def test_change_creates_row(self, tenant): result = PluginAutoUpgradeService.change_strategy( @@ -81,10 +83,11 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, exclude_plugins=[], include_plugins=[], + category=PLUGIN_CATEGORY, ) assert result is True - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST assert strategy.upgrade_time_of_day == 3 @@ -97,6 +100,7 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, exclude_plugins=[], include_plugins=[], + category=PLUGIN_CATEGORY, ) PluginAutoUpgradeService.change_strategy( tenant, @@ -105,9 +109,10 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL, exclude_plugins=[], include_plugins=["plugin-a"], + category=PLUGIN_CATEGORY, ) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST assert strategy.upgrade_time_of_day == 12 @@ -115,9 +120,9 @@ class TestPluginAutoUpgradeLifecycle: assert strategy.include_plugins == ["plugin-a"] def test_exclude_plugin_creates_strategy_when_none_exists(self, tenant): - PluginAutoUpgradeService.exclude_plugin(tenant, "my-plugin") + PluginAutoUpgradeService.exclude_plugin(tenant, "my-plugin", PLUGIN_CATEGORY) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE assert "my-plugin" in strategy.exclude_plugins @@ -130,10 +135,11 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, exclude_plugins=["existing"], include_plugins=[], + category=PLUGIN_CATEGORY, ) - PluginAutoUpgradeService.exclude_plugin(tenant, "new-plugin") + PluginAutoUpgradeService.exclude_plugin(tenant, "new-plugin", PLUGIN_CATEGORY) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert "existing" in strategy.exclude_plugins assert "new-plugin" in strategy.exclude_plugins @@ -146,10 +152,11 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, exclude_plugins=["same-plugin"], include_plugins=[], + category=PLUGIN_CATEGORY, ) - PluginAutoUpgradeService.exclude_plugin(tenant, "same-plugin") + PluginAutoUpgradeService.exclude_plugin(tenant, "same-plugin", PLUGIN_CATEGORY) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert strategy.exclude_plugins.count("same-plugin") == 1 @@ -161,10 +168,11 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL, exclude_plugins=[], include_plugins=["p1", "p2"], + category=PLUGIN_CATEGORY, ) - PluginAutoUpgradeService.exclude_plugin(tenant, "p1") + PluginAutoUpgradeService.exclude_plugin(tenant, "p1", PLUGIN_CATEGORY) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert "p1" not in strategy.include_plugins assert "p2" in strategy.include_plugins @@ -177,10 +185,11 @@ class TestPluginAutoUpgradeLifecycle: upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, exclude_plugins=[], include_plugins=[], + category=PLUGIN_CATEGORY, ) - PluginAutoUpgradeService.exclude_plugin(tenant, "excluded-plugin") + PluginAutoUpgradeService.exclude_plugin(tenant, "excluded-plugin", PLUGIN_CATEGORY) - strategy = PluginAutoUpgradeService.get_strategy(tenant) + strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) assert strategy is not None assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE assert "excluded-plugin" in strategy.exclude_plugins diff --git a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py index e4a106694b1..0f7c790ba14 100644 --- a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py +++ b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py @@ -51,6 +51,7 @@ def _create_recommended_app( categories: list[str] | None = None, language: str = "en-US", is_listed: bool = True, + is_learn_dify: bool = False, position: int = 1, ) -> RecommendedApp: rec = RecommendedApp( @@ -62,6 +63,7 @@ def _create_recommended_app( categories=[category] if categories is None else categories, language=language, is_listed=is_listed, + is_learn_dify=is_learn_dify, position=position, ) rec.id = str(uuid4()) @@ -205,6 +207,65 @@ class TestFetchRecommendedAppsFromDb: app_ids = {r["app_id"] for r in result["recommended_apps"]} assert app1.id not in app_ids + def test_fetch_learn_dify_apps_uses_flag_not_categories( + self, + flask_app_with_containers, + db_session_with_containers: Session, + ): + tenant_id = str(uuid4()) + learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=learn_dify_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=learn_dify_app.id, + category="workflow", + categories=["Workflow"], + is_learn_dify=True, + ) + + category_only_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=category_only_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=category_only_app.id, + category="Learn Dify", + categories=["Learn Dify"], + is_learn_dify=False, + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db("en-US") + + app_ids = {r["app_id"] for r in result["recommended_apps"]} + assert learn_dify_app.id in app_ids + assert category_only_app.id not in app_ids + recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == learn_dify_app.id) + assert recommended_app["categories"] == ["Workflow"] + + def test_fetch_learn_dify_apps_falls_back_to_default_language( + self, + flask_app_with_containers, + db_session_with_containers: Session, + ): + tenant_id = str(uuid4()) + learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=learn_dify_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=learn_dify_app.id, + categories=["Workflow"], + is_learn_dify=True, + language="en-US", + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db("fr-FR") + + app_ids = {r["app_id"] for r in result["recommended_apps"]} + assert learn_dify_app.id in app_ids + class TestFetchRecommendedAppDetailFromDb: def test_returns_none_when_not_listed(self, flask_app_with_containers: Flask, db_session_with_containers: Session): diff --git a/api/tests/test_containers_integration_tests/services/test_app_service.py b/api/tests/test_containers_integration_tests/services/test_app_service.py index 56d643e4c1d..384f83fce3e 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_service.py @@ -1,6 +1,8 @@ +from datetime import datetime from unittest.mock import create_autospec, patch import pytest +import sqlalchemy as sa from faker import Faker from pydantic import ValidationError from sqlalchemy.orm import Session @@ -245,6 +247,236 @@ class TestAppService: assert app.tenant_id == tenant.id assert app.mode == "chat" + def test_get_paginate_apps_sorts_by_modified_and_created_times( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + """ + Test app list sort options for modified time and creation time. + """ + fake = Faker() + + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + tenant = account.current_tenant + + from services.app_service import AppListParams, AppService, CreateAppParams + + app_service = AppService() + oldest_created = app_service.create_app( + tenant.id, + CreateAppParams(name="Oldest Created", mode="chat", icon_type="emoji", icon="1"), + account, + ) + newest_modified = app_service.create_app( + tenant.id, + CreateAppParams(name="Newest Modified", mode="chat", icon_type="emoji", icon="2"), + account, + ) + newest_created = app_service.create_app( + tenant.id, + CreateAppParams(name="Newest Created", mode="chat", icon_type="emoji", icon="3"), + account, + ) + + timestamp_by_app_id = { + oldest_created.id: (datetime(2026, 1, 1, 10, 0, 0), datetime(2026, 1, 1, 10, 0, 0)), + newest_modified.id: (datetime(2026, 1, 2, 10, 0, 0), datetime(2026, 1, 4, 10, 0, 0)), + newest_created.id: (datetime(2026, 1, 3, 10, 0, 0), datetime(2026, 1, 3, 10, 0, 0)), + } + for app_id, (created_at, updated_at) in timestamp_by_app_id.items(): + db_session_with_containers.execute( + sa.update(App).where(App.id == app_id).values(created_at=created_at, updated_at=updated_at) + ) + db_session_with_containers.commit() + + last_modified_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat") + ) + assert last_modified_apps is not None + assert [app.name for app in last_modified_apps.items] == [ + "Newest Modified", + "Newest Created", + "Oldest Created", + ] + + recently_created_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", sort_by="recently_created") + ) + assert recently_created_apps is not None + assert [app.name for app in recently_created_apps.items] == [ + "Newest Created", + "Newest Modified", + "Oldest Created", + ] + + earliest_created_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", sort_by="earliest_created") + ) + assert earliest_created_apps is not None + assert [app.name for app in earliest_created_apps.items] == [ + "Oldest Created", + "Newest Modified", + "Newest Created", + ] + + def test_get_paginate_apps_marks_starred_apps( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + """ + Test app list marks apps starred by the current account. + """ + fake = Faker() + + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + tenant = account.current_tenant + + from models import AppStar + from services.app_service import AppListParams, AppService, CreateAppParams + + app_service = AppService() + starred_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Starred App", mode="chat", icon_type="emoji", icon="1"), + account, + ) + unstarred_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Unstarred App", mode="chat", icon_type="emoji", icon="2"), + account, + ) + + app_service.star_app(db_session_with_containers, app=starred_app, account_id=account.id) + app_service.star_app(db_session_with_containers, app=starred_app, account_id=account.id) + db_session_with_containers.commit() + + star_count = db_session_with_containers.scalar( + sa.select(sa.func.count()).select_from(AppStar).where(AppStar.app_id == starred_app.id) + ) + assert star_count == 1 + + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat") + ) + assert paginated_apps is not None + starred_by_app_id = {app.id: app.is_starred for app in paginated_apps.items} + assert starred_by_app_id[starred_app.id] is True + assert starred_by_app_id[unstarred_app.id] is False + + app_service.unstar_app(db_session_with_containers, app=starred_app, account_id=account.id) + db_session_with_containers.commit() + + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat") + ) + assert paginated_apps is not None + starred_by_app_id = {app.id: app.is_starred for app in paginated_apps.items} + assert starred_by_app_id[starred_app.id] is False + assert starred_by_app_id[unstarred_app.id] is False + + def test_get_paginate_starred_apps_returns_only_starred_apps_with_requested_sort( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + """ + Test starred app list returns only starred apps ordered by requested app sort. + """ + fake = Faker() + + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + tenant = account.current_tenant + + from services.app_service import AppService, CreateAppParams, StarredAppListParams + + app_service = AppService() + oldest_created_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Oldest Created Starred App", mode="chat", icon_type="emoji", icon="1"), + account, + ) + newest_modified_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Newest Modified Starred App", mode="chat", icon_type="emoji", icon="2"), + account, + ) + newest_created_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Newest Created Starred App", mode="chat", icon_type="emoji", icon="3"), + account, + ) + unstarred_app = app_service.create_app( + tenant.id, + CreateAppParams(name="Unstarred App", mode="chat", icon_type="emoji", icon="4"), + account, + ) + + app_service.star_app(db_session_with_containers, app=oldest_created_app, account_id=account.id) + app_service.star_app(db_session_with_containers, app=newest_modified_app, account_id=account.id) + app_service.star_app(db_session_with_containers, app=newest_created_app, account_id=account.id) + + timestamp_by_app_id = { + oldest_created_app.id: (datetime(2026, 1, 1, 10, 0, 0), datetime(2026, 1, 1, 10, 0, 0)), + newest_modified_app.id: (datetime(2026, 1, 2, 10, 0, 0), datetime(2026, 1, 4, 10, 0, 0)), + newest_created_app.id: (datetime(2026, 1, 3, 10, 0, 0), datetime(2026, 1, 3, 10, 0, 0)), + unstarred_app.id: (datetime(2026, 1, 5, 10, 0, 0), datetime(2026, 1, 5, 10, 0, 0)), + } + for app_id, (created_at, updated_at) in timestamp_by_app_id.items(): + db_session_with_containers.execute( + sa.update(App).where(App.id == app_id).values(created_at=created_at, updated_at=updated_at) + ) + db_session_with_containers.commit() + + last_modified_apps = app_service.get_paginate_starred_apps( + account.id, tenant.id, StarredAppListParams(page=1, limit=10, mode="chat") + ) + assert last_modified_apps is not None + assert [app.name for app in last_modified_apps.items] == [ + "Newest Modified Starred App", + "Newest Created Starred App", + "Oldest Created Starred App", + ] + assert all(app.is_starred for app in last_modified_apps.items) + assert unstarred_app.id not in {app.id for app in last_modified_apps.items} + + recently_created_apps = app_service.get_paginate_starred_apps( + account.id, + tenant.id, + StarredAppListParams(page=1, limit=10, mode="chat", sort_by="recently_created"), + ) + assert recently_created_apps is not None + assert [app.name for app in recently_created_apps.items] == [ + "Newest Created Starred App", + "Newest Modified Starred App", + "Oldest Created Starred App", + ] + + earliest_created_apps = app_service.get_paginate_starred_apps( + account.id, + tenant.id, + StarredAppListParams(page=1, limit=10, mode="chat", sort_by="earliest_created"), + ) + assert earliest_created_apps is not None + assert [app.name for app in earliest_created_apps.items] == [ + "Oldest Created Starred App", + "Newest Modified Starred App", + "Newest Created Starred App", + ] + def test_get_paginate_apps_with_filters( self, db_session_with_containers: Session, mock_external_service_dependencies ): diff --git a/api/tests/test_containers_integration_tests/services/test_end_user_service.py b/api/tests/test_containers_integration_tests/services/test_end_user_service.py index 3f611d92f72..998b3378e2c 100644 --- a/api/tests/test_containers_integration_tests/services/test_end_user_service.py +++ b/api/tests/test_containers_integration_tests/services/test_end_user_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from unittest.mock import patch from uuid import uuid4 @@ -104,7 +105,9 @@ class TestEndUserServiceGetOrCreateEndUser: """Provide test data factory.""" return TestEndUserServiceFactory() - def test_get_or_create_end_user_with_custom_user_id(self, db_session_with_containers: Session, factory): + def test_get_or_create_end_user_with_custom_user_id( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test getting or creating end user with custom user_id.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -120,7 +123,9 @@ class TestEndUserServiceGetOrCreateEndUser: assert result.type == InvokeFrom.SERVICE_API assert result.is_anonymous is False - def test_get_or_create_end_user_without_user_id(self, db_session_with_containers: Session, factory): + def test_get_or_create_end_user_without_user_id( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test getting or creating end user without user_id uses default session.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -133,7 +138,7 @@ class TestEndUserServiceGetOrCreateEndUser: # Verify _is_anonymous is set correctly (property always returns False) assert result._is_anonymous is True - def test_get_existing_end_user(self, db_session_with_containers: Session, factory): + def test_get_existing_end_user(self, db_session_with_containers: Session, factory: TestEndUserServiceFactory): """Test retrieving an existing end user.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -169,7 +174,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: """Provide test data factory.""" return TestEndUserServiceFactory() - def test_create_end_user_service_api_type(self, db_session_with_containers: Session, factory): + def test_create_end_user_service_api_type( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test creating new end user with SERVICE_API type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -191,7 +198,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result.app_id == app_id assert result.session_id == user_id - def test_create_end_user_web_app_type(self, db_session_with_containers: Session, factory): + def test_create_end_user_web_app_type( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test creating new end user with WEB_APP type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -210,8 +219,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Assert assert result.type == InvokeFrom.WEB_APP - @patch("services.end_user_service.logger") - def test_upgrade_legacy_end_user_type(self, mock_logger, db_session_with_containers: Session, factory): + def test_upgrade_legacy_end_user_type( + self, caplog: pytest.LogCaptureFixture, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test upgrading legacy end user with different type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -227,25 +237,32 @@ class TestEndUserServiceGetOrCreateEndUserByType: session_id=user_id, invoke_type=InvokeFrom.SERVICE_API, ) - - # Act - Request with different type - result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.WEB_APP, - tenant_id=tenant_id, - app_id=app_id, - user_id=user_id, - ) + with caplog.at_level(logging.INFO, logger="services.end_user_service"): + # Act - Request with different type + result = EndUserService.get_or_create_end_user_by_type( + type=InvokeFrom.WEB_APP, + tenant_id=tenant_id, + app_id=app_id, + user_id=user_id, + ) # Assert assert result.id == existing_user.id assert result.type == InvokeFrom.WEB_APP # Type should be updated - mock_logger.info.assert_called_once() - # Verify log message contains upgrade info - log_call = mock_logger.info.call_args[0][0] - assert "Upgrading legacy EndUser" in log_call + matching_logs = [ + record + for record in caplog.records + if record.name == "services.end_user_service" + and record.levelno == logging.INFO + and "Upgrading legacy EndUser" in record.message + ] + + assert len(matching_logs) == 1 @patch("services.end_user_service.logger") - def test_get_existing_end_user_matching_type(self, mock_logger, db_session_with_containers: Session, factory): + def test_get_existing_end_user_matching_type( + self, mock_logger, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test retrieving existing end user with matching type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -274,7 +291,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result.type == InvokeFrom.SERVICE_API mock_logger.info.assert_not_called() - def test_create_anonymous_user_with_default_session(self, db_session_with_containers: Session, factory): + def test_create_anonymous_user_with_default_session( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test creating anonymous user when user_id is None.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -295,7 +314,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result._is_anonymous is True assert result.external_user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID - def test_query_ordering_prioritizes_matching_type(self, db_session_with_containers: Session, factory): + def test_query_ordering_prioritizes_matching_type( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test that query ordering prioritizes records with matching type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -330,7 +351,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result.id == matching.id assert result.id != non_matching.id - def test_external_user_id_matches_session_id(self, db_session_with_containers: Session, factory): + def test_external_user_id_matches_session_id( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): """Test that external_user_id is set to match session_id.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -360,7 +383,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: ], ) def test_create_end_user_with_different_invoke_types( - self, db_session_with_containers: Session, invoke_type, factory + self, db_session_with_containers: Session, invoke_type: InvokeFrom, factory: TestEndUserServiceFactory ): """Test creating end users with different InvokeFrom types.""" # Arrange @@ -389,7 +412,9 @@ class TestEndUserServiceGetEndUserById: """Provide test data factory.""" return TestEndUserServiceFactory() - def test_get_end_user_by_id_returns_end_user(self, db_session_with_containers: Session, factory): + def test_get_end_user_by_id_returns_end_user( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): app = factory.create_app_and_account(db_session_with_containers) existing_user = factory.create_end_user( db_session_with_containers, @@ -408,7 +433,9 @@ class TestEndUserServiceGetEndUserById: assert result is not None assert result.id == existing_user.id - def test_get_end_user_by_id_returns_none(self, db_session_with_containers: Session, factory): + def test_get_end_user_by_id_returns_none( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): app = factory.create_app_and_account(db_session_with_containers) result = EndUserService.get_end_user_by_id( @@ -427,7 +454,9 @@ class TestEndUserServiceCreateBatch: def factory(self): return TestEndUserServiceFactory() - def _create_multiple_apps(self, db_session_with_containers: Session, factory, count: int = 3): + def _create_multiple_apps( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory, count: int = 3 + ): """Create multiple apps under the same tenant.""" first_app = factory.create_app_and_account(db_session_with_containers) tenant_id = first_app.tenant_id @@ -462,7 +491,9 @@ class TestEndUserServiceCreateBatch: ) assert result == {} - def test_create_batch_creates_users_for_all_apps(self, db_session_with_containers: Session, factory): + def test_create_batch_creates_users_for_all_apps( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=3) app_ids = [a.id for a in apps] user_id = f"user-{uuid4()}" @@ -477,7 +508,9 @@ class TestEndUserServiceCreateBatch: assert result[app_id].session_id == user_id assert result[app_id].type == InvokeFrom.SERVICE_API - def test_create_batch_default_session_id(self, db_session_with_containers: Session, factory): + def test_create_batch_default_session_id( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2) app_ids = [a.id for a in apps] @@ -490,7 +523,9 @@ class TestEndUserServiceCreateBatch: assert end_user.session_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID assert end_user._is_anonymous is True - def test_create_batch_deduplicate_app_ids(self, db_session_with_containers: Session, factory): + def test_create_batch_deduplicate_app_ids( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2) app_ids = [apps[0].id, apps[1].id, apps[0].id, apps[1].id] user_id = f"user-{uuid4()}" @@ -501,7 +536,9 @@ class TestEndUserServiceCreateBatch: assert len(result) == 2 - def test_create_batch_returns_existing_users(self, db_session_with_containers: Session, factory): + def test_create_batch_returns_existing_users( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2) app_ids = [a.id for a in apps] user_id = f"user-{uuid4()}" @@ -520,7 +557,9 @@ class TestEndUserServiceCreateBatch: for app_id in app_ids: assert first_result[app_id].id == second_result[app_id].id - def test_create_batch_partial_existing_users(self, db_session_with_containers: Session, factory): + def test_create_batch_partial_existing_users( + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=3) user_id = f"user-{uuid4()}" @@ -549,7 +588,9 @@ class TestEndUserServiceCreateBatch: "invoke_type", [InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP, InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER], ) - def test_create_batch_all_invoke_types(self, db_session_with_containers: Session, invoke_type, factory): + def test_create_batch_all_invoke_types( + self, db_session_with_containers: Session, invoke_type: InvokeFrom, factory: TestEndUserServiceFactory + ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=1) user_id = f"user-{uuid4()}" diff --git a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py index c4c4f0ac1f3..750e35843be 100644 --- a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py @@ -263,6 +263,54 @@ class TestRecommendedAppServiceGetDetail: mock_factory_class.get_recommend_app_factory.assert_called_with(mode) +# ── Pure logic tests: get_learn_dify_apps ────────────────────────────── + + +class TestRecommendedAppServiceGetLearnDifyApps: + def test_returns_database_learn_dify_apps_without_remote_factory(self, monkeypatch: pytest.MonkeyPatch) -> None: + expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow") + mock_database_retrieval = MagicMock() + mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = { + "recommended_apps": [expected_app], + "categories": ["Workflow"], + } + monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval) + monkeypatch.setattr( + service_module.FeatureService, + "get_system_features", + MagicMock(return_value=SimpleNamespace(enable_trial_app=False)), + ) + factory_mock = MagicMock() + monkeypatch.setattr(service_module.RecommendAppRetrievalFactory, "get_recommend_app_factory", factory_mock) + + result = RecommendedAppService.get_learn_dify_apps(db.session, "en-US") + + assert result == {"recommended_apps": [expected_app]} + mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US") + factory_mock.assert_not_called() + + def test_sets_can_trial_when_trial_feature_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + app = RecommendedAppPayload(app_id="app-1", category="Workflow") + mock_database_retrieval = MagicMock() + mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = { + "recommended_apps": [app], + "categories": ["Workflow"], + } + monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval) + monkeypatch.setattr( + service_module.FeatureService, + "get_system_features", + MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), + ) + can_trial_mock = MagicMock(return_value=True) + monkeypatch.setattr(RecommendedAppService, "_can_trial_app", can_trial_mock) + + result = RecommendedAppService.get_learn_dify_apps(db.session, "en-US") + + assert result["recommended_apps"][0]["can_trial"] is True + can_trial_mock.assert_called_once_with(db.session, "app-1") + + # ── Integration tests: trial app features (real DB) ──────────────────── diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index 5ab1912d4c3..0fa4d3261bc 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -327,3 +327,49 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell(): assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config) assert shell_config.env[0].name == "APP_ENV" + + +# ── ENG-635 / ENG-638: ask_human layer injection + deferred_tool_results ───── + + +def test_ask_human_layer_injected_when_configured(): + + from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig + + from clients.agent_backend.request_builder import DIFY_ASK_HUMAN_LAYER_ID + + run_input = _run_input().model_copy(update={"ask_human_config": DifyAskHumanLayerConfig()}) + request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input) + + layers = {layer.name: layer for layer in request.composition.layers} + assert DIFY_ASK_HUMAN_LAYER_ID in layers + assert layers[DIFY_ASK_HUMAN_LAYER_ID].type == DIFY_ASK_HUMAN_LAYER_TYPE_ID + # the deferred tool needs the history layer to resume, so history must precede it + names = [layer.name for layer in request.composition.layers] + assert names.index(DIFY_AGENT_HISTORY_LAYER_ID) < names.index(DIFY_ASK_HUMAN_LAYER_ID) + + +def test_no_ask_human_layer_when_unconfigured(): + from clients.agent_backend.request_builder import DIFY_ASK_HUMAN_LAYER_ID + + request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input()) + assert all(layer.name != DIFY_ASK_HUMAN_LAYER_ID for layer in request.composition.layers) + + +def test_deferred_tool_results_threaded_into_request(): + from dify_agent.protocol import DeferredToolResultsPayload + + payload = DeferredToolResultsPayload( + calls={ + "tool-call-1": { + "status": "submitted", + "action": {"id": "submit", "label": "Submit"}, + "values": {"x": "y"}, + } + } + ) + run_input = _run_input().model_copy(update={"deferred_tool_results": payload}) + request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input) + + assert request.deferred_tool_results is not None + assert "tool-call-1" in request.deferred_tool_results.calls diff --git a/api/tests/unit_tests/configs/test_nacos_http_client.py b/api/tests/unit_tests/configs/test_nacos_http_client.py new file mode 100644 index 00000000000..855a1a8acc1 --- /dev/null +++ b/api/tests/unit_tests/configs/test_nacos_http_client.py @@ -0,0 +1,51 @@ +from unittest.mock import MagicMock, patch + +import httpx + +from configs.remote_settings_sources.nacos.http_request import NacosHttpClient + + +def _ok_response(text: str = "ok", json_data: dict | None = None) -> MagicMock: + response = MagicMock() + response.text = text + response.raise_for_status.return_value = None + if json_data is not None: + response.json.return_value = json_data + return response + + +def test_http_request_passes_bounded_timeout(): + client = NacosHttpClient() + with patch("configs.remote_settings_sources.nacos.http_request.httpx.request") as mock_request: + mock_request.return_value = _ok_response() + client.http_request("/nacos/v1/cs/configs") + + timeout = mock_request.call_args.kwargs["timeout"] + assert isinstance(timeout, httpx.Timeout) + assert timeout.read is not None + assert timeout.connect is not None + + +def test_http_request_returns_graceful_message_on_timeout(): + client = NacosHttpClient() + with patch( + "configs.remote_settings_sources.nacos.http_request.httpx.request", + side_effect=httpx.ConnectTimeout("connection timed out"), + ): + result = client.http_request("/nacos/v1/cs/configs") + + assert "Nacos" in result + assert "timed out" in result.lower() + + +def test_get_access_token_passes_bounded_timeout(): + client = NacosHttpClient() + client.username = "user" + client.password = "pass" + with patch("configs.remote_settings_sources.nacos.http_request.httpx.request") as mock_request: + mock_request.return_value = _ok_response(json_data={"accessToken": "tok", "tokenTtl": 100}) + token = client.get_access_token(force_refresh=True) + + assert token == "tok" + timeout = mock_request.call_args.kwargs["timeout"] + assert isinstance(timeout, httpx.Timeout) diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 0007bddfa5b..91b644b1c75 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -1,16 +1,18 @@ from inspect import unwrap from types import SimpleNamespace -from typing import cast +from typing import Any, cast import pytest from flask import Flask +from werkzeug.exceptions import InternalServerError, NotFound +from controllers.console import console_ns from controllers.console.agent import composer as composer_controller from controllers.console.agent import roster as roster_controller from controllers.console.agent.composer import ( - AgentAppComposerApi, - AgentAppComposerCandidatesApi, - AgentAppComposerValidateApi, + AgentComposerApi, + AgentComposerCandidatesApi, + AgentComposerValidateApi, WorkflowAgentComposerApi, WorkflowAgentComposerCandidatesApi, WorkflowAgentComposerImpactApi, @@ -18,42 +20,24 @@ from controllers.console.agent.composer import ( WorkflowAgentComposerValidateApi, ) from controllers.console.agent.roster import ( + AgentAppApi, + AgentAppListApi, AgentInviteOptionsApi, - AgentRosterDetailApi, - AgentRosterListApi, AgentRosterVersionDetailApi, AgentRosterVersionsApi, ) -from models.model import AppMode +from controllers.console.app import completion as completion_controller +from controllers.console.app import message as message_controller +from controllers.console.app.completion import AgentChatMessageApi, AgentChatMessageStopApi +from controllers.console.app.message import ( + AgentChatMessageListApi, + AgentMessageApi, + AgentMessageFeedbackApi, + AgentMessageSuggestedQuestionApi, +) from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant -def _agent_response(agent_id: str = "agent-1") -> dict: - return { - "id": agent_id, - "name": "Analyst", - "description": "", - "icon_type": None, - "icon": None, - "icon_background": None, - "agent_kind": "dify_agent", - "scope": "roster", - "source": "agent_app", - "app_id": None, - "workflow_id": None, - "workflow_node_id": None, - "active_config_snapshot_id": "version-1", - "active_config_snapshot": _version_response(), - "status": "active", - "created_by": "account-1", - "updated_by": "account-1", - "archived_by": None, - "archived_at": None, - "created_at": None, - "updated_at": None, - } - - def _version_response(version_id: str = "version-1") -> dict: return { "id": version_id, @@ -103,6 +87,38 @@ def _agent_app_composer_response() -> dict: } +def _app_detail_obj(**overrides): + data = { + "id": "app-1", + "name": "Iris", + "description": "Agent app", + "mode_compatible_with_agent": "agent", + "icon_type": "emoji", + "icon": "robot", + "icon_background": "#fff", + "enable_site": False, + "enable_api": False, + "app_model_config": None, + "workflow": None, + "tracing": None, + "use_icon_as_answer_icon": False, + "created_by": "account-1", + "created_at": None, + "updated_by": "account-1", + "updated_at": None, + "access_mode": None, + "tags": [], + "api_base_url": None, + "max_active_requests": 0, + "deleted_tools": [], + "site": None, + "bound_agent_id": "00000000-0000-0000-0000-000000000001", + "tenant_id": "tenant-1", + } + data.update(overrides) + return SimpleNamespace(**data) + + def _candidates_response(variant: str) -> dict: return { "variant": variant, @@ -112,20 +128,43 @@ def _candidates_response(variant: str) -> dict: } -def _get_app_model_modes(view) -> list[AppMode]: - current = view - while current is not None: - closure = getattr(current, "__closure__", None) - if closure is not None: - for cell in closure: - try: - value = cell.cell_contents - except ValueError: - continue - if isinstance(value, list) and all(isinstance(item, AppMode) for item in value): - return value - current = getattr(current, "__wrapped__", None) - return [] +def test_agent_v2_console_routes_are_agent_id_first() -> None: + paths = {route for item in console_ns.resources for route in item.urls} + + for route in ( + "/agent", + "/agent/", + "/agent//composer", + "/agent//composer/validate", + "/agent//composer/candidates", + "/agent//features", + "/agent//referencing-workflows", + "/agent//drive/files", + "/agent//sandbox/files", + "/agent//skills/upload", + "/agent//files", + "/agent//chat-messages", + "/agent//chat-messages//stop", + "/agent//feedbacks", + "/agent//chat-messages//suggested-questions", + "/agent//messages/", + "/agent/invite-options", + ): + assert route in paths + + for route in ( + "/agents", + "/agents/invite-options", + "/agents/", + "/agents//versions", + "/apps//agent-composer", + "/apps//agent-composer/validate", + "/apps//agent-composer/candidates", + "/apps//agent-features", + "/apps//agent-referencing-workflows", + "/apps//agent-sandbox/files", + ): + assert route not in paths @pytest.fixture @@ -133,26 +172,145 @@ def account_id() -> str: return "account-1" -def test_roster_list_get_parses_query_and_calls_service(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +def test_agent_app_list_and_create_use_agent_route( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: captured: dict[str, object] = {} - def list_roster_agents(_self: object, **kwargs: object) -> dict[str, object]: - captured.update(kwargs) - return {"data": [], "page": kwargs["page"], "limit": kwargs["limit"], "total": 0, "has_more": False} + class FakeAppService: + def get_app(self, app_obj: object) -> object: + return app_obj - monkeypatch.setattr(roster_controller.AgentRosterService, "list_roster_agents", list_roster_agents) + def get_paginate_apps(self, user_id: str, tenant_id: str, params) -> object: + captured["list"] = {"user_id": user_id, "tenant_id": tenant_id, "params": params} + return SimpleNamespace( + page=1, + per_page=10, + total=1, + has_next=False, + items=[_app_detail_obj(id="app-list", bound_agent_id="agent-list")], + ) - with app.test_request_context("/console/api/agents?page=2&limit=5&keyword=analyst"): - result = unwrap(AgentRosterListApi.get)(AgentRosterListApi(), "tenant-1") + def create_app(self, tenant_id: str, params, current_user: object) -> object: + captured["create"] = {"tenant_id": tenant_id, "params": params, "current_user": current_user} + return _app_detail_obj(id="app-created", bound_agent_id="agent-created") - assert result["page"] == 2 - assert captured == {"tenant_id": "tenant-1", "page": 2, "limit": 5, "keyword": "analyst"} + monkeypatch.setattr(roster_controller, "AppService", FakeAppService) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "load_app_backing_agents_by_app_id", + lambda _self, **kwargs: { + "app-list": SimpleNamespace(id="agent-list", role="List role", active_config_snapshot_id=None) + }, + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_app_backing_agent", + lambda _self, **kwargs: SimpleNamespace( + id="agent-created", role="Created role", active_config_snapshot_id=None + ), + ) + monkeypatch.setattr( + roster_controller.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + with app.test_request_context("/console/api/agent?page=1&limit=10&mode=workflow"): + listed = unwrap(AgentAppListApi.get)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) + + assert listed["page"] == 1 + assert listed["limit"] == 10 + assert listed["total"] == 1 + assert listed["data"][0]["id"] == "agent-list" + assert listed["data"][0]["app_id"] == "app-list" + assert listed["data"][0]["role"] == "List role" + assert listed["data"][0]["active_config_is_published"] is False + assert "bound_agent_id" not in listed["data"][0] + list_call = cast(dict[str, object], captured["list"]) + list_params = cast(Any, list_call["params"]) + assert list_params.mode == "agent" + assert list_params.status == "normal" + + with app.test_request_context( + "/console/api/agent", + json={"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"}, + ): + created, status = unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) + + assert status == 201 + assert created["id"] == "agent-created" + assert created["app_id"] == "app-created" + assert created["role"] == "Created role" + assert created["active_config_is_published"] is False + assert "bound_agent_id" not in created + create_call = cast(dict[str, object], captured["create"]) + create_params = cast(Any, create_call["params"]) + assert create_params.mode == "agent" -def test_roster_direct_mutation_endpoints_are_not_exposed() -> None: - assert not hasattr(AgentRosterListApi, "post") - assert not hasattr(AgentRosterDetailApi, "patch") - assert not hasattr(AgentRosterDetailApi, "delete") +def test_agent_app_detail_update_delete_resolve_app_from_agent_id( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id) + captured: dict[str, object] = {} + + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_agent_app_model", + lambda _self, **kwargs: app_model, + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_app_backing_agent", + lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="Resolved role", active_config_snapshot_id=None), + ) + monkeypatch.setattr( + roster_controller.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + class FakeAppService: + def get_app(self, app_obj: object) -> object: + captured["get_app"] = app_obj + return app_obj + + def update_app(self, app_obj: object, args: dict[str, object]) -> object: + captured["update"] = {"app": app_obj, "args": args} + return _app_detail_obj(id="app-1", name=args["name"], bound_agent_id=agent_id) + + def delete_app(self, app_obj: object) -> None: + captured["delete"] = app_obj + + monkeypatch.setattr(roster_controller, "AppService", FakeAppService) + + detail = unwrap(AgentAppApi.get)(AgentAppApi(), "tenant-1", agent_id) + assert detail["id"] == agent_id + assert detail["app_id"] == "app-1" + assert detail["role"] == "Resolved role" + assert detail["active_config_is_published"] is False + assert "bound_agent_id" not in detail + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001", + json={"name": "Renamed", "description": "", "icon_type": "emoji", "icon": "R"}, + ): + updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id) + + assert updated["name"] == "Renamed" + assert updated["id"] == agent_id + assert updated["app_id"] == "app-1" + assert updated["role"] == "Resolved role" + assert updated["active_config_is_published"] is False + assert "bound_agent_id" not in updated + update_call = cast(dict[str, object], captured["update"]) + assert update_call["app"] is app_model + + deleted, status = unwrap(AgentAppApi.delete)(AgentAppApi(), "tenant-1", agent_id) + assert (deleted, status) == ("", 204) + assert captured["delete"] is app_model def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -164,21 +322,16 @@ def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.Monkey monkeypatch.setattr(roster_controller.AgentRosterService, "list_invite_options", list_invite_options) - with app.test_request_context("/console/api/agents/invite-options?page=1&limit=10&app_id=app-1"): + with app.test_request_context("/console/api/agent/invite-options?page=1&limit=10&app_id=app-1"): result = unwrap(AgentInviteOptionsApi.get)(AgentInviteOptionsApi(), "tenant-1") assert result == {"data": [], "page": 1, "limit": 10, "total": 0, "has_more": False} assert captured == {"tenant_id": "tenant-1", "page": 1, "limit": 10, "keyword": None, "app_id": "app-1"} -def test_roster_detail_and_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: agent_id = "00000000-0000-0000-0000-000000000001" version_id = "00000000-0000-0000-0000-000000000002" - monkeypatch.setattr( - roster_controller.AgentRosterService, - "get_roster_agent_detail", - lambda _self, **kwargs: _agent_response(cast(str, kwargs["agent_id"])), - ) monkeypatch.setattr( roster_controller.AgentRosterService, "list_agent_versions", @@ -207,7 +360,6 @@ def test_roster_detail_and_versions_call_services(app: Flask, monkeypatch: pytes }, ) - assert unwrap(AgentRosterDetailApi.get)(AgentRosterDetailApi(), "tenant-1", agent_id)["id"] == agent_id assert ( unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), "tenant-1", agent_id)["data"][0]["id"] == "version-1" @@ -294,59 +446,342 @@ def test_workflow_impact_returns_empty_without_version(app: Flask) -> None: assert result == {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []} -def test_agent_app_composer_get_put_validate_and_candidates( +def test_agent_composer_routes_resolve_app_from_agent_id( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: - app_model = SimpleNamespace(id="app-1") + agent_id = "00000000-0000-0000-0000-000000000001" + captured: dict[str, object] = {} payload = { "variant": ComposerVariant.AGENT_APP.value, "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, "agent_soul": {"prompt": {"system_prompt": "x"}}, } + + monkeypatch.setattr(composer_controller, "resolve_agent_app_model", lambda **kwargs: SimpleNamespace(id="app-1")) + + def load_agent_app_composer(**kwargs: object) -> dict: + captured["load"] = kwargs + return _agent_app_composer_response() + + def save_agent_app_composer(**kwargs: object) -> dict: + captured["save"] = kwargs + return _agent_app_composer_response() + + def collect_validation_findings(**kwargs: object) -> dict: + captured["validate"] = kwargs + return {"warnings": [], "knowledge_retrieval_placeholder": []} + + def get_agent_app_candidates(**kwargs: object) -> dict: + captured["candidates"] = kwargs + return _candidates_response("agent_app") + monkeypatch.setattr( composer_controller.AgentComposerService, "load_agent_app_composer", - lambda **kwargs: _agent_app_composer_response(), + load_agent_app_composer, ) monkeypatch.setattr( composer_controller.AgentComposerService, "save_agent_app_composer", - lambda **kwargs: _agent_app_composer_response(), + save_agent_app_composer, ) monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None) monkeypatch.setattr( - composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None + composer_controller.AgentComposerService, + "collect_validation_findings", + collect_validation_findings, ) - monkeypatch.setattr(composer_controller.AgentComposerService, "resolve_bound_agent_id", lambda **kwargs: None) monkeypatch.setattr( composer_controller.AgentComposerService, "get_agent_app_candidates", - lambda **kwargs: _candidates_response("agent_app"), + get_agent_app_candidates, ) - assert unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), "tenant-1", app_model)["variant"] == "agent_app" + assert unwrap(AgentComposerApi.get)(AgentComposerApi(), "tenant-1", agent_id)["variant"] == "agent_app" + assert cast(dict[str, object], captured["load"])["app_id"] == "app-1" + with app.test_request_context(json=payload): assert ( - unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), "tenant-1", account_id, app_model)["variant"] - == "agent_app" + unwrap(AgentComposerApi.put)(AgentComposerApi(), "tenant-1", account_id, agent_id)["variant"] == "agent_app" ) - assert unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), "tenant-1", app_model) == { + assert cast(dict[str, object], captured["save"])["app_id"] == "app-1" + assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), "tenant-1", agent_id) == { "result": "success", "errors": [], "warnings": [], "knowledge_retrieval_placeholder": [], } - agent_app_candidates = unwrap(AgentAppComposerCandidatesApi.get)( - AgentAppComposerCandidatesApi(), "tenant-1", account_id, app_model + assert cast(dict[str, object], captured["validate"])["agent_id"] == agent_id + + candidates = unwrap(AgentComposerCandidatesApi.get)(AgentComposerCandidatesApi(), "tenant-1", account_id, agent_id) + assert candidates["variant"] == "agent_app" + assert cast(dict[str, object], captured["candidates"])["app_id"] == "app-1" + + +def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = SimpleNamespace(id="app-1", mode="agent") + captured: dict[str, object] = {} + + def resolve_agent_app_model(**kwargs: object) -> object: + captured["resolve"] = kwargs + return app_model + + def create_chat_message(**kwargs: object) -> dict[str, object]: + captured["create"] = kwargs + return {"result": "generated"} + + def stop_chat_message(**kwargs: object) -> tuple[dict[str, object], int]: + captured["stop"] = kwargs + return {"result": "success"}, 200 + + monkeypatch.setattr(completion_controller, "resolve_agent_app_model", resolve_agent_app_model) + monkeypatch.setattr(completion_controller, "_create_chat_message", create_chat_message) + monkeypatch.setattr(completion_controller, "_stop_chat_message", stop_chat_message) + + with app.test_request_context(json={"inputs": {}, "query": "hello"}): + assert unwrap(AgentChatMessageApi.post)( + AgentChatMessageApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id + ) == {"result": "generated"} + + assert cast(dict[str, object], captured["resolve"]) == {"tenant_id": "tenant-1", "agent_id": agent_id} + create_call = cast(dict[str, object], captured["create"]) + assert create_call["app_model"] is app_model + assert cast(SimpleNamespace, create_call["current_user"]).id == account_id + + assert unwrap(AgentChatMessageStopApi.post)( + AgentChatMessageStopApi(), "tenant-1", account_id, agent_id, "task-1" + ) == ({"result": "success"}, 200) + stop_call = cast(dict[str, object], captured["stop"]) + assert stop_call == {"current_user_id": account_id, "app_model": app_model, "task_id": "task-1"} + + +def test_agent_chat_helper_forces_agent_streaming_and_external_trace( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + app_model = SimpleNamespace(id="app-1", mode="agent") + current_user = SimpleNamespace(id=account_id) + captured: dict[str, object] = {} + + def generate(**kwargs: object) -> dict[str, object]: + captured.update(kwargs) + return {"answer": "ok"} + + monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate) + monkeypatch.setattr( + completion_controller.helper, + "compact_generate_response", + lambda response: {"response": response}, ) - assert agent_app_candidates["variant"] == "agent_app" + + with app.test_request_context( + json={"inputs": {}, "query": "hello", "response_mode": "streaming"}, + headers={"X-Trace-Id": "trace-1"}, + ): + result = completion_controller._create_chat_message(current_user=current_user, app_model=app_model) + + assert result == {"response": {"answer": "ok"}} + assert captured["app_model"] is app_model + assert captured["user"] is current_user + assert captured["streaming"] is True + args = cast(dict[str, object], captured["args"]) + assert args["response_mode"] == "streaming" + assert args["auto_generate_name"] is False + assert args["external_trace_id"] == "trace-1" -def test_agent_app_composer_routes_are_agent_mode_only() -> None: - assert _get_app_model_modes(AgentAppComposerApi.get) == [AppMode.AGENT] - assert _get_app_model_modes(AgentAppComposerApi.put) == [AppMode.AGENT] - assert _get_app_model_modes(AgentAppComposerValidateApi.post) == [AppMode.AGENT] - assert _get_app_model_modes(AgentAppComposerCandidatesApi.get) == [AppMode.AGENT] +@pytest.mark.parametrize( + ("error", "expected"), + [ + (completion_controller.services.errors.conversation.ConversationNotExistsError(), NotFound), + ( + completion_controller.services.errors.conversation.ConversationCompletedError(), + completion_controller.ConversationCompletedError, + ), + ( + completion_controller.services.errors.app_model_config.AppModelConfigBrokenError(), + completion_controller.AppUnavailableError, + ), + ( + completion_controller.ProviderTokenNotInitError("not initialized"), + completion_controller.ProviderNotInitializeError, + ), + (completion_controller.QuotaExceededError(), completion_controller.ProviderQuotaExceededError), + ( + completion_controller.ModelCurrentlyNotSupportError(), + completion_controller.ProviderModelCurrentlyNotSupportError, + ), + (completion_controller.InvokeRateLimitError("rate limited"), completion_controller.InvokeRateLimitHttpError), + (completion_controller.InvokeError("invoke failed"), completion_controller.CompletionRequestError), + (RuntimeError("unexpected"), InternalServerError), + ], +) +def test_agent_chat_helper_maps_generation_errors( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + error: Exception, + expected: type[Exception], +) -> None: + app_model = SimpleNamespace(id="app-1", mode="chat") + monkeypatch.setattr(completion_controller.AppGenerateService, "generate", lambda **_: (_ for _ in ()).throw(error)) + + with app.test_request_context(json={"inputs": {}, "query": "hello"}): + with pytest.raises(expected): + completion_controller._create_chat_message( + current_user=SimpleNamespace(id="account-1"), + app_model=app_model, + ) + + +def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + message_id = "00000000-0000-0000-0000-000000000002" + app_model = SimpleNamespace(id="app-1") + current_user = SimpleNamespace(id="account-1") + captured: dict[str, object] = {} + + def resolve_agent_app_model(**kwargs: object) -> object: + captured["resolve"] = kwargs + return app_model + + def list_chat_messages(**kwargs: object) -> dict[str, object]: + captured["list"] = kwargs + return {"data": []} + + def update_message_feedback(**kwargs: object) -> dict[str, object]: + captured["feedback"] = kwargs + return {"result": "success"} + + def get_message_suggested_questions(**kwargs: object) -> dict[str, object]: + captured["suggested"] = kwargs + return {"data": ["next"]} + + def get_message_detail(**kwargs: object) -> dict[str, object]: + captured["detail"] = kwargs + return {"id": message_id} + + monkeypatch.setattr(message_controller, "resolve_agent_app_model", resolve_agent_app_model) + monkeypatch.setattr(message_controller, "_list_chat_messages", list_chat_messages) + monkeypatch.setattr(message_controller, "_update_message_feedback", update_message_feedback) + monkeypatch.setattr(message_controller, "_get_message_suggested_questions", get_message_suggested_questions) + monkeypatch.setattr(message_controller, "_get_message_detail", get_message_detail) + + assert unwrap(AgentChatMessageListApi.get)(AgentChatMessageListApi(), "tenant-1", agent_id) == {"data": []} + assert cast(dict[str, object], captured["list"])["app_model"] is app_model + + with app.test_request_context(json={"message_id": message_id, "rating": "like"}): + assert unwrap(AgentMessageFeedbackApi.post)(AgentMessageFeedbackApi(), "tenant-1", current_user, agent_id) == { + "result": "success" + } + feedback_call = cast(dict[str, object], captured["feedback"]) + assert feedback_call["app_model"] is app_model + assert feedback_call["current_user"] is current_user + + assert unwrap(AgentMessageSuggestedQuestionApi.get)( + AgentMessageSuggestedQuestionApi(), "tenant-1", current_user, agent_id, message_id + ) == {"data": ["next"]} + suggested_call = cast(dict[str, object], captured["suggested"]) + assert suggested_call["app_model"] is app_model + assert suggested_call["current_user"] is current_user + assert suggested_call["message_id"] == message_id + + assert unwrap(AgentMessageApi.get)(AgentMessageApi(), "tenant-1", agent_id, message_id) == {"id": message_id} + detail_call = cast(dict[str, object], captured["detail"]) + assert detail_call == {"app_model": app_model, "message_id": message_id} + + +def test_list_chat_messages_supports_first_id_pagination(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + conversation_id = "00000000-0000-0000-0000-000000000010" + first_message_id = "00000000-0000-0000-0000-000000000011" + older_message_id = "00000000-0000-0000-0000-000000000012" + conversation = SimpleNamespace(id=conversation_id) + first_message = SimpleNamespace(id=first_message_id, created_at=2) + older_message = SimpleNamespace(id=older_message_id, created_at=1) + scalar_values = iter([conversation, first_message, True]) + scalars_result = SimpleNamespace(all=lambda: [older_message]) + session = SimpleNamespace( + scalar=lambda _stmt: next(scalar_values), + scalars=lambda _stmt: scalars_result, + ) + + class FakeMessagePaginationResponse: + @classmethod + def model_validate(cls, pagination: object, from_attributes: bool = False) -> object: + return SimpleNamespace( + model_dump=lambda mode: { + "data": [item.id for item in pagination.data], + "limit": pagination.limit, + "has_more": pagination.has_more, + } + ) + + monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session)) + monkeypatch.setattr(message_controller, "attach_message_extra_contents", lambda messages: None) + monkeypatch.setattr(message_controller, "MessageInfiniteScrollPaginationResponse", FakeMessagePaginationResponse) + + with app.test_request_context( + "/console/api/agent/agent-1/chat-messages" + f"?conversation_id={conversation_id}&first_id={first_message_id}&limit=1" + ): + result = message_controller._list_chat_messages(app_model=SimpleNamespace(id="app-1")) + + assert result == {"data": [older_message_id], "limit": 1, "has_more": True} + + +def test_update_message_feedback_rejects_empty_rating_without_existing_feedback( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + message_id = "00000000-0000-0000-0000-000000000002" + message = SimpleNamespace(id=message_id, app_id="app-1", admin_feedback=None) + session = SimpleNamespace(scalar=lambda _stmt: message) + monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session)) + + with app.test_request_context(json={"message_id": message_id, "rating": None}): + with pytest.raises(ValueError, match="rating cannot be None"): + message_controller._update_message_feedback( + current_user=SimpleNamespace(id="account-1"), + app_model=SimpleNamespace(id="app-1"), + ) + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (message_controller.MessageNotExistsError(), NotFound), + (message_controller.ConversationNotExistsError(), NotFound), + ( + message_controller.ProviderTokenNotInitError("not initialized"), + message_controller.ProviderNotInitializeError, + ), + (message_controller.QuotaExceededError(), message_controller.ProviderQuotaExceededError), + (message_controller.ModelCurrentlyNotSupportError(), message_controller.ProviderModelCurrentlyNotSupportError), + (message_controller.InvokeError("invoke failed"), message_controller.CompletionRequestError), + ( + message_controller.SuggestedQuestionsAfterAnswerDisabledError(), + message_controller.AppSuggestedQuestionsAfterAnswerDisabledError, + ), + (RuntimeError("unexpected"), InternalServerError), + ], +) +def test_get_message_suggested_questions_maps_service_errors( + monkeypatch: pytest.MonkeyPatch, + error: Exception, + expected: type[Exception], +) -> None: + monkeypatch.setattr( + message_controller.MessageService, + "get_suggested_questions_after_answer", + lambda **_: (_ for _ in ()).throw(error), + ) + + with pytest.raises(expected): + message_controller._get_message_suggested_questions( + current_user=SimpleNamespace(id="account-1"), + app_model=SimpleNamespace(id="app-1"), + message_id="00000000-0000-0000-0000-000000000002", + ) def test_dify_tool_candidate_response_keeps_granularity_fields(): diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py index b1e473ec6d7..3cda0a34332 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py @@ -119,6 +119,7 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None: def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None: service = _AgentAppService() monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service) + monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model()) monkeypatch.setattr( module, "query_params_from_request", @@ -129,11 +130,10 @@ def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPat "request", SimpleNamespace(get_json=lambda silent=True: {"conversation_id": "conv-1", "path": "report.txt"}), ) - app_model = _app_model() - listing = unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", app_model) - preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), "tenant-1", app_model) - upload = unwrap(module.AgentAppSandboxUploadResource.post)(object(), "tenant-1", app_model) + listing = unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", "agent-1") + preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), "tenant-1", "agent-1") + upload = unwrap(module.AgentAppSandboxUploadResource.post)(object(), "tenant-1", "agent-1") assert listing["path"] == "sub/report.txt" assert preview["text"] == "hello" @@ -151,11 +151,12 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytes raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404) monkeypatch.setattr(module, "AgentAppSandboxService", FailingService) + monkeypatch.setattr(module, "resolve_agent_app_model", lambda *, tenant_id, agent_id: _app_model()) monkeypatch.setattr( module, "query_params_from_request", lambda model: SimpleNamespace(conversation_id="conv-1", path=".") ) - assert unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", _app_model()) == ( + assert unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", "agent-1") == ( {"code": "no_active_session", "message": "no active session"}, 404, ) diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py index 516c87989b4..9d1b6c4c0e9 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py @@ -15,8 +15,11 @@ from flask import Flask from controllers.console.app.agent_drive_inspector import ( AgentDriveDownloadApi, + AgentDriveDownloadByAgentApi, AgentDriveListApi, + AgentDriveListByAgentApi, AgentDrivePreviewApi, + AgentDrivePreviewByAgentApi, ) from services.agent_drive_service import AgentDriveError @@ -53,6 +56,32 @@ def test_list_filters_value_pointers_out_of_console_payload(): assert drive.return_value.manifest.call_args.kwargs["prefix"] == "pdf-toolkit/" +def test_list_by_agent_filters_value_pointers_out_of_console_payload(): + raw = _raw(AgentDriveListByAgentApi.get) + with app.test_request_context("/?prefix=pdf-toolkit/"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + drive.return_value.manifest.return_value = [ + { + "key": "pdf-toolkit/SKILL.md", + "size": 5, + "hash": "h", + "mime_type": "text/markdown", + "file_kind": "tool_file", + "file_id": "tf-1", + "created_at": 1718000000, + } + ] + body = raw(AgentDriveListByAgentApi(), "tenant-1", "agent-1") + + assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md" + assert "file_id" not in body["items"][0] + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1" + + def test_list_resolves_workflow_node_binding_agent(): raw = _raw(AgentDriveListApi.get) with app.test_request_context("/?node_id=agent-node-1"): @@ -101,6 +130,37 @@ def test_preview_passes_through_and_maps_errors(): assert body["code"] == "drive_key_not_found" +def test_preview_by_agent_passes_through_and_maps_errors(): + raw = _raw(AgentDrivePreviewByAgentApi.get) + with app.test_request_context("/?key=pdf-toolkit/SKILL.md"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + drive.return_value.preview.return_value = { + "key": "pdf-toolkit/SKILL.md", + "size": 5, + "truncated": False, + "binary": False, + "text": "# hi", + } + body = raw(AgentDrivePreviewByAgentApi(), "tenant-1", "agent-1") + assert body["text"] == "# hi" + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + + with app.test_request_context("/?key=ghost/SKILL.md"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP), + patch(f"{_MOD}.AgentDriveService") as drive, + ): + drive.return_value.preview.side_effect = AgentDriveError( + "drive_key_not_found", "no drive entry", status_code=404 + ) + body, status = raw(AgentDrivePreviewByAgentApi(), "tenant-1", "agent-1") + assert status == 404 + assert body["code"] == "drive_key_not_found" + + def test_download_returns_signed_url_json(): raw = _raw(AgentDriveDownloadApi.get) with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"): @@ -108,3 +168,16 @@ def test_download_returns_signed_url_json(): drive.return_value.download_url.return_value = "https://signed.example/zip" body = raw(AgentDriveDownloadApi(), _APP) assert body == {"url": "https://signed.example/zip"} + + +def test_download_by_agent_returns_signed_url_json(): + raw = _raw(AgentDriveDownloadByAgentApi.get) + with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + drive.return_value.download_url.return_value = "https://signed.example/zip" + body = raw(AgentDriveDownloadByAgentApi(), "tenant-1", "agent-1") + assert body == {"url": "https://signed.example/zip"} + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py index 21cc0f77900..bcb4aeab462 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py @@ -14,7 +14,16 @@ from unittest.mock import MagicMock, patch from flask import Flask -from controllers.console.app.agent import AgentSkillStandardizeApi, AgentSkillUploadApi +from controllers.console.app.agent import ( + AgentDriveFilesByAgentApi, + AgentSkillByAgentApi, + AgentSkillInferToolsByAgentApi, + AgentSkillStandardizeApi, + AgentSkillStandardizeByAgentApi, + AgentSkillUploadApi, + AgentSkillUploadByAgentApi, +) +from models.model import AppMode from services.agent.skill_package_service import SkillPackageError from services.agent_drive_service import AgentDriveError @@ -32,7 +41,8 @@ def _file_ctx(*, files: dict[str, bytes] | None = None): _USER = SimpleNamespace(id="user-1") -_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id="agent-1") +_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT, bound_agent_id="agent-1") +_WORKFLOW_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW, bound_agent_id=None) def test_upload_validates_and_returns_skill_ref(): @@ -56,6 +66,28 @@ def test_upload_validates_and_returns_skill_ref(): manifest.to_skill_ref.assert_called_once_with(file_id="uf-1") +def test_upload_by_agent_resolves_app_and_returns_skill_ref(): + raw = _raw(AgentSkillUploadByAgentApi.post) + manifest = MagicMock() + manifest.to_skill_ref.return_value.model_dump.return_value = {"name": "S", "file_id": "uf-1"} + manifest.model_dump.return_value = {"name": "S"} + + with _file_ctx(files={"file": b"zip-bytes"}): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.SkillPackageService") as pkg, + patch(f"{_MOD}.FileService") as fs, + patch(f"{_MOD}.db"), + ): + pkg.return_value.validate_and_extract.return_value = manifest + fs.return_value.upload_file.return_value = SimpleNamespace(id="uf-1") + body, status = raw(AgentSkillUploadByAgentApi(), "tenant-1", _USER, "agent-1") + + assert status == 201 + assert body["skill"] == {"name": "S", "file_id": "uf-1"} + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + + def test_upload_no_file_is_400(): raw = _raw(AgentSkillUploadApi.post) with _file_ctx(files={}): @@ -87,9 +119,25 @@ def test_standardize_returns_result(): assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1" +def test_standardize_by_agent_resolves_app(): + raw = _raw(AgentSkillStandardizeByAgentApi.post) + with _file_ctx(files={"file": b"zip"}): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.SkillStandardizeService") as svc, + ): + svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}} + body, status = raw(AgentSkillStandardizeByAgentApi(), "tenant-1", _USER, "agent-1") + + assert status == 201 + assert body["skill"] == {"path": "s"} + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1" + + def test_standardize_no_bound_agent_is_400(): raw = _raw(AgentSkillStandardizeApi.post) - app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) + app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT, bound_agent_id=None) with _file_ctx(files={"file": b"zip"}): body, status = raw(AgentSkillStandardizeApi(), _USER, app_without_agent) assert status == 400 @@ -98,7 +146,6 @@ def test_standardize_no_bound_agent_is_400(): def test_standardize_resolves_workflow_node_agent(): raw = _raw(AgentSkillStandardizeApi.post) - workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) with app.test_request_context( "/?node_id=agent-node-1", method="POST", data={"file": (io.BytesIO(b"zip"), "skill.zip")} ): @@ -108,7 +155,7 @@ def test_standardize_resolves_workflow_node_agent(): ): composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}} - body, status = raw(AgentSkillStandardizeApi(), _USER, workflow_app) + body, status = raw(AgentSkillStandardizeApi(), _USER, _WORKFLOW_APP) assert status == 201 assert body["skill"] == {"path": "s"} @@ -165,6 +212,31 @@ def test_files_commit_validates_upload_and_returns_drive_ref(): assert composer.add_drive_file_ref.call_args.kwargs["app_id"] == "app-1" +def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id(): + raw = _raw(AgentDriveFilesByAgentApi.post) + upload = SimpleNamespace(id="uf-1", name="sample.pdf") + with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=ignored"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.console_ns") as ns, + patch(f"{_MOD}.db") as db_mock, + patch(f"{_MOD}.AgentDriveService") as drive, + patch(f"{_MOD}.AgentComposerService") as composer, + ): + ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"} + db_mock.session.scalar.return_value = upload + drive.return_value.commit.return_value = [ + {"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"} + ] + composer.add_drive_file_ref.return_value = "ver-2" + body, status = raw(AgentDriveFilesByAgentApi(), "tenant-1", _USER, "agent-1") + + assert status == 201 + assert body["config_version_id"] == "ver-2" + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert composer.add_drive_file_ref.call_args.kwargs["node_id"] is None + + def test_files_commit_404_when_upload_not_in_tenant(): from controllers.console.app.agent import AgentDriveFilesApi @@ -186,7 +258,6 @@ def test_files_commit_resolves_workflow_node_agent(): raw = _raw(AgentDriveFilesApi.post) upload = SimpleNamespace(id="uf-1", name="sample.pdf") - workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=agent-node-1"): with ( patch(f"{_MOD}.console_ns") as ns, @@ -201,7 +272,7 @@ def test_files_commit_resolves_workflow_node_agent(): {"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"} ] composer.add_drive_file_ref.return_value = "ver-2" - body, status = raw(AgentDriveFilesApi(), _USER, workflow_app) + body, status = raw(AgentDriveFilesApi(), _USER, _WORKFLOW_APP) assert status == 201 assert body["config_version_id"] == "ver-2" @@ -229,11 +300,27 @@ def test_files_delete_updates_soul_then_drive(): assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1" +def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id(): + raw = _raw(AgentDriveFilesByAgentApi.delete) + with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.remove_drive_refs.return_value = "ver-2" + drive.return_value.delete.return_value = ["files/sample.pdf"] + body = raw(AgentDriveFilesByAgentApi(), "tenant-1", _USER, "agent-1") + + assert body["config_version_id"] == "ver-2" + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert composer.remove_drive_refs.call_args.kwargs["node_id"] is None + + def test_files_delete_resolves_workflow_node_agent(): from controllers.console.app.agent import AgentDriveFilesApi raw = _raw(AgentDriveFilesApi.delete) - workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=agent-node-1"): with ( patch(f"{_MOD}.AgentComposerService") as composer, @@ -242,7 +329,7 @@ def test_files_delete_resolves_workflow_node_agent(): composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" composer.remove_drive_refs.return_value = "ver-2" drive.return_value.delete.return_value = ["files/sample.pdf"] - body = raw(AgentDriveFilesApi(), _USER, workflow_app) + body = raw(AgentDriveFilesApi(), _USER, _WORKFLOW_APP) assert body["config_version_id"] == "ver-2" assert drive.return_value.delete.call_args.kwargs["agent_id"] == "wf-agent-1" @@ -284,6 +371,23 @@ def test_skill_delete_uses_slug_prefix_and_is_idempotent(): assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1" +def test_skill_delete_by_agent_uses_agent_route(): + raw = _raw(AgentSkillByAgentApi.delete) + with _json_ctx(method="DELETE", query_string="node_id=ignored"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.remove_drive_refs.return_value = "ver-2" + drive.return_value.delete.return_value = ["tender-analyzer/SKILL.md"] + body = raw(AgentSkillByAgentApi(), "tenant-1", _USER, "agent-1", "tender-analyzer") + + assert body["config_version_id"] == "ver-2" + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert composer.remove_drive_refs.call_args.kwargs["node_id"] is None + + def test_skill_delete_rejects_path_like_slug(): from controllers.console.app.agent import AgentSkillApi @@ -314,11 +418,25 @@ def test_infer_tools_returns_draft_suggestions(): assert svc.return_value.infer.call_args.kwargs["slug"] == "audio-transcribe" +def test_infer_tools_by_agent_uses_agent_route(): + raw = _raw(AgentSkillInferToolsByAgentApi.post) + with _json_ctx(query_string="node_id=ignored"): + with ( + patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, + patch(f"{_MOD}.SkillToolInferenceService") as svc, + ): + svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None} + body = raw(AgentSkillInferToolsByAgentApi(), "tenant-1", "agent-1", "audio-transcribe") + + assert body["inferable"] is True + resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert svc.return_value.infer.call_args.kwargs["agent_id"] == "agent-1" + + def test_infer_tools_resolves_workflow_node_agent(): from controllers.console.app.agent import AgentSkillInferToolsApi raw = _raw(AgentSkillInferToolsApi.post) - workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) with _json_ctx(query_string="node_id=agent-node-1"): with ( patch(f"{_MOD}.AgentComposerService") as composer, @@ -326,7 +444,7 @@ def test_infer_tools_resolves_workflow_node_agent(): ): composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" svc.return_value.infer.return_value = {"inferable": False, "cli_tools": [], "reason": "none"} - body = raw(AgentSkillInferToolsApi(), workflow_app, "audio-transcribe") + body = raw(AgentSkillInferToolsApi(), _WORKFLOW_APP, "audio-transcribe") assert body["inferable"] is False assert svc.return_value.infer.call_args.kwargs["agent_id"] == "wf-agent-1" @@ -355,7 +473,7 @@ def test_infer_tools_rejects_path_like_slug_and_unbound_app(): body, status = raw(AgentSkillInferToolsApi(), _APP, "a/b") assert (status, body["code"]) == (400, "drive_key_invalid") - app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) + app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT, bound_agent_id=None) with _json_ctx(): body, status = raw(AgentSkillInferToolsApi(), app_without_agent, "x") assert (status, body["code"]) == (400, "agent_not_bound") diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index 47126d2b927..ef8f90e5c9c 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -314,37 +314,19 @@ def test_app_list_query_rejects_flat_tag_ids(app_module): app_module.AppListQuery.model_validate(normalized) -def test_create_agent_app_response_includes_bound_agent_id(app_module, monkeypatch: pytest.MonkeyPatch): +def test_create_app_endpoint_rejects_agent_mode(app_module, monkeypatch: pytest.MonkeyPatch): payload = {"name": "Iris", "mode": "agent", "description": "Agent app"} - app_obj = SimpleNamespace( - id="app-1", - name="Iris", - description="Agent app", - mode_compatible_with_agent="agent", - icon_type="emoji", - icon="robot", - icon_background="#fff", - enable_site=False, - enable_api=False, - created_at=_ts(), - updated_at=_ts(), - bound_agent_id="agent-1", - ) app_service = MagicMock() - app_service.create_app.return_value = app_obj monkeypatch.setattr(app_module, "AppService", lambda: app_service) app_module.console_ns.payload = payload try: - response, status = _unwrap(app_module.AppListApi().post)("tenant-1", SimpleNamespace(id="account-1")) + with pytest.raises(ValidationError): + _unwrap(app_module.AppListApi().post)("tenant-1", SimpleNamespace(id="account-1")) finally: app_module.console_ns.payload = None - assert status == 201 - assert response["id"] == "app-1" - assert response["bound_agent_id"] == "agent-1" - created_params = app_service.create_app.call_args.args[1] - assert created_params.mode == "agent" + app_service.create_app.assert_not_called() def test_app_partial_serialization_uses_aliases(app_models): diff --git a/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py b/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py index ebabe50c938..dfb2a4c1398 100644 --- a/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py +++ b/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py @@ -1,9 +1,4 @@ -"""Regression tests for CreateAppPayload mode validation. - -The HTTP create-app payload must accept the new "agent" app mode; without it a -user cannot create an Agent App through POST /console/api/apps even though the -service layer (CreateAppParams) supports it. -""" +"""Regression tests for CreateAppPayload mode validation.""" import pytest from pydantic import ValidationError @@ -14,12 +9,16 @@ from controllers.console.app.app import CreateAppPayload class TestCreateAppPayloadMode: @pytest.mark.parametrize( "mode", - ["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"], + ["chat", "agent-chat", "advanced-chat", "workflow", "completion"], ) def test_accepts_supported_modes(self, mode: str): payload = CreateAppPayload.model_validate({"name": "X", "mode": mode}) assert payload.mode == mode + def test_rejects_agent_mode(self): + with pytest.raises(ValidationError): + CreateAppPayload.model_validate({"name": "X", "mode": "agent"}) + def test_rejects_unknown_mode(self): with pytest.raises(ValidationError): CreateAppPayload.model_validate({"name": "X", "mode": "not-a-mode"}) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow.py b/api/tests/unit_tests/controllers/console/app/test_workflow.py index d2f7770b2fb..e05796d8853 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow.py @@ -540,6 +540,58 @@ def test_draft_workflow_get_not_found(monkeypatch: pytest.MonkeyPatch) -> None: handler(api, app_model=SimpleNamespace(id="app")) +def test_draft_workflow_get_projects_agent_node_job_to_graph(monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _make_workflow( + graph_dict={ + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + }, + } + ], + "edges": [], + } + ) + projected_graph = { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_task": "Summarize it.", + "agent_declared_outputs": [{"name": "summary", "type": "string"}], + }, + } + ], + "edges": [], + } + + monkeypatch.setattr( + workflow_module, + "WorkflowService", + lambda: SimpleNamespace(get_draft_workflow=lambda **_k: workflow), + ) + + from services.agent.workflow_publish_service import WorkflowAgentPublishService + + monkeypatch.setattr( + WorkflowAgentPublishService, + "project_draft_bindings_to_graph", + lambda **_k: projected_graph, + ) + + api = workflow_module.DraftWorkflowApi() + handler = inspect.unwrap(api.get) + + response = handler(api, app_model=SimpleNamespace(id="app")) + + assert response["graph"] == projected_graph + + def test_advanced_chat_run_conversation_not_exists(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( workflow_module.AppGenerateService, diff --git a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py index e0eab9a4d34..ef08aa1b36a 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py +++ b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py @@ -79,6 +79,46 @@ class TestRecommendedAppListApi: assert result == result_data +class TestLearnDifyAppListApi: + def test_get_with_language_param(self, app: Flask): + api = module.LearnDifyAppListApi() + method = unwrap(api.get) + + result_data = {"recommended_apps": []} + + with ( + app.test_request_context("/", query_string={"language": "en-US"}), + patch.object( + module.RecommendedAppService, + "get_learn_dify_apps", + return_value=result_data, + ) as service_mock, + ): + result = method(api, make_account("fr-FR")) + + service_mock.assert_called_once_with(ANY, "en-US") + assert result == result_data + + def test_get_fallback_to_user_language(self, app: Flask): + api = module.LearnDifyAppListApi() + method = unwrap(api.get) + + result_data = {"recommended_apps": []} + + with ( + app.test_request_context("/", query_string={"language": "invalid"}), + patch.object( + module.RecommendedAppService, + "get_learn_dify_apps", + return_value=result_data, + ) as service_mock, + ): + result = method(api, make_account("fr-FR")) + + service_mock.assert_called_once_with(ANY, "fr-FR") + assert result == result_data + + class TestRecommendedAppApi: def test_get_success(self, app: Flask): api = module.RecommendedAppApi() @@ -144,3 +184,29 @@ class TestRecommendedAppResponseModels: assert response["recommended_apps"][0]["app_id"] == "app-1" assert response["recommended_apps"][0]["categories"] == ["cat", "other"] assert response["categories"] == ["cat"] + + def test_learn_dify_app_list_response_serialization(self): + response = module.LearnDifyAppListResponse.model_validate( + { + "recommended_apps": [ + { + "app": { + "id": "app-1", + "name": "App", + "mode": "chat", + "icon": "icon.png", + "icon_type": "emoji", + "icon_background": "#fff", + }, + "app_id": "app-1", + "description": "desc", + "categories": ["Workflow"], + "position": 1, + "is_listed": True, + } + ], + } + ).model_dump(mode="json") + + assert response["recommended_apps"][0]["app_id"] == "app-1" + assert response["recommended_apps"][0]["categories"] == ["Workflow"] diff --git a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py index 2f9c7d4fd68..bc76560dcac 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py @@ -1,5 +1,7 @@ import io +from datetime import datetime from inspect import unwrap +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -10,12 +12,14 @@ from werkzeug.exceptions import Forbidden from controllers.console.workspace.plugin import ( PluginAssetApi, PluginAutoUpgradeExcludePluginApi, + PluginCategoryListApi, + PluginChangeAutoUpgradeApi, PluginChangePermissionApi, - PluginChangePreferencesApi, PluginDebuggingKeyApi, PluginDeleteAllInstallTaskItemsApi, PluginDeleteInstallTaskApi, PluginDeleteInstallTaskItemApi, + PluginFetchAutoUpgradeApi, PluginFetchDynamicSelectOptionsApi, PluginFetchDynamicSelectOptionsWithCredentialsApi, PluginFetchInstallTaskApi, @@ -23,7 +27,6 @@ from controllers.console.workspace.plugin import ( PluginFetchManifestApi, PluginFetchMarketplacePkgApi, PluginFetchPermissionApi, - PluginFetchPreferencesApi, PluginIconApi, PluginInstallFromGithubApi, PluginInstallFromMarketplaceApi, @@ -43,6 +46,69 @@ from core.plugin.impl.exc import PluginDaemonClientSideError from models.account import Account, TenantAccountRole, TenantPluginAutoUpgradeStrategy, TenantPluginPermission +def _plugin_category_list_item(category: str = "tool") -> dict[str, Any]: + now = datetime(2023, 1, 1, 0, 0, 0) + return { + "id": "entity-1", + "created_at": now, + "updated_at": now, + "tenant_id": "t1", + "endpoints_setups": 0, + "endpoints_active": 0, + "runtime_type": "remote", + "source": "marketplace", + "meta": {}, + "plugin_id": "test-author/test-plugin", + "plugin_unique_identifier": "test-author/test-plugin:1.0.0@checksum", + "version": "1.0.0", + "checksum": "checksum", + "name": "test-plugin", + "installation_id": "entity-1", + "declaration": { + "version": "1.0.0", + "author": "test-author", + "name": "test-plugin", + "description": {"en_US": "Test plugin"}, + "icon": "icon.svg", + "label": {"en_US": "Test Plugin"}, + "category": category, + "created_at": now, + "resource": {"memory": 268435456, "permission": None}, + "plugins": {"tools": ["provider/test.yaml"]}, + "meta": {"version": "1.0.0"}, + "tool": { + "identity": { + "author": "test-author", + "name": "test-plugin", + "description": {"en_US": "Test plugin"}, + "icon": "icon.svg", + "label": {"en_US": "Test Plugin"}, + } + }, + }, + } + + +def _builtin_tool_provider_item() -> dict[str, Any]: + return { + "id": "builtin", + "author": "dify", + "name": "builtin", + "plugin_id": "", + "plugin_unique_identifier": "", + "description": {"en_US": "Builtin tool provider"}, + "icon": "icon.svg", + "icon_dark": "", + "label": {"en_US": "Builtin"}, + "type": "builtin", + "team_credentials": {}, + "is_team_authorization": False, + "allow_delete": True, + "tools": [], + "labels": [], + } + + def _account(role: TenantAccountRole = TenantAccountRole.OWNER) -> Account: account = Account(name="Test User", email="u1@example.com") account.id = "u1" @@ -142,6 +208,83 @@ class TestPluginListApi: mock_list_with_total.assert_called_once_with("t1", "u1", 1, 10) +class TestPluginCategoryListApi: + def test_plugin_category_list(self, app: Flask): + api = PluginCategoryListApi() + method = unwrap(api.get) + plugin_item = _plugin_category_list_item() + builtin_item = _builtin_tool_provider_item() + mock_list = MagicMock(list=[plugin_item], has_more=True) + + with ( + app.test_request_context("/?page=2&page_size=10"), + patch( + "controllers.console.workspace.plugin.PluginService.list_by_category", return_value=mock_list + ) as list_mock, + patch( + "controllers.console.workspace.plugin._list_hardcoded_builtin_tool_providers", + return_value=[builtin_item], + ) as builtin_mock, + ): + result = method(api, "t1", "tool") + + list_mock.assert_called_once() + assert list_mock.call_args.args[0] == "t1" + assert list_mock.call_args.args[1] == "tool" + assert list_mock.call_args.args[2] == 2 + assert list_mock.call_args.args[3] == 10 + assert result["plugins"][0]["id"] == "entity-1" + assert result["plugins"][0]["plugin_unique_identifier"] == "test-author/test-plugin:1.0.0@checksum" + assert result["builtin_tools"][0]["id"] == "builtin" + assert result["builtin_tools"][0]["type"] == "builtin" + assert result["has_more"] is True + assert "total" not in result + builtin_mock.assert_called_once_with("t1") + + def test_non_tool_category_does_not_include_builtin_tools(self, app: Flask): + api = PluginCategoryListApi() + method = unwrap(api.get) + mock_list = MagicMock(list=[_plugin_category_list_item(category="datasource")], has_more=False) + + with ( + app.test_request_context("/?page=1&page_size=10"), + patch("controllers.console.workspace.plugin.PluginService.list_by_category", return_value=mock_list), + patch("controllers.console.workspace.plugin._list_hardcoded_builtin_tool_providers") as builtin_mock, + ): + result = method(api, "t1", "datasource") + + assert result["plugins"][0]["id"] == "entity-1" + assert result["builtin_tools"] == [] + assert result["has_more"] is False + builtin_mock.assert_not_called() + + def test_invalid_category(self, app: Flask): + api = PluginCategoryListApi() + method = unwrap(api.get) + + with ( + app.test_request_context("/?page=1&page_size=10"), + ): + result = method(api, "t1", "unknown") + + assert result == ({"code": "invalid_param", "message": "invalid plugin category"}, 400) + + def test_daemon_error(self, app: Flask): + api = PluginCategoryListApi() + method = unwrap(api.get) + + with ( + app.test_request_context("/?page=1&page_size=10"), + patch( + "controllers.console.workspace.plugin.PluginService.list_by_category", + side_effect=PluginDaemonClientSideError("error"), + ), + ): + result = method(api, "t1", "tool") + + assert result == ({"code": "plugin_error", "message": "error"}, 400) + + class TestPluginIconApi: def test_plugin_icon(self, app: Flask): api = PluginIconApi() @@ -857,18 +1000,15 @@ class TestPluginFetchDynamicSelectOptionsWithCredentialsApi: assert result == ({"code": "plugin_error", "message": "error"}, 400) -class TestPluginChangePreferencesApi: +class TestPluginChangeAutoUpgradeApi: def test_success(self, app: Flask): - api = PluginChangePreferencesApi() + api = PluginChangeAutoUpgradeApi() method = unwrap(api.post) user = _account() payload = { - "permission": { - "install_permission": TenantPluginPermission.InstallPermission.EVERYONE, - "debug_permission": TenantPluginPermission.DebugPermission.EVERYONE, - }, + "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value, "auto_upgrade": { "strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, "upgrade_time_of_day": 0, @@ -880,24 +1020,52 @@ class TestPluginChangePreferencesApi: with ( app.test_request_context("/", json=payload), - patch("controllers.console.workspace.plugin.PluginPermissionService.change_permission", return_value=True), - patch("controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True), + patch( + "controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True + ) as change, ): result = method(api, "t1", user) assert result["success"] is True + change.assert_called_once() - def test_permission_fail(self, app: Flask): - api = PluginChangePreferencesApi() + def test_success_with_model_category_auto_upgrade(self, app: Flask): + api = PluginChangeAutoUpgradeApi() method = unwrap(api.post) user = _account() payload = { - "permission": { - "install_permission": TenantPluginPermission.InstallPermission.EVERYONE, - "debug_permission": TenantPluginPermission.DebugPermission.EVERYONE, + "category": TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL.value, + "auto_upgrade": { + "strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST, + "upgrade_time_of_day": 3600, + "upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, + "exclude_plugins": [], + "include_plugins": [], }, + } + + with ( + app.test_request_context("/", json=payload), + patch( + "controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True + ) as change, + ): + result = method(api, "t1", user) + + assert result["success"] is True + change.assert_called_once() + assert change.call_args.kwargs["category"] == TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL + + def test_auto_upgrade_fail(self, app: Flask): + api = PluginChangeAutoUpgradeApi() + method = unwrap(api.post) + + user = MagicMock(is_admin_or_owner=True) + + payload = { + "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value, "auto_upgrade": { "strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, "upgrade_time_of_day": 0, @@ -909,24 +1077,20 @@ class TestPluginChangePreferencesApi: with ( app.test_request_context("/", json=payload), - patch("controllers.console.workspace.plugin.PluginPermissionService.change_permission", return_value=False), + patch("controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=False), ): result = method(api, "t1", user) assert result["success"] is False -class TestPluginFetchPreferencesApi: +class TestPluginFetchAutoUpgradeApi: def test_success(self, app: Flask): - api = PluginFetchPreferencesApi() + api = PluginFetchAutoUpgradeApi() method = unwrap(api.get) - permission = MagicMock( - install_permission=TenantPluginPermission.InstallPermission.EVERYONE, - debug_permission=TenantPluginPermission.DebugPermission.EVERYONE, - ) - auto_upgrade = MagicMock( + category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL, strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, upgrade_time_of_day=1, upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, @@ -935,18 +1099,16 @@ class TestPluginFetchPreferencesApi: ) with ( - app.test_request_context("/"), + app.test_request_context(f"/?category={TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value}"), patch( - "controllers.console.workspace.plugin.PluginPermissionService.get_permission", return_value=permission - ), - patch( - "controllers.console.workspace.plugin.PluginAutoUpgradeService.get_strategy", return_value=auto_upgrade + "controllers.console.workspace.plugin.PluginAutoUpgradeService.get_strategy", + return_value=auto_upgrade, ), ): result = method(api, "t1") - assert "permission" in result - assert "auto_upgrade" in result + assert result["category"] == TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL + assert result["auto_upgrade"]["upgrade_time_of_day"] == 1 class TestPluginAutoUpgradeExcludePluginApi: @@ -954,7 +1116,7 @@ class TestPluginAutoUpgradeExcludePluginApi: api = PluginAutoUpgradeExcludePluginApi() method = unwrap(api.post) - payload = {"plugin_id": "p"} + payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value} with ( app.test_request_context("/", json=payload), @@ -968,7 +1130,7 @@ class TestPluginAutoUpgradeExcludePluginApi: api = PluginAutoUpgradeExcludePluginApi() method = unwrap(api.post) - payload = {"plugin_id": "p"} + payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value} with ( app.test_request_context("/", json=payload), diff --git a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py index 68d5a879e48..4663d503e3d 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -53,6 +53,12 @@ def make_tenant( return tenant +def make_membership(*, last_opened_at=None) -> MagicMock: + membership = MagicMock() + membership.last_opened_at = last_opened_at + return membership + + def make_account_with_tenant(tenant: Tenant) -> Account: account = make_account() account._current_tenant = tenant @@ -66,13 +72,17 @@ class TestTenantListApi: tenant1 = make_tenant("t1", name="Tenant 1") tenant2 = make_tenant("t2", name="Tenant 2") + last_opened_at = naive_utc_now() user = make_account() with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", - return_value=[tenant1, tenant2], + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", + return_value=[ + (tenant1, make_membership(last_opened_at=last_opened_at)), + (tenant2, make_membership()), + ], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False), patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True), @@ -92,7 +102,9 @@ class TestTenantListApi: assert len(result["workspaces"]) == 2 assert result["workspaces"][0]["current"] is True assert result["workspaces"][0]["plan"] == CloudPlan.TEAM + assert result["workspaces"][0]["last_opened_at"] == int(last_opened_at.timestamp()) assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL + assert result["workspaces"][1]["last_opened_at"] is None get_plan_bulk_mock.assert_called_once_with(["t1", "t2"]) get_features_mock.assert_not_called() @@ -116,8 +128,8 @@ class TestTenantListApi: with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", - return_value=[tenant1, tenant2], + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", + return_value=[(tenant1, make_membership()), (tenant2, make_membership())], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False), patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True), @@ -159,8 +171,8 @@ class TestTenantListApi: with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", - return_value=[tenant1, tenant2], + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", + return_value=[(tenant1, make_membership()), (tenant2, make_membership())], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False), patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True), @@ -198,8 +210,8 @@ class TestTenantListApi: with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", - return_value=[tenant], + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", + return_value=[(tenant, make_membership())], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False), patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False), @@ -226,8 +238,8 @@ class TestTenantListApi: with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", - return_value=[tenant1, tenant2], + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", + return_value=[(tenant1, make_membership()), (tenant2, make_membership())], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True), patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False), @@ -251,7 +263,7 @@ class TestTenantListApi: with ( app.test_request_context("/workspaces"), patch( - "controllers.console.workspace.workspace.TenantService.get_join_tenants", + "controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", return_value=[], ), patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True), diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py index 8ad590c4dd0..898eb2e86b7 100644 --- a/api/tests/unit_tests/controllers/test_swagger.py +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -1,5 +1,6 @@ """OpenAPI JSON rendering tests for Flask-RESTX API blueprints.""" +import json from collections.abc import Iterator import pytest @@ -187,3 +188,39 @@ def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest assert "payload" not in params assert params["avatar"]["in"] == "query" assert params["avatar"]["required"] is True + + +def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path): + from dev.generate_swagger_specs import generate_specs + + written_paths = generate_specs(tmp_path) + console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json") + payload = json.loads(console_openapi_path.read_text(encoding="utf-8")) + operation = payload["paths"]["/workspaces/current/plugin/{category}/list"]["get"] + response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix( + "#/components/schemas/" + ) + schemas = payload["components"]["schemas"] + response_schema = schemas[response_ref] + + assert response_schema["properties"]["plugins"]["items"]["$ref"] == ( + "#/components/schemas/PluginCategoryInstalledPluginResponse" + ) + assert response_schema["properties"]["builtin_tools"]["items"]["$ref"] == ( + "#/components/schemas/PluginCategoryBuiltinToolProviderResponse" + ) + + installed_plugin_schema = schemas["PluginCategoryInstalledPluginResponse"] + for field in ( + "plugin_unique_identifier", + "source", + "version", + "declaration", + "endpoints_active", + "endpoints_setups", + ): + assert field in installed_plugin_schema["properties"] + + builtin_tool_schema = schemas["PluginCategoryBuiltinToolProviderResponse"] + for field in ("plugin_unique_identifier", "team_credentials", "type", "tools"): + assert field in builtin_tool_schema["properties"] 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 9726c939e94..b75f6d44943 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 @@ -562,7 +562,7 @@ class TestAdvancedChatGenerateTaskPipeline: assert list(pipeline._handle_human_input_form_timeout_event(timeout_event)) == ["timeout"] assert persisted == ["saved"] - def test_save_message_strips_markdown_and_sets_usage(self): + def test_save_message_preserves_full_answer_and_sets_usage(self): pipeline = _make_pipeline() pipeline._recorded_files = [ { @@ -572,7 +572,8 @@ class TestAdvancedChatGenerateTaskPipeline: "related_id": "file-id", } ] - pipeline._task_state.answer = "![img](url) hello" + # The answer is stored verbatim; markdown image links are never stripped. + pipeline._task_state.answer = "![img](http://example.com/file.png) hello ![inline](http://llm.com/img.jpg)" pipeline._task_state.is_streaming_response = True pipeline._task_state.first_token_time = pipeline._base_task_pipeline.start_at + 0.1 pipeline._task_state.last_token_time = pipeline._base_task_pipeline.start_at + 0.2 @@ -614,7 +615,7 @@ class TestAdvancedChatGenerateTaskPipeline: pipeline._save_message(session=_Session(), graph_runtime_state=graph_runtime_state) assert message.status == MessageStatus.NORMAL - assert message.answer == "hello" + assert message.answer == "![img](http://example.com/file.png) hello ![inline](http://llm.com/img.jpg)" assert message.message_metadata def test_handle_stop_event_saves_message_for_moderation(self, monkeypatch: pytest.MonkeyPatch): diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py index dcaa31e15eb..6c36815753e 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py @@ -201,17 +201,18 @@ class TestGenerateWorker: mocker.patch(f"{MODULE}.AgentAppRunner", return_value=runner) return runner - def _call(self, generator, mocker: MockerFixture, queue_manager): + def _call(self, generator, mocker: MockerFixture, queue_manager, *, is_resume=False, query="query"): generator._generate_worker( flask_app=mocker.MagicMock(), context=mocker.MagicMock(), application_generate_entity=mocker.MagicMock( - agent_id="a", agent_config_snapshot_id="s", model_conf=mocker.MagicMock(model="m") + agent_id="a", agent_config_snapshot_id="s", model_conf=mocker.MagicMock(model="m"), query=query ), queue_manager=queue_manager, conversation_id="conv", message_id="msg", user_from=UserFrom.END_USER, + is_resume=is_resume, ) def test_happy_path_runs_backend(self, generator, mocker: MockerFixture): @@ -227,6 +228,20 @@ class TestGenerateWorker: self._call(generator, mocker, queue_manager) runner.run.assert_not_called() + def test_resume_skips_input_guards_and_consumes_reply(self, generator, mocker: MockerFixture): + # ENG-638 (review): on resume the replayed query is NOT new end-user input. + # Input guards must be skipped, even if moderation/annotation would match, + # so the run continues and the human reply (deferred_tool_results) is used. + runner = self._wire(generator, mocker, handled=True) # guards WOULD short-circuit + queue_manager = mocker.MagicMock() + + self._call(generator, mocker, queue_manager, is_resume=True, query="the approved reply") + + generator._run_input_guards.assert_not_called() + runner.run.assert_called_once() + # the replayed paused-turn query flows straight to the runner (snapshot match) + assert runner.run.call_args.kwargs["query"] == "the approved reply" + def test_generate_task_stopped_is_swallowed(self, generator, mocker: MockerFixture): self._wire(generator, mocker, run_side_effect=GenerateTaskStoppedError()) queue_manager = mocker.MagicMock() @@ -238,3 +253,56 @@ class TestGenerateWorker: queue_manager = mocker.MagicMock() self._call(generator, mocker, queue_manager) assert queue_manager.publish_error.called + + +class TestResumeAfterFormSubmission: + """ENG-638: a resume turn re-sends the paused turn's original query so the + composition's user-prompt layer matches the suspended snapshot (never blank).""" + + def _wire(self, generator, mocker: MockerFixture): + generator._resolve_agent = mocker.MagicMock( + return_value=(mocker.MagicMock(id="agent1"), mocker.MagicMock(id="snap1"), mocker.MagicMock()) + ) + generator._init_generate_records = mocker.MagicMock( + return_value=(mocker.MagicMock(id="conv", mode="agent"), mocker.MagicMock(id="msg")) + ) + generator._handle_response = mocker.MagicMock(return_value=None) + mocker.patch(f"{MODULE}.ConversationService.get_conversation", return_value=mocker.MagicMock(id="conv")) + mocker.patch(f"{MODULE}.AgentAppConfigManager.get_app_config", return_value=mocker.MagicMock(variables=[])) + mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock()) + mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock()) + mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock()) + mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock()) + return mocker.patch( + f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user") + ) + + def test_resume_resends_paused_turn_query(self, generator, mocker: MockerFixture): + entity = self._wire(generator, mocker) + db_mock = mocker.patch(f"{MODULE}.db") + db_mock.session.scalar.return_value = mocker.MagicMock(query="original question") + + generator.resume_after_form_submission( + app_model=mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent"), + user=DummyAccount("user"), + conversation_id="conv", + invoke_from=InvokeFrom.WEB_APP, + ) + + # The paused turn's query is re-sent verbatim — never blank. + assert entity.call_args.kwargs["query"] == "original question" + + def test_resume_falls_back_to_placeholder_when_no_paused_message(self, generator, mocker: MockerFixture): + entity = self._wire(generator, mocker) + db_mock = mocker.patch(f"{MODULE}.db") + db_mock.session.scalar.return_value = None + + generator.resume_after_form_submission( + app_model=mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent"), + user=DummyAccount("user"), + conversation_id="conv", + invoke_from=InvokeFrom.WEB_APP, + ) + + # No prior user message -> a non-blank placeholder, still never blank. + assert entity.call_args.kwargs["query"] == "(resumed)" diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index b41e7b6ab09..e696d4aaa0b 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -6,9 +6,11 @@ from __future__ import annotations from types import SimpleNamespace from typing import Any, override +from unittest.mock import MagicMock import pytest from agenton.compositor import CompositorSessionSnapshot +from dify_agent.layers.ask_human import AskHumanToolResult from dify_agent.protocol import CancelRunRequest, CancelRunResponse, RuntimeLayerSpec from clients.agent_backend import ( @@ -19,10 +21,11 @@ from clients.agent_backend import ( ) from core.app.apps.agent_app.app_runner import AgentAppRunner from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder -from core.app.apps.agent_app.session_store import AgentAppSessionScope +from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent +from core.workflow.nodes.agent_v2.ask_human_resume import AskHumanResumeOutcome from models.agent_config_entities import AgentSoulConfig @@ -65,17 +68,47 @@ class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient): class _FakeSessionStore: - def __init__(self, loaded: CompositorSessionSnapshot | None = None) -> None: + def __init__( + self, + loaded: CompositorSessionSnapshot | None = None, + loaded_session: StoredAgentAppSession | None = None, + ) -> None: self.loaded = loaded + self._loaded_session = loaded_session self.saved: list[ - tuple[AgentAppSessionScope, str, CompositorSessionSnapshot | None, list[RuntimeLayerSpec]] + tuple[ + AgentAppSessionScope, + str, + CompositorSessionSnapshot | None, + list[RuntimeLayerSpec], + str | None, + str | None, + ] ] = [] def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None: return self.loaded - def save_active_snapshot(self, *, scope, backend_run_id, snapshot, runtime_layer_specs) -> None: - self.saved.append((scope, backend_run_id, snapshot, list(runtime_layer_specs))) + def load_active_session(self, scope: AgentAppSessionScope) -> StoredAgentAppSession | None: + if self._loaded_session is not None: + return self._loaded_session + if self.loaded is None: + return None + return StoredAgentAppSession(scope=scope, session_snapshot=self.loaded, backend_run_id=None) + + def save_active_snapshot( + self, + *, + scope, + backend_run_id, + snapshot, + runtime_layer_specs, + pending_form_id=None, + pending_tool_call_id=None, + ) -> None: + self.saved.append( + (scope, backend_run_id, snapshot, list(runtime_layer_specs), pending_form_id, pending_tool_call_id) + ) def _soul() -> AgentSoulConfig: @@ -127,6 +160,16 @@ def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None: ) +def _message_end(qm: _FakeQueueManager) -> QueueMessageEndEvent: + return next(e for e in qm.events if isinstance(e, QueueMessageEndEvent)) + + +def _saved_user_query(qm: _FakeQueueManager) -> str: + prompt_messages = _message_end(qm).llm_result.prompt_messages + assert len(prompt_messages) == 1 + return prompt_messages[0].content + + def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(): client = FakeAgentBackendRunClient() # SUCCESS: output {"text": "hello agent"} store = _FakeSessionStore() @@ -142,13 +185,17 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(): assert chunk_events[0].chunk.delta.message.content == "hello agent" assert end_events[0].llm_result.message.content == "hello agent" assert end_events[0].llm_result.model == "gpt-4o-mini" + assert _saved_user_query(qm) == "hello" # The conversation session snapshot is persisted for multi-turn continuity. assert store.saved - saved_scope, saved_run_id, saved_snapshot, saved_specs = store.saved[0] + saved_scope, saved_run_id, saved_snapshot, saved_specs, pending_form_id, pending_tool_call_id = store.saved[0] assert saved_scope.conversation_id == "conv-1" assert saved_scope.agent_config_snapshot_id == "snap-1" assert saved_run_id == "fake-run-1" assert saved_snapshot is not None + # A successful turn carries no ask_human pause correlation. + assert pending_form_id is None + assert pending_tool_call_id is None assert [spec.name for spec in saved_specs] == [ "agent_soul_prompt", "agent_app_user_prompt", @@ -197,3 +244,69 @@ def test_extract_answer_handles_plain_string_and_dict(): assert AgentAppRunner._extract_answer("plain text") == "plain text" assert AgentAppRunner._extract_answer({"text": "hi"}) == "hi" assert AgentAppRunner._extract_answer({"a": 1}) == '{"a": 1}' + + +def test_ask_human_pauses_turn_creates_form_and_persists_correlation(): + # ENG-635/637: the PAUSED scenario emits a dify.ask_human deferred call, so + # the chat turn ends by creating a conversation-owned HITL form + saving the + # pause correlation, instead of crashing. Stub the form repo (DB-free). + client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.PAUSED) + store = _FakeSessionStore() + qm = _FakeQueueManager() + runner = _runner(client, store) + + fake_repo = MagicMock() + fake_repo.create_form.return_value = MagicMock(id="form-1") + runner._build_form_repository = lambda dify_context: fake_repo # type: ignore[assignment] + + _run(runner, qm) + + # The conversation-owned form was created and the agent's question surfaced. + fake_repo.create_form.assert_called_once() + created_params = fake_repo.create_form.call_args.args[0] + assert created_params.conversation_id == "conv-1" + assert created_params.workflow_execution_id is None + assert [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert _saved_user_query(qm) == "hello" + # The pause correlation is persisted so a form submission can resume the run. + assert store.saved + assert store.saved[0][4] == "form-1" + assert store.saved[0][5] == "fake-ask-human-1" + + +def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch): + # ENG-638: a turn that runs while a pending form is answered threads the + # human's reply into the request as deferred_tool_results. + snapshot = CompositorSessionSnapshot(layers=[]) + stored = StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id="tenant-1", + app_id="app-1", + conversation_id="conv-1", + agent_id="agent-1", + agent_config_snapshot_id="snap-1", + ), + session_snapshot=snapshot, + backend_run_id="run-0", + pending_form_id="form-1", + pending_tool_call_id="call-1", + ) + store = _FakeSessionStore(loaded_session=stored) + submitted = AskHumanResumeOutcome(deferred_result=AskHumanToolResult(status="submitted", values={"ok": True})) + monkeypatch.setattr( + "core.app.apps.agent_app.app_runner.resolve_ask_human_form", + lambda **_kwargs: submitted, + ) + + client = FakeAgentBackendRunClient() # SUCCESS -> the resumed run completes + qm = _FakeQueueManager() + _run(_runner(client, store), qm) + + assert client.request is not None + assert client.request.deferred_tool_results is not None + assert set(client.request.deferred_tool_results.calls) == {"call-1"} + # ENG-638: the resume composition must keep the user-prompt layer so it + # matches the suspended snapshot's layer names (the agent backend rejects a + # mismatch). A resume therefore re-sends a non-blank query, never blank. + layer_names = [layer.name for layer in client.request.composition.layers] + assert "agent_app_user_prompt" in layer_names diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py index e1c8d51b0d9..31bc4a10806 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py @@ -65,6 +65,13 @@ def _answer_text(events: list[Any]) -> str: return end.llm_result.message.content +def _saved_user_query(events: list[Any]) -> str: + end = next(e for e in events if isinstance(e, QueueMessageEndEvent)) + prompt_messages = end.llm_result.prompt_messages + assert len(prompt_messages) == 1 + return prompt_messages[0].content + + class TestRunInputGuards: def test_no_guards_passes_through(self, monkeypatch): _patch_moderation(monkeypatch, returns=(False, {}, "hello")) @@ -113,6 +120,7 @@ class TestRunInputGuards: assert handled is True assert any(isinstance(e, QueueLLMChunkEvent) for e in qm.events) assert _answer_text(qm.events) == "blocked preset answer" + assert _saved_user_query(qm.events) == "forbidden" def test_annotation_hit_short_circuits(self, monkeypatch): _patch_moderation(monkeypatch, returns=(False, {}, "what is your name")) @@ -131,3 +139,4 @@ class TestRunInputGuards: assert len(annotation_events) == 1 assert annotation_events[0].message_annotation_id == "anno-1" assert _answer_text(qm.events) == "I am the annotated Iris." + assert _saved_user_query(qm.events) == "what is your name" diff --git a/api/tests/unit_tests/core/plugin/test_plugin_manager.py b/api/tests/unit_tests/core/plugin/test_plugin_manager.py index 510aedd5516..1c5e1b9c0ca 100644 --- a/api/tests/unit_tests/core/plugin/test_plugin_manager.py +++ b/api/tests/unit_tests/core/plugin/test_plugin_manager.py @@ -33,6 +33,7 @@ from core.plugin.entities.plugin_daemon import ( PluginInstallTaskStartResponse, PluginInstallTaskStatus, PluginListResponse, + PluginListWithoutTotalResponse, PluginReadmeResponse, PluginVerification, ) @@ -123,6 +124,26 @@ class TestPluginDiscovery: assert call_args[1]["params"]["page_size"] == 5 assert result.total == 10 + def test_list_plugins_by_category(self, plugin_installer, mock_plugin_entity): + """Test category plugin listing without total.""" + mock_response = PluginListWithoutTotalResponse(list=[mock_plugin_entity], has_more=True) + + with patch.object( + plugin_installer, "_request_with_plugin_daemon_response", return_value=mock_response + ) as mock_request: + result = plugin_installer.list_plugins_by_category( + "test-tenant", category=PluginCategory.Tool, page=2, page_size=10 + ) + + mock_request.assert_called_once() + call_args = mock_request.call_args + assert call_args.args[1] == "plugin/test-tenant/management/tool/list" + assert call_args.args[2] is PluginListWithoutTotalResponse + assert call_args.kwargs["params"]["page"] == 2 + assert call_args.kwargs["params"]["page_size"] == 10 + assert result.list == [mock_plugin_entity] + assert result.has_more is True + def test_list_plugins_empty_result(self, plugin_installer): """Test plugin listing when no plugins are installed.""" # Arrange: Mock empty response diff --git a/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py b/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py index 95878fc688b..35e581ccc15 100644 --- a/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py +++ b/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py @@ -168,6 +168,13 @@ class TestWaterCrawlAPIClient: assert client.process_response(_response(200, {"ok": True})) == {"ok": True} assert client.process_response(_response(200, None)) == {} + def test_process_response_accepts_json_content_type_parameters(self): + client = WaterCrawlAPIClient(api_key="k") + + response = _response(200, {"ok": True}, content_type="application/json; charset=utf-8") + + assert client.process_response(response) == {"ok": True} + def test_process_response_octet_stream_returns_bytes(self): client = WaterCrawlAPIClient(api_key="k") assert ( @@ -242,11 +249,18 @@ class TestWaterCrawlAPIClient: client = WaterCrawlAPIClient(api_key="k") response = _response(200, {"markdown": "body"}) - monkeypatch.setattr(client_module.httpx, "get", lambda *args, **kwargs: response) + captured = {} + + def fake_get(*args, **kwargs): + captured.update(kwargs) + return response + + monkeypatch.setattr(client_module.httpx, "get", fake_get) result = client.download_result({"result": "https://example.com/result.json"}) assert result["result"] == {"markdown": "body"} + assert captured["timeout"] is not None response.close.assert_called_once() diff --git a/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py b/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py index 4368e9cddc0..182930b19d1 100644 --- a/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py +++ b/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py @@ -528,21 +528,24 @@ class TestParagraphIndexProcessor: session.scalars.return_value = scalars_result with ( - patch("core.rag.index_processor.processor.paragraph_index_processor.db.session", session), patch( "core.rag.index_processor.processor.paragraph_index_processor.build_from_mapping", return_value=SimpleNamespace(id="file-1"), ) as mock_builder, patch("core.rag.index_processor.processor.paragraph_index_processor.logger") as mock_logger, ): - files = ParagraphIndexProcessor._extract_images_from_text("tenant-1", text) + files = ParagraphIndexProcessor._extract_images_from_text("tenant-1", text, session) assert len(files) == 1 assert mock_builder.call_count == 1 mock_logger.warning.assert_not_called() def test_extract_images_from_text_returns_empty_when_no_matches(self) -> None: - assert ParagraphIndexProcessor._extract_images_from_text("tenant-1", "no images here") == [] + scalars_result = Mock() + scalars_result.all.return_value = [] + session = Mock() + session.scalars.return_value = scalars_result + assert ParagraphIndexProcessor._extract_images_from_text("tenant-1", "no images here", session) == [] def test_extract_images_from_text_logs_when_build_fails(self) -> None: text = "![img](/files/11111111-1111-1111-1111-111111111111/image-preview)" @@ -562,14 +565,13 @@ class TestParagraphIndexProcessor: session.scalars.return_value = scalars_result with ( - patch("core.rag.index_processor.processor.paragraph_index_processor.db.session", session), patch( "core.rag.index_processor.processor.paragraph_index_processor.build_from_mapping", side_effect=RuntimeError("build failed"), ), patch("core.rag.index_processor.processor.paragraph_index_processor.logger") as mock_logger, ): - files = ParagraphIndexProcessor._extract_images_from_text("tenant-1", text) + files = ParagraphIndexProcessor._extract_images_from_text("tenant-1", text, session) assert files == [] mock_logger.warning.assert_called_once() @@ -608,10 +610,9 @@ class TestParagraphIndexProcessor: session.execute.return_value = execute_result with ( - patch("core.rag.index_processor.processor.paragraph_index_processor.db.session", session), patch("core.rag.index_processor.processor.paragraph_index_processor.logger") as mock_logger, ): - files = ParagraphIndexProcessor._extract_images_from_segment_attachments("tenant-1", "seg-1") + files = ParagraphIndexProcessor._extract_images_from_segment_attachments("tenant-1", "seg-1", session) assert len(files) == 1 mock_logger.warning.assert_called_once() @@ -622,7 +623,6 @@ class TestParagraphIndexProcessor: session = Mock() session.execute.return_value = execute_result - with patch("core.rag.index_processor.processor.paragraph_index_processor.db.session", session): - empty_files = ParagraphIndexProcessor._extract_images_from_segment_attachments("tenant-1", "seg-1") + empty_files = ParagraphIndexProcessor._extract_images_from_segment_attachments("tenant-1", "seg-1", session) assert empty_files == [] diff --git a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py index a2e10d924c2..5bd35e6d3c2 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py @@ -288,6 +288,7 @@ class _DummyForm: form_definition: str rendered_content: str expiration_time: datetime + conversation_id: str | None = None form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME created_at: datetime = dataclasses.field(default_factory=naive_utc_now) selected_action_id: str | None = None diff --git a/api/tests/unit_tests/core/repositories/test_human_input_repository.py b/api/tests/unit_tests/core/repositories/test_human_input_repository.py index edd8be86184..780687bec53 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_repository.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_repository.py @@ -73,6 +73,7 @@ class _DummyForm: form_definition: str rendered_content: str expiration_time: datetime + conversation_id: str | None = None form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME created_at: datetime = dataclasses.field(default_factory=naive_utc_now) selected_action_id: str | None = None diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py index 471e17468c2..b2a5ef3931e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py @@ -1,8 +1,9 @@ from types import SimpleNamespace from typing import cast -from unittest.mock import patch +from unittest.mock import MagicMock, patch from agenton.compositor import CompositorSessionSnapshot +from dify_agent.layers.ask_human import AskHumanToolResult from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceededEventData from clients.agent_backend import ( @@ -15,15 +16,22 @@ from clients.agent_backend import ( from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom from core.workflow.file_reference import build_file_reference from core.workflow.nodes.agent_v2 import DifyAgentNode +from core.workflow.nodes.agent_v2.ask_human_resume import AskHumanResumeOutcome from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingBundle, WorkflowAgentBindingResolver from core.workflow.nodes.agent_v2.entities import DifyAgentNodeData from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder -from core.workflow.nodes.agent_v2.session_store import WorkflowAgentRuntimeSessionStore, WorkflowAgentSessionScope +from core.workflow.nodes.agent_v2.session_store import ( + StoredWorkflowAgentSession, + WorkflowAgentRuntimeSessionStore, + WorkflowAgentSessionScope, +) from graphon.entities import GraphInitParams +from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod, FileType from graphon.node_events import PauseRequestedEvent, StreamCompletedEvent +from graphon.nodes.human_input.entities import UserActionConfig from graphon.runtime import GraphRuntimeState from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding @@ -113,12 +121,16 @@ class FakeBindingResolver(WorkflowAgentBindingResolver): class FakeSessionStore: def __init__(self, snapshot: CompositorSessionSnapshot | None = None) -> None: self.loaded_snapshot = snapshot + # ENG-638: set to simulate resume after a submitted/timed-out form. + self.loaded_session: StoredWorkflowAgentSession | None = None self.saved: list[ tuple[ WorkflowAgentSessionScope, str, CompositorSessionSnapshot | None, list[RuntimeLayerSpec], + str | None, + str | None, ] ] = [] self.cleaned: list[tuple[WorkflowAgentSessionScope, str | None]] = [] @@ -126,6 +138,9 @@ class FakeSessionStore: def load_active_snapshot(self, scope: WorkflowAgentSessionScope) -> CompositorSessionSnapshot | None: return self.loaded_snapshot + def load_active_session(self, scope: WorkflowAgentSessionScope) -> StoredWorkflowAgentSession | None: + return self.loaded_session + def save_active_snapshot( self, *, @@ -133,8 +148,12 @@ class FakeSessionStore: backend_run_id: str, snapshot: CompositorSessionSnapshot | None, runtime_layer_specs: list[RuntimeLayerSpec], + pending_form_id: str | None = None, + pending_tool_call_id: str | None = None, ) -> None: - self.saved.append((scope, backend_run_id, snapshot, list(runtime_layer_specs))) + self.saved.append( + (scope, backend_run_id, snapshot, list(runtime_layer_specs), pending_form_id, pending_tool_call_id) + ) def mark_cleaned( self, @@ -378,10 +397,13 @@ def test_agent_node_saves_success_snapshot_and_reuses_existing_snapshot(): assert len(events) == 1 assert store.saved - scope, backend_run_id, saved_snapshot, saved_specs = store.saved[0] + scope, backend_run_id, saved_snapshot, saved_specs, pending_form_id, pending_tool_call_id = store.saved[0] assert scope.workflow_run_id == "workflow-run-1" assert backend_run_id == "fake-run-1" assert saved_snapshot is not None + # A successful terminal carries no ask_human pause correlation. + assert pending_form_id is None + assert pending_tool_call_id is None assert client.request is not None assert client.request.session_snapshot is existing_snapshot # Persist enough composition shape to replay a cleanup run; plugin layers @@ -462,13 +484,100 @@ def test_agent_node_paused_run_requests_workflow_pause_and_persists_snapshot(): store = FakeSessionStore() node = _node(scenario=FakeAgentBackendScenario.PAUSED, session_store=store) + # ENG-636: the PAUSED scenario emits a dify.ask_human deferred call, so the + # node now builds a HITL form and pauses with HumanInputRequired. Stub the + # form repository so the unit test stays DB-free. + fake_repo = MagicMock() + fake_repo.create_form.return_value = MagicMock(id="form-1") + node._build_human_input_form_repository = lambda *, dify_ctx, workflow_run_id: fake_repo # type: ignore[assignment] + events = list(node._run()) assert len(events) == 1 assert isinstance(events[0], PauseRequestedEvent) + assert isinstance(events[0].reason, HumanInputRequired) + assert events[0].reason.form_id == "form-1" + assert events[0].reason.node_id == "agent-node" + fake_repo.create_form.assert_called_once() assert store.saved assert store.saved[0][1] == "fake-run-1" assert store.saved[0][3], "paused agent run should still persist replayable layer specs" + # ENG-637: the awaiting form + deferred tool_call correlation is persisted. + assert store.saved[0][4] == "form-1" + assert store.saved[0][5] == "fake-ask-human-1" + + +def _pending_session(snapshot: CompositorSessionSnapshot) -> StoredWorkflowAgentSession: + return StoredWorkflowAgentSession( + scope=WorkflowAgentSessionScope( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_run_id="workflow-run-1", + node_id="agent-node", + node_execution_id="exec-1", + binding_id="binding-1", + agent_id="agent-1", + agent_config_snapshot_id="snapshot-1", + ), + session_snapshot=snapshot, + backend_run_id="run-0", + pending_form_id="form-1", + pending_tool_call_id="call-1", + ) + + +def test_agent_node_resumes_with_deferred_tool_results_after_submitted_form(monkeypatch): + # ENG-638: a submitted form re-enters _run; the human's answer is threaded + # into the second Agent run as deferred_tool_results. + snapshot = CompositorSessionSnapshot(layers=[]) + store = FakeSessionStore(snapshot=snapshot) + store.loaded_session = _pending_session(snapshot) + + def _fake_resolve(*, form_id: str, tenant_id: str, node_id: str) -> AskHumanResumeOutcome: + assert form_id == "form-1" + return AskHumanResumeOutcome(deferred_result=AskHumanToolResult(status="submitted", values={"note": "ok"})) + + monkeypatch.setattr("core.workflow.nodes.agent_v2.agent_node.resolve_ask_human_form", _fake_resolve) + + client = FakeAgentBackendRunClient() # SUCCESS scenario -> second run completes + node = _node(agent_backend_client=client, session_store=store) + + events = list(node._run()) + + assert client.request is not None + assert client.request.deferred_tool_results is not None + assert set(client.request.deferred_tool_results.calls) == {"call-1"} + assert any(isinstance(event, StreamCompletedEvent) for event in events) + + +def test_agent_node_repauses_when_resumed_form_still_waiting(monkeypatch): + snapshot = CompositorSessionSnapshot(layers=[]) + store = FakeSessionStore(snapshot=snapshot) + store.loaded_session = _pending_session(snapshot) + + repause = HumanInputRequired( + form_id="form-1", + form_content="Approve?", + inputs=[], + actions=[UserActionConfig(id="ok", title="OK")], + node_id="agent-node", + node_title="Budget review", + ) + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.agent_node.resolve_ask_human_form", + lambda **_kwargs: AskHumanResumeOutcome(repause=repause), + ) + + client = FakeAgentBackendRunClient() + node = _node(agent_backend_client=client, session_store=store) + + events = list(node._run()) + + assert len(events) == 1 + assert isinstance(events[0], PauseRequestedEvent) + assert isinstance(events[0].reason, HumanInputRequired) + assert client.request is None # no second Agent run was created def test_agent_node_records_stream_usage_metadata(): diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py new file mode 100644 index 00000000000..5f656f13a29 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py @@ -0,0 +1,338 @@ +"""Unit tests for the ask_human -> HITL form translation layer (ENG-636).""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from dify_agent.layers.ask_human import AskHumanToolArgs +from dify_agent.protocol import DeferredToolCallPayload + +from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepository +from core.workflow.human_input_adapter import ( + EmailDeliveryMethod, + ExternalRecipient, + InteractiveSurfaceDeliveryMethod, +) +from core.workflow.nodes.agent_v2.ask_human_hitl import ( + AskHumanFormBuildError, + ask_human_args_to_node_data, + build_ask_human_pause_reason, + build_delivery_methods, + parse_ask_human_args, +) +from graphon.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + ParagraphInputConfig, + SelectInputConfig, +) +from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit +from models.agent_config_entities import AgentHumanContactConfig + + +def _args(**overrides: Any) -> AskHumanToolArgs: + payload: dict[str, Any] = {"question": "Approve the budget?"} + payload.update(overrides) + return AskHumanToolArgs.model_validate(payload) + + +def _deferred_call(args: dict[str, Any], *, tool_name: str = "ask_human") -> DeferredToolCallPayload: + return DeferredToolCallPayload(tool_call_id="call-1", tool_name=tool_name, args=args) + + +def _fake_repository(form_id: str = "form-123") -> MagicMock: + repo = MagicMock(spec=HumanInputFormRepository) + repo.create_form.return_value = MagicMock(id=form_id) + return repo + + +# ─────────────────────────── parse_ask_human_args ─────────────────────────── + + +def test_parse_ask_human_args_from_mapping() -> None: + parsed = parse_ask_human_args({"question": "Need a decision"}) + assert isinstance(parsed, AskHumanToolArgs) + assert parsed.question == "Need a decision" + + +def test_parse_ask_human_args_passthrough() -> None: + original = _args() + assert parse_ask_human_args(original) is original + + +def test_parse_ask_human_args_invalid_payload_raises() -> None: + with pytest.raises(AskHumanFormBuildError): + parse_ask_human_args({"question": ""}) # blank question is rejected + + +def test_parse_ask_human_args_non_mapping_raises() -> None: + with pytest.raises(AskHumanFormBuildError): + parse_ask_human_args("not a mapping") + + +# ───────────────────────── ask_human_args_to_node_data ────────────────────── + + +def test_node_data_maps_every_field_type() -> None: + args = _args( + fields=[ + {"type": "paragraph", "name": "reason", "label": "Reason", "default": "n/a"}, + { + "type": "select", + "name": "tier", + "label": "Tier", + "options": [{"value": "t1", "label": "Tier 1"}, {"value": "t2", "label": "Tier 2"}], + "default": "t2", + }, + {"type": "file", "name": "doc", "label": "Document"}, + {"type": "file-list", "name": "evidence", "label": "Evidence", "max_files": 3}, + ], + ) + + node_data = ask_human_args_to_node_data(args, node_title="Budget review") + + assert [type(i) for i in node_data.inputs] == [ + ParagraphInputConfig, + SelectInputConfig, + FileInputConfig, + FileListInputConfig, + ] + paragraph, select, _file, file_list = node_data.inputs + assert isinstance(paragraph, ParagraphInputConfig) + assert isinstance(select, SelectInputConfig) + assert isinstance(file_list, FileListInputConfig) + assert paragraph.output_variable_name == "reason" + assert paragraph.default is not None + assert paragraph.default.value == "n/a" + assert select.option_source.value == ["t1", "t2"] + assert file_list.number_limits == 3 + assert node_data.timeout == 36 + assert node_data.timeout_unit == TimeoutUnit.HOUR + assert node_data.title == "Budget review" + + +def test_node_data_form_content_embeds_title_question_and_field_markers() -> None: + args = _args( + title="Decision needed", + markdown="Some **context** here.", + fields=[{"type": "paragraph", "name": "reason", "label": "Reason", "required": True}], + ) + + content = ask_human_args_to_node_data(args, node_title="t").form_content + + assert "## Decision needed" in content + assert "Approve the budget?" in content + assert "Some **context** here." in content + # The label carries a required marker and positions the input via $output. + assert "**Reason ***" in content + assert "{{#$output.reason#}}" in content + + +def test_node_data_maps_action_styles_and_titles() -> None: + args = _args( + actions=[ + {"id": "approve", "label": "Approve", "style": "primary"}, + {"id": "reject", "label": "Reject", "style": "destructive"}, + {"id": "later", "label": "Decide later"}, + ], + ) + + actions = ask_human_args_to_node_data(args, node_title="t").user_actions + + assert [(a.id, a.title, a.button_style) for a in actions] == [ + ("approve", "Approve", ButtonStyle.PRIMARY), + ("reject", "Reject", ButtonStyle.ACCENT), # destructive -> accent + ("later", "Decide later", ButtonStyle.DEFAULT), + ] + + +def test_node_data_synthesizes_submit_action_when_none_given() -> None: + actions = ask_human_args_to_node_data(_args(), node_title="t").user_actions + assert len(actions) == 1 + assert actions[0].id == "submit" + assert actions[0].button_style == ButtonStyle.PRIMARY + + +def test_node_data_clamps_overlong_action_id_deterministically() -> None: + long_id = "approve_the_quarterly_budget_request" # > 20 chars, valid identifier + args = _args(actions=[{"id": long_id, "label": "Approve"}]) + + first = ask_human_args_to_node_data(args, node_title="t").user_actions[0] + second = ask_human_args_to_node_data(args, node_title="t").user_actions[0] + + assert len(first.id) <= 20 + assert first.id.isidentifier() + assert first.id == second.id # stable across builds + assert first.title == "Approve" # label preserved verbatim + + +# ───────────────────────────── build_delivery_methods ────────────────────── + + +def test_delivery_always_includes_interactive_surface() -> None: + methods = build_delivery_methods([], args=_args()) + assert len(methods) == 1 + assert isinstance(methods[0], InteractiveSurfaceDeliveryMethod) + + +def test_delivery_adds_email_for_contacts_and_dedupes() -> None: + contacts = [ + AgentHumanContactConfig(email="a@x.com"), + AgentHumanContactConfig(email="a@x.com"), # duplicate + AgentHumanContactConfig(email=None), # no email + AgentHumanContactConfig(email="b@x.com"), + ] + + methods = build_delivery_methods(contacts, args=_args()) + + email_methods = [m for m in methods if isinstance(m, EmailDeliveryMethod)] + assert len(email_methods) == 1 + recipients = email_methods[0].config.recipients.items + assert [r.email for r in recipients if isinstance(r, ExternalRecipient)] == ["a@x.com", "b@x.com"] + + +def test_delivery_high_urgency_prefixes_subject() -> None: + methods = build_delivery_methods( + [AgentHumanContactConfig(email="a@x.com")], + args=_args(title="Sign off", urgency="high"), + ) + email_method = next(m for m in methods if isinstance(m, EmailDeliveryMethod)) + assert email_method.config.subject.startswith("[Action needed] ") + + +# ─────────────────────────── build_ask_human_pause_reason ─────────────────── + + +def test_pause_reason_none_for_non_ask_human_tool() -> None: + result = build_ask_human_pause_reason( + deferred_tool_call=_deferred_call({"question": "q"}, tool_name="final_output"), + node_id="node-1", + default_node_title="Agent", + workflow_run_id="wf-1", + contacts=[], + repository=_fake_repository(), + ) + assert result is None + + +def test_pause_reason_requires_workflow_run_id() -> None: + with pytest.raises(AskHumanFormBuildError): + build_ask_human_pause_reason( + deferred_tool_call=_deferred_call({"question": "q"}), + node_id="node-1", + default_node_title="Agent", + workflow_run_id="", + contacts=[], + repository=_fake_repository(), + ) + + +def test_pause_reason_builds_form_and_returns_human_input_required() -> None: + repo = _fake_repository(form_id="form-xyz") + contacts = [AgentHumanContactConfig(email="a@x.com")] + + result = build_ask_human_pause_reason( + deferred_tool_call=_deferred_call( + { + "title": "Approve?", + "question": "Please approve", + "fields": [{"type": "paragraph", "name": "note", "label": "Note"}], + "actions": [{"id": "ok", "label": "OK"}], + } + ), + node_id="node-1", + default_node_title="Agent fallback", + workflow_run_id="wf-1", + contacts=contacts, + repository=repo, + ) + + assert result is not None + assert result.form_id == "form-xyz" + assert result.node_id == "node-1" + assert result.node_title == "Approve?" # args.title wins over default + assert [i.output_variable_name for i in result.inputs] == ["note"] + assert [a.id for a in result.actions] == ["ok"] + + params: FormCreateParams = repo.create_form.call_args.args[0] + assert params.workflow_execution_id == "wf-1" + assert params.node_id == "node-1" + # No conversation_id passed -> pure workflow run owns the form by workflow_run_id only. + assert params.conversation_id is None + assert any(isinstance(m, EmailDeliveryMethod) for m in params.delivery_methods) + + +def test_pause_reason_forwards_conversation_id_for_chatflow() -> None: + # ENG-635 (review): an agent node running in a chatflow tags its ask_human form + # with the conversation in addition to the workflow run. + repo = _fake_repository(form_id="form-xyz") + + build_ask_human_pause_reason( + deferred_tool_call=_deferred_call({"question": "Please approve"}), + node_id="node-1", + default_node_title="Agent", + workflow_run_id="wf-1", + conversation_id="conv-1", + contacts=[], + repository=repo, + ) + + params: FormCreateParams = repo.create_form.call_args.args[0] + assert params.workflow_execution_id == "wf-1" + assert params.conversation_id == "conv-1" + + +def test_pause_reason_falls_back_to_default_node_title() -> None: + result = build_ask_human_pause_reason( + deferred_tool_call=_deferred_call({"question": "q with no title"}), + node_id="node-1", + default_node_title="Agent fallback", + workflow_run_id="wf-1", + contacts=[], + repository=_fake_repository(), + ) + assert result is not None + assert result.node_title == "Agent fallback" + + +def test_pause_reason_select_default_flows_into_resolved_defaults() -> None: + repo = _fake_repository() + result = build_ask_human_pause_reason( + deferred_tool_call=_deferred_call( + { + "question": "pick", + "fields": [ + { + "type": "select", + "name": "tier", + "label": "Tier", + "options": [{"value": "t1", "label": "Tier 1"}], + "default": "t1", + } + ], + } + ), + node_id="node-1", + default_node_title="Agent", + workflow_run_id="wf-1", + contacts=[], + repository=repo, + ) + assert result is not None + assert result.resolved_default_values == {"tier": "t1"} + + +def test_pause_reason_wraps_repository_value_error() -> None: + repo = MagicMock(spec=HumanInputFormRepository) + repo.create_form.side_effect = ValueError("db boom") + with pytest.raises(AskHumanFormBuildError): + build_ask_human_pause_reason( + deferred_tool_call=_deferred_call({"question": "q"}), + node_id="node-1", + default_node_title="Agent", + workflow_run_id="wf-1", + contacts=[], + repository=repo, + ) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_resume.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_resume.py new file mode 100644 index 00000000000..8f7454313df --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_resume.py @@ -0,0 +1,121 @@ +"""Unit tests for mapping a submitted/timed-out HITL form back to ask_human (ENG-638).""" + +from __future__ import annotations + +from datetime import datetime + +from dify_agent.layers.ask_human import AskHumanToolResult + +from core.workflow.nodes.agent_v2.ask_human_resume import ( + build_deferred_tool_results, + map_form_to_outcome, +) +from graphon.entities.pause_reason import HumanInputRequired +from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig +from graphon.nodes.human_input.enums import HumanInputFormStatus + + +def _form_definition_json() -> str: + return FormDefinition( + form_content="Approve? {{#$output.note#}}", + inputs=[ParagraphInputConfig(output_variable_name="note")], + user_actions=[UserActionConfig(id="approve", title="Approve"), UserActionConfig(id="reject", title="Reject")], + rendered_content="Approve? ", + expiration_time=datetime(2026, 1, 1), + default_values={"note": "default"}, + node_title="Budget review", + ).model_dump_json() + + +def test_map_submitted_form_to_result() -> None: + outcome = map_form_to_outcome( + status=HumanInputFormStatus.SUBMITTED, + selected_action_id="approve", + submitted_data='{"note": "looks good"}', + rendered_content="Approve? ", + form_definition=_form_definition_json(), + form_id="form-1", + node_id="node-1", + ) + + assert outcome.repause is None + result = outcome.deferred_result + assert result is not None + assert result.status == "submitted" + assert result.action is not None + assert result.action.id == "approve" + assert result.action.label == "Approve" # verbatim label recovered from the form + assert result.values == {"note": "looks good"} + assert result.rendered_content == "Approve? " + + +def test_map_timeout_form_to_timeout_result() -> None: + outcome = map_form_to_outcome( + status=HumanInputFormStatus.TIMEOUT, + selected_action_id=None, + submitted_data=None, + rendered_content="x", + form_definition=_form_definition_json(), + form_id="form-1", + node_id="node-1", + ) + assert outcome.deferred_result is not None + assert outcome.deferred_result.status == "timeout" + assert outcome.deferred_result.action is None + + +def test_map_expired_form_to_timeout_result() -> None: + outcome = map_form_to_outcome( + status=HumanInputFormStatus.EXPIRED, + selected_action_id=None, + submitted_data=None, + rendered_content="x", + form_definition=_form_definition_json(), + form_id="form-1", + node_id="node-1", + ) + assert outcome.deferred_result is not None + assert outcome.deferred_result.status == "timeout" + + +def test_map_waiting_form_rebuilds_pause() -> None: + outcome = map_form_to_outcome( + status=HumanInputFormStatus.WAITING, + selected_action_id=None, + submitted_data=None, + rendered_content="x", + form_definition=_form_definition_json(), + form_id="form-1", + node_id="node-1", + ) + + assert outcome.deferred_result is None + pause = outcome.repause + assert isinstance(pause, HumanInputRequired) + assert pause.form_id == "form-1" + assert pause.node_id == "node-1" + assert pause.node_title == "Budget review" + assert [a.id for a in pause.actions] == ["approve", "reject"] + assert [i.output_variable_name for i in pause.inputs] == ["note"] + + +def test_map_submitted_without_action_id() -> None: + outcome = map_form_to_outcome( + status=HumanInputFormStatus.SUBMITTED, + selected_action_id=None, + submitted_data="{}", + rendered_content="x", + form_definition=_form_definition_json(), + form_id="form-1", + node_id="node-1", + ) + assert outcome.deferred_result is not None + assert outcome.deferred_result.action is None + assert outcome.deferred_result.values == {} + + +def test_build_deferred_tool_results_keys_by_tool_call_id() -> None: + result = AskHumanToolResult(status="timeout") + payload = build_deferred_tool_results(tool_call_id="call-42", result=result) + assert set(payload.calls) == {"call-42"} + assert payload.calls["call-42"] == result.model_dump(mode="json") diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py index 1a2e09fd817..1ff87614b08 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py @@ -17,6 +17,7 @@ from core.tools.entities.tool_entities import ( ToolInvokeMessage, ToolParameter, ) +from core.tools.tool_manager import ToolManager from core.workflow.nodes.agent_v2.plugin_tools_builder import ( WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError, @@ -32,6 +33,8 @@ class FakeRuntimeProvider: self.tool = tool self.last_agent_tool: AgentToolEntity | None = None self.last_invoke_from: InvokeFrom | None = None + self.last_allow_file_parameters: bool | None = None + self.last_use_default_for_missing_form_parameters: bool | None = None def get_agent_tool_runtime( self, @@ -41,11 +44,25 @@ class FakeRuntimeProvider: user_id: str | None = None, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, variable_pool: Any | None = None, + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> Tool: self.last_agent_tool = agent_tool self.last_invoke_from = invoke_from + self.last_allow_file_parameters = allow_file_parameters + self.last_use_default_for_missing_form_parameters = use_default_for_missing_form_parameters if isinstance(self.tool, Exception): raise self.tool + if self.tool.runtime is not None: + runtime_parameters = ToolManager._convert_tool_parameters_type( + self.tool.get_merged_runtime_parameters(), + variable_pool, + agent_tool.tool_parameters, + typ="agent", + allow_file_parameters=allow_file_parameters, + use_default_for_missing_form_parameters=use_default_for_missing_form_parameters, + ) + self.tool.runtime.runtime_parameters.update(runtime_parameters) return self.tool @@ -103,6 +120,67 @@ def _tool(*, runtime_parameters: dict[str, Any] | None = None) -> FakeTool: return FakeTool(entity=entity, runtime=runtime) +def _file_tool() -> FakeTool: + parameters = [ + ToolParameter( + name="audio_file", + label=I18nObject(en_US="Audio File"), + type=ToolParameter.ToolParameterType.FILE, + form=ToolParameter.ToolParameterForm.LLM, + required=True, + llm_description="The audio file to be converted.", + ) + ] + entity = ToolEntity( + identity=ToolIdentity( + author="langgenius", + name="asr", + label=I18nObject(en_US="Speech To Text"), + provider="audio", + ), + description=ToolDescription(human=I18nObject(en_US="Speech To Text"), llm="Convert audio file to text."), + parameters=parameters, + ) + runtime = ToolRuntime(tenant_id="tenant-1", user_id="user-1", credentials={}, runtime_parameters={}) + return FakeTool(entity=entity, runtime=runtime) + + +def _tts_tool() -> FakeTool: + parameters = [ + ToolParameter( + name="text", + label=I18nObject(en_US="Text"), + type=ToolParameter.ToolParameterType.STRING, + form=ToolParameter.ToolParameterForm.LLM, + required=True, + llm_description="The text to be converted.", + ), + ToolParameter( + name="model", + label=I18nObject(en_US="Model"), + type=ToolParameter.ToolParameterType.SELECT, + form=ToolParameter.ToolParameterForm.FORM, + required=True, + options=[ + {"value": "provider-a#model-a", "label": {"en_US": "model-a(provider-a)"}}, + {"value": "provider-b#model-b", "label": {"en_US": "model-b(provider-b)"}}, + ], + ), + ] + entity = ToolEntity( + identity=ToolIdentity( + author="langgenius", + name="tts", + label=I18nObject(en_US="Text To Speech"), + provider="audio", + ), + description=ToolDescription(human=I18nObject(en_US="Text To Speech"), llm="Convert text to audio file."), + parameters=parameters, + ) + runtime = ToolRuntime(tenant_id="tenant-1", user_id="user-1", credentials={}, runtime_parameters={}) + return FakeTool(entity=entity, runtime=runtime) + + def _build( builder: WorkflowAgentPluginToolsBuilder, tools: AgentSoulToolsConfig, @@ -157,6 +235,62 @@ def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime(): assert runtime_provider.last_agent_tool.provider_type.value == "plugin" +def test_builds_dify_plugin_tool_with_file_llm_parameter(): + runtime_provider = FakeRuntimeProvider(_file_tool()) + builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "audio", + "provider_type": "builtin", + "tool_name": "asr", + "credential_type": "unauthorized", + } + ] + } + ) + + result = _build(builder, tools) + + assert result is not None + prepared = result.tools[0] + assert prepared.tool_name == "asr" + assert prepared.runtime_parameters == {} + assert prepared.parameters[0].name == "audio_file" + assert prepared.parameters[0].type == "file" + # The public Agent backend DTO carries non-scalar tool inputs in + # ``parameters``; legacy JSON schema generation omits file fields. + assert prepared.parameters_json_schema == {"type": "object", "properties": {}, "required": []} + assert runtime_provider.last_allow_file_parameters is True + assert runtime_provider.last_use_default_for_missing_form_parameters is True + + +def test_builds_dify_plugin_tool_with_missing_required_select_default(): + runtime_provider = FakeRuntimeProvider(_tts_tool()) + builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "audio", + "provider_type": "builtin", + "tool_name": "tts", + "credential_type": "unauthorized", + } + ] + } + ) + + result = _build(builder, tools) + + assert result is not None + prepared = result.tools[0] + assert prepared.tool_name == "tts" + assert prepared.runtime_parameters == {"model": "provider-a#model-a"} + assert runtime_provider.last_use_default_for_missing_form_parameters is True + + def test_rejects_duplicate_exposed_tool_names(): builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool())) tools = AgentSoulToolsConfig.model_validate( diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 0fffb0617a3..f402f851b8b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -21,6 +21,9 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( AgentSoulConfig, AgentSoulModelConfig, + DeclaredArrayItem, + DeclaredOutputChildConfig, + DeclaredOutputConfig, DeclaredOutputType, WorkflowNodeJobConfig, ) @@ -321,6 +324,7 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys(): "secret_refs": [ {"variable": "TOKEN", "credential_id": "credential-1"}, {"name": "API_KEY", "provider_credential_id": "credential-2"}, + {"name": "EDITABLE_TOKEN", "value": "credential-3"}, {"ref": "missing-name"}, ], }, @@ -341,6 +345,7 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys(): assert config["secret_refs"] == [ {"name": "TOKEN", "ref": "credential-1"}, {"name": "API_KEY", "ref": "credential-2"}, + {"name": "EDITABLE_TOKEN", "ref": "credential-3"}, ] assert config["sandbox"] is None @@ -630,6 +635,40 @@ def test_array_output_emits_typed_items_per_array_item(): assert output_schema["required"] == ["tags"] +def test_nested_declared_output_emits_object_and_array_child_schema(): + profile_output = DeclaredOutputConfig( + name="profile", + type=DeclaredOutputType.OBJECT, + children=[ + DeclaredOutputChildConfig(name="email", type=DeclaredOutputType.STRING), + DeclaredOutputChildConfig( + name="nickname", + type=DeclaredOutputType.STRING, + required=False, + description="Optional display name", + ), + DeclaredOutputChildConfig( + name="addresses", + type=DeclaredOutputType.ARRAY, + array_item=DeclaredArrayItem( + type=DeclaredOutputType.OBJECT, + description="Address item", + children=[DeclaredOutputChildConfig(name="city", type=DeclaredOutputType.STRING)], + ), + ), + ], + ) + + schema = WorkflowAgentRuntimeRequestBuilder._schema_for_declared_output(profile_output) + + assert schema["properties"]["email"] == {"type": "string"} + assert schema["properties"]["nickname"] == {"type": "string", "description": "Optional display name"} + assert schema["properties"]["addresses"]["items"]["properties"]["city"] == {"type": "string"} + assert schema["properties"]["addresses"]["items"]["description"] == "Address item" + assert schema["properties"]["addresses"]["items"]["required"] == ["city"] + assert schema["required"] == ["email", "addresses"] + + def test_effective_declared_outputs_passthrough_when_user_declared(): """effective_declared_outputs() must return user-provided outputs verbatim when non-empty; only empty input gets PRD defaults injected.""" @@ -796,3 +835,36 @@ def test_build_drive_layer_config_all_refs_dangling_yields_no_config(): config, warnings = build_drive_layer_config(soul, agent_id="agent-1") assert config is None assert [w["code"] for w in warnings] == ["skill_ref_dangling"] + + +# ── ENG-635: ask_human layer gating + feature manifest ─────────────────────── + + +def test_build_ask_human_layer_config_gated_on_human_contacts(): + from dify_agent.layers.ask_human import DifyAskHumanLayerConfig + + from core.workflow.nodes.agent_v2.runtime_request_builder import build_ask_human_layer_config + + # no human involvement configured -> tool stays off + assert build_ask_human_layer_config(AgentSoulConfig()) is None + + soul = AgentSoulConfig.model_validate( + {"human": {"contacts": [{"id": "c-1", "name": "David", "email": "d@acme.com", "channel": "email"}]}} + ) + config = build_ask_human_layer_config(soul) + assert isinstance(config, DifyAskHumanLayerConfig) + assert config.enabled is True + + +def test_feature_manifest_marks_human_supported_when_configured(): + from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest + + soul = AgentSoulConfig.model_validate( + {"human": {"contacts": [{"id": "c-1", "name": "David", "email": "d@acme.com", "channel": "email"}]}} + ) + manifest = build_runtime_feature_manifest(soul) + assert "human" in manifest["supported"] + assert "human" not in manifest["reserved"] + assert manifest["reserved_status"]["human"] == "supported_by_ask_human_hitl" + # configured human no longer produces a "not executed" warning + assert all("human" not in w["section"] for w in manifest["unsupported_runtime_warnings"]) diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index 216ce513f8e..bdccea478d9 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -625,12 +625,43 @@ def test_dify_human_input_runtime_create_form_filters_debugger_delivery_methods( params = repository.create_form.call_args.args[0] assert params.node_id == "human-input-node" assert params.workflow_execution_id == "workflow-execution-id" + # No conversation_id_getter wired -> a pure workflow run leaves it None. + assert params.conversation_id is None assert params.display_in_ui is True assert len(params.delivery_methods) == 1 assert params.delivery_methods[0].type == DeliveryMethodType.EMAIL assert params.delivery_methods[0].config.recipients.items[0].reference_id == "user-id" +def test_dify_human_input_runtime_create_form_tags_conversation_id_for_chatflow() -> None: + # ENG-635 (review): a chatflow (advanced-chat) run carries a conversation, so its + # Human Input form is tagged with BOTH its workflow run and its conversation — + # making the form queryable per conversation without changing resume routing. + repository = MagicMock() + repository.create_form.return_value = sentinel.form + node_data = HumanInputNodeData( + title="Human Input", + delivery_methods=[WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig())], + ) + runtime = DifyHumanInputNodeRuntime( + _build_run_context(), + workflow_execution_id_getter=lambda: "workflow-execution-id", + conversation_id_getter=lambda: "conversation-id", + form_repository=repository, + ) + + runtime.create_form( + node_id="human-input-node", + node_data=node_data, + rendered_content="

Rendered

", + resolved_default_values={}, + ) + + params = repository.create_form.call_args.args[0] + assert params.workflow_execution_id == "workflow-execution-id" + assert params.conversation_id == "conversation-id" + + def test_dify_human_input_runtime_preserves_webapp_delivery_for_web_invocations() -> None: repository = MagicMock() repository.create_form.return_value = sentinel.form diff --git a/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py b/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py index 0edd0ace27e..791c665d93c 100644 --- a/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py +++ b/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py @@ -2,8 +2,10 @@ from __future__ import annotations +import logging import uuid -from unittest.mock import MagicMock, patch + +import pytest # --------------------------------------------------------------------------- # compute_trace_id_hex @@ -135,134 +137,128 @@ class TestEmitTelemetryLog: compute_trace_id_hex.cache_clear() compute_span_id_hex.cache_clear() - @patch("enterprise.telemetry.telemetry_log.logger") - def test_logs_info_with_event_name_and_signal(self, mock_logger: MagicMock) -> None: + def test_logs_info_with_event_name_and_signal(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log( + event_name="dify.workflow.run", + attributes={"tenant_id": "t1"}, + signal="metric_only", + ) - emit_telemetry_log( - event_name="dify.workflow.run", - attributes={"tenant_id": "t1"}, - signal="metric_only", - ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.levelno == logging.INFO + assert record.getMessage() == "telemetry.metric_only" + assert hasattr(record, "attributes") + assert record.attributes["dify.event.name"] == "dify.workflow.run" + assert record.attributes["dify.event.signal"] == "metric_only" + assert record.attributes["tenant_id"] == "t1" - mock_logger.info.assert_called_once() - args, kwargs = mock_logger.info.call_args - assert args[0] == "telemetry.%s" - assert args[1] == "metric_only" - extra = kwargs["extra"] - assert extra["attributes"]["dify.event.name"] == "dify.workflow.run" - assert extra["attributes"]["dify.event.signal"] == "metric_only" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_no_log_when_info_disabled(self, mock_logger: MagicMock) -> None: + def test_no_log_when_info_disabled(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = False + with caplog.at_level(logging.WARNING, logger="dify.telemetry"): + emit_telemetry_log(event_name="dify.workflow.run", attributes={}) - emit_telemetry_log(event_name="dify.workflow.run", attributes={}) + assert len(caplog.records) == 0 - mock_logger.info.assert_not_called() - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_trace_id_added_to_extra_when_valid_uuid(self, mock_logger: MagicMock) -> None: + def test_trace_id_added_to_extra_when_valid_uuid(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True uid = "123e4567-e89b-12d3-a456-426614174000" - emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source=uid) + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source=uid) - extra = mock_logger.info.call_args.kwargs["extra"] - assert "trace_id" in extra - assert len(extra["trace_id"]) == 32 + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "trace_id") + assert len(record.trace_id) == 32 - @patch("enterprise.telemetry.telemetry_log.logger") - def test_trace_id_absent_when_invalid_source(self, mock_logger: MagicMock) -> None: + def test_trace_id_absent_when_invalid_source(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source="bad-id") - emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source="bad-id") + assert len(caplog.records) == 1 + record = caplog.records[0] + assert not hasattr(record, "trace_id") - extra = mock_logger.info.call_args.kwargs["extra"] - assert "trace_id" not in extra - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_span_id_added_to_extra_when_valid_uuid(self, mock_logger: MagicMock) -> None: + def test_span_id_added_to_extra_when_valid_uuid(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True uid = "123e4567-e89b-12d3-a456-426614174000" - emit_telemetry_log(event_name="test.event", attributes={}, span_id_source=uid) + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, span_id_source=uid) - extra = mock_logger.info.call_args.kwargs["extra"] - assert "span_id" in extra - assert len(extra["span_id"]) == 16 + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "span_id") + assert len(record.span_id) == 16 - @patch("enterprise.telemetry.telemetry_log.logger") - def test_tenant_id_added_when_provided(self, mock_logger: MagicMock) -> None: + def test_tenant_id_added_when_provided(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, tenant_id="tenant-99") - emit_telemetry_log(event_name="test.event", attributes={}, tenant_id="tenant-99") + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "tenant_id") + assert record.tenant_id == "tenant-99" - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["tenant_id"] == "tenant-99" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_user_id_added_when_provided(self, mock_logger: MagicMock) -> None: + def test_user_id_added_when_provided(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, user_id="user-42") - emit_telemetry_log(event_name="test.event", attributes={}, user_id="user-42") + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "user_id") + assert record.user_id == "user-42" - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["user_id"] == "user-42" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_tenant_and_user_id_absent_when_not_provided(self, mock_logger: MagicMock) -> None: + def test_tenant_and_user_id_absent_when_not_provided(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}) - emit_telemetry_log(event_name="test.event", attributes={}) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert not hasattr(record, "tenant_id") + assert not hasattr(record, "user_id") - extra = mock_logger.info.call_args.kwargs["extra"] - assert "tenant_id" not in extra - assert "user_id" not in extra - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_caller_attributes_merged_into_attrs(self, mock_logger: MagicMock) -> None: + def test_caller_attributes_merged_into_attrs(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log( + event_name="dify.node.run", + attributes={"node_type": "code", "elapsed": 0.5}, + ) - emit_telemetry_log( - event_name="dify.node.run", - attributes={"node_type": "code", "elapsed": 0.5}, - ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "attributes") + assert record.attributes["node_type"] == "code" + assert record.attributes["elapsed"] == 0.5 - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["attributes"]["node_type"] == "code" - assert extra["attributes"]["elapsed"] == 0.5 - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_signal_span_detail_forwarded(self, mock_logger: MagicMock) -> None: + def test_signal_span_detail_forwarded(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, signal="span_detail") - emit_telemetry_log(event_name="test.event", attributes={}, signal="span_detail") - - args = mock_logger.info.call_args[0] - assert args[1] == "span_detail" - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["attributes"]["dify.event.signal"] == "span_detail" + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.getMessage() == "telemetry.span_detail" + assert hasattr(record, "attributes") + assert record.attributes["dify.event.signal"] == "span_detail" # --------------------------------------------------------------------------- @@ -277,51 +273,50 @@ class TestEmitMetricOnlyEvent: compute_trace_id_hex.cache_clear() compute_span_id_hex.cache_clear() - @patch("enterprise.telemetry.telemetry_log.logger") - def test_delegates_to_emit_telemetry_log_with_metric_only_signal(self, mock_logger: MagicMock) -> None: + def test_delegates_to_emit_telemetry_log_with_metric_only_signal(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_metric_only_event - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_metric_only_event( + event_name="dify.app.created", + attributes={"app_id": "app-1"}, + tenant_id="t1", + user_id="u1", + ) - emit_metric_only_event( - event_name="dify.app.created", - attributes={"app_id": "app-1"}, - tenant_id="t1", - user_id="u1", - ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "attributes") + assert record.attributes["dify.event.signal"] == "metric_only" + assert record.attributes["dify.event.name"] == "dify.app.created" + assert record.attributes["app_id"] == "app-1" + assert hasattr(record, "tenant_id") + assert record.tenant_id == "t1" + assert hasattr(record, "user_id") + assert record.user_id == "u1" - mock_logger.info.assert_called_once() - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["attributes"]["dify.event.signal"] == "metric_only" - assert extra["attributes"]["dify.event.name"] == "dify.app.created" - assert extra["attributes"]["app_id"] == "app-1" - assert extra["tenant_id"] == "t1" - assert extra["user_id"] == "u1" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_trace_and_span_ids_passed_through(self, mock_logger: MagicMock) -> None: + def test_trace_and_span_ids_passed_through(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_metric_only_event - mock_logger.isEnabledFor.return_value = True uid = "123e4567-e89b-12d3-a456-426614174000" - emit_metric_only_event( - event_name="dify.workflow.run", - attributes={}, - trace_id_source=uid, - span_id_source=uid, - ) + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_metric_only_event( + event_name="dify.workflow.run", + attributes={}, + trace_id_source=uid, + span_id_source=uid, + ) - extra = mock_logger.info.call_args.kwargs["extra"] - assert "trace_id" in extra - assert "span_id" in extra + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "trace_id") + assert hasattr(record, "span_id") - @patch("enterprise.telemetry.telemetry_log.logger") - def test_no_log_emitted_when_logger_disabled(self, mock_logger: MagicMock) -> None: + def test_no_log_emitted_when_logger_disabled(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_metric_only_event - mock_logger.isEnabledFor.return_value = False + with caplog.at_level(logging.WARNING, logger="dify.telemetry"): + emit_metric_only_event(event_name="dify.workflow.run", attributes={}) - emit_metric_only_event(event_name="dify.workflow.run", attributes={}) - - mock_logger.info.assert_not_called() + assert len(caplog.records) == 0 diff --git a/api/tests/unit_tests/libs/test_workspace_permission.py b/api/tests/unit_tests/libs/test_workspace_permission.py index 48d9b351a4d..93e69e11c4b 100644 --- a/api/tests/unit_tests/libs/test_workspace_permission.py +++ b/api/tests/unit_tests/libs/test_workspace_permission.py @@ -1,3 +1,4 @@ +import logging from unittest.mock import Mock, patch import pytest @@ -124,10 +125,9 @@ class TestWorkspacePermissionHelper: mock_enterprise_service.WorkspacePermissionService.get_permission.assert_called_once_with("test-workspace-id") - @patch("libs.workspace_permission.logger") @patch("libs.workspace_permission.EnterpriseService") @patch("libs.workspace_permission.dify_config") - def test_enterprise_service_error_fails_open(self, mock_config, mock_enterprise_service, mock_logger): + def test_enterprise_service_error_fails_open(self, mock_config, mock_enterprise_service, caplog): """On enterprise service error, should fail-open (allow) and log error.""" mock_config.ENTERPRISE_ENABLED = True @@ -135,8 +135,7 @@ class TestWorkspacePermissionHelper: mock_enterprise_service.WorkspacePermissionService.get_permission.side_effect = Exception("Service unavailable") # Should not raise (fail-open) - check_workspace_member_invite_permission("test-workspace-id") + with caplog.at_level(logging.ERROR, logger="libs.workspace_permission"): + check_workspace_member_invite_permission("test-workspace-id") - # Should log the error - mock_logger.exception.assert_called_once() - assert "Failed to check workspace invite permission" in str(mock_logger.exception.call_args) + assert "Failed to check workspace invite permission for test-workspace-id" in caplog.text diff --git a/api/tests/unit_tests/models/test_agent_config_entities.py b/api/tests/unit_tests/models/test_agent_config_entities.py index 51e51fb6d46..5538a1981de 100644 --- a/api/tests/unit_tests/models/test_agent_config_entities.py +++ b/api/tests/unit_tests/models/test_agent_config_entities.py @@ -1,7 +1,12 @@ import pytest from core.workflow.file_reference import build_file_reference -from models.agent_config_entities import DeclaredOutputConfig, DeclaredOutputType +from models.agent_config_entities import ( + DeclaredArrayItem, + DeclaredOutputChildConfig, + DeclaredOutputConfig, + DeclaredOutputType, +) def test_file_default_value_accepts_canonical_reference_mapping() -> None: @@ -92,3 +97,90 @@ def test_array_file_default_value_rejects_legacy_item_shape() -> None: }, } ) + + +def test_declared_array_item_rejects_nested_arrays_and_non_object_children() -> None: + with pytest.raises(ValueError, match="nested arrays"): + DeclaredArrayItem(type=DeclaredOutputType.ARRAY) + + with pytest.raises(ValueError, match="array_item.children"): + DeclaredArrayItem( + type=DeclaredOutputType.STRING, + children=[DeclaredOutputChildConfig(name="label", type=DeclaredOutputType.STRING)], + ) + + +def test_declared_output_child_validates_shape_and_defaults() -> None: + file_child = DeclaredOutputChildConfig(name="report", type=DeclaredOutputType.FILE) + assert file_child.file is not None + + array_child = DeclaredOutputChildConfig(name="items", type=DeclaredOutputType.ARRAY) + assert array_child.array_item is not None + assert array_child.array_item.type == DeclaredOutputType.OBJECT + + with pytest.raises(ValueError, match="output child name"): + DeclaredOutputChildConfig(name="bad-name", type=DeclaredOutputType.STRING) + + with pytest.raises(ValueError, match="file metadata"): + DeclaredOutputChildConfig(name="title", type=DeclaredOutputType.STRING, file={}) + + with pytest.raises(ValueError, match="array_item is only allowed"): + DeclaredOutputChildConfig( + name="title", + type=DeclaredOutputType.STRING, + array_item={"type": DeclaredOutputType.STRING}, + ) + + with pytest.raises(ValueError, match="children is only allowed"): + DeclaredOutputChildConfig( + name="title", + type=DeclaredOutputType.STRING, + children=[DeclaredOutputChildConfig(name="label", type=DeclaredOutputType.STRING)], + ) + + +def test_declared_output_validates_shape_and_defaults() -> None: + file_output = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE) + assert file_output.file is not None + + array_output = DeclaredOutputConfig(name="items", type=DeclaredOutputType.ARRAY) + assert array_output.array_item is not None + assert array_output.array_item.type == DeclaredOutputType.OBJECT + + default_failure_strategy = DeclaredOutputConfig.model_validate( + {"name": "summary", "type": "string", "failure_strategy": None} + ) + assert default_failure_strategy.failure_strategy.on_failure == "stop" + + with pytest.raises(ValueError, match="output name"): + DeclaredOutputConfig(name="bad-name", type=DeclaredOutputType.STRING) + + with pytest.raises(ValueError, match="file metadata"): + DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, file={}) + + with pytest.raises(ValueError, match="array_item is only allowed"): + DeclaredOutputConfig( + name="summary", + type=DeclaredOutputType.STRING, + array_item={"type": DeclaredOutputType.STRING}, + ) + + with pytest.raises(ValueError, match="children is only allowed"): + DeclaredOutputConfig( + name="summary", + type=DeclaredOutputType.STRING, + children=[DeclaredOutputChildConfig(name="title", type=DeclaredOutputType.STRING)], + ) + + with pytest.raises(ValueError, match="output check is only allowed"): + DeclaredOutputConfig.model_validate( + { + "name": "summary", + "type": "string", + "check": { + "enabled": True, + "prompt": "Compare output", + "benchmark_file_ref": {"name": "expected.pdf"}, + }, + } + ) diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index b64fc561709..5d6ba1d0c99 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -1,3 +1,4 @@ +import json from datetime import UTC, datetime from types import SimpleNamespace @@ -14,7 +15,14 @@ from models.agent import ( WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) -from models.agent_config_entities import AgentFileRefConfig, WorkflowNodeJobConfig +from models.agent_config_entities import ( + AgentFileRefConfig, + DeclaredArrayItem, + DeclaredOutputChildConfig, + DeclaredOutputConfig, + DeclaredOutputType, + WorkflowNodeJobConfig, +) from models.workflow import Workflow from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model @@ -23,6 +31,7 @@ from services.agent.composer_validator import ComposerConfigValidator from services.agent.errors import InvalidComposerConfigError from services.agent.roster_service import AgentRosterService from services.agent.workflow_publish_service import WorkflowAgentPublishService +from services.app_service import AppListParams, AppService from services.entities.agent_entities import AgentSoulConfig, ComposerSavePayload, ComposerSaveStrategy, ComposerVariant @@ -105,7 +114,7 @@ def test_load_workflow_composer_returns_empty_state(monkeypatch): effective = result["effective_declared_outputs"] assert [o["name"] for o in effective] == ["text", "files", "json"] files_output = next(o for o in effective if o["name"] == "files") - assert files_output["array_item"] == {"type": "file", "description": None} + assert files_output["array_item"] == {"type": "file", "description": None, "children": []} def test_load_workflow_composer_serializes_existing_binding(monkeypatch): @@ -642,6 +651,7 @@ def test_roster_list_and_invite_options(monkeypatch): lambda version_ids: {"version-1": version, "version-2": unconfigured_version}, ) monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {}) + monkeypatch.setattr(service, "_load_published_active_snapshot_agent_ids", lambda **kwargs: {"agent-1"}) listed = service.list_roster_agents(tenant_id="tenant-1", page=1, limit=20) invited = service.list_invite_options(tenant_id="tenant-1", page=1, limit=20, app_id="app-1") @@ -654,6 +664,8 @@ def test_roster_list_and_invite_options(monkeypatch): assert listed["data"][0]["created_at"] == int(created_at.timestamp()) assert listed["data"][0]["updated_at"] == int(updated_at.timestamp()) assert listed["data"][0]["active_config_snapshot"]["created_at"] == int(version_created_at.timestamp()) + assert listed["data"][0]["active_config_is_published"] is True + assert listed["data"][1]["active_config_is_published"] is False assert invited["data"][0]["is_in_current_workflow"] is True assert invited["data"][0]["existing_node_ids"] == ["node-1"] @@ -683,6 +695,7 @@ def test_invite_options_uses_db_filtered_pagination(monkeypatch): }, ) monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {}) + monkeypatch.setattr(service, "_load_published_active_snapshot_agent_ids", lambda **kwargs: set()) result = service.list_invite_options(tenant_id="tenant-1", page=1, limit=1) @@ -691,6 +704,97 @@ def test_invite_options_uses_db_filtered_pagination(monkeypatch): assert [item["id"] for item in result["data"]] == ["agent-2"] +def test_active_config_is_published_flags_handle_matching_and_empty_snapshots(): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Published", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="version-1", + ) + draft_agent = Agent( + id="agent-2", + tenant_id="tenant-1", + name="Draft", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id=None, + ) + service = AgentRosterService(FakeSession(scalars=[["agent-1"], ["agent-1"]])) + + flags = service.load_active_config_is_published_by_agent_id(tenant_id="tenant-1", agents=[agent, draft_agent]) + + assert flags == {"agent-1": True, "agent-2": False} + assert service.active_config_is_published(tenant_id="tenant-1", agent=agent) is True + assert AgentRosterService(FakeSession()).load_active_config_is_published_by_agent_id( + tenant_id="tenant-1", + agents=[draft_agent], + ) == {"agent-2": False} + + +def test_published_references_include_app_display_fields_and_sort_by_updated_at(): + recent_updated_at = datetime(2026, 1, 7, 3, 4, 5, tzinfo=UTC) + stale_updated_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC) + bindings = [ + SimpleNamespace( + tenant_id="tenant-1", + agent_id="agent-1", + app_id="app-stale", + workflow_id="workflow-stale", + workflow_version="published-stale", + node_id="node-b", + ), + SimpleNamespace( + tenant_id="tenant-1", + agent_id="agent-1", + app_id="app-recent", + workflow_id="workflow-recent", + workflow_version="published-recent", + node_id="node-a", + ), + ] + apps = [ + SimpleNamespace( + id="app-stale", + name="Stale Workflow", + mode="advanced-chat", + workflow_id="workflow-stale", + icon_type=SimpleNamespace(value="emoji"), + icon="old", + icon_background="#F3F4F6", + updated_at=stale_updated_at, + ), + SimpleNamespace( + id="app-recent", + name="Recent Workflow", + mode="advanced-chat", + workflow_id="workflow-recent", + icon_type=SimpleNamespace(value="image"), + icon="upload-file-id", + icon_background="#E0F2FE", + updated_at=recent_updated_at, + ), + ] + service = AgentRosterService(FakeSession(scalars=[bindings, apps])) + + result = service._load_published_references_by_agent_id(tenant_id="tenant-1", agent_ids=["agent-1"]) + + references = result["agent-1"] + assert [item["app_id"] for item in references] == ["app-recent", "app-stale"] + assert references[0]["app_icon_type"] == "image" + assert references[0]["app_icon"] == "upload-file-id" + assert references[0]["app_icon_background"] == "#E0F2FE" + assert references[0]["app_updated_at"] == int(recent_updated_at.timestamp()) + assert references[0]["workflow_version"] == "published-recent" + + def test_roster_update_archive_versions_and_detail(monkeypatch): listed_version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2) listed_version_created_at = datetime(2026, 1, 5, 3, 4, 5, tzinfo=UTC) @@ -707,7 +811,7 @@ def test_roster_update_archive_versions_and_detail(monkeypatch): created_by="account-1", created_at=revision_created_at, ) - fake_session = FakeSession(scalars=[[listed_version], [revision]]) + fake_session = FakeSession(scalar=["visible-revision"], scalars=[[listed_version], [revision]]) agent = Agent( id="agent-1", tenant_id="tenant-1", @@ -763,6 +867,7 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch): payload = roster_service.RosterAgentCreatePayload( name="Analyst", description="desc", + role="Research assistant", icon_type="emoji", icon="A", icon_background="#fff", @@ -776,6 +881,7 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch): account_id="account-1", app_id="app-1", name="Backing Agent", + role="Support agent", ) found_agent = service._get_agent(tenant_id="tenant-1", agent_id="agent-1") with pytest.raises(roster_service.AgentNotFoundError): @@ -787,9 +893,11 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch): assert service._load_versions_by_id([]) == {} assert created.name == "Analyst" + assert created.role == "Research assistant" assert created.source == AgentSource.ROSTER assert created.active_config_snapshot_id is not None assert created.active_config_has_model is False + assert backing_agent.role == "Support agent" assert backing_agent.active_config_snapshot_id is not None assert backing_agent.active_config_has_model is False assert found_agent.id == "agent-1" @@ -797,6 +905,30 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch): assert loaded_versions["version-1"].agent_id == "agent-1" +def test_agent_app_visible_versions_exclude_draft_saves(): + agent_app = Agent(source=AgentSource.AGENT_APP) + roster_agent = Agent(source=AgentSource.ROSTER) + + agent_app_operations = AgentRosterService._visible_version_operations(agent_app) + roster_operations = AgentRosterService._visible_version_operations(roster_agent) + + assert agent_app_operations == {AgentConfigRevisionOperation.SAVE_NEW_VERSION} + assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in agent_app_operations + assert AgentConfigRevisionOperation.CREATE_VERSION in roster_operations + assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in roster_operations + + +def test_app_list_all_excludes_agent_apps_by_default(): + filters = AppService._build_app_list_filters( + "account-1", + "tenant-1", + AppListParams(mode="all"), + ) + sql = " ".join(str(filter_) for filter_ in filters) + + assert "apps.mode != :mode_1" in sql + + def test_validator_dict_helpers_wrap_validation_errors(): valid_soul = ComposerConfigValidator.validate_agent_soul_dict({"prompt": {"system_prompt": "x"}}) valid_node_job = ComposerConfigValidator.validate_node_job_dict({"workflow_prompt": "x"}) @@ -951,7 +1083,7 @@ def test_composer_validator_accepts_valid_shell_env_and_cli(): { "env": { "variables": [{"name": "MY_VAR", "value": "v"}], - "secret_refs": [{"name": "API_TOKEN", "id": "credential-1"}], + "secret_refs": [{"name": "API_TOKEN", "value": "credential-1"}], }, "tools": { "cli_tools": [ @@ -960,7 +1092,7 @@ def test_composer_validator_accepts_valid_shell_env_and_cli(): "command": "apt-get install -y jq", "env": { "variables": [{"name": "JQ_COLOR", "value": "1"}], - "secret_refs": [{"name": "JQ_TOKEN", "id": "credential-2"}], + "secret_refs": [{"name": "JQ_TOKEN", "value": "credential-2"}], }, }, { @@ -976,8 +1108,10 @@ def test_composer_validator_accepts_valid_shell_env_and_cli(): ) assert {variable.name for variable in config.env.variables} == {"MY_VAR"} assert {secret.name for secret in config.env.secret_refs} == {"API_TOKEN"} + assert config.env.secret_refs[0].value == "credential-1" assert config.tools.cli_tools[0].env.variables[0].name == "JQ_COLOR" assert config.tools.cli_tools[0].env.secret_refs[0].name == "JQ_TOKEN" + assert config.tools.cli_tools[0].env.secret_refs[0].value == "credential-2" class TestAgentAppBackingAgent: @@ -1031,6 +1165,31 @@ class TestAgentAppBackingAgent: assert service.get_app_backing_agent(tenant_id="tenant-1", app_id="app-x") is None + def test_get_agent_app_model_resolves_app_backing_agent(self): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Iris", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + app_id="app-1", + ) + app = SimpleNamespace(id="app-1", mode="agent", status="normal") + session = FakeSession(scalar=[agent, app]) + service = AgentRosterService(session) + + assert service.get_agent_app_model(tenant_id="tenant-1", agent_id="agent-1") is app + + def test_get_agent_app_model_rejects_unbound_agent(self): + session = FakeSession() + service = AgentRosterService(session) + + with pytest.raises(roster_service.AgentNotFoundError): + service.get_agent_app_model(tenant_id="tenant-1", agent_id="agent-x") + class TestListWorkflowsReferencingAppAgent: def test_groups_bindings_by_workflow_app_and_sorts_by_name(self): @@ -1109,13 +1268,111 @@ class TestListWorkflowsReferencingAppAgent: class TestWorkflowAgentDraftBindingSync: + def test_projects_binding_declared_outputs_to_draft_graph_response(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "roster_agent", + "agent_id": "agent-1", + }, + }, + } + ], + "edges": [], + } + ), + ) + binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="agent-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig( + workflow_prompt="Summarize the upstream result.", + declared_outputs=[ + DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary"), + DeclaredOutputConfig( + name="profile", + type=DeclaredOutputType.OBJECT, + children=[ + DeclaredOutputChildConfig(name="email", type=DeclaredOutputType.STRING), + DeclaredOutputChildConfig( + name="addresses", + type=DeclaredOutputType.ARRAY, + array_item=DeclaredArrayItem( + type=DeclaredOutputType.OBJECT, + children=[DeclaredOutputChildConfig(name="city", type=DeclaredOutputType.STRING)], + ), + ), + ], + ), + ], + ), + ) + session = FakeSession(scalars=[[binding]]) + + graph = WorkflowAgentPublishService.project_draft_bindings_to_graph( + session=session, + draft_workflow=workflow, + ) + + node_data = graph["nodes"][0]["data"] + assert node_data["agent_task"] == "Summarize the upstream result." + assert node_data["agent_declared_outputs"][0]["name"] == "summary" + assert node_data["agent_declared_outputs"][0]["type"] == "string" + assert node_data["agent_declared_outputs"][0]["description"] == "Short summary" + profile_output = node_data["agent_declared_outputs"][1] + assert profile_output["children"][0]["name"] == "email" + assert profile_output["children"][1]["array_item"]["children"][0]["name"] == "city" + assert "agent_declared_outputs" not in workflow.graph_dict["nodes"][0]["data"] + def test_creates_roster_binding_from_agent_node_graph(self): workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, - graph='{"nodes":[{"id":"agent-node","data":{"type":"agent","version":"2","agent_binding":{"binding_type":"roster_agent","agent_id":"agent-1"}}}]}', + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_task": "Summarize the upstream result.", + "agent_declared_outputs": [ + { + "name": "summary", + "type": "string", + "description": "Short summary", + } + ], + "agent_binding": { + "binding_type": "roster_agent", + "agent_id": "agent-1", + }, + }, + } + ] + } + ), ) agent = Agent( id="agent-1", @@ -1139,7 +1396,370 @@ class TestWorkflowAgentDraftBindingSync: assert binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT assert binding.agent_id == "agent-1" assert binding.current_snapshot_id == "snapshot-2" - assert binding.node_job_config_dict == WorkflowNodeJobConfig().model_dump(mode="json") + assert binding.node_job_config_dict == WorkflowNodeJobConfig( + workflow_prompt="Summarize the upstream result.", + declared_outputs=[ + DeclaredOutputConfig( + name="summary", + type=DeclaredOutputType.STRING, + description="Short summary", + ) + ], + ).model_dump(mode="json") + + def test_creates_inline_binding_from_agent_node_graph(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_task": "Use the current node context.", + "agent_binding": { + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + "current_snapshot_id": "inline-snapshot-1", + }, + }, + } + ] + } + ), + ) + agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Workflow Agent agent-node", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + app_id="app-1", + workflow_id="workflow-1", + workflow_node_id="agent-node", + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-snapshot-1", + ) + snapshot = AgentConfigSnapshot( + id="inline-snapshot-1", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=1, + config_snapshot=AgentSoulConfig(), + ) + session = FakeSession(scalar=[agent, snapshot], scalars=[[]]) + + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + binding = next(item for item in session.added if isinstance(item, WorkflowAgentNodeBinding)) + assert binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT + assert binding.agent_id == "inline-agent-1" + assert binding.current_snapshot_id == "inline-snapshot-1" + assert binding.node_job_config_dict == WorkflowNodeJobConfig( + workflow_prompt="Use the current node context.", + ).model_dump(mode="json") + + def test_rejects_inline_binding_for_agent_owned_by_another_node(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + "current_snapshot_id": "inline-snapshot-1", + }, + }, + } + ] + } + ), + ) + agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Workflow Agent other-node", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + app_id="app-1", + workflow_id="workflow-1", + workflow_node_id="other-node", + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-snapshot-1", + ) + session = FakeSession(scalar=[agent], scalars=[[]]) + + with pytest.raises(ValueError, match="inline_agent binding does not belong to this node"): + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + def test_rejects_agent_node_graph_binding_with_unsupported_type(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "unknown", + "agent_id": "agent-1", + }, + }, + } + ] + } + ), + ) + + with pytest.raises(ValueError, match="unsupported agent_binding type"): + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=FakeSession(scalars=[[]]), + draft_workflow=workflow, + account_id="account-1", + ) + + def test_rejects_inline_binding_without_current_snapshot_id(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + }, + }, + } + ] + } + ), + ) + + with pytest.raises(ValueError, match="inline_agent binding requires current_snapshot_id"): + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=FakeSession(scalars=[[]]), + draft_workflow=workflow, + account_id="account-1", + ) + + def test_rejects_inline_binding_with_missing_snapshot(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_binding": { + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + "current_snapshot_id": "missing-snapshot", + }, + }, + } + ] + } + ), + ) + agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Workflow Agent agent-node", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + app_id="app-1", + workflow_id="workflow-1", + workflow_node_id="agent-node", + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-snapshot-1", + ) + + with pytest.raises(ValueError, match="missing inline agent config snapshot"): + WorkflowAgentPublishService.sync_agent_bindings_for_draft( + session=FakeSession(scalar=[agent, None], scalars=[[]]), + draft_workflow=workflow, + account_id="account-1", + ) + + def test_updates_existing_roster_binding_prompt_from_agent_node_graph(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_task": "Use the latest tender context.", + "agent_binding": { + "binding_type": "roster_agent", + "agent_id": "agent-1", + }, + }, + } + ] + } + ), + ) + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Agent", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="snapshot-2", + ) + existing_binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="agent-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig( + workflow_prompt="Old prompt", + declared_outputs=[ + DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary") + ], + ), + ) + session = FakeSession(scalar=[agent], scalars=[[existing_binding]]) + + WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + node_job = WorkflowNodeJobConfig.model_validate(existing_binding.node_job_config_dict) + assert node_job.workflow_prompt == "Use the latest tender context." + assert [output.name for output in node_job.declared_outputs] == ["summary"] + assert existing_binding.current_snapshot_id == "snapshot-2" + + def test_updates_existing_roster_binding_declared_outputs_from_agent_node_graph(self): + workflow = Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [ + { + "id": "agent-node", + "data": { + "type": "agent", + "version": "2", + "agent_task": "Keep the prompt.", + "agent_declared_outputs": [], + "agent_binding": { + "binding_type": "roster_agent", + "agent_id": "agent-1", + }, + }, + } + ] + } + ), + ) + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Agent", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="snapshot-2", + ) + existing_binding = WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="agent-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig( + workflow_prompt="Old prompt", + declared_outputs=[ + DeclaredOutputConfig( + name="summary", + type=DeclaredOutputType.STRING, + description="Short summary", + ) + ], + ), + ) + session = FakeSession(scalar=[agent], scalars=[[existing_binding]]) + + WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft( + session=session, + draft_workflow=workflow, + account_id="account-1", + ) + + node_job = WorkflowNodeJobConfig.model_validate(existing_binding.node_job_config_dict) + assert node_job.workflow_prompt == "Keep the prompt." + assert node_job.declared_outputs == [] + assert existing_binding.current_snapshot_id == "snapshot-2" def test_deletes_draft_binding_when_agent_node_removed(self): workflow = Workflow( diff --git a/api/tests/unit_tests/services/auth/test_jina_auth.py b/api/tests/unit_tests/services/auth/test_jina_auth.py index 2c34d46f1e4..eb409c61d4e 100644 --- a/api/tests/unit_tests/services/auth/test_jina_auth.py +++ b/api/tests/unit_tests/services/auth/test_jina_auth.py @@ -68,6 +68,22 @@ class TestJinaAuth: auth.validate_credentials() assert str(exc_info.value) == "Failed to authorize. Status code: 402. Error: Payment required" + @patch("services.auth.jina.jina._http_client.post", autospec=True) + def test_should_handle_http_error_with_non_json_text_response(self, mock_post): + """Test handling of known HTTP errors with non-JSON text response.""" + mock_response = MagicMock() + mock_response.status_code = 402 + mock_response.text = "Payment required" + mock_response.json.side_effect = ValueError("Not JSON") + mock_post.return_value = mock_response + + credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}} + auth = JinaAuth(credentials) + + with pytest.raises(Exception) as exc_info: + auth.validate_credentials() + assert str(exc_info.value) == "Failed to authorize. Status code: 402. Error: Payment required" + @patch("services.auth.jina.jina._http_client.post", autospec=True) def test_should_handle_http_409_error(self, mock_post): """Test handling of 409 Conflict error""" @@ -114,6 +130,22 @@ class TestJinaAuth: auth.validate_credentials() assert str(exc_info.value) == "Failed to authorize. Status code: 403. Error: Forbidden" + @patch("services.auth.jina.jina._http_client.post", autospec=True) + def test_should_handle_unexpected_error_with_non_json_text_response(self, mock_post): + """Test handling of unexpected errors with non-JSON text response.""" + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.text = "Forbidden" + mock_response.json.side_effect = Exception("Not JSON") + mock_post.return_value = mock_response + + credentials = {"auth_type": "bearer", "config": {"api_key": "test_api_key_123"}} + auth = JinaAuth(credentials) + + with pytest.raises(Exception) as exc_info: + auth.validate_credentials() + assert str(exc_info.value) == "Failed to authorize. Status code: 403. Error: Forbidden" + @patch("services.auth.jina.jina._http_client.post", autospec=True) def test_should_handle_unexpected_error_without_text(self, mock_post): """Test handling of unexpected errors without text response""" diff --git a/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py b/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py index b31af996ae9..9e45cbbd944 100644 --- a/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py +++ b/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py @@ -118,6 +118,17 @@ def test_handle_error_statuses_default_unknown_error(jina_module: ModuleType) -> auth._handle_error(response) +def test_handle_error_statuses_fall_back_to_text_body(jina_module: ModuleType) -> None: + auth = jina_module.JinaAuth(_credentials(api_key="k")) + response = MagicMock() + response.status_code = 402 + response.text = "Payment required" + response.json.side_effect = ValueError("Not JSON") + + with pytest.raises(Exception, match="Status code: 402.*Payment required"): + auth._handle_error(response) + + def test_handle_error_with_text_json_body(jina_module: ModuleType) -> None: auth = jina_module.JinaAuth(_credentials(api_key="k")) response = MagicMock() @@ -128,6 +139,16 @@ def test_handle_error_with_text_json_body(jina_module: ModuleType) -> None: auth._handle_error(response) +def test_handle_error_with_non_json_text_body(jina_module: ModuleType) -> None: + auth = jina_module.JinaAuth(_credentials(api_key="k")) + response = MagicMock() + response.status_code = 403 + response.text = "Forbidden" + + with pytest.raises(Exception, match="Status code: 403.*Forbidden"): + auth._handle_error(response) + + def test_handle_error_with_text_json_body_missing_error(jina_module: ModuleType) -> None: auth = jina_module.JinaAuth(_credentials(api_key="k")) response = MagicMock() diff --git a/api/tests/unit_tests/services/auth/test_watercrawl_auth.py b/api/tests/unit_tests/services/auth/test_watercrawl_auth.py index 1d561731d4c..6d76d046874 100644 --- a/api/tests/unit_tests/services/auth/test_watercrawl_auth.py +++ b/api/tests/unit_tests/services/auth/test_watercrawl_auth.py @@ -99,12 +99,25 @@ class TestWatercrawlAuth: auth_instance.validate_credentials() assert str(exc_info.value) == f"Failed to authorize. Status code: {status_code}. Error: {error_message}" + @patch("services.auth.watercrawl.watercrawl.httpx.get", autospec=True) + def test_should_handle_http_error_with_non_json_text_response(self, mock_get, auth_instance): + """Test handling of known HTTP errors with non-JSON text response.""" + mock_response = MagicMock() + mock_response.status_code = 402 + mock_response.text = "Payment required" + mock_response.json.side_effect = ValueError("Not JSON") + mock_get.return_value = mock_response + + with pytest.raises(Exception) as exc_info: + auth_instance.validate_credentials() + assert str(exc_info.value) == "Failed to authorize. Status code: 402. Error: Payment required" + @pytest.mark.parametrize( ("status_code", "response_text", "has_json_error", "expected_error_contains"), [ (403, '{"error": "Forbidden"}', True, "Failed to authorize. Status code: 403. Error: Forbidden"), (404, "", True, "Unexpected error occurred while trying to authorize. Status code: 404"), - (401, "Not JSON", True, "Expecting value"), # JSON decode error + (401, "Not JSON", True, "Failed to authorize. Status code: 401. Error: Not JSON"), ], ) @patch("services.auth.watercrawl.watercrawl.httpx.get", autospec=True) diff --git a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py index 021bebceff3..88d5853a78b 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py @@ -1,8 +1,10 @@ +from types import SimpleNamespace from unittest.mock import MagicMock, patch from models.account import TenantPluginAutoUpgradeStrategy MODULE = "services.plugin.plugin_auto_upgrade_service" +PLUGIN_CATEGORY = TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL def _patched_session(): @@ -25,7 +27,7 @@ class TestGetStrategy: with p1: from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.get_strategy("t1") + result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY) assert result is strategy @@ -36,7 +38,7 @@ class TestGetStrategy: with p1: from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.get_strategy("t1") + result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY) assert result is None @@ -57,6 +59,7 @@ class TestChangeStrategy: TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, [], [], + category=PLUGIN_CATEGORY, ) assert result is True @@ -77,6 +80,7 @@ class TestChangeStrategy: TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL, ["p1"], ["p2"], + category=PLUGIN_CATEGORY, ) assert result is True @@ -96,17 +100,19 @@ class TestExcludePlugin: p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls, - patch(f"{MODULE}.PluginAutoUpgradeService.change_strategy") as cs, ): strat_cls.StrategySetting.FIX_ONLY = "fix_only" strat_cls.UpgradeMode.EXCLUDE = "exclude" - cs.return_value = True from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.exclude_plugin("t1", "plugin-1") + result = PluginAutoUpgradeService.exclude_plugin( + "t1", + "plugin-1", + PLUGIN_CATEGORY, + ) assert result is True - cs.assert_called_once() + session.add.assert_called_once() def test_appends_to_exclude_list_in_exclude_mode(self): p1, session = _patched_session() @@ -121,7 +127,7 @@ class TestExcludePlugin: strat_cls.UpgradeMode.ALL = "all" from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new") + result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new", PLUGIN_CATEGORY) assert result is True assert existing.exclude_plugins == ["p-existing", "p-new"] @@ -139,7 +145,7 @@ class TestExcludePlugin: strat_cls.UpgradeMode.ALL = "all" from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.exclude_plugin("t1", "p1") + result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY) assert result is True assert existing.include_plugins == ["p2"] @@ -156,7 +162,7 @@ class TestExcludePlugin: strat_cls.UpgradeMode.ALL = "all" from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - result = PluginAutoUpgradeService.exclude_plugin("t1", "p1") + result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY) assert result is True assert existing.upgrade_mode == "exclude" @@ -175,6 +181,101 @@ class TestExcludePlugin: strat_cls.UpgradeMode.ALL = "all" from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - PluginAutoUpgradeService.exclude_plugin("t1", "p1") + PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY) assert existing.exclude_plugins == ["p1"] + + +class TestBackfillStrategyCategories: + def test_creates_default_missing_categories_without_fetching_daemon(self): + p1, session = _patched_session() + tool_strategy = SimpleNamespace( + category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL, + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, + upgrade_time_of_day=0, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + exclude_plugins=[], + include_plugins=[], + ) + session.scalars.return_value.all.return_value = [tool_strategy] + installer = MagicMock() + + with p1, patch(f"{MODULE}.PluginInstaller", return_value=installer): + from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService + + result = PluginAutoUpgradeService.backfill_strategy_categories("t1") + expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1") + + assert result.created_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 1 + assert result.normalized is False + installer.list_plugins.assert_not_called() + assert tool_strategy.upgrade_time_of_day == expected_time + created_strategies = [call.args[0] for call in session.add.call_args_list] + model_strategy = next( + strategy + for strategy in created_strategies + if strategy.category == TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL + ) + assert model_strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST + assert model_strategy.upgrade_time_of_day == expected_time + + def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self): + from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService + + default_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1") + + assert default_time % (15 * 60) == 0 + assert 0 <= default_time < 24 * 60 * 60 + + def test_creates_missing_categories_and_splits_known_plugins(self): + p1, session = _patched_session() + tool_strategy = SimpleNamespace( + category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL, + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, + upgrade_time_of_day=0, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"], + include_plugins=["model-plugin", "tool-plugin"], + ) + model_strategy = SimpleNamespace( + category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL, + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, + upgrade_time_of_day=0, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"], + include_plugins=["model-plugin", "tool-plugin"], + ) + session.scalars.return_value.all.return_value = [tool_strategy, model_strategy] + + installed_plugins = [ + SimpleNamespace( + plugin_id="tool-plugin", + declaration=SimpleNamespace(category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL), + ), + SimpleNamespace( + plugin_id="model-plugin", + declaration=SimpleNamespace(category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL), + ), + ] + installer = MagicMock() + installer.list_plugins.return_value = installed_plugins + + with p1, patch(f"{MODULE}.PluginInstaller", return_value=installer), patch(f"{MODULE}.logger") as logger: + from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService + + result = PluginAutoUpgradeService.backfill_strategy_categories("t1") + + assert result.created_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 2 + assert result.normalized is True + assert session.add.call_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 2 + assert tool_strategy.exclude_plugins == ["tool-plugin"] + assert tool_strategy.include_plugins == ["tool-plugin"] + assert model_strategy.exclude_plugins == ["model-plugin"] + assert model_strategy.include_plugins == ["model-plugin"] + logger.warning.assert_called_once_with( + "Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: " + "tenant_id=%s, field=%s, plugin_ids=%s", + "t1", + "exclude_plugins", + ["unknown-plugin"], + ) diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py index 3f511a109ae..f6a3f524fef 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py @@ -67,7 +67,7 @@ def test_deal_file_extensions_returns_original_when_empty() -> None: assert result is node -def test_deal_dependencies_installs_missing_marketplace_plugins(mocker) -> None: +def test_deal_dependencies_installs_missing_marketplace_plugins(mocker: MockerFixture) -> None: service = RagPipelineTransformService() installer_cls = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller") @@ -92,7 +92,7 @@ def test_deal_dependencies_installs_missing_marketplace_plugins(mocker) -> None: install_mock.assert_called_once_with("tenant-1", ["missing-plugin:1.0.0"]) -def test_transform_to_empty_pipeline_updates_dataset_and_commits(mocker) -> None: +def test_transform_to_empty_pipeline_updates_dataset_and_commits(mocker: MockerFixture) -> None: service = RagPipelineTransformService() mocker.patch( "services.rag_pipeline.rag_pipeline_transform_service.current_user", @@ -142,7 +142,7 @@ def test_transform_to_empty_pipeline_updates_dataset_and_commits(mocker) -> None # --- transform_dataset --- -def test_transform_dataset_returns_early_when_pipeline_exists(mocker) -> None: +def test_transform_dataset_returns_early_when_pipeline_exists(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -151,30 +151,21 @@ def test_transform_dataset_returns_early_when_pipeline_exists(mocker) -> None: ) session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) - result = service.transform_dataset("d1") + result = service.transform_dataset("d1", session_mock) assert result == {"pipeline_id": "p1", "dataset_id": "d1", "status": "success"} -def test_transform_dataset_raises_for_dataset_not_found(mocker) -> None: +def test_transform_dataset_raises_for_dataset_not_found(mocker: MockerFixture) -> None: service = RagPipelineTransformService() session_mock = mocker.Mock() session_mock.get.return_value = None - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) - with pytest.raises(ValueError, match="Dataset not found"): - service.transform_dataset("d1") + service.transform_dataset("d1", session_mock) -def test_transform_dataset_raises_for_external_dataset(mocker) -> None: +def test_transform_dataset_raises_for_external_dataset(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -184,16 +175,12 @@ def test_transform_dataset_raises_for_external_dataset(mocker) -> None: ) session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) with pytest.raises(ValueError, match="External dataset is not supported"): - service.transform_dataset("d1") + service.transform_dataset("d1", session_mock) -def test_transform_dataset_calls_empty_pipeline_when_no_datasource(mocker) -> None: +def test_transform_dataset_calls_empty_pipeline_when_no_datasource(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -205,20 +192,16 @@ def test_transform_dataset_calls_empty_pipeline_when_no_datasource(mocker) -> No ) session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) empty_result = {"pipeline_id": "p-empty", "dataset_id": "d1", "status": "success"} mocker.patch.object(service, "_transform_to_empty_pipeline", return_value=empty_result) - result = service.transform_dataset("d1") + result = service.transform_dataset("d1", session_mock) assert result == empty_result -def test_transform_dataset_calls_empty_pipeline_when_no_doc_form(mocker) -> None: +def test_transform_dataset_calls_empty_pipeline_when_no_doc_form(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -231,15 +214,11 @@ def test_transform_dataset_calls_empty_pipeline_when_no_doc_form(mocker) -> None ) session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) empty_result = {"pipeline_id": "p-empty", "dataset_id": "d1", "status": "success"} mocker.patch.object(service, "_transform_to_empty_pipeline", return_value=empty_result) - result = service.transform_dataset("d1") + result = service.transform_dataset("d1", session_mock) assert result == empty_result @@ -247,7 +226,7 @@ def test_transform_dataset_calls_empty_pipeline_when_no_doc_form(mocker) -> None # --- _deal_knowledge_index --- -def test_deal_knowledge_index_high_quality_sets_embedding(mocker) -> None: +def test_deal_knowledge_index_high_quality_sets_embedding(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = cast( Dataset, @@ -299,7 +278,7 @@ def test_deal_knowledge_index_high_quality_sets_embedding(mocker) -> None: # --- _deal_document_data --- -def test_deal_document_data_notion(mocker) -> None: +def test_deal_document_data_notion(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace(id="d1", pipeline_id="p1") doc = SimpleNamespace( @@ -324,12 +303,8 @@ def test_deal_document_data_notion(mocker) -> None: session_mock = mocker.Mock() session_mock.scalars.return_value = scalars_mock add_mock = session_mock.add - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) - service._deal_document_data(cast(Dataset, dataset)) + service._deal_document_data(cast(Dataset, dataset), session_mock) assert doc.data_source_type == "online_document" assert "page1" in doc.data_source_info @@ -337,7 +312,7 @@ def test_deal_document_data_notion(mocker) -> None: @pytest.mark.parametrize(("provider", "node_id"), [("firecrawl", "1752565402678"), ("jinareader", "1752491761974")]) -def test_deal_document_data_website(mocker, provider: str, node_id: str) -> None: +def test_deal_document_data_website(mocker: MockerFixture, provider: str, node_id: str) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace(id="d1", pipeline_id="p1") doc = SimpleNamespace( @@ -359,12 +334,8 @@ def test_deal_document_data_website(mocker, provider: str, node_id: str) -> None session_mock = mocker.Mock() session_mock.scalars.return_value = scalars_mock add_mock = session_mock.add - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) - service._deal_document_data(cast(Dataset, dataset)) + service._deal_document_data(cast(Dataset, dataset), session_mock) assert doc.data_source_type == "website_crawl" assert "example.com" in doc.data_source_info @@ -376,7 +347,7 @@ def test_deal_document_data_website(mocker, provider: str, node_id: str) -> None # --- transform_dataset complex flow --- -def test_transform_dataset_full_flow(mocker) -> None: +def test_transform_dataset_full_flow(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -398,10 +369,6 @@ def test_transform_dataset_full_flow(mocker) -> None: session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) mocker.patch.object(service, "_deal_dependencies") mocker.patch.object(service, "_deal_document_data") @@ -414,14 +381,14 @@ def test_transform_dataset_full_flow(mocker) -> None: pipeline = SimpleNamespace(id="p-new") mocker.patch.object(service, "_create_pipeline", return_value=pipeline) - result = service.transform_dataset("d1") + result = service.transform_dataset("d1", session_mock) assert result["pipeline_id"] == "p-new" assert dataset.runtime_mode == "rag_pipeline" assert dataset.chunk_structure == "text_model" -def test_transform_dataset_raises_for_unsupported_doc_form_after_pipeline_create(mocker) -> None: +def test_transform_dataset_raises_for_unsupported_doc_form_after_pipeline_create(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -447,10 +414,10 @@ def test_transform_dataset_raises_for_unsupported_doc_form_after_pipeline_create mocker.patch.object(service, "_create_pipeline", return_value=SimpleNamespace(id="p-new")) with pytest.raises(ValueError, match="Unsupported doc form"): - service.transform_dataset("d1") + service.transform_dataset("d1", session_mock) -def test_transform_dataset_raises_when_transform_yaml_missing_workflow(mocker) -> None: +def test_transform_dataset_raises_when_transform_yaml_missing_workflow(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace( id="d1", @@ -467,15 +434,11 @@ def test_transform_dataset_raises_when_transform_yaml_missing_workflow(mocker) - ) session_mock = mocker.Mock() session_mock.get.return_value = dataset - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) mocker.patch.object(service, "_get_transform_yaml", return_value={}) mocker.patch.object(service, "_deal_dependencies") with pytest.raises(ValueError, match="Missing workflow data for rag pipeline"): - service.transform_dataset("d1") + service.transform_dataset("d1", session_mock) def test_create_pipeline_raises_when_workflow_data_missing() -> None: @@ -485,7 +448,7 @@ def test_create_pipeline_raises_when_workflow_data_missing() -> None: service._create_pipeline({"rag_pipeline": {"name": "N"}}) -def test_deal_document_data_upload_file_with_existing_file(mocker) -> None: +def test_deal_document_data_upload_file_with_existing_file(mocker: MockerFixture) -> None: service = RagPipelineTransformService() dataset = SimpleNamespace(id="d1", pipeline_id="p1") document = SimpleNamespace( @@ -506,12 +469,8 @@ def test_deal_document_data_upload_file_with_existing_file(mocker) -> None: session_mock.scalars.return_value = scalars_mock session_mock.get.return_value = upload_file add_mock = session_mock.add - mocker.patch( - "services.rag_pipeline.rag_pipeline_transform_service.db", - new=SimpleNamespace(session=session_mock), - ) - service._deal_document_data(cast(Dataset, dataset)) + service._deal_document_data(cast(Dataset, dataset), session_mock) assert document.data_source_type == "local_file" assert "real_file_id" in document.data_source_info @@ -522,7 +481,9 @@ def _make_service(): return RagPipelineTransformService.__new__(RagPipelineTransformService) -def test_deal_dependencies_skips_marketplace_when_disabled(mocker: MockerFixture, caplog) -> None: +def test_deal_dependencies_skips_marketplace_when_disabled( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: mocker.patch( "services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED", False, diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 626c4b97063..db79cf4cb55 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -65,6 +65,7 @@ class TestAccountAssociatedDataFactory: tenant_join.account_id = account_id tenant_join.current = current tenant_join.role = role + tenant_join.last_opened_at = kwargs.pop("last_opened_at", None) for key, value in kwargs.items(): setattr(tenant_join, key, value) return tenant_join @@ -489,11 +490,13 @@ class TestAccountService: # Mock datetime with ( patch("services.account_service.datetime") as mock_datetime, + patch("services.account_service.naive_utc_now") as mock_naive_utc_now, patch.object(AccountService, "_refresh_account_last_active") as mock_refresh_last_active, ): mock_now = datetime.now() mock_datetime.now.return_value = mock_now mock_datetime.UTC = "UTC" + mock_naive_utc_now.return_value = mock_now # Execute test result = AccountService.load_user("user-123") @@ -501,6 +504,7 @@ class TestAccountService: # Verify results assert result == mock_account assert mock_available_tenant.current is True + assert mock_available_tenant.last_opened_at == mock_now self._assert_database_operations_called(mock_db_dependencies["db"]) mock_refresh_last_active.assert_called_once_with(mock_account) @@ -922,11 +926,16 @@ class TestTenantService: # Mock scalar for the join query mock_db.session.scalar.return_value = mock_tenant_join - # Execute test - TenantService.switch_tenant(mock_account, "tenant-456") + with patch("services.account_service.naive_utc_now") as mock_naive_utc_now: + mock_now = datetime(2026, 6, 5, 11, 0, 0) + mock_naive_utc_now.return_value = mock_now + + # Execute test + TenantService.switch_tenant(mock_account, "tenant-456") # Verify tenant was switched assert mock_tenant_join.current is True + assert mock_tenant_join.last_opened_at == mock_now self._assert_database_operations_called(mock_db) def test_switch_tenant_no_tenant_id(self): diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index a0434f9b436..01d918cd897 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -281,6 +281,36 @@ def test_submit_form_by_token_calls_repository_and_enqueue( enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id) +def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form( + sample_form_record, mock_session_factory, mocker: MockerFixture +): + # ENG-635: a conversation-owned (Agent v2 chat) form routes to the chat + # resume, not the workflow resume. + session_factory, _ = mock_session_factory + repo = MagicMock(spec=HumanInputFormSubmissionRepository) + conversation_record = dataclasses.replace( + sample_form_record, + workflow_run_id=None, + conversation_id="conv-1", + ) + repo.get_by_token.return_value = conversation_record + repo.mark_submitted.return_value = conversation_record + service = HumanInputService(session_factory, form_repository=repo) + workflow_enqueue_spy = mocker.patch.object(service, "enqueue_resume") + chat_enqueue_spy = mocker.patch.object(service, "enqueue_agent_app_resume") + + service.submit_form_by_token( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token", + selected_action_id="submit", + form_data={"field": "value"}, + submission_end_user_id="end-user-id", + ) + + chat_enqueue_spy.assert_called_once_with(conversation_id="conv-1", form_id=conversation_record.form_id) + workflow_enqueue_spy.assert_not_called() + + def test_submit_form_by_token_skips_enqueue_for_delivery_test( sample_form_record, mock_session_factory, mocker: MockerFixture ): diff --git a/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py index 591da56f494..f2203bae25f 100644 --- a/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py +++ b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py @@ -63,6 +63,7 @@ class _FakeFormRepo: return SimpleNamespace( form_id=form_id, workflow_run_id=getattr(form, "workflow_run_id", None), + conversation_id=getattr(form, "conversation_id", None), node_id=getattr(form, "node_id", None), ) @@ -70,11 +71,15 @@ class _FakeFormRepo: class _FakeService: def __init__(self, _session_factory, form_repository=None): self.enqueued: list[str] = [] + self.agent_app_resumed: list[tuple[str, str]] = [] def enqueue_resume(self, workflow_run_id: str | None) -> None: if workflow_run_id is not None: self.enqueued.append(workflow_run_id) + def enqueue_agent_app_resume(self, *, conversation_id: str, form_id: str) -> None: + self.agent_app_resumed.append((conversation_id, form_id)) + def _build_form( *, @@ -84,6 +89,7 @@ def _build_form( expiration_time: datetime, workflow_run_id: str | None, node_id: str, + conversation_id: str | None = None, ) -> SimpleNamespace: return SimpleNamespace( id=form_id, @@ -91,6 +97,7 @@ def _build_form( created_at=created_at, expiration_time=expiration_time, workflow_run_id=workflow_run_id, + conversation_id=conversation_id, node_id=node_id, status=HumanInputFormStatus.WAITING, ) @@ -208,3 +215,45 @@ def test_check_and_handle_human_input_timeouts_omits_global_filter_when_disabled assert stmt is not None stmt_text = str(stmt) assert "created_at <=" not in stmt_text + + +def test_check_and_handle_human_input_timeouts_routes_conversation_owned_form_to_agent_app_resume( + monkeypatch: pytest.MonkeyPatch, +): + # ENG-635 (review): a conversation-owned Agent v2 chat ask_human form has no + # workflow_run_id. On timeout it must enqueue the Agent App resume (so the + # timeout is threaded back as the ask_human result), instead of asserting on + # workflow_run_id — which previously raised and was swallowed by the except. + now = datetime(2025, 1, 1, 12, 0, 0) + monkeypatch.setattr(task_module, "naive_utc_now", lambda: now) + monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600) + monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object())) + + forms = [ + _build_form( + form_id="form-chat", + form_kind=HumanInputFormKind.RUNTIME, + created_at=now - timedelta(minutes=5), + expiration_time=now - timedelta(seconds=1), + workflow_run_id=None, + conversation_id="conv-1", + node_id="agent", + ), + ] + capture: dict[str, Any] = {} + monkeypatch.setattr(task_module, "sessionmaker", lambda *args, **kwargs: _FakeSessionFactory(forms, capture)) + + repo = _FakeFormRepo(form_map={form.id: form for form in forms}) + service = _FakeService(None) + monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", lambda: repo) + monkeypatch.setattr(task_module, "HumanInputService", lambda *_args, **_kwargs: service) + monkeypatch.setattr(task_module, "_handle_global_timeout", lambda **_kwargs: None) + + task_module.check_and_handle_human_input_timeouts(limit=100) + + # Node timeout (conversation forms are never "global"), routed to Agent App resume. + assert repo.calls == [ + {"form_id": "form-chat", "timeout_status": HumanInputFormStatus.TIMEOUT, "reason": "node_timeout"} + ] + assert service.agent_app_resumed == [("conv-1", "form-chat")] + assert service.enqueued == [] diff --git a/api/tests/unit_tests/tasks/test_mail_send_task.py b/api/tests/unit_tests/tasks/test_mail_send_task.py index 5cb933e3f3f..2b14deaf6df 100644 --- a/api/tests/unit_tests/tasks/test_mail_send_task.py +++ b/api/tests/unit_tests/tasks/test_mail_send_task.py @@ -8,6 +8,7 @@ This module tests the mail sending functionality including: - Error handling and logging """ +import logging import smtplib from unittest.mock import ANY, MagicMock, patch @@ -371,8 +372,7 @@ class TestMailTaskRetryLogic: @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") - @patch("tasks.mail_register_task.logger") - def test_mail_task_logs_success(self, mock_logger, mock_mail, mock_email_service): + def test_mail_task_logs_success(self, mock_mail, mock_email_service, caplog): """Test that successful mail sends are logged properly.""" # Arrange mock_mail.is_inited.return_value = True @@ -380,7 +380,8 @@ class TestMailTaskRetryLogic: mock_email_service.return_value = mock_service # Act - send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") + with caplog.at_level(logging.INFO, logger="tasks.mail_register_task"): + send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") # Assert mock_service.send_email.assert_called_once_with( @@ -390,12 +391,14 @@ class TestMailTaskRetryLogic: template_context={"to": "test@example.com", "code": "123456"}, ) # Verify logging calls - assert mock_logger.info.call_count == 2 # Start and success logs + log_messages = [record.getMessage() for record in caplog.records] + assert len(log_messages) == 2 # Start and success logs + assert "Start email register mail to test@example.com" in log_messages[0] + assert "Send email register mail to test@example.com succeeded" in log_messages[1] @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") - @patch("tasks.mail_register_task.logger") - def test_mail_task_logs_failure(self, mock_logger, mock_mail, mock_email_service): + def test_mail_task_logs_failure(self, mock_mail, mock_email_service, caplog): """Test that failed mail sends are logged with exception details.""" # Arrange mock_mail.is_inited.return_value = True @@ -404,10 +407,13 @@ class TestMailTaskRetryLogic: mock_email_service.return_value = mock_service # Act - send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") + with caplog.at_level(logging.ERROR, logger="tasks.mail_register_task"): + send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") # Assert - mock_logger.exception.assert_called_once_with("Send email register mail to %s failed", "test@example.com") + assert "Send email register mail to test@example.com failed" in caplog.text + assert len(caplog.records) == 1 + assert caplog.records[0].exc_info is not None @patch("tasks.mail_reset_password_task.get_email_i18n_service") @patch("tasks.mail_reset_password_task.mail") @@ -574,8 +580,7 @@ class TestInnerEmailTask: @patch("tasks.mail_inner_task.get_email_i18n_service") @patch("tasks.mail_inner_task.mail") @patch("tasks.mail_inner_task._render_template_with_strategy") - @patch("tasks.mail_inner_task.logger") - def test_inner_email_task_logs_failure(self, mock_logger, mock_render, mock_mail, mock_email_service): + def test_inner_email_task_logs_failure(self, mock_render, mock_mail, mock_email_service, caplog): """Test inner email task logs failures properly.""" # Arrange mock_mail.is_inited.return_value = True @@ -587,10 +592,13 @@ class TestInnerEmailTask: to_list = ["user@example.com"] # Act - send_inner_email_task(to=to_list, subject="Test", body="Body", substitutions={}) + with caplog.at_level(logging.ERROR, logger="tasks.mail_inner_task"): + send_inner_email_task(to=to_list, subject="Test", body="Body", substitutions={}) # Assert - mock_logger.exception.assert_called_once() + assert "Send enterprise mail to ['user@example.com'] failed" in caplog.text + assert len(caplog.records) == 1 + assert caplog.records[0].exc_info is not None class TestSendGridIntegration: @@ -890,9 +898,8 @@ class TestPerformanceAndTiming: @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") - @patch("tasks.mail_register_task.logger") @patch("tasks.mail_register_task.time") - def test_mail_task_tracks_execution_time(self, mock_time, mock_logger, mock_mail, mock_email_service): + def test_mail_task_tracks_execution_time(self, mock_time, mock_mail, mock_email_service, caplog): """Test that mail tasks track and log execution time.""" # Arrange mock_mail.is_inited.return_value = True @@ -903,13 +910,14 @@ class TestPerformanceAndTiming: mock_time.perf_counter.side_effect = [100.0, 100.5] # 0.5 second execution # Act - send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") + with caplog.at_level(logging.INFO, logger="tasks.mail_register_task"): + send_email_register_mail_task(language="en-US", to="test@example.com", code="123456") # Assert assert mock_time.perf_counter.call_count == 2 # Verify latency is logged - success_log_call = mock_logger.info.call_args_list[1] - assert "latency" in str(success_log_call) + assert len(caplog.records) == 2 + assert "latency: 0.5" in caplog.records[1].getMessage() class TestEdgeCasesAndErrorHandling: @@ -1457,8 +1465,7 @@ class TestLoggingAndMonitoring: @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") - @patch("tasks.mail_register_task.logger") - def test_mail_task_logs_recipient_information(self, mock_logger, mock_mail, mock_email_service): + def test_mail_task_logs_recipient_information(self, mock_mail, mock_email_service, caplog): """ Test that mail tasks log recipient information for audit trails. @@ -1471,17 +1478,16 @@ class TestLoggingAndMonitoring: mock_email_service.return_value = mock_service # Act - send_email_register_mail_task(language="en-US", to="audit@example.com", code="123456") + with caplog.at_level(logging.INFO, logger="tasks.mail_register_task"): + send_email_register_mail_task(language="en-US", to="audit@example.com", code="123456") # Assert # Check that recipient is logged in start message - start_log_call = mock_logger.info.call_args_list[0] - assert "audit@example.com" in str(start_log_call) + assert "audit@example.com" in caplog.records[0].getMessage() @patch("tasks.mail_inner_task.get_email_i18n_service") @patch("tasks.mail_inner_task.mail") - @patch("tasks.mail_inner_task.logger") - def test_inner_email_task_logs_subject_for_tracking(self, mock_logger, mock_mail, mock_email_service): + def test_inner_email_task_logs_subject_for_tracking(self, mock_mail, mock_email_service, caplog): """ Test that inner email task logs subject for tracking purposes. @@ -1494,11 +1500,11 @@ class TestLoggingAndMonitoring: mock_email_service.return_value = mock_service # Act - send_inner_email_task( - to=["user@example.com"], subject="Important Notification", body="

Body

", substitutions={} - ) + with caplog.at_level(logging.INFO, logger="tasks.mail_inner_task"): + send_inner_email_task( + to=["user@example.com"], subject="Important Notification", body="

Body

", substitutions={} + ) # Assert # Check that subject is logged - start_log_call = mock_logger.info.call_args_list[0] - assert "Important Notification" in str(start_log_call) + assert "Important Notification" in caplog.records[0].getMessage() diff --git a/api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py b/api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py index 75d8b920443..a4412c1ee93 100644 --- a/api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py +++ b/api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py @@ -4,19 +4,25 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch from core.plugin.entities.marketplace import MarketplacePluginSnapshot -from core.plugin.entities.plugin import PluginInstallationSource +from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource from models.account import TenantPluginAutoUpgradeStrategy MODULE = "tasks.process_tenant_plugin_autoupgrade_check_task" -def _make_plugin(plugin_id: str, version: str, source=PluginInstallationSource.Marketplace): +def _make_plugin( + plugin_id: str, + version: str, + source=PluginInstallationSource.Marketplace, + category: PluginCategory = PluginCategory.Tool, +): """Build a minimal stand-in for a PluginInstallation entry returned by manager.list_plugins.""" return SimpleNamespace( plugin_id=plugin_id, version=version, plugin_unique_identifier=f"{plugin_id}:{version}@deadbeef", source=source, + declaration=SimpleNamespace(category=category), ) @@ -39,6 +45,7 @@ def _run_task( upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, exclude_plugins=None, include_plugins=None, + category=None, ): """ Execute the celery task synchronously with mocks for the plugin manager, @@ -72,6 +79,7 @@ def _run_task( upgrade_mode, exclude_plugins or [], include_plugins or [], + category, ) return upgrade_mock, upgrade_calls @@ -246,6 +254,26 @@ class TestUpgradeMode: assert upgrade_mock.call_count == 1 assert calls[0][1] == plugins[0].plugin_unique_identifier + def test_category_strategy_only_upgrades_matching_category(self): + plugins = [ + _make_plugin("acme/model-provider", "1.0.0", category=PluginCategory.Model), + _make_plugin("acme/tool-provider", "1.0.0", category=PluginCategory.Tool), + ] + manifests = [ + _make_manifest("acme/model-provider", "1.0.1"), + _make_manifest("acme/tool-provider", "1.0.1"), + ] + + upgrade_mock, calls = _run_task( + plugins=plugins, + manifests=manifests, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, + category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL, + ) + + upgrade_mock.assert_called_once() + assert calls[0][1] == plugins[0].plugin_unique_identifier + class TestErrorIsolation: def test_one_plugin_failure_does_not_block_others(self): diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index 626d1ee0a8f..3fb673198b3 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -1,9 +1,11 @@ +import logging from unittest.mock import MagicMock, call, patch import pytest from libs.archive_storage import ArchiveStorageNotConfiguredError from tasks.remove_app_and_related_data_task import ( + _delete_app_stars, _delete_app_workflow_archive_logs, _delete_archived_workflow_run_files, _delete_draft_variable_offload_data, @@ -89,39 +91,59 @@ class TestDeleteWorkflowArchiveLogs: mock_session.execute.assert_called_once() +class TestDeleteAppStars: + @patch("tasks.remove_app_and_related_data_task._delete_records") + def test_delete_app_stars_calls_delete_records(self, mock_delete_records): + tenant_id = "tenant-1" + app_id = "app-1" + + _delete_app_stars(tenant_id, app_id) + + mock_delete_records.assert_called_once() + query_sql, params, delete_func, name = mock_delete_records.call_args[0] + assert "app_stars" in query_sql + assert params == {"tenant_id": tenant_id, "app_id": app_id} + assert name == "app star" + + mock_session = MagicMock() + + delete_func(mock_session, "star-1") + + mock_session.execute.assert_called_once() + + class TestDeleteArchivedWorkflowRunFiles: @patch("tasks.remove_app_and_related_data_task.get_archive_storage") - @patch("tasks.remove_app_and_related_data_task.logger") - def test_delete_archived_workflow_run_files_not_configured(self, mock_logger, mock_get_storage): + def test_delete_archived_workflow_run_files_not_configured(self, mock_get_storage, caplog): mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("missing config") - _delete_archived_workflow_run_files("tenant-1", "app-1") + with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"): + _delete_archived_workflow_run_files("tenant-1", "app-1") - assert mock_logger.info.call_count == 1 - assert "Archive storage not configured" in mock_logger.info.call_args[0][0] + assert caplog.text.count("Archive storage not configured") == 1 @patch("tasks.remove_app_and_related_data_task.get_archive_storage") - @patch("tasks.remove_app_and_related_data_task.logger") - def test_delete_archived_workflow_run_files_list_failure(self, mock_logger, mock_get_storage): + def test_delete_archived_workflow_run_files_list_failure(self, mock_get_storage, caplog): storage = MagicMock() storage.list_objects.side_effect = Exception("list failed") mock_get_storage.return_value = storage - _delete_archived_workflow_run_files("tenant-1", "app-1") + with caplog.at_level(logging.ERROR, logger="tasks.remove_app_and_related_data_task"): + _delete_archived_workflow_run_files("tenant-1", "app-1") storage.list_objects.assert_called_once_with("tenant-1/app_id=app-1/") storage.delete_object.assert_not_called() - mock_logger.exception.assert_called_once_with("Failed to list archive files for app %s", "app-1") + assert "Failed to list archive files for app app-1" in caplog.text @patch("tasks.remove_app_and_related_data_task.get_archive_storage") - @patch("tasks.remove_app_and_related_data_task.logger") - def test_delete_archived_workflow_run_files_success(self, mock_logger, mock_get_storage): + def test_delete_archived_workflow_run_files_success(self, mock_get_storage, caplog): storage = MagicMock() storage.list_objects.return_value = ["key-1", "key-2"] mock_get_storage.return_value = storage - _delete_archived_workflow_run_files("tenant-1", "app-1") + with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"): + _delete_archived_workflow_run_files("tenant-1", "app-1") storage.list_objects.assert_called_once_with("tenant-1/app_id=app-1/") storage.delete_object.assert_has_calls([call("key-1"), call("key-2")], any_order=False) - mock_logger.info.assert_called_with("Deleted %s archive objects for app %s", 2, "app-1") + assert "Deleted 2 archive objects for app app-1" in caplog.text diff --git a/api/tests/unit_tests/tasks/test_resume_agent_app_task.py b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py new file mode 100644 index 00000000000..237b43200de --- /dev/null +++ b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py @@ -0,0 +1,138 @@ +"""Unit tests for the ``resume_agent_app_execution`` celery task (ENG-635). + +Every DB access (``db.session.get``) and the generator are patched at the module +level, so the task's branch logic is exercised without a database or live stack. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from pytest_mock import MockerFixture + +from models.account import Account +from models.human_input import HumanInputForm +from models.model import App, Conversation, EndUser +from tasks.app_generate import resume_agent_app_task as mod + +MODULE = "tasks.app_generate.resume_agent_app_task" + + +def _form(conversation_id: str = "conv-1", app_id: str = "app-1") -> MagicMock: + return MagicMock(conversation_id=conversation_id, app_id=app_id) + + +def _wire_db( + mocker: MockerFixture, + *, + form=None, + app=None, + conversation=None, + account=None, + end_user=None, +) -> MagicMock: + """Patch the module ``db`` so ``db.session.get(Model, id)`` dispatches by model.""" + table = { + HumanInputForm: form, + App: app, + Conversation: conversation, + Account: account, + EndUser: end_user, + } + db = mocker.patch(f"{MODULE}.db") + db.session.get.side_effect = lambda model, _id: table.get(model) + return db + + +def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture): + conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None) + account = MagicMock() + app = MagicMock(tenant_id="tenant-1") + _wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + account.set_tenant_id.assert_called_once_with("tenant-1") + gen.return_value.resume_after_form_submission.assert_called_once() + kwargs = gen.return_value.resume_after_form_submission.call_args.kwargs + assert kwargs["conversation_id"] == "conv-1" + assert kwargs["user"] is account + assert kwargs["app_model"] is app + + +def test_resume_end_user_path(mocker: MockerFixture): + conversation = MagicMock(from_account_id=None, from_end_user_id="eu-1") + end_user = MagicMock() + _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, end_user=end_user) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + assert gen.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user + + +def test_resume_returns_when_form_missing(mocker: MockerFixture): + _wire_db(mocker, form=None) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_returns_on_conversation_mismatch(mocker: MockerFixture): + _wire_db(mocker, form=_form(conversation_id="other-conv")) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_returns_when_app_missing(mocker: MockerFixture): + _wire_db(mocker, form=_form(), app=None) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_returns_when_conversation_missing(mocker: MockerFixture): + _wire_db(mocker, form=_form(), app=MagicMock(), conversation=None) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture): + conversation = MagicMock(from_account_id=None, from_end_user_id=None) + _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture): + conversation = MagicMock(from_account_id="acct-x", from_end_user_id=None) + _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation, account=None) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + + gen.assert_not_called() + + +def test_resume_swallows_generator_exception(mocker: MockerFixture): + conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None) + _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, account=MagicMock()) + gen = mocker.patch(f"{MODULE}.AgentAppGenerator") + gen.return_value.resume_after_form_submission.side_effect = RuntimeError("boom") + + # The task must not propagate the failure (it is logged and the session closed). + mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") diff --git a/cli/README.md b/cli/README.md index 61414a22768..8a8521e8d1b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -2,25 +2,33 @@ CLI client for [Dify] platform. Browser device-flow signin, list/inspect apps, run with structured input, parse output as JSON, YAML, or human text. -## Install +## Install (edge, internal) -Builds are standalone binaries (Bun-compiled) published as **GitHub Actions workflow artifacts** — no npm, no GitHub Release assets. The installer fetches the latest successful `cli-release.yml` run on `main`, verifies sha256, and copies the binary into `$HOME/.local/bin/difyctl`. +Per-commit `edge` builds are published to Cloudflare R2. The installer script lives in this repo; binaries are fetched from R2 via `DIFYCTL_R2_BASE` (shared internally): ```sh -# GH_TOKEN with `actions:read` scope is required — workflow artifact downloads -# need auth even on public repos. -export GH_TOKEN= -curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-cli.sh | sh +curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.sh | DIFYCTL_R2_BASE= sh ``` -| Env | Default | Purpose | -| ---------------- | ----------------- | ----------------------------------------------------- | -| `GH_TOKEN` | — | GitHub PAT (or `GITHUB_TOKEN`) with `actions:read`. | -| `DIFYCTL_PREFIX` | `$HOME/.local` | Install root. Binary lands at `/bin/difyctl`. | -| `DIFYCTL_REPO` | `langgenius/dify` | Source repo. | -| `DIFYCTL_BRANCH` | `main` | Branch to pick the latest successful run from. | +| Env | Default | Purpose | +| ----------------------- | ------------------ | ------------------------------------------------------------------- | +| `DIFYCTL_R2_BASE` | — (required) | R2 public base, e.g. `https://pub-….r2.dev`. | +| `DIFYCTL_CHANNEL` | `edge` | Channel to install. | +| `DIFYCTL_INSTALL_DIR` | `$HOME/.local/bin` | Directory the binary is written to (`/difyctl`). | +| `DIFYCTL_VERSION` | latest | Pin an exact published version. | +| `DIFYCTL_COMMIT` | latest | Pin by git commit (short or full sha). | +| `DIFYCTL_R2_PREFIX` | `difyctl` | R2 key root for the pointer JSONs (`manifest.json` / `index.json`). | +| `DIFYCTL_R2_BIN_PREFIX` | `difyctl/bin` | R2 key root for binaries (the lifecycle/TTL target). | -Supported targets: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `windows-x64.exe`. The shell installer covers Linux + macOS; Windows users can download the `.exe` directly from the same artifact. +By default the channel pointer (latest build) is installed. Set `DIFYCTL_COMMIT` (e.g. `ce4af86`) or `DIFYCTL_VERSION` to install a specific past build — both resolve through the channel's `index.json`: + +```sh +curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.sh | DIFYCTL_R2_BASE= DIFYCTL_COMMIT=ce4af86 sh +``` + +Windows: `$env:DIFYCTL_R2_BASE=''; irm https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.ps1 | iex` (same env vars, e.g. `$env:DIFYCTL_COMMIT='ce4af86'`). + +Re-run to upgrade. For tagged `rc`/`stable` builds, use the GitHub installer (`install-cli.sh`). ## Quickstart diff --git a/cli/scripts/install-local.sh b/cli/scripts/install-local.sh index 3a1b76f78d9..892bddf9d90 100755 --- a/cli/scripts/install-local.sh +++ b/cli/scripts/install-local.sh @@ -1,6 +1,10 @@ #!/bin/sh -# install-local.sh — install difyctl from a locally built tarball. -# Run via: pnpm install:local +# install-local.sh — install difyctl from locally built standalone binaries. +# Run via: pnpm install:local (after `pnpm build:bin`) +# +# Consumes the raw, self-contained binaries emitted by scripts/release-build.sh +# into dist/bin (difyctl-v--). No GitHub Release needed: build on +# one machine, copy dist/bin to the tester, point this script at that directory. set -eu PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}" @@ -10,7 +14,7 @@ BIN_DIR="${PREFIX}/bin" case "$(uname -s)" in Linux*) os=linux ;; Darwin*) os=darwin ;; - *) echo "unsupported OS: $(uname -s)" >&2; exit 1 ;; + *) echo "unsupported OS: $(uname -s) (use install.ps1 on Windows)" >&2; exit 1 ;; esac case "$(uname -m)" in @@ -20,20 +24,21 @@ case "$(uname -m)" in esac # Accept an optional directory path as the first argument. -# Default to the cli/dist directory if not provided. -ARTIFACT_DIR="${1:-$(cd "$(dirname "$0")/../dist" && pwd)}" -TARBALL="$(ls "${ARTIFACT_DIR}"/difyctl-*-${os}-${arch}.tar.xz 2>/dev/null | head -1)" +# Default to the cli/dist/bin directory if not provided. +ARTIFACT_DIR="${1:-$(cd "$(dirname "$0")/../dist/bin" 2>/dev/null && pwd || true)}" +BINARY="$(ls "${ARTIFACT_DIR}"/difyctl-v*-${os}-${arch} 2>/dev/null | sort -V | tail -1)" -if [ -z "$TARBALL" ]; then - echo "no tarball found for ${os}-${arch} in ${ARTIFACT_DIR}" >&2 - echo "run: pnpm pack:tarballs" >&2 +if [ -z "$BINARY" ]; then + echo "no binary found for ${os}-${arch} in ${ARTIFACT_DIR:-}" >&2 + echo "run: pnpm build:bin" >&2 exit 1 fi -echo "installing from $(basename "$TARBALL") ..." +echo "installing from $(basename "$BINARY") ..." rm -rf "$SHARE_DIR" -mkdir -p "$SHARE_DIR" "$BIN_DIR" -tar -xJf "$TARBALL" -C "$SHARE_DIR" --strip-components=1 +mkdir -p "${SHARE_DIR}/bin" "$BIN_DIR" +cp "$BINARY" "${SHARE_DIR}/bin/difyctl" +chmod +x "${SHARE_DIR}/bin/difyctl" ln -sf "${SHARE_DIR}/bin/difyctl" "${BIN_DIR}/difyctl" echo "installed: ${BIN_DIR}/difyctl" diff --git a/cli/scripts/install-r2.ps1 b/cli/scripts/install-r2.ps1 new file mode 100644 index 00000000000..44321d13fac --- /dev/null +++ b/cli/scripts/install-r2.ps1 @@ -0,0 +1,104 @@ +# install-r2.ps1 — one-line difyctl installer (Windows) from Cloudflare R2. +# Usage: $env:DIFYCTL_R2_BASE=''; irm https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.ps1 | iex +# Env: DIFYCTL_R2_BASE (required), DIFYCTL_CHANNEL (default edge), +# DIFYCTL_INSTALL_DIR (default %LOCALAPPDATA%\difyctl\bin; binary written here as difyctl.exe), +# DIFYCTL_VERSION (pin exact published version), +# DIFYCTL_COMMIT (pin by git commit, short or full sha; via index.json), +# DIFYCTL_R2_PREFIX (default difyctl; key root for pointer JSONs), +# DIFYCTL_R2_BIN_PREFIX (default /bin; key root for binaries). +# With no pin the channel pointer (latest) is installed. +$ErrorActionPreference = 'Stop' + +function Get-TargetField($manifest, [string]$target, [string]$field) { + $t = $manifest.targets.$target + if (-not $t) { return $null } + return $t.$field +} + +# First build in index.json matching by exact version or commit prefix. +function Resolve-IndexBuild($index, [string]$kind, [string]$want) { + foreach ($b in $index.builds) { + $sel = if ($kind -eq 'commit') { $b.commit } else { $b.version } + $hit = if ($kind -eq 'commit') { $sel.StartsWith($want) } else { $sel -eq $want } + if ($hit) { return $b } + } + return $null +} + +# Parse a checksums.txt (" ") for the line whose asset matches the +# target; returns @{ Sha; Asset } or $null. +function Get-ChecksumTarget([string]$text, [string]$target) { + foreach ($line in ($text -split "`n")) { + $line = $line.Trim() + if ($line -match "\sdifyctl-v.*-$target(\.exe)?$") { + $parts = $line -split '\s+' + return @{ Sha = $parts[0]; Asset = $parts[-1] } + } + } + return $null +} + +# Download, sha256-verify, place. Returns nothing; throws on mismatch. +function Install-DifyctlBinary([string]$dlUrl, [string]$sha, [string]$version, [string]$channel, [string]$installDir) { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetFileName($dlUrl)) + Invoke-WebRequest -Uri $dlUrl -OutFile $tmp + $actual = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $sha.ToLower()) { throw "checksum mismatch for $dlUrl" } + + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + $dest = Join-Path $installDir 'difyctl.exe' + try { Move-Item -Path $tmp -Destination $dest -Force } + catch { throw "cannot replace $dest — close any running difyctl and re-run." } + + Write-Host "difyctl $version (channel $channel) installed: $dest" + if (($env:PATH -split ';') -notcontains $installDir) { + Write-Host "$installDir is not on your PATH. Add it with:" + Write-Host " [Environment]::SetEnvironmentVariable('PATH', `"$installDir;`$env:PATH`", 'User')" + } +} + +function Install-DifyctlR2 { + $base = $env:DIFYCTL_R2_BASE + if (-not $base) { throw "set DIFYCTL_R2_BASE to the R2 public base (e.g. https://pub-….r2.dev)" } + $base = $base.TrimEnd('/') + $channel = if ($env:DIFYCTL_CHANNEL) { $env:DIFYCTL_CHANNEL } else { 'edge' } + $prefix = if ($env:DIFYCTL_R2_PREFIX) { $env:DIFYCTL_R2_PREFIX } else { 'difyctl' } + $binPrefix = if ($env:DIFYCTL_R2_BIN_PREFIX) { $env:DIFYCTL_R2_BIN_PREFIX } else { "$prefix/bin" } + $installDir = if ($env:DIFYCTL_INSTALL_DIR) { $env:DIFYCTL_INSTALL_DIR } else { Join-Path (Join-Path $env:LOCALAPPDATA 'difyctl') 'bin' } + $target = 'windows-x64' + + if ($env:DIFYCTL_VERSION -or $env:DIFYCTL_COMMIT) { + $iurl = "$base/$prefix/$channel/index.json" + try { $index = Invoke-RestMethod -Uri $iurl } + catch { throw "R2 unavailable fetching $iurl; retry." } + $build = if ($env:DIFYCTL_VERSION) { Resolve-IndexBuild $index 'version' $env:DIFYCTL_VERSION } + else { Resolve-IndexBuild $index 'commit' $env:DIFYCTL_COMMIT } + $pin = if ($env:DIFYCTL_VERSION) { $env:DIFYCTL_VERSION } else { $env:DIFYCTL_COMMIT } + if (-not $build) { throw "no build matching $pin in channel $channel" } + $version = $build.version + $vbase = "$base/$binPrefix/$channel/$($build.dir)" + try { $cf = (Invoke-WebRequest -Uri "$vbase/difyctl-v$version-checksums.txt").Content } + catch { throw "checksums missing for $version (channel $channel)" } + $ct = Get-ChecksumTarget $cf $target + if (-not $ct) { throw "no build for $target at $version" } + Install-DifyctlBinary "$vbase/$($ct.Asset)" $ct.Sha $version $channel $installDir + return + } + + $murl = "$base/$prefix/$channel/manifest.json" + try { $manifest = Invoke-RestMethod -Uri $murl } + catch { + if ($_.Exception.Response.StatusCode.value__ -eq 404) { + throw "channel '$channel' not published to R2. For rc/stable use install.ps1 (GitHub)." + } + throw "R2 unavailable fetching $murl; retry." + } + if ($manifest.channel -ne $channel) { throw "manifest channel '$($manifest.channel)' != requested '$channel'" } + + $asset = Get-TargetField $manifest $target 'asset' + $sha = Get-TargetField $manifest $target 'sha256' + if (-not $asset) { throw "no build for $target in channel $channel" } + Install-DifyctlBinary "$($manifest.baseUrl)/$asset" $sha $manifest.version $channel $installDir +} + +if ($env:DIFYCTL_INSTALL_LIB -ne '1') { Install-DifyctlR2 } diff --git a/cli/scripts/install-r2.ps1.test.ts b/cli/scripts/install-r2.ps1.test.ts new file mode 100644 index 00000000000..672f103c4aa --- /dev/null +++ b/cli/scripts/install-r2.ps1.test.ts @@ -0,0 +1,46 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = fileURLToPath(new URL('./install-r2.ps1', import.meta.url)) +const hasPwsh = spawnSync('pwsh', ['-v'], { encoding: 'utf8' }).status === 0 +const d = hasPwsh ? describe : describe.skip + +const MANIFEST = JSON.stringify({ + schema: 1, + name: 'difyctl', + channel: 'edge', + version: '0.1.0-edge.2fd7b82', + baseUrl: 'https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82', + targets: { 'windows-x64': { asset: 'difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe', sha256: 'deadbeef' } }, +}) + +function pwsh(program: string): { code: number, stdout: string, stderr: string } { + const full = `. '${SCRIPT}'\n${program}` + const r = spawnSync('pwsh', ['-NoProfile', '-Command', full], { + encoding: 'utf8', + env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' }, + }) + return { code: r.status ?? 1, stdout: (r.stdout ?? '').replace(/\r\n/g, '\n').trim(), stderr: r.stderr ?? '' } +} + +d('install-r2.ps1', () => { + it('parses a target asset + sha from the manifest', () => { + const prog = `$m = ConvertFrom-Json @'\n${MANIFEST}\n'@\n` + + `Write-Output (Get-TargetField $m 'windows-x64' 'asset')\n` + + `Write-Output (Get-TargetField $m 'windows-x64' 'sha256')` + const { stdout } = pwsh(prog) + expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe\ndeadbeef') + }) + + it('errors when DIFYCTL_R2_BASE is unset', () => { + const r = spawnSync('pwsh', ['-NoProfile', '-File', SCRIPT], { + encoding: 'utf8', + env: { ...process.env, DIFYCTL_R2_BASE: '' }, + }) + if (hasPwsh) { + expect(r.status).not.toBe(0) + expect(r.stderr + r.stdout).toMatch(/DIFYCTL_R2_BASE/) + } + }) +}) diff --git a/cli/scripts/install-r2.sh b/cli/scripts/install-r2.sh new file mode 100755 index 00000000000..ffb4d980429 --- /dev/null +++ b/cli/scripts/install-r2.sh @@ -0,0 +1,164 @@ +#!/bin/sh +# install-r2.sh — one-line difyctl installer from Cloudflare R2. +# Reads a per-channel pointer manifest, sha256-verifies, installs to PATH. +# Usage: curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.sh | DIFYCTL_R2_BASE= sh +# Env: +# DIFYCTL_R2_BASE (required) R2 public base, e.g. https://pub-….r2.dev +# DIFYCTL_CHANNEL (default edge) +# DIFYCTL_INSTALL_DIR (default $HOME/.local/bin) directory the binary is written to as /difyctl +# DIFYCTL_VERSION pin an exact published version (e.g. 0.1.0-edge.ce4af868) +# DIFYCTL_COMMIT pin by git commit (short or full sha); resolved via index.json +# DIFYCTL_R2_PREFIX (default difyctl) key root for pointer JSONs +# DIFYCTL_R2_BIN_PREFIX (default /bin) key root for binaries +# With no pin the channel pointer (latest) is installed. A pin resolves through +# //index.json -> the build's immutable dir under the bin prefix. +set -eu + +# --- library functions (sourced for tests when DIFYCTL_INSTALL_LIB=1) --- +tmp_m="$(mktemp 2>/dev/null || echo /tmp/difyctl-manifest.$$)" +trap 'rm -f "$tmp_m" "${tmp_c:-}" "${tmp_b:-}"' EXIT INT TERM + +err() { printf '%s\n' "install-r2: $*" >&2; } +die() { err "$*"; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } + +detect_target() { + case "$(uname -s)" in + Linux*) _os=linux ;; + Darwin*) _os=darwin ;; + *) die "unsupported OS: $(uname -s) (use install.ps1 on Windows)" ;; + esac + case "$(uname -m)" in + x86_64|amd64) _arch=x64 ;; + arm64|aarch64) _arch=arm64 ;; + *) die "unsupported arch: $(uname -m)" ;; + esac + printf '%s-%s' "$_os" "$_arch" +} + +# grep/sed (no jq). Correct only because release-r2-edge.mjs renders one key per line. +# manifest_str -> value of a top-level string field +manifest_str() { + grep "\"$2\"[[:space:]]*:" "$1" | head -1 \ + | sed -E "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"([^\"]+)\".*/\\1/" +} + +# manifest_target_field -> value on that target's line +manifest_target_field() { + grep "\"$2\"[[:space:]]*:" "$1" | head -1 \ + | sed -E "s/.*\"$3\"[[:space:]]*:[[:space:]]*\"([^\"]+)\".*/\\1/" +} + +# Resolve a pinned build from index.json (no jq). Correct only because +# release-r2-edge.mjs renders each build's fields one per line, dir last. +# Prints "\t" of the first match, nothing if none. +# index_resolve (commit = prefix match) +index_resolve() { + awk -v kind="$2" -v want="$3" ' + function val(s) { sub(/^[^:]*:[[:space:]]*"/, "", s); sub(/".*$/, "", s); return s } + /"version"[[:space:]]*:/ { v = val($0) } + /"commit"[[:space:]]*:/ { c = val($0) } + /"dir"[[:space:]]*:/ { + d = val($0) + sel = (kind == "commit") ? c : v + if (kind == "commit") { if (index(sel, want) == 1) { print v "\t" d; exit } } + else if (sel == want) { print v "\t" d; exit } + } + ' "$1" +} + +# checksums_target -> "\t" +# checksums lines are " "; match asset ending - or -.exe. +checksums_target() { + grep -E "[[:space:]]difyctl-v.*-$2(\.exe)?\$" "$1" | head -1 \ + | awk '{ print $1 "\t" $NF }' +} + +sha256_check() { + # $1 = file, $2 = expected hex + if command -v sha256sum >/dev/null 2>&1; then _a="$(sha256sum "$1" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then _a="$(shasum -a 256 "$1" | awk '{print $1}')" + else die "no sha256 tool (need sha256sum or shasum)"; fi + [ "$_a" = "$2" ] || die "checksum mismatch for $1" +} + +# fetch_verify_install +fetch_verify_install() { + tmp_b="$(mktemp)" + # NOTE: no --compressed — must hash the raw bytes + curl -fsSL "$1" -o "$tmp_b" || die "download failed: $1" + sha256_check "$tmp_b" "$2" + + mkdir -p "$install_dir" + chmod +x "$tmp_b" + mv "$tmp_b" "${install_dir}/difyctl" 2>/dev/null || { cp "$tmp_b" "${install_dir}/difyctl"; rm -f "$tmp_b"; } + + printf 'difyctl %s (channel %s) installed: %s\n' "$3" "$4" "${install_dir}/difyctl" + case ":${PATH}:" in + *":${install_dir}:"*) ;; + *) printf 'note: add %s to your PATH\n' "$install_dir" ;; + esac +} + +# Resolve a pinned build into download url + sha. Sets: version, dl_url, dl_sha. +resolve_pinned() { + iurl="${base}/${prefix}/${channel}/index.json" + curl -fsSL "$iurl" -o "$tmp_m" || die "R2 unavailable fetching ${iurl}; retry." + if [ -n "${DIFYCTL_VERSION:-}" ]; then res="$(index_resolve "$tmp_m" version "$DIFYCTL_VERSION")" + else res="$(index_resolve "$tmp_m" commit "$DIFYCTL_COMMIT")"; fi + [ -n "$res" ] || die "no build matching ${DIFYCTL_VERSION:-$DIFYCTL_COMMIT} in channel ${channel}" + version="$(printf '%s' "$res" | cut -f1)" + dir="$(printf '%s' "$res" | cut -f2)" + + vbase="${base}/${bin_prefix}/${channel}/${dir}" + tmp_c="$(mktemp)" + curl -fsSL "${vbase}/difyctl-v${version}-checksums.txt" -o "$tmp_c" \ + || die "checksums missing for ${version} (channel ${channel})" + line="$(checksums_target "$tmp_c" "$target")" + [ -n "$line" ] || die "no build for ${target} at ${version}" + dl_sha="$(printf '%s' "$line" | cut -f1)" + dl_url="${vbase}/$(printf '%s' "$line" | cut -f2)" +} + +# Resolve the channel pointer (latest) into download url + sha. Sets the same. +resolve_pointer() { + murl="${base}/${prefix}/${channel}/manifest.json" + _code="$(curl -fsS -o "$tmp_m" -w '%{http_code}' "$murl" 2>/dev/null || true)" + if [ ! -s "$tmp_m" ]; then + case "$_code" in + 404) die "channel '${channel}' not published to R2. For rc/stable use the GitHub installer (install-cli.sh)." ;; + *) die "R2 unavailable (HTTP ${_code:-?}) fetching ${murl}; retry." ;; + esac + fi + mchannel="$(manifest_str "$tmp_m" channel)" + [ "$mchannel" = "$channel" ] || die "manifest channel '${mchannel}' != requested '${channel}'" + version="$(manifest_str "$tmp_m" version)" + baseUrl="$(manifest_str "$tmp_m" baseUrl)" + asset="$(manifest_target_field "$tmp_m" "$target" asset)" + dl_sha="$(manifest_target_field "$tmp_m" "$target" sha256)" + [ -n "$asset" ] && [ -n "$dl_sha" ] || die "no build for ${target} in channel ${channel}" + dl_url="${baseUrl}/${asset}" +} + +install_main() { + need curl + [ -n "${DIFYCTL_R2_BASE:-}" ] || die "set DIFYCTL_R2_BASE to the R2 public base (e.g. https://pub-….r2.dev)" + base="${DIFYCTL_R2_BASE%/}" + channel="${DIFYCTL_CHANNEL:-edge}" + prefix="${DIFYCTL_R2_PREFIX:-difyctl}" + bin_prefix="${DIFYCTL_R2_BIN_PREFIX:-${prefix}/bin}" + install_dir="${DIFYCTL_INSTALL_DIR:-${HOME}/.local/bin}" + target="$(detect_target)" + + if [ -n "${DIFYCTL_VERSION:-}" ] || [ -n "${DIFYCTL_COMMIT:-}" ]; then + resolve_pinned + else + resolve_pointer + fi + + fetch_verify_install "$dl_url" "$dl_sha" "$version" "$channel" +} + +if [ "${DIFYCTL_INSTALL_LIB:-0}" != "1" ]; then + install_main +fi diff --git a/cli/scripts/install-r2.test.ts b/cli/scripts/install-r2.test.ts new file mode 100644 index 00000000000..3e02439d6cf --- /dev/null +++ b/cli/scripts/install-r2.test.ts @@ -0,0 +1,131 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = fileURLToPath(new URL('./install-r2.sh', import.meta.url)) + +const MANIFEST = [ + '{', + ' "schema": 1,', + ' "name": "difyctl",', + ' "channel": "edge",', + ' "version": "0.1.0-edge.2fd7b82",', + ' "commit": "abc1234",', + ' "buildDate": "2026-06-14T12:00:00Z",', + ' "compat": {"minDify":"1.14.0","maxDify":"1.15.0"},', + ' "baseUrl": "https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82",', + ' "targets": {', + ' "linux-x64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-linux-x64", "sha256": "deadbeef" },', + ' "darwin-arm64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-darwin-arm64", "sha256": "cafef00d" }', + ' }', + '}', +].join('\n') + +const INDEX = [ + '{', + ' "schema": 1,', + ' "channel": "edge",', + ' "updated": "2026-06-15T00:00:00Z",', + ' "builds": [', + ' {', + ' "version": "0.1.0-edge.ce4af868",', + ' "commit": "ce4af868d653f405070fabb3be3303430cc030ad",', + ' "buildDate": "2026-06-15T00:00:00Z",', + ' "dir": "0.1.0-edge.ce4af868"', + ' },', + ' {', + ' "version": "0.1.0-edge.aaaa111",', + ' "commit": "aaaa111bbbbcccc000011112222333344445555",', + ' "buildDate": "2026-06-14T00:00:00Z",', + ' "dir": "0.1.0-edge.aaaa111"', + ' }', + ' ]', + '}', +].join('\n') + +const CHECKSUMS = [ + 'deadbeef difyctl-v0.1.0-edge.ce4af868-linux-x64', + 'cafef00d difyctl-v0.1.0-edge.ce4af868-darwin-arm64', + 'beadc0de difyctl-v0.1.0-edge.ce4af868-windows-x64.exe', +].join('\n') + +function lib(program: string, env: Record = {}): { code: number, stdout: string, stderr: string } { + const full = `. "${SCRIPT}"\n${program}` + const r = spawnSync('sh', ['-c', full], { + encoding: 'utf8', + env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', ...env }, + }) + return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' } +} + +describe('install-r2 manifest parsing', () => { + // install-r2.sh is POSIX-only; under git-bash on Windows `uname -s` is MINGW*, + // so detect_target intentionally dies (Windows installs go through install-r2.ps1). + it.skipIf(process.platform === 'win32')('detect_target maps to one of the 5 ids', () => { + const { stdout } = lib('detect_target') + expect(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']).toContain(stdout) + }) + + it('manifest_str reads a top-level string field', () => { + const { stdout } = lib(`printf '%s' '${MANIFEST}' > "$tmp_m"; manifest_str "$tmp_m" channel`, {}) + expect(stdout).toBe('edge') + }) + + it('manifest_target_field extracts per-target values from a single line', () => { + const prog = `printf '%s' '${MANIFEST}' > "$tmp_m"\n` + + 'manifest_target_field "$tmp_m" darwin-arm64 asset\n' + + 'manifest_target_field "$tmp_m" darwin-arm64 sha256' + const { stdout } = lib(prog) + expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-darwin-arm64\ncafef00d') + }) + + it('requires DIFYCTL_R2_BASE when run as the installer (not lib)', () => { + const r = spawnSync('sh', [SCRIPT], { encoding: 'utf8', env: { ...process.env, DIFYCTL_R2_BASE: '' } }) + expect(r.status).not.toBe(0) + expect(r.stderr).toMatch(/DIFYCTL_R2_BASE/) + }) + + it('sha256_check aborts on a checksum mismatch', () => { + const r = lib('f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" deadbeef') + expect(r.code).not.toBe(0) + expect(r.stderr).toMatch(/checksum mismatch/) + }) + + it('sha256_check passes on the correct hash', () => { + // sha256('hello') = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + const r = lib('f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 && echo OK') + expect(r.stdout).toBe('OK') + }) +}) + +describe('install-r2 version/commit pin', () => { + it('index_resolve matches a build by exact version', () => { + const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" version 0.1.0-edge.aaaa111`) + expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111') + }) + + it('index_resolve matches a build by commit prefix', () => { + const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit ce4af868`) + expect(r.stdout).toBe('0.1.0-edge.ce4af868\t0.1.0-edge.ce4af868') + }) + + it('index_resolve matches the full 40-char commit too', () => { + const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit aaaa111bbbbcccc000011112222333344445555`) + expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111') + }) + + it('index_resolve prints nothing when no build matches', () => { + const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit ffffff`) + expect(r.stdout).toBe('') + }) + + it('checksums_target extracts sha and asset for a posix target', () => { + const r = lib(`printf '%s' '${CHECKSUMS}' > "$tmp_m"; checksums_target "$tmp_m" darwin-arm64`) + expect(r.stdout).toBe('cafef00d\tdifyctl-v0.1.0-edge.ce4af868-darwin-arm64') + }) + + it('checksums_target does not bleed x64 into arm64', () => { + const r = lib(`printf '%s' '${CHECKSUMS}' > "$tmp_m"; checksums_target "$tmp_m" linux-x64`) + expect(r.stdout).toBe('deadbeef\tdifyctl-v0.1.0-edge.ce4af868-linux-x64') + }) +}) diff --git a/cli/scripts/release-naming.mjs b/cli/scripts/release-naming.mjs index 17ce210222d..5d7457b59b0 100644 --- a/cli/scripts/release-naming.mjs +++ b/cli/scripts/release-naming.mjs @@ -17,19 +17,17 @@ // validate -> exit 1 if difyctl.release, version, or channel is malformed // compat-check -> exit 1 if difyVer outside compat.minDify..maxDify -import { readFileSync } from 'node:fs' +import { readFileSync, realpathSync } from 'node:fs' +import { fileURLToPath } from 'node:url' const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/ const SEMVER_CORE_LEN = 3 -// Channel registry — single source for which version forms are releasable and -// resolvable. Each `versionForm` is pinned to exactly what the installers' -// channel filters accept (stable = no prerelease; rc = -rc.N with nothing -// trailing), so any version that passes `validate` is guaranteed resolvable at -// install time. Extend by adding an entry: { name, prerelease, versionForm }. +// Add channels here: { name, prerelease, versionForm }. const CHANNELS = [ { name: 'stable', prerelease: false, versionForm: /^\d+\.\d+\.\d+(\+[0-9A-Z.-]+)?$/i }, { name: 'rc', prerelease: true, versionForm: /^\d+\.\d+\.\d+-rc\.\d+$/ }, + { name: 'edge', prerelease: true, versionForm: /^\d+\.\d+\.\d+-edge\.[0-9a-f]{7,40}$/ }, ] const channelByName = name => CHANNELS.find(c => c.name === name) @@ -43,6 +41,41 @@ function parsePrecedence(v) { return { nums: core.split('.').map(Number), pre } } +function versionCore(v) { + return String(v).replace(/^v/, '').replace(/\+.*$/, '').split('-')[0] +} + +function edgeVersion(sha) { + if (!/^[0-9a-f]{7,40}$/.test(sha ?? '')) + die('edge-version requires a git short sha (7-40 hex chars)') + const { version } = loadPkg() + const core = versionCore(version) + if (!/^\d+\.\d+\.\d+$/.test(core)) + die(`cannot derive edge base from version: ${version}`) + return `${core}-edge.${sha}` +} + +// Returns a problem string if `version` cannot be resolved under `channel`, else +// null. Shared by validateVersionForChannel (die-now) and validateVersionChannel +// (collect for the `validate` gate). +function channelVersionProblem(version, channel) { + if (typeof version !== 'string' || version.length === 0) + return 'version must be a non-empty string' + const ch = channelByName(channel) + if (!ch) + return `unknown channel: ${channel} (expected one of: ${channelNames()})` + if (!ch.versionForm.test(version)) + return `version ${version} does not match the ${channel} channel form` + return null +} + +function validateVersionForChannel(version, channelName) { + const problem = channelVersionProblem(version, channelName) + if (problem) + die(problem) + return `valid: ${version} is a ${channelName} version` +} + function comparePre(a, b) { const aparts = a.split('.') const bparts = b.split('.') @@ -105,9 +138,7 @@ function loadPkg() { } } -// Every field downstream CI needs, as `key=value` lines for $GITHUB_ENV. Each -// job pipes this once into the environment, then references ${{ env. }} -// at use sites. +// Emits key=value lines for $GITHUB_ENV. function githubEnv() { const { version, channel, compat, release } = loadPkg() const fields = { @@ -167,19 +198,9 @@ function validateRelease(release) { return problems } -// Enforce that the version matches the form its declared channel can resolve. -// Rejects e.g. channel=rc + 1.2.3-rc5 (no dot), channel=stable + 1.2.3-rc.1, -// or any unknown channel — before a release that no installer could find ships. function validateVersionChannel(version, channel) { - const problems = [] - if (typeof version !== 'string' || version.length === 0) - return ['package.json version must be a non-empty string'] - const ch = channelByName(channel) - if (!ch) - return [`difyctl.channel ${JSON.stringify(channel)} is not a known channel (expected one of: ${channelNames()})`] - if (!ch.versionForm.test(version)) - problems.push(`version "${version}" does not match the ${channel} channel form ${ch.versionForm}; an installer could not resolve it`) - return problems + const problem = channelVersionProblem(version, channel) + return problem ? [problem] : [] } function main(argv) { @@ -223,9 +244,21 @@ function main(argv) { die(`invalid difyctl release config:\n - ${problems.join('\n - ')}`) return `difyctl release valid: version=${version} channel=${channel} targets=${release.targets.length}` } + case 'edge-version': + return edgeVersion(rest[0]) + case 'validate-version': + return validateVersionForChannel( + requireVersion(rest[0]), + rest[1] ?? die('channel argument is required'), + ) default: die(`unknown subcommand: ${cmd ?? '(none)'}`) } } -process.stdout.write(`${main(process.argv.slice(2))}\n`) +const invokedDirectly = process.argv[1] + && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +if (invokedDirectly) + process.stdout.write(`${main(process.argv.slice(2))}\n`) + +export { assetName, channelByName, CHANNELS, edgeVersion, loadPkg, validateVersionForChannel, versionCore } diff --git a/cli/scripts/release-naming.test.ts b/cli/scripts/release-naming.test.ts index bf971db50eb..ad2a699ef8d 100644 --- a/cli/scripts/release-naming.test.ts +++ b/cli/scripts/release-naming.test.ts @@ -73,3 +73,35 @@ describe('release-naming github-env', () => { expect(stdout).toMatch(new RegExp(`^${key}=`, 'm')) }) }) + +describe('release-naming edge channel', () => { + it('lists edge among channels', () => { + expect(run(['channels']).stdout).toMatch(/^edge$/m) + }) + + it('edge-version derives -edge. stripping the rc prerelease', () => { + // package.json version is 0.1.0-rc.1 -> core 0.1.0 + expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.1.0-edge.2fd7b82') + }) + + it('edge-version accepts a 40-char sha', () => { + const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000' + expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.1.0-edge.${sha}`) + }) + + it('edge-version rejects a non-hex sha', () => { + expect(run(['edge-version', 'nothex!']).code).not.toBe(0) + }) + + it('edge-version requires a sha argument', () => { + expect(run(['edge-version']).code).not.toBe(0) + }) + + it('the edge version form matches a computed edge version', () => { + expect(run(['validate-version', '0.1.0-edge.2fd7b82', 'edge']).code).toBe(0) + }) + + it('validate-version rejects an rc string under the edge channel', () => { + expect(run(['validate-version', '0.1.0-rc.1', 'edge']).code).not.toBe(0) + }) +}) diff --git a/cli/scripts/release-r2-edge.mjs b/cli/scripts/release-r2-edge.mjs new file mode 100644 index 00000000000..55b40435b6f --- /dev/null +++ b/cli/scripts/release-r2-edge.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// release-r2-edge.mjs — edge/R2 release metadata generator. Two subcommands: +// manifest -> the per-channel pointer manifest.json (the installer reads this) +// index -> the per-channel build-history ledger index.json +import { existsSync, readFileSync } from 'node:fs' +import { assetName, loadPkg, validateVersionForChannel } from './release-naming.mjs' + +function die(msg) { + process.stderr.write(`release-r2-edge: ${msg}\n`) + process.exit(1) +} + +function parseArgs(argv) { + const out = {} + for (let i = 0; i < argv.length; i += 2) { + const key = argv[i]?.replace(/^--/, '') + const val = argv[i + 1] + if (!key || val === undefined || val.startsWith('--')) + die(`malformed argument near ${argv[i]} (expected --key value)`) + out[key] = val + } + return out +} + +function requireArgs(args, keys) { + for (const k of keys) { + if (!args[k]) + die(`missing --${k}`) + } +} + +// checksums lines are " " +function shaMap(checksumsPath) { + const map = new Map() + for (const line of readFileSync(checksumsPath, 'utf8').split('\n')) { + const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i) + if (m) + map.set(m[2], m[1]) + } + return map +} + +function emitManifest(args) { + requireArgs(args, ['channel', 'version', 'commit', 'build-date', 'base-url', 'checksums']) + validateVersionForChannel(args.version, args.channel) + const { release, compat } = loadPkg() + const shas = shaMap(args.checksums) + + const targetLines = release.targets.map((t) => { + const asset = assetName(release, args.version, t.id) + const sha = shas.get(asset) + if (!sha) + die(`no sha256 for ${asset} in ${args.checksums}`) + // one target per line: install-r2.sh grep/sed depends on this layout + return ` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(asset)}, "sha256": ${JSON.stringify(sha)} }` + }).join(',\n') + + const head = { + schema: 1, + name: release.binName, + channel: args.channel, + version: args.version, + commit: args.commit, + buildDate: args['build-date'], + compat: { minDify: compat.minDify, maxDify: compat.maxDify }, + baseUrl: args['base-url'], + } + const headLines = Object.entries(head).map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(',\n') + process.stdout.write(`{\n${headLines},\n "targets": {\n${targetLines}\n }\n}\n`) +} + +// Newline-delimited dir names of binaries that still exist in R2. Absent file = +// no reconciliation (caller could not list); empty file = no survivors. +function loadExistingDirs(path) { + if (!path || !existsSync(path)) + return null + const set = new Set() + for (const line of readFileSync(path, 'utf8').split('\n')) { + const d = line.trim() + if (d) + set.add(d) + } + return set +} + +function emitIndex(args) { + requireArgs(args, ['current', 'channel', 'version', 'commit', 'build-date']) + + // empty / "-" / missing = no ledger yet (first publish) + let current = { schema: 1, channel: args.channel, builds: [] } + if (args.current !== '-' && existsSync(args.current)) { + const raw = readFileSync(args.current, 'utf8').trim() + if (raw && raw !== '-') { + try { + current = JSON.parse(raw) + } + catch { + die(`current index at ${args.current} is not valid JSON`) + } + } + } + + const entry = { version: args.version, commit: args.commit, buildDate: args['build-date'], dir: args.version } + const kept = (current.builds ?? []).filter(b => b.version !== entry.version) + let builds = [entry, ...kept] + + // Reconcile to binaries that still exist in R2: lifecycle/TTL on the bin prefix + // is the only deletion mechanism, so the ledger never advertises a build whose + // binary is gone. The new build is always kept (just uploaded). No count cap. + const existing = loadExistingDirs(args['existing-dirs']) + if (existing) + builds = builds.filter(b => b.dir === entry.dir || existing.has(b.dir)) + + const index = { schema: 1, channel: args.channel, updated: args['build-date'], builds } + process.stdout.write(`${JSON.stringify(index, null, 2)}\n`) +} + +const [cmd, ...rest] = process.argv.slice(2) +const args = parseArgs(rest) +switch (cmd) { + case 'manifest': + emitManifest(args) + break + case 'index': + emitIndex(args) + break + default: + die(`unknown subcommand: ${cmd ?? '(none)'} (expected: manifest | index)`) +} diff --git a/cli/scripts/release-r2-edge.test.ts b/cli/scripts/release-r2-edge.test.ts new file mode 100644 index 00000000000..1514c6a28e3 --- /dev/null +++ b/cli/scripts/release-r2-edge.test.ts @@ -0,0 +1,210 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url)) + +function run(args: string[]): { code: number, stdout: string, stderr: string } { + try { + return { code: 0, stdout: execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }), stderr: '' } + } + catch (e) { + const err = e as { status?: number, stdout?: string, stderr?: string } + return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' } + } +} + +// ---- manifest ---- + +function writeChecksums(version: string): string { + const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-')) + const ids = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] + const lines = ids.map((id, i) => { + const exe = id === 'windows-x64' ? '.exe' : '' + const sha = String(i).repeat(64) + return `${sha} difyctl-v${version}-${id}${exe}` + }) + const file = join(dir, `difyctl-v${version}-checksums.txt`) + writeFileSync(file, `${lines.join('\n')}\n`) + return file +} + +const VERSION = '0.1.0-edge.2fd7b82' +const BASE_URL = 'https://example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82' + +type ManifestJson = { + schema: number + name: string + channel: string + version: string + commit: string + buildDate: string + compat: { minDify: string, maxDify: string } + baseUrl: string + targets: Record +} + +type IndexBuild = { + version: string + commit: string + buildDate: string + dir: string +} + +type IndexJson = { + schema: number + channel: string + updated: string + builds: IndexBuild[] +} + +function buildManifest(version = VERSION): { code: number, json: ManifestJson, stdout: string, stderr: string } { + const checksums = writeChecksums(version) + const r = run(['manifest', '--channel', 'edge', '--version', version, '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', BASE_URL, '--checksums', checksums]) + return { code: r.code, json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson, stdout: r.stdout, stderr: r.stderr } +} + +describe('release-r2-edge manifest', () => { + it('emits the core pointer fields', () => { + const { json } = buildManifest() + expect(json.schema).toBe(1) + expect(json.name).toBe('difyctl') + expect(json.channel).toBe('edge') + expect(json.version).toBe(VERSION) + expect(json.commit).toBe('abc1234') + expect(json.buildDate).toBe('2026-06-14T12:00:00Z') + expect(json.baseUrl).toBe(BASE_URL) + }) + + it('carries the compat window from package.json', () => { + const { json } = buildManifest() + expect(json.compat).toEqual({ minDify: '1.14.0', maxDify: '1.15.0' }) + }) + + it('lists all 5 targets with asset name + sha256 from the checksums file', () => { + const { json } = buildManifest() + expect(Object.keys(json.targets).sort()).toEqual( + ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'windows-x64'], + ) + expect(json.targets['linux-x64'].asset).toBe(`difyctl-v${VERSION}-linux-x64`) + expect(json.targets['windows-x64'].asset).toBe(`difyctl-v${VERSION}-windows-x64.exe`) + expect(json.targets['linux-x64'].sha256).toMatch(/^\d{64}$/) + }) + + it('renders each target on a single line (installer greps it)', () => { + const { stdout } = buildManifest() + expect(stdout).toMatch(/^ {4}"linux-x64": \{ "asset": ".*", "sha256": ".*" \}/m) + }) + + it('rejects a version that does not match the channel form', () => { + const { code } = buildManifest('0.1.0-rc.1') + expect(code).not.toBe(0) + }) + + it('dies when a target sha is missing from the checksums file', () => { + const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-')) + const file = join(dir, `difyctl-v${VERSION}-checksums.txt`) + writeFileSync(file, `${'0'.repeat(64)} difyctl-v${VERSION}-linux-x64\n`) // only 1 of 5 + const r = run(['manifest', '--channel', 'edge', '--version', VERSION, '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', BASE_URL, '--checksums', file]) + expect(r.code).not.toBe(0) + }) + + it('rejects a malformed dropped-value argument (no silent misparse)', () => { + // --version has no value; --commit must NOT be swallowed as the version + const r = run(['manifest', '--channel', 'edge', '--version', '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', 'https://x', '--checksums', '/nonexistent']) + expect(r.code).not.toBe(0) + }) +}) + +// ---- index ---- + +function runIndex(currentContent: string | null, build: Record, existingDirs?: string[]) { + let currentArg = '-' + if (currentContent !== null) { + const dir = mkdtempSync(join(tmpdir(), 'difyctl-index-')) + currentArg = join(dir, 'index.json') + writeFileSync(currentArg, currentContent) + } + const extra: string[] = [] + if (existingDirs !== undefined) { + const dir = mkdtempSync(join(tmpdir(), 'difyctl-existing-')) + const f = join(dir, 'existing.txt') + writeFileSync(f, `${existingDirs.join('\n')}\n`) + extra.push('--existing-dirs', f) + } + const r = spawnSync('node', [SCRIPT, 'index', '--current', currentArg, '--channel', 'edge', '--version', build.version, '--commit', build.commit, '--build-date', build.buildDate, ...extra], { encoding: 'utf8' }) + return { + code: r.status ?? 1, + index: (r.status === 0 ? JSON.parse(r.stdout) : null) as IndexJson, + } +} + +const B1 = { version: '0.1.0-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' } +const B2 = { version: '0.1.0-edge.bbbbbbb', commit: 'bbbbbbb', buildDate: '2026-06-14T10:00:00Z' } + +describe('release-r2-edge index', () => { + it('creates a fresh index from a missing current (arg "-")', () => { + const { index } = runIndex(null, B1) + expect(index.schema).toBe(1) + expect(index.channel).toBe('edge') + expect(index.builds).toHaveLength(1) + expect(index.builds[0]).toMatchObject({ version: B1.version, commit: B1.commit, dir: B1.version }) + }) + + it('treats an empty current file as fresh (first publish, curl wrote nothing)', () => { + const { code, index } = runIndex('', B1) + expect(code).toBe(0) + expect(index.builds).toHaveLength(1) + }) + + it('treats a "-"-content current file as fresh (curl 404 fallback)', () => { + const { code, index } = runIndex('-\n', B1) + expect(code).toBe(0) + expect(index.builds).toHaveLength(1) + }) + + it('prepends the new build (publish order; newest at [0])', () => { + const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }] }) + const { index } = runIndex(current, B2) + expect(index.builds.map(b => b.version)).toEqual([B2.version, B1.version]) + }) + + it('dedups a re-cut of the same version (no duplicate, moves to top)', () => { + const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [ + { version: B2.version, commit: B2.commit, buildDate: B2.buildDate, dir: B2.version }, + { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, + ] }) + const { index } = runIndex(current, B1) // re-cut B1 + expect(index.builds.map(b => b.version)).toEqual([B1.version, B2.version]) + }) + + it('reconciles to surviving binary dirs (drops a build whose binary expired)', () => { + const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [ + { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, + ] }) + // B1's binary is gone (not in existing); the new B2 is always kept. + const { index } = runIndex(current, B2, [B2.version]) + expect(index.builds.map(b => b.version)).toEqual([B2.version]) + }) + + it('keeps the new build even when it is absent from the existing-dirs list', () => { + const { index } = runIndex(null, B1, []) // empty survivors, fresh ledger + expect(index.builds.map(b => b.version)).toEqual([B1.version]) + }) + + it('does not reconcile when no --existing-dirs is given (list unavailable)', () => { + const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [ + { version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }, + ] }) + const { index } = runIndex(current, B2) // no existing-dirs → keep all + expect(index.builds.map(b => b.version)).toEqual([B2.version, B1.version]) + }) + + it('dies on a non-empty current file that is not valid JSON', () => { + const { code } = runIndex('{not json', B1) + expect(code).not.toBe(0) + }) +}) diff --git a/cli/scripts/release-r2-publish.sh b/cli/scripts/release-r2-publish.sh new file mode 100755 index 00000000000..4b411504012 --- /dev/null +++ b/cli/scripts/release-r2-publish.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# release-r2-publish.sh — direct-publish difyctl binaries + manifest + index to R2. +# Strict order so the pointer never references missing bytes: +# sync binaries -> HEAD-verify -> list survivors -> put index.json -> put manifest.json. +# Binaries live under the bin prefix and expire via an R2 lifecycle (TTL) rule on +# that prefix; the ledger is reconciled to surviving dirs, not pruned here. The +# pointer JSONs (manifest.json/index.json) live under the (non-expiring) prefix. +# Installers (install-r2.sh/.ps1) are served from the GitHub repo, not R2. +# Usage: release-r2-publish.sh +# Env: DIFYCTL_R2_S3_ENDPOINT DIFYCTL_R2_BUCKET DIFYCTL_R2_PUBLIC_BASE (+ AWS creds, AWS_REQUEST_CHECKSUM_CALCULATION). +# DIFYCTL_R2_PREFIX (default difyctl) key root for pointer JSONs +# DIFYCTL_R2_BIN_PREFIX (default /bin) key root for binaries (TTL target) +set -euo pipefail + +_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DIST_DIR="${DIST_DIR:-${_dir}/../dist/bin}" + +aws_s3() { aws --endpoint-url "$DIFYCTL_R2_S3_ENDPOINT" "$@"; } + +publish_main() { + local channel="$1" version="$2" + local prefix="${DIFYCTL_R2_PREFIX:-difyctl}" + local bin_prefix="${DIFYCTL_R2_BIN_PREFIX:-${prefix}/bin}" + local key="${bin_prefix}/${channel}/${version}" + local pointer_prefix="${prefix}/${channel}" + local base_url="${DIFYCTL_R2_PUBLIC_BASE}/${key}" + + local current="" new_index="" existing_dirs="" manifest="" + trap 'rm -f "$current" "$new_index" "$existing_dirs" "$manifest"' RETURN + + # 1. binaries (+ checksums) — immutable + aws_s3 s3 sync "$DIST_DIR" "s3://${DIFYCTL_R2_BUCKET}/${key}/" \ + --content-type application/octet-stream \ + --cache-control "public, max-age=31536000, immutable" + + # 2. HEAD-verify each expected target asset exists on R2. Capture targets into + # a variable first; a process-substitution producer failure bypasses set -e. + local targets + targets="$(node "${_dir}/release-naming.mjs" targets)" + [ -n "$targets" ] || { echo "release-r2-publish: no release targets resolved" >&2; exit 1; } + local verified=0 _bun id _exe asset + while IFS=$'\t' read -r _bun id _exe; do + asset="$(node "${_dir}/release-naming.mjs" asset "$version" "$id")" + aws_s3 s3api head-object --bucket "$DIFYCTL_R2_BUCKET" --key "${key}/${asset}" >/dev/null + verified=$((verified + 1)) + done <<< "$targets" + [ "$verified" -gt 0 ] || { echo "release-r2-publish: verified zero targets" >&2; exit 1; } + + # 3. survivors: binary dirs still present under the bin prefix (lifecycle/TTL may + # have deleted old ones). aws CLI auto-paginates list-objects-v2, so --query + # aggregates CommonPrefixes across all pages. On a list failure, skip + # reconciliation (keep the ledger as-is) rather than wrongly drop builds. + existing_dirs="$(mktemp)" + local listed=0 raw + raw="$(mktemp)" + if aws_s3 s3api list-objects-v2 --bucket "$DIFYCTL_R2_BUCKET" \ + --prefix "${bin_prefix}/${channel}/" --delimiter / \ + --query 'CommonPrefixes[].Prefix' --output text > "$raw" 2>/dev/null; then + # text rows are "///"; emit just . "None" = empty. + tr '\t' '\n' < "$raw" | awk -F/ 'NF>1 && $0 != "None" { print $(NF-1) }' > "$existing_dirs" + listed=1 + fi + rm -f "$raw" + + # 4. index.json: fetch current, prepend, reconcile to survivors. + current="$(mktemp)" + curl -fsSL "${DIFYCTL_R2_PUBLIC_BASE}/${pointer_prefix}/index.json" -o "$current" 2>/dev/null || echo '-' > "$current" + new_index="$(mktemp)" + if [ "$listed" = "1" ]; then + emit_index "$current" "$channel" "$version" --existing-dirs "$existing_dirs" >"$new_index" + else + emit_index "$current" "$channel" "$version" >"$new_index" + fi + aws_s3 s3 cp "$new_index" "s3://${DIFYCTL_R2_BUCKET}/${pointer_prefix}/index.json" \ + --content-type application/json --cache-control "public, max-age=60, must-revalidate" + + # 5. manifest.json — pointer; written last so it never references missing binaries + manifest="$(mktemp)" + node "${_dir}/release-r2-edge.mjs" manifest --channel "$channel" --version "$version" \ + --commit "${DIFYCTL_COMMIT:-unknown}" --build-date "${DIFYCTL_BUILD_DATE:-unknown}" \ + --base-url "$base_url" --checksums "${DIST_DIR}/difyctl-v${version}-checksums.txt" >"$manifest" + aws_s3 s3 cp "$manifest" "s3://${DIFYCTL_R2_BUCKET}/${pointer_prefix}/manifest.json" \ + --content-type application/json --cache-control "public, max-age=60, must-revalidate" +} + +# emit_index [extra args...] — wrapper so the +# survivor flag can be omitted without tripping `set -u` on empty arrays (bash 3.2). +emit_index() { + local current="$1" channel="$2" version="$3"; shift 3 + node "${_dir}/release-r2-edge.mjs" index --current "$current" --channel "$channel" \ + --version "$version" --commit "${DIFYCTL_COMMIT:-unknown}" --build-date "${DIFYCTL_BUILD_DATE:-unknown}" "$@" +} + +if [ "${RELEASE_PUBLISH_LIB:-0}" != "1" ]; then + [ "$#" -eq 2 ] || { echo "usage: release-r2-publish.sh " >&2; exit 2; } + publish_main "$1" "$2" +fi diff --git a/cli/scripts/release-r2-publish.test.ts b/cli/scripts/release-r2-publish.test.ts new file mode 100644 index 00000000000..0b99622c3da --- /dev/null +++ b/cli/scripts/release-r2-publish.test.ts @@ -0,0 +1,87 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = fileURLToPath(new URL('./release-r2-publish.sh', import.meta.url)) + +// Stub `aws` + `curl` + `node` as shell functions that just log action verbs to +// $ORDER_LOG, then run the publish `main` and assert the order of operations. +function runPublish(): { code: number, order: string[], stderr: string } { + const stub = [ + 'ORDER_LOG="$(mktemp)"', + 'aws() {', + ' case "$*" in', + ' *"list-objects-v2"*) echo list-survivors >>"$ORDER_LOG" ;;', + ' *" cp "*"/index.json"*) echo put-index >>"$ORDER_LOG" ;;', + ' *" cp "*"/manifest.json"*) echo put-manifest >>"$ORDER_LOG" ;;', + ' *" sync "*) echo sync-binaries >>"$ORDER_LOG" ;;', + ' *"head-object"*) echo head-verify >>"$ORDER_LOG" ;;', + ' *) : ;;', + ' esac', + '}', + 'curl() { echo "{}"; }', + 'node() {', + ' case "$*" in', + ' *release-naming.mjs*targets*)', + ' printf \'bun-linux-x64\\tlinux-x64\\t0\\nbun-linux-arm64\\tlinux-arm64\\t0\\nbun-darwin-x64\\tdarwin-x64\\t0\\nbun-darwin-arm64\\tdarwin-arm64\\t0\\nbun-windows-x64\\twindows-x64\\t1\\n\' ;;', + ' *release-naming.mjs*\' asset \'*) printf \'difyctl-vX\\n\' ;;', + ' *release-r2-edge.mjs*\' index \'*) echo \'{}\' ;;', + ' *release-r2-edge.mjs*\' manifest \'*) echo \'{}\' ;;', + ' *) : ;;', + ' esac', + '}', + ].join('\n') + const program = [ + stub, + `. "${SCRIPT}"`, + 'publish_main edge 0.1.0-edge.2fd7b82', + 'cat "$ORDER_LOG"', + ].join('\n') + // bash, NOT sh: the script uses BASH_SOURCE + process substitution. + const r = spawnSync('bash', ['-c', program], { + encoding: 'utf8', + env: { + ...process.env, + RELEASE_PUBLISH_LIB: '1', + DIFYCTL_R2_S3_ENDPOINT: 'https://endpoint.example', + DIFYCTL_R2_BUCKET: 'cli-dev', + DIFYCTL_R2_PUBLIC_BASE: 'https://pub.example.r2.dev', + DIST_DIR: '/tmp', + }, + }) + return { code: r.status ?? 1, order: (r.stdout ?? '').trim().split('\n').filter(Boolean), stderr: r.stderr ?? '' } +} + +describe('release-r2-publish order', () => { + it('uploads binaries, verifies, lists survivors, then index, then manifest', () => { + const { code, order } = runPublish() + expect(code).toBe(0) + expect(order.indexOf('sync-binaries')).toBeLessThan(order.indexOf('head-verify')) + expect(order.indexOf('head-verify')).toBeLessThan(order.indexOf('list-survivors')) + expect(order.indexOf('list-survivors')).toBeLessThan(order.indexOf('put-index')) + expect(order.indexOf('put-index')).toBeLessThan(order.indexOf('put-manifest')) + // pointer is never pruned here — deletion is owned by the R2 lifecycle rule + expect(order).not.toContain('prune') + }) + + it('exits non-zero when no targets resolve (head-verify safety gate)', () => { + const stub = [ + 'aws() { :; }', + 'curl() { echo "{}"; }', + 'node() { case "$*" in *release-naming.mjs*targets*) : ;; *) echo "{}" ;; esac; }', + ].join('\n') + const program = [stub, `. "${SCRIPT}"`, 'publish_main edge 0.1.0-edge.2fd7b82'].join('\n') + const r = spawnSync('bash', ['-c', program], { + encoding: 'utf8', + env: { + ...process.env, + RELEASE_PUBLISH_LIB: '1', + DIFYCTL_R2_S3_ENDPOINT: 'https://endpoint.example', + DIFYCTL_R2_BUCKET: 'cli-dev', + DIFYCTL_R2_PUBLIC_BASE: 'https://pub.example.r2.dev', + DIST_DIR: '/tmp', + }, + }) + expect(r.status).not.toBe(0) + }) +}) diff --git a/cli/src/store/token-store.ts b/cli/src/store/token-store.ts index d04a8a3d6b3..e38f2dd803f 100644 --- a/cli/src/store/token-store.ts +++ b/cli/src/store/token-store.ts @@ -14,7 +14,7 @@ export type TokenStore = { const DOC_VERSION = 1 -type TokenDoc = { +export type TokenDoc = { version?: number tokens?: Record> } diff --git a/cli/src/version/info.ts b/cli/src/version/info.ts index 23b8624b342..e8d38180fca 100644 --- a/cli/src/version/info.ts +++ b/cli/src/version/info.ts @@ -1,7 +1,7 @@ import { arch, platform } from '@/sys/index' import { compatString } from './compat' -export type Channel = 'dev' | 'rc' | 'stable' +export type Channel = 'dev' | 'edge' | 'rc' | 'stable' export type VersionInfo = { version: string diff --git a/cli/test/e2e/fixtures/apps/echo-chat.yml b/cli/test/e2e/fixtures/apps/echo-chat.yml index 6e40f2723bb..4a7539f3aef 100644 --- a/cli/test/e2e/fixtures/apps/echo-chat.yml +++ b/cli/test/e2e/fixtures/apps/echo-chat.yml @@ -6,12 +6,7 @@ app: mode: advanced-chat name: echo-bot use_icon_as_answer_icon: false -dependencies: - - current_identifier: null - type: marketplace - value: - marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46 - version: null +dependencies: [] kind: app version: 0.6.0 workflow: @@ -68,18 +63,9 @@ workflow: edges: - data: sourceType: start - targetType: llm - id: 1779690795511-llm - source: '1779690795511' - sourceHandle: source - target: llm - targetHandle: target - type: custom - - data: - sourceType: llm targetType: answer - id: llm-answer - source: llm + id: 1779690795511-answer + source: '1779690795511' sourceHandle: source target: answer targetHandle: target @@ -87,7 +73,7 @@ workflow: nodes: - data: selected: false - title: 用户输入 + title: User Input type: start variables: - default: '' @@ -107,66 +93,24 @@ workflow: positionAbsolute: x: 79 y: 282 - selected: true - sourcePosition: right - targetPosition: left - type: custom - width: 242 - - data: - context: - enabled: false - variable_selector: [] - memory: - query_prompt_template: '{{#sys.query#}} - - {{#sys.files#}}' - role_prefix: - assistant: '' - user: '' - window: - enabled: false - size: 10 - model: - completion_params: - temperature: 0.7 - mode: chat - name: qwen3.6-plus - provider: langgenius/tongyi/tongyi - prompt_template: - - id: 9b866a63-3619-4f5c-a46f-0aed04078587 - role: system - text: 'User says: {{{#sys.query#}} Reply exactly: echo:{{#sys.query#}}' - selected: false - title: LLM - type: llm - vision: - enabled: false - height: 88 - id: llm - position: - x: 380 - y: 282 - positionAbsolute: - x: 380 - y: 282 selected: false sourcePosition: right targetPosition: left type: custom width: 242 - data: - answer: '{{#llm.text#}}' + answer: 'echo:{{#sys.query#}}' selected: false - title: 直接回复 + title: Reply type: answer variables: [] height: 103 id: answer position: - x: 680 + x: 380 y: 282 positionAbsolute: - x: 680 + x: 380 y: 282 selected: false sourcePosition: right diff --git a/cli/test/e2e/fixtures/apps/echo-workflow.yml b/cli/test/e2e/fixtures/apps/echo-workflow.yml index 22cb3cd64aa..e29e3b90fa8 100644 --- a/cli/test/e2e/fixtures/apps/echo-workflow.yml +++ b/cli/test/e2e/fixtures/apps/echo-workflow.yml @@ -6,12 +6,7 @@ app: mode: workflow name: basic_auto_test use_icon_as_answer_icon: false -dependencies: - - current_identifier: null - type: marketplace - value: - marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46 - version: null +dependencies: [] kind: app version: 0.6.0 workflow: @@ -70,20 +65,9 @@ workflow: isInIteration: false isInLoop: false sourceType: start - targetType: llm - id: 1779097154262-source-1779097204645-target - source: '1779097154262' - sourceHandle: source - target: '1779097204645' - targetHandle: target - type: custom - zIndex: 0 - - data: - isInLoop: false - sourceType: llm targetType: end - id: 1779097204645-source-1779171097399-target - source: '1779097204645' + id: 1779097154262-source-1779171097399-target + source: '1779097154262' sourceHandle: source target: '1779171097399' targetHandle: target @@ -92,7 +76,7 @@ workflow: nodes: - data: selected: true - title: 用户输入 + title: User Input type: start variables: - default: '' @@ -143,49 +127,15 @@ workflow: targetPosition: left type: custom width: 242 - - data: - context: - enabled: false - variable_selector: [] - model: - completion_params: - temperature: 0.7 - mode: chat - name: qwen3.6-plus - provider: langgenius/tongyi/tongyi - prompt_template: - - id: 1ddb3202-d84c-4faf-afe3-424eedc9049a - role: system - text: 'User says:{{#1779097154262.x#}}. Reply exactly: echo:{{#1779097154262.x#}} - - ' - selected: false - title: LLM - type: llm - vision: - enabled: false - height: 88 - id: '1779097204645' - position: - x: 382 - y: 282 - positionAbsolute: - x: 382 - y: 282 - selected: false - sourcePosition: right - targetPosition: left - type: custom - width: 242 - data: outputs: - value_selector: - - '1779097204645' - - text + - '1779097154262' + - x value_type: string variable: x selected: false - title: 输出 + title: Output type: end height: 88 id: '1779171097399' diff --git a/cli/test/e2e/fixtures/apps/file-chat.yml b/cli/test/e2e/fixtures/apps/file-chat.yml index fe3a2df15d6..c76752f0f26 100644 --- a/cli/test/e2e/fixtures/apps/file-chat.yml +++ b/cli/test/e2e/fixtures/apps/file-chat.yml @@ -6,12 +6,7 @@ app: mode: advanced-chat name: DIFY_E2E_FILE_CHAT use_icon_as_answer_icon: false -dependencies: - - current_identifier: null - type: marketplace - value: - marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46 - version: null +dependencies: [] kind: app version: 0.6.0 workflow: @@ -68,18 +63,9 @@ workflow: edges: - data: sourceType: start - targetType: llm - id: 1780453002656-llm - source: '1780453002656' - sourceHandle: source - target: llm - targetHandle: target - type: custom - - data: - sourceType: llm targetType: answer - id: llm-answer - source: llm + id: 1780453002656-answer + source: '1780453002656' sourceHandle: source target: answer targetHandle: target @@ -87,7 +73,7 @@ workflow: nodes: - data: selected: false - title: 用户输入 + title: User Input type: start variables: - allowed_file_extensions: [] @@ -119,60 +105,18 @@ workflow: type: custom width: 242 - data: - context: - enabled: false - variable_selector: [] - memory: - query_prompt_template: '{{#sys.query#}} - - {{#sys.files#}}' - role_prefix: - assistant: '' - user: '' - window: - enabled: false - size: 10 - model: - completion_params: - temperature: 0.7 - mode: chat - name: qwen3.6-plus - provider: langgenius/tongyi/tongyi - prompt_template: - - id: ebc516ad-be6b-4a78-af32-77f447305b34 - role: system - text: 输出固定内容:""hello + answer: ok selected: false - title: LLM - type: llm - vision: - enabled: false - height: 88 - id: llm - position: - x: 380 - y: 282 - positionAbsolute: - x: 380 - y: 282 - selected: true - sourcePosition: right - targetPosition: left - type: custom - width: 242 - - data: - answer: '{{#llm.text#}}' - selected: false - title: 直接回复 + title: Reply type: answer variables: [] height: 103 id: answer position: - x: 680 + x: 380 y: 282 positionAbsolute: - x: 680 + x: 380 y: 282 selected: false sourcePosition: right diff --git a/cli/test/e2e/fixtures/apps/file-upload.yml b/cli/test/e2e/fixtures/apps/file-upload.yml index 52f6d623a33..1730c51b7fe 100644 --- a/cli/test/e2e/fixtures/apps/file-upload.yml +++ b/cli/test/e2e/fixtures/apps/file-upload.yml @@ -6,12 +6,7 @@ app: mode: workflow name: file_auto_test use_icon_as_answer_icon: false -dependencies: - - current_identifier: null - type: marketplace - value: - marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46 - version: null +dependencies: [] kind: app version: 0.6.0 workflow: @@ -70,21 +65,9 @@ workflow: isInIteration: false isInLoop: false sourceType: start - targetType: llm - id: 1779693724732-source-1779693759949-target - source: '1779693724732' - sourceHandle: source - target: '1779693759949' - targetHandle: target - type: custom - zIndex: 0 - - data: - isInIteration: false - isInLoop: false - sourceType: llm targetType: end - id: 1779693759949-source-1779693765299-target - source: '1779693759949' + id: 1779693724732-source-1779693765299-target + source: '1779693724732' sourceHandle: source target: '1779693765299' targetHandle: target @@ -93,7 +76,7 @@ workflow: nodes: - data: selected: true - title: 用户输入 + title: User Input type: start variables: - allowed_file_extensions: [] @@ -140,46 +123,9 @@ workflow: type: custom width: 242 - data: - context: - enabled: false - variable_selector: [] - model: - completion_params: - temperature: 0.7 - mode: chat - name: qwen3.6-plus - provider: langgenius/tongyi/tongyi - prompt_template: - - id: bb929f8f-5fa9-415b-91c3-c30228488dcf - role: system - text: 直接输出内容:hello + outputs: [] selected: false - title: LLM - type: llm - vision: - enabled: false - height: 88 - id: '1779693759949' - position: - x: 382 - y: 282 - positionAbsolute: - x: 382 - y: 282 - selected: false - sourcePosition: right - targetPosition: left - type: custom - width: 242 - - data: - outputs: - - value_selector: - - '1779693759949' - - text - value_type: string - variable: x - selected: false - title: 输出 + title: Output type: end height: 88 id: '1779693765299' diff --git a/cli/test/e2e/fixtures/apps/ws2-workflow.yml b/cli/test/e2e/fixtures/apps/ws2-workflow.yml index d8ddac836ce..92961a9900f 100644 --- a/cli/test/e2e/fixtures/apps/ws2-workflow.yml +++ b/cli/test/e2e/fixtures/apps/ws2-workflow.yml @@ -6,12 +6,7 @@ app: mode: workflow name: auto_test_workspace2 use_icon_as_answer_icon: false -dependencies: - - current_identifier: null - type: marketplace - value: - marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46 - version: null +dependencies: [] kind: app version: 0.6.0 workflow: @@ -70,21 +65,9 @@ workflow: isInIteration: false isInLoop: false sourceType: start - targetType: llm - id: 1780305524693-source-1780305526186-target - source: '1780305524693' - sourceHandle: source - target: '1780305526186' - targetHandle: target - type: custom - zIndex: 0 - - data: - isInIteration: false - isInLoop: false - sourceType: llm targetType: end - id: 1780305526186-source-1780305600095-target - source: '1780305526186' + id: 1780305524693-source-1780305600095-target + source: '1780305524693' sourceHandle: source target: '1780305600095' targetHandle: target @@ -93,7 +76,7 @@ workflow: nodes: - data: selected: false - title: 用户输入 + title: User Input type: start variables: [] height: 73 @@ -109,45 +92,9 @@ workflow: type: custom width: 242 - data: - context: - enabled: false - variable_selector: [] - model: - completion_params: - temperature: 0.7 - mode: chat - name: qwen3.6-plus - provider: langgenius/tongyi/tongyi - prompt_template: - - id: cd753cdd-d950-44bf-99ad-7cb19f42d5b6 - role: system - text: 输出内容:hello + outputs: [] selected: false - title: LLM - type: llm - vision: - enabled: false - height: 88 - id: '1780305526186' - position: - x: 382 - y: 282 - positionAbsolute: - x: 382 - y: 282 - sourcePosition: right - targetPosition: left - type: custom - width: 242 - - data: - outputs: - - value_selector: - - '1780305526186' - - text - value_type: string - variable: x - selected: false - title: 输出 + title: Output type: end height: 88 id: '1780305600095' @@ -157,6 +104,7 @@ workflow: positionAbsolute: x: 684 y: 282 + selected: false sourcePosition: right targetPosition: left type: custom diff --git a/cli/test/e2e/helpers/cli.ts b/cli/test/e2e/helpers/cli.ts index 9e52348c0fe..18c6a213f34 100644 --- a/cli/test/e2e/helpers/cli.ts +++ b/cli/test/e2e/helpers/cli.ts @@ -8,11 +8,13 @@ * withTempConfig) to prevent session state leaking between tests. */ +import type { TokenDoc } from '@/store/token-store' import { Buffer } from 'node:buffer' import { execSync, spawn } from 'node:child_process' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import yaml from 'js-yaml' /** Path to the dev entry point — no build required. */ export const BIN = resolve(__dirname, '../../../bin/dev.js') @@ -208,13 +210,11 @@ function splitHost(host: string): { bare: string, scheme: string } { } async function writeFileToken(configDir: string, host: string, email: string, bearer: string): Promise { - const dotParts = `tokens.${host}.${email}`.split('.') - let yaml = '' - for (let i = 0; i < dotParts.length - 1; i++) { - yaml += `${' '.repeat(i) + dotParts[i]}:\n` + const doc: TokenDoc = { + version: 1, + tokens: { [host]: { [email]: bearer } }, } - yaml += `${' '.repeat(dotParts.length - 1) + (dotParts[dotParts.length - 1] ?? '')}: "${bearer}"\n` - await writeFile(join(configDir, 'tokens.yml'), yaml, { mode: 0o600 }) + await writeFile(join(configDir, 'tokens.yml'), yaml.dump(doc, { lineWidth: -1, noRefs: true }), { mode: 0o600 }) } /** @@ -289,11 +289,8 @@ export async function injectAuth(configDir: string, opts: AuthInjectionOptions): new Entry('difyctl', account).setPassword(JSON.stringify(opts.bearer)) } else { - // Fall back to tokens.yml. - // YamlStore.doGet splits the key on '.' and traverses the nested object, - // so "tokens.localhost.user@dify.ai" splits into 4 parts: - // tokens -> localhost -> user@dify -> ai - // The YAML must mirror that exact nesting. + // Fall back to tokens.yml — FileTokenStore uses getTyped() + // which expects flat tokens[host][email] with version: 1. await writeFileToken(configDir, bare, email, opts.bearer) } } diff --git a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts b/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts index 5298314ab15..a1dea13e66a 100644 --- a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts +++ b/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts @@ -349,9 +349,11 @@ describe('E2E / agent skill — describe app -o json (auth required)', () => { assertPipeFriendlyJson(r) }) - itWithAuth('[P0] nonexistent app → exit 1 + JSON error envelope', async () => { + itWithAuth('[P0] invalid (non-UUID) app id → exit 2 + usage error envelope', async () => { + // 'app-id-nonexistent-e2e-xyz' is not a valid UUID; describe app rejects it + // client-side via isValidUuid() with usage_invalid_flag (exit 2). const r = await fx.r(['describe', 'app', 'app-id-nonexistent-e2e-xyz', '-o', 'json']) - expect(r.exitCode).toBe(1) + expect(r.exitCode).toBe(2) assertErrorEnvelope(r) }) }) diff --git a/cli/test/e2e/suites/discovery/describe-app.e2e.ts b/cli/test/e2e/suites/discovery/describe-app.e2e.ts index 5b63a63f110..75cfe226370 100644 --- a/cli/test/e2e/suites/discovery/describe-app.e2e.ts +++ b/cli/test/e2e/suites/discovery/describe-app.e2e.ts @@ -131,11 +131,12 @@ describe('E2E / difyctl describe app', () => { // ── Not found ───────────────────────────────────────────────────────────── - it('[P0] non-existent app returns exit code 1 with not-found error (3.83)', async () => { - // Spec 3.83: describe non-existent app → stderr contains not-found, exit code 1. + it('[P0] invalid (non-UUID) app id returns usage error (exit code 2)', async () => { + // NONEXISTENT_ID is not a valid UUID, so the CLI rejects it client-side via + // isValidUuid() before making any network request → usage_invalid_flag (exit 2). const result = await fx.r(['describe', 'app', NONEXISTENT_ID]) - expect(result.exitCode, 'non-existent app should exit with code 1').toBe(1) - expect(result.stderr).toMatch(/not.?found|404|does not exist|server_5xx/i) + expect(result.exitCode, 'invalid UUID should exit with code 2').toBe(2) + expect(result.stderr).toMatch(/uuid|valid|usage_invalid_flag/i) }) it('[P1] non-existent app in JSON mode outputs JSON error envelope', async () => { diff --git a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts index d5ff63d87e1..30a5d213b7d 100644 --- a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts +++ b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts @@ -141,7 +141,7 @@ describe('E2E / error message standards (spec 5.3)', () => { // Spec 5.70: submitting a value of the wrong type must fail. // The workflow app (workflowAppId) expects x as a string; passing a JSON // number causes the server to reject the request. - // In v1.0 the server returns HTTP 500 for type validation failures. + // After the @accepts/@returns contract unification, the server returns HTTP 422 for request schema failures. const result = await fx.r([ 'run', 'app', @@ -156,6 +156,65 @@ describe('E2E / error message standards (spec 5.3)', () => { expect(result.stderr.trim().length).toBeGreaterThan(0) }) + // ── 5.70a/b/c P4 sanitization — 422 error body is clean (no leaks) ──────── + + it('[P0] 5.70a validation failure message is a plain string, not double-encoded JSON', async () => { + // After the @accepts contract fix, the server aborts with + // abort(422, message="Request validation failed", errors=[...]) + // The CLI wraps this into its envelope. The message field must be a plain + // human-readable string — NOT a JSON-serialised string that itself contains + // pydantic error details (which was the double-encoding bug in P4). + const result = await fx.r([ + 'run', + 'app', + E.workflowAppId, + '--inputs', + JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A', paragraph: 'ok' }), + '-o', + 'json', + ]) + assertNonZeroExit(result) + const envelope = assertErrorEnvelope(result) + // message must be a plain string, not a JSON string (no double encoding) + expect(typeof envelope.error.message).toBe('string') + expect(() => JSON.parse(envelope.error.message)).toThrow() + }) + + it('[P1] 5.70b validation error response does not leak pydantic version URL', async () => { + // Before the P4 fix, exc.json() included a "url" field pointing to + // https://errors.pydantic.dev//... — exposing the server's pydantic + // version. The sanitised response must not contain this URL. + const result = await fx.r([ + 'run', + 'app', + E.workflowAppId, + '--inputs', + JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A', paragraph: 'ok' }), + '-o', + 'json', + ]) + assertNonZeroExit(result) + expect(result.stderr).not.toMatch(/errors\.pydantic\.dev|pydantic\.dev\//) + }) + + it('[P1] 5.70c validation error response does not echo back user input', async () => { + // Before the P4 fix, exc.json() included the user's original "input" value + // inside the error details. The sanitised response must not repeat the + // submitted value so that sensitive payloads are not reflected to callers. + const sentValue = 'not-a-number-sentinel-12345' + const result = await fx.r([ + 'run', + 'app', + E.workflowAppId, + '--inputs', + JSON.stringify({ x: 'hello', num: sentValue, enum_var: 'A', paragraph: 'ok' }), + '-o', + 'json', + ]) + assertNonZeroExit(result) + expect(result.stderr).not.toContain(sentValue) + }) + // ── 5.76 Failed command + -o yaml → stderr is still JSON envelope ──────── it('[P1] 5.76 failed command with -o yaml still outputs a JSON error envelope on stderr', async () => { diff --git a/cli/test/e2e/suites/run/run-app-basic.e2e.ts b/cli/test/e2e/suites/run/run-app-basic.e2e.ts index 7c395186d6a..1e3acdd6668 100644 --- a/cli/test/e2e/suites/run/run-app-basic.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-basic.e2e.ts @@ -8,7 +8,7 @@ * * Staging app prerequisites (specified via DIFY_E2E_* env vars): * echo-chat — mode=chat, query variable, outputs "echo: {query}" - * echo-workflow — mode=workflow, x variable (required), outputs "echo: {x}" + * echo-workflow — mode=workflow, x variable (required), outputs x directly (no echo prefix) */ import type { AuthFixture } from '../../helpers/cli.js' @@ -263,7 +263,7 @@ describe('E2E / difyctl run app', () => { JSON.stringify({ x: 'hello', num: 42, enum_var: 'A', paragraph: 'short text' }), ]) assertExitCode(happyResult, 0) - assertStdoutContains(happyResult, 'echo:hello') + assertStdoutContains(happyResult, 'hello') // workflow outputs x directly; echo: prefix removed (no sandbox on server) // ── 4.1.17: number field receives a string value ───────────────────── const typedResult = await fx.r([ @@ -289,6 +289,26 @@ describe('E2E / difyctl run app', () => { }) }) + it('[P1] validation failure returns http_status 422 in JSON error envelope', async () => { + // After the @accepts/@returns server contract unification, input schema + // validation failures consistently return HTTP 422 (not 400 or 500). + // This verifies the CLI propagates the unified status code. + const result = await fx.r([ + 'run', + 'app', + E.workflowAppId, + '--inputs', + JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A', paragraph: 'ok' }), + '-o', + 'json', + ]) + expect(result.exitCode).not.toBe(0) + const envelope = JSON.parse(result.stderr.trim()) as { + error: { code: string, message: string, http_status?: number } + } + expect(envelope.error.http_status, 'validation failure must return http_status 422').toBe(422) + }) + // ========================================================================= // Error scenarios // ========================================================================= diff --git a/cli/test/e2e/suites/run/run-app-conversation.e2e.ts b/cli/test/e2e/suites/run/run-app-conversation.e2e.ts index 5dc3162066c..ec69c9ced84 100644 --- a/cli/test/e2e/suites/run/run-app-conversation.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-conversation.e2e.ts @@ -118,7 +118,7 @@ describe('E2E / difyctl run app --conversation', () => { 'invalid-conv-id-xyz-not-exist', ]) assertExitCode(result, 1) - expect(result.stderr).toMatch(/not.?found|conversation|404/i) + expect(result.stderr).toMatch(/not.?found|conversation|404|422|validation/i) }) // ── Combined flags ────────────────────────────────────────────────────── diff --git a/cli/test/e2e/suites/run/run-app-streaming.e2e.ts b/cli/test/e2e/suites/run/run-app-streaming.e2e.ts index 168ee0d7e77..a8713404c41 100644 --- a/cli/test/e2e/suites/run/run-app-streaming.e2e.ts +++ b/cli/test/e2e/suites/run/run-app-streaming.e2e.ts @@ -277,7 +277,7 @@ describe('E2E / difyctl run app --stream (specialisation)', () => { '--stream', ]) expect(result.exitCode, 'wrong-type input should cause non-zero exit').not.toBe(0) - expect(result.stderr).toMatch(/validation|invalid|type|400|server_5xx|must be/i) + expect(result.stderr).toMatch(/validation|invalid|type|422|must be/i) }) // ── Non-existent app with positional query (4.2.16) ──────────────────── diff --git a/docker/.env.example b/docker/.env.example index 76d04b4b0ce..8daa82d05a1 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -135,7 +135,6 @@ AMPLITUDE_API_KEY= TEXT_GENERATION_TIMEOUT_MS=60000 CSP_WHITELIST= ALLOW_EMBED=false -ALLOW_INLINE_STYLES=false ALLOW_UNSAFE_DATA_SCHEME=false TOP_K_MAX_VALUE=10 INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000 diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 309ea20eb6f..9987483156f 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -387,7 +387,6 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} - ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index d8aee5bb1e9..2b9f91492eb 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -393,7 +393,6 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} - ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index 49a8d9bbaad..0cc840d2a4d 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -481,5 +481,11 @@ MILVUS_ENABLE_HYBRID_SEARCH=False ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1 +# Nacos remote settings source HTTP timeouts (seconds). +# Bound how long requests to the Nacos endpoint wait before failing, so a slow or +# unresponsive Nacos server cannot stall API startup or token refresh. +DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0 +DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0 + # uv cache dir UV_CACHE_DIR=/tmp/uv_cache diff --git a/e2e/features/smoke/authenticated-entry.feature b/e2e/features/smoke/authenticated-entry.feature index 3c1191a330d..53d72bd667c 100644 --- a/e2e/features/smoke/authenticated-entry.feature +++ b/e2e/features/smoke/authenticated-entry.feature @@ -4,5 +4,4 @@ Feature: Authenticated app console Given I am signed in as the default E2E admin When I open the apps console Then I should stay on the apps console - And I should see the "Create from Blank" button And I should not see the "Sign in" button diff --git a/e2e/features/smoke/install.feature b/e2e/features/smoke/install.feature index 39fc1f996b9..5685d874866 100644 --- a/e2e/features/smoke/install.feature +++ b/e2e/features/smoke/install.feature @@ -4,4 +4,3 @@ Feature: Fresh installation bootstrap Given the last authentication bootstrap came from a fresh install When I open the apps console Then I should stay on the apps console - And I should see the "Create from Blank" button diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index 931d4662a22..d6a5eb21d54 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -1,12 +1,10 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' +import { openBlankAppCreation } from '../../../support/apps' When('I start creating a blank app', async function (this: DifyWorld) { - const page = this.getPage() - - await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible() - await page.getByRole('button', { name: 'Create from Blank' }).click() + await openBlankAppCreation(this.getPage()) }) When('I enter a unique E2E app name', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/apps/delete-app.steps.ts b/e2e/features/step-definitions/apps/delete-app.steps.ts index e5da626645f..e5a5da424aa 100644 --- a/e2e/features/step-definitions/apps/delete-app.steps.ts +++ b/e2e/features/step-definitions/apps/delete-app.steps.ts @@ -29,7 +29,7 @@ Then('the app should no longer appear in the apps console', async function (this ) } - await expect(this.getPage().getByTitle(appName)).not.toBeVisible({ + await expect(this.getPage().getByRole('link', { name: appName, exact: true })).not.toBeVisible({ timeout: 10_000, }) }) diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts index e5e3694e4d4..5e998b3603e 100644 --- a/e2e/features/step-definitions/apps/duplicate-app.steps.ts +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -1,5 +1,6 @@ import type { DifyWorld } from '../../support/world' import { Given, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' import { createTestApp } from '../../../support/api' Given('there is an existing E2E app available for testing', async function (this: DifyWorld) { @@ -15,14 +16,13 @@ When('I open the options menu for the last created E2E app', async function (thi throw new Error('No app name stored. Run "I enter a unique E2E app name" first.') const page = this.getPage() - // Scope to the specific card: the card root is the innermost div that contains - // both the unique app name text and a More button (they are in separate branches, - // so no child div satisfies both). .last() picks the deepest match in DOM order. + const appLink = page.getByRole('link', { name: appName, exact: true }) const appCard = page .locator('div') - .filter({ has: page.getByText(appName, { exact: true }) }) + .filter({ has: appLink }) .filter({ has: page.getByRole('button', { name: 'More' }) }) .last() + await expect(appLink).toBeVisible() await appCard.hover() await appCard.getByRole('button', { name: 'More' }).click() }) diff --git a/e2e/features/step-definitions/auth/sign-in.steps.ts b/e2e/features/step-definitions/auth/sign-in.steps.ts index 095f8164078..469203d8bfd 100644 --- a/e2e/features/step-definitions/auth/sign-in.steps.ts +++ b/e2e/features/step-definitions/auth/sign-in.steps.ts @@ -4,7 +4,7 @@ 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') + await this.getPage().goto('/signin?redirect_url=%2Fapps') }) When('I sign in as the default E2E admin', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/common/app.steps.ts b/e2e/features/step-definitions/common/app.steps.ts index 93e808e3c53..6deca22c609 100644 --- a/e2e/features/step-definitions/common/app.steps.ts +++ b/e2e/features/step-definitions/common/app.steps.ts @@ -2,6 +2,7 @@ import type { DifyWorld } from '../../support/world' import { Given, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { createTestApp, syncMinimalWorkflowDraft } from '../../../support/api' +import { waitForAppsConsole } from '../../../support/apps' Given('a {string} app has been created via API', async function (this: DifyWorld, mode: string) { const app = await createTestApp(`E2E ${Date.now()}`, mode) @@ -17,6 +18,8 @@ Given('a minimal workflow draft has been synced', async function (this: DifyWorl 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() + await waitForAppsConsole(page) + const appLink = page.getByRole('link', { name: this.lastCreatedAppName!, exact: true }) + await expect(appLink).toBeVisible() + await appLink.click() }) diff --git a/e2e/features/step-definitions/common/navigation.steps.ts b/e2e/features/step-definitions/common/navigation.steps.ts index 9bec34c2248..02b860b372b 100644 --- a/e2e/features/step-definitions/common/navigation.steps.ts +++ b/e2e/features/step-definitions/common/navigation.steps.ts @@ -1,13 +1,14 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' +import { waitForAppsConsole } from '../../../support/apps' When('I open the apps console', async function (this: DifyWorld) { await this.getPage().goto('/apps') }) Then('I should stay on the apps console', async function (this: DifyWorld) { - await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/) + await waitForAppsConsole(this.getPage()) }) Then('I should be redirected to the signin page', async function (this: DifyWorld) { diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index cc54a6d47b1..9039f97483d 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -1,9 +1,10 @@ -import type { Browser, Page } from '@playwright/test' +import type { APIResponse, Browser, BrowserContext } from '@playwright/test' +import { Buffer } from 'node:buffer' import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { expect } from '@playwright/test' -import { defaultBaseURL, defaultLocale } from '../test-env' +import { waitForAppsConsole } from '../support/apps' +import { apiURL, defaultBaseURL, defaultLocale } from '../test-env' export type AuthSessionMetadata = { adminEmail: string @@ -12,7 +13,8 @@ export type AuthSessionMetadata = { usedInitPassword: boolean } -export const AUTH_BOOTSTRAP_TIMEOUT_MS = 120_000 +export const AUTH_BOOTSTRAP_TIMEOUT_MS = 180_000 +const AUTH_FLOW_TIMEOUT_MS = AUTH_BOOTSTRAP_TIMEOUT_MS - 30_000 const e2eRoot = fileURLToPath(new URL('..', import.meta.url)) export const authDir = path.join(e2eRoot, '.auth') @@ -35,89 +37,106 @@ export const readAuthSessionMetadata = async () => { return JSON.parse(content) as AuthSessionMetadata } -const escapeRegex = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') - const appURL = (baseURL: string, pathname: string) => new URL(pathname, baseURL).toString() +const apiEndpoint = (pathname: string) => new URL(pathname, apiURL).toString() -type AuthPageState = 'install' | 'login' | 'init' +type SetupStatusResponse = { + step: 'not_started' | 'finished' +} + +type InitStatusResponse = { + status: 'not_started' | 'finished' +} + +type AuthBootstrapResult = { + mode: AuthSessionMetadata['mode'] + usedInitPassword: boolean +} const getRemainingTimeout = (deadline: number) => Math.max(deadline - Date.now(), 1) -const waitForPageState = async (page: Page, deadline: number): Promise => { - const installHeading = page.getByRole('heading', { name: 'Setting up an admin account' }) - const signInButton = page.getByRole('button', { name: 'Sign in' }) - const initPasswordField = page.getByLabel('Admin initialization password') +const encodeField = (value: string) => Buffer.from(value, 'utf8').toString('base64') - try { - return await Promise.any([ - installHeading - .waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) }) - .then(() => 'install'), - signInButton - .waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) }) - .then(() => 'login'), - initPasswordField - .waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) }) - .then(() => 'init'), - ]) - } - catch { - throw new Error(`Unable to determine auth page state for ${page.url()}`) - } +const assertAPIResponse = async (response: APIResponse, action: string) => { + if (response.ok()) + return + + const body = await response.text().catch(() => '') + throw new Error( + `${action} failed with ${response.status()} ${response.statusText()}${body ? `: ${body}` : ''}`, + ) } -const completeInitPasswordIfNeeded = async (page: Page, deadline: number) => { - const initPasswordField = page.getByLabel('Admin initialization password') - - const needsInitPassword = await initPasswordField - .waitFor({ state: 'visible', timeout: Math.min(getRemainingTimeout(deadline), 3_000) }) - .then(() => true) - .catch(() => false) - - if (!needsInitPassword) - return false - - await initPasswordField.fill(initPassword) - await page.getByRole('button', { name: 'Validate' }).click() - await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({ +const getConsoleAPI = async (context: BrowserContext, pathname: string, deadline: number) => { + const response = await context.request.get(apiEndpoint(pathname), { timeout: getRemainingTimeout(deadline), }) + await assertAPIResponse(response, `GET ${pathname}`) + return response.json() as Promise +} +const postConsoleAPI = async ( + context: BrowserContext, + pathname: string, + deadline: number, + data: Record, +) => { + const response = await context.request.post(apiEndpoint(pathname), { + data, + timeout: getRemainingTimeout(deadline), + }) + await assertAPIResponse(response, `POST ${pathname}`) +} + +const validateInitPasswordIfNeeded = async (context: BrowserContext, deadline: number) => { + const initStatus = await getConsoleAPI(context, '/console/api/init', deadline) + if (initStatus.status === 'finished') + return false + + console.warn('[e2e] auth bootstrap: validating init password') + await postConsoleAPI(context, '/console/api/init', deadline, { password: initPassword }) return true } -const completeInstall = async (page: Page, baseURL: string, deadline: number) => { - await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({ - timeout: getRemainingTimeout(deadline), - }) +const ensureAdminAccount = async ( + context: BrowserContext, + deadline: number, +): Promise => { + const setupStatus = await getConsoleAPI( + context, + '/console/api/setup', + deadline, + ) + let usedInitPassword = false - await page.getByLabel('Email address').fill(adminCredentials.email) - await page.getByLabel('Username').fill(adminCredentials.name) - await page.getByLabel('Password').fill(adminCredentials.password) - await page.getByRole('button', { name: 'Set up' }).click() + if (setupStatus.step === 'not_started') { + usedInitPassword = await validateInitPasswordIfNeeded(context, deadline) + console.warn('[e2e] auth bootstrap: creating admin account') + await postConsoleAPI(context, '/console/api/setup', deadline, { + email: adminCredentials.email, + name: adminCredentials.name, + password: adminCredentials.password, + language: defaultLocale, + }) - await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), { - timeout: getRemainingTimeout(deadline), - }) + return { mode: 'install', usedInitPassword } + } + + return { mode: 'login', usedInitPassword } } -const completeLogin = async (page: Page, baseURL: string, deadline: number) => { - await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible({ - timeout: getRemainingTimeout(deadline), - }) - - await page.getByLabel('Email address').fill(adminCredentials.email) - await page.getByLabel('Password').fill(adminCredentials.password) - await page.getByRole('button', { name: 'Sign in' }).click() - - await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), { - timeout: getRemainingTimeout(deadline), +const loginAdmin = async (context: BrowserContext, deadline: number) => { + console.warn('[e2e] auth bootstrap: logging in admin') + await postConsoleAPI(context, '/console/api/login', deadline, { + email: adminCredentials.email, + password: encodeField(adminCredentials.password), + remember_me: true, }) } export const ensureAuthenticatedState = async (browser: Browser, configuredBaseURL?: string) => { const baseURL = resolveBaseURL(configuredBaseURL) - const deadline = Date.now() + AUTH_BOOTSTRAP_TIMEOUT_MS + const deadline = Date.now() + AUTH_FLOW_TIMEOUT_MS await mkdir(authDir, { recursive: true }) @@ -128,37 +147,22 @@ export const ensureAuthenticatedState = async (browser: Browser, configuredBaseU const page = await context.newPage() try { - await page.goto(appURL(baseURL, '/install'), { + const { mode, usedInitPassword } = await ensureAdminAccount(context, deadline) + await loginAdmin(context, deadline) + + console.warn('[e2e] auth bootstrap: verifying apps console') + await page.goto(appURL(baseURL, '/apps'), { timeout: getRemainingTimeout(deadline), waitUntil: 'domcontentloaded', }) - - let usedInitPassword = await completeInitPasswordIfNeeded(page, deadline) - let pageState = await waitForPageState(page, deadline) - - while (pageState === 'init') { - const completedInitPassword = await completeInitPasswordIfNeeded(page, deadline) - if (!completedInitPassword) - throw new Error(`Unable to validate initialization password for ${page.url()}`) - - usedInitPassword = true - pageState = await waitForPageState(page, deadline) - } - - if (pageState === 'install') - await completeInstall(page, baseURL, deadline) - else await completeLogin(page, baseURL, deadline) - - await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible({ - timeout: getRemainingTimeout(deadline), - }) + await waitForAppsConsole(page, getRemainingTimeout(deadline)) await context.storageState({ path: authStatePath }) const metadata: AuthSessionMetadata = { adminEmail: adminCredentials.email, baseURL, - mode: pageState, + mode, usedInitPassword, } diff --git a/e2e/support/apps.ts b/e2e/support/apps.ts new file mode 100644 index 00000000000..f035b5f4a1b --- /dev/null +++ b/e2e/support/apps.ts @@ -0,0 +1,24 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export const waitForAppsConsole = async (page: Page, timeout?: number) => { + await expect(page).toHaveURL(/\/apps(?:\?.*)?$/, timeout === undefined ? undefined : { timeout }) + await expect(page.getByRole('heading', { name: 'Studio' })).toBeVisible( + timeout === undefined ? undefined : { timeout }, + ) +} + +export const openBlankAppCreation = async (page: Page) => { + const createFromBlankButton = page.getByRole('button', { name: 'Create from Blank' }).first() + const isDirectCreateVisible = await createFromBlankButton + .isVisible({ timeout: 3_000 }) + .catch(() => false) + + if (isDirectCreateVisible) { + await createFromBlankButton.click() + return + } + + await page.getByRole('button', { name: 'Create' }).click() + await page.getByRole('menuitem', { name: 'Create from Blank' }).click() +} diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0f5754a8131..87230e947ef 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -152,17 +152,6 @@ "count": 1 } }, - "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx": { - "no-restricted-globals": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -203,11 +192,6 @@ "count": 1 } }, - "web/app/(commonLayout)/snippets/[snippetId]/page.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/(shareLayout)/components/authenticated-layout.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -396,9 +380,6 @@ }, "web/app/components/app-sidebar/index.tsx": { "no-restricted-globals": { - "count": 2 - }, - "ts/no-explicit-any": { "count": 1 } }, @@ -990,9 +971,6 @@ }, "jsx-a11y/no-static-element-interactions": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "web/app/components/app/overview/trigger-card.tsx": { @@ -1051,10 +1029,10 @@ }, "web/app/components/apps/app-card.tsx": { "jsx-a11y/click-events-have-key-events": { - "count": 2 + "count": 1 }, "jsx-a11y/no-static-element-interactions": { - "count": 2 + "count": 1 } }, "web/app/components/apps/import-from-marketplace-template-modal.tsx": { @@ -1065,17 +1043,6 @@ "count": 1 } }, - "web/app/components/apps/new-app-card.tsx": { - "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/action-button/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -1886,7 +1853,7 @@ }, "web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 2 + "count": 1 } }, "web/app/components/base/icons/src/vender/line/arrows/index.ts": { @@ -1921,7 +1888,7 @@ }, "web/app/components/base/icons/src/vender/line/financeAndECommerce/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 4 + "count": 3 } }, "web/app/components/base/icons/src/vender/line/general/index.ts": { @@ -3474,6 +3441,11 @@ "count": 1 } }, + "web/app/components/datasets/list/__tests__/header.spec.tsx": { + "jsx-a11y/label-has-associated-control": { + "count": 1 + } + }, "web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3628,11 +3600,6 @@ "count": 2 } }, - "web/app/components/explore/app-list/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/explore/banner/__tests__/indicator-button.spec.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3647,12 +3614,6 @@ }, "jsx-a11y/no-static-element-interactions": { "count": 1 - }, - "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 } }, "web/app/components/explore/banner/indicator-button.tsx": { @@ -3663,14 +3624,6 @@ "count": 2 } }, - "web/app/components/explore/category.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/explore/item-operation/__tests__/index.spec.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3679,12 +3632,17 @@ "count": 1 } }, + "web/app/components/explore/learn-dify/item.tsx": { + "jsx-a11y/no-noninteractive-element-interactions": { + "count": 1 + } + }, "web/app/components/explore/sidebar/app-nav-item/index.tsx": { "jsx-a11y/click-events-have-key-events": { - "count": 2 + "count": 1 }, "jsx-a11y/no-static-element-interactions": { - "count": 2 + "count": 1 } }, "web/app/components/explore/try-app/app/text-generation.tsx": { @@ -3700,34 +3658,16 @@ "count": 2 } }, - "web/app/components/goto-anything/actions/commands/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 2 - } - }, "web/app/components/goto-anything/actions/commands/registry.ts": { "ts/no-explicit-any": { "count": 3 } }, - "web/app/components/goto-anything/actions/commands/slash.tsx": { - "react-refresh/only-export-components": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/goto-anything/actions/commands/types.ts": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/goto-anything/actions/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 2 - } - }, "web/app/components/goto-anything/actions/plugin.tsx": { "no-restricted-imports": { "count": 1 @@ -3743,11 +3683,6 @@ "count": 2 } }, - "web/app/components/goto-anything/command-selector.tsx": { - "react/unsupported-syntax": { - "count": 2 - } - }, "web/app/components/goto-anything/components/__tests__/search-input.spec.tsx": { "jsx-a11y/no-autofocus": { "count": 1 @@ -3779,11 +3714,6 @@ "count": 4 } }, - "web/app/components/goto-anything/hooks/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 4 - } - }, "web/app/components/goto-anything/hooks/use-goto-anything-results.ts": { "@tanstack/query/exhaustive-deps": { "count": 1 @@ -3798,10 +3728,10 @@ } }, "web/app/components/header/account-setting/data-source-page-new/card.tsx": { - "jsx-a11y/alt-text": { - "count": 1 + "jsx-a11y/click-events-have-key-events": { + "count": 2 }, - "ts/no-explicit-any": { + "jsx-a11y/no-static-element-interactions": { "count": 2 } }, @@ -3815,16 +3745,11 @@ "count": 1 } }, - "web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/header/account-setting/data-source-page-new/item.tsx": { - "no-restricted-imports": { + "web/app/components/header/account-setting/data-source-page-new/plugin-actions.tsx": { + "jsx-a11y/click-events-have-key-events": { "count": 1 }, - "ts/no-explicit-any": { + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -4027,6 +3952,11 @@ "count": 2 } }, + "web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx": { + "jsx-a11y/anchor-has-content": { + "count": 1 + } + }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx": { "react/set-state-in-effect": { "count": 1 @@ -4096,8 +4026,13 @@ "count": 1 } }, - "web/app/components/plugins/card/index.tsx": { - "ts/no-non-null-asserted-optional-chain": { + "web/app/components/main-nav/components/web-apps-section.tsx": { + "jsx-a11y/no-autofocus": { + "count": 1 + } + }, + "web/app/components/main-nav/components/workspace-switcher.tsx": { + "jsx-a11y/no-autofocus": { "count": 1 } }, @@ -4490,11 +4425,6 @@ "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/__tests__/index.spec.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -4513,11 +4443,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -4555,11 +4480,6 @@ "count": 25 } }, - "web/app/components/plugins/update-plugin/from-market-place.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/plugins/update-plugin/plugin-version-picker.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -4940,32 +4860,11 @@ "count": 2 } }, - "web/app/components/tools/mcp/create-card.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/tools/mcp/headers-input.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/tools/mcp/mcp-server-param-item.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/tools/mcp/modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/tools/mcp/provider-card.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 2 @@ -4977,40 +4876,8 @@ "count": 3 } }, - "web/app/components/tools/mcp/sections/authentication-section.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/tools/mcp/sections/configurations-section.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/tools/provider-list.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/tools/provider/custom-create-card.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, - "web/app/components/tools/provider/empty.tsx": { - "ts/no-explicit-any": { + "web/app/components/tools/provider/detail.tsx": { + "jsx-a11y/anchor-has-content": { "count": 1 } }, @@ -5027,6 +4894,14 @@ "count": 3 } }, + "web/app/components/tools/tool-provider-grid.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/tools/types.ts": { "erasable-syntax-only/enums": { "count": 4 @@ -5334,11 +5209,6 @@ "count": 1 } }, - "web/app/components/workflow/header/__tests__/index.spec.tsx": { - "react/static-components": { - "count": 2 - } - }, "web/app/components/workflow/header/online-users.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 2 @@ -5448,16 +5318,6 @@ "count": 1 } }, - "web/app/components/workflow/hooks/use-workflow-canvas-maximize.ts": { - "no-restricted-globals": { - "count": 1 - } - }, - "web/app/components/workflow/hooks/use-workflow-interactions.ts": { - "no-barrel-files/no-barrel-files": { - "count": 5 - } - }, "web/app/components/workflow/hooks/use-workflow-run-event/index.ts": { "no-barrel-files/no-barrel-files": { "count": 19 @@ -7406,11 +7266,6 @@ "count": 2 } }, - "web/app/components/workflow/store/workflow/layout-slice.ts": { - "no-restricted-properties": { - "count": 1 - } - }, "web/app/components/workflow/store/workflow/workflow-draft-slice.ts": { "ts/no-explicit-any": { "count": 1 @@ -7728,11 +7583,6 @@ "count": 3 } }, - "web/context/provider-context-provider.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/context/web-app-context.tsx": { "react-refresh/only-export-components": { "count": 1 @@ -7799,46 +7649,6 @@ "count": 1 } }, - "web/i18n/de-DE/billing.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/en-US/app-debug.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/fr-FR/app-debug.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/fr-FR/plugin-trigger.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/fr-FR/tools.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/pt-BR/common.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, - "web/i18n/ru-RU/common.json": { - "no-irregular-whitespace": { - "count": 2 - } - }, - "web/i18n/uk-UA/app-debug.json": { - "no-irregular-whitespace": { - "count": 1 - } - }, "web/models/access-control.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -7849,14 +7659,6 @@ "count": 2 } }, - "web/models/common.ts": { - "erasable-syntax-only/enums": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/models/datasets.ts": { "erasable-syntax-only/enums": { "count": 7 @@ -7971,7 +7773,7 @@ "count": 1 }, "ts/no-explicit-any": { - "count": 29 + "count": 27 } }, "web/service/datasets.ts": { @@ -8167,20 +7969,6 @@ "count": 4 } }, - "web/service/use-plugins.ts": { - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - }, - "regexp/no-unused-capturing-group": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/service/use-snippet-workflows.ts": { "no-restricted-imports": { "count": 1 diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts new file mode 100644 index 00000000000..b749f644532 --- /dev/null +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -0,0 +1,713 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { oc } from '@orpc/contract' +import * as z from 'zod' + +import { + zDeleteAgentByAgentIdFilesPath, + zDeleteAgentByAgentIdFilesQuery, + zDeleteAgentByAgentIdFilesResponse, + zDeleteAgentByAgentIdPath, + zDeleteAgentByAgentIdResponse, + zDeleteAgentByAgentIdSkillsBySlugPath, + zDeleteAgentByAgentIdSkillsBySlugResponse, + zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath, + zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse, + zGetAgentByAgentIdChatMessagesPath, + zGetAgentByAgentIdChatMessagesQuery, + zGetAgentByAgentIdChatMessagesResponse, + zGetAgentByAgentIdComposerCandidatesPath, + zGetAgentByAgentIdComposerCandidatesResponse, + zGetAgentByAgentIdComposerPath, + zGetAgentByAgentIdComposerResponse, + zGetAgentByAgentIdDriveFilesDownloadPath, + zGetAgentByAgentIdDriveFilesDownloadQuery, + zGetAgentByAgentIdDriveFilesDownloadResponse, + zGetAgentByAgentIdDriveFilesPath, + zGetAgentByAgentIdDriveFilesPreviewPath, + zGetAgentByAgentIdDriveFilesPreviewQuery, + zGetAgentByAgentIdDriveFilesPreviewResponse, + zGetAgentByAgentIdDriveFilesQuery, + zGetAgentByAgentIdDriveFilesResponse, + zGetAgentByAgentIdMessagesByMessageIdPath, + zGetAgentByAgentIdMessagesByMessageIdResponse, + zGetAgentByAgentIdPath, + zGetAgentByAgentIdReferencingWorkflowsPath, + zGetAgentByAgentIdReferencingWorkflowsResponse, + zGetAgentByAgentIdResponse, + zGetAgentByAgentIdSandboxFilesPath, + zGetAgentByAgentIdSandboxFilesQuery, + zGetAgentByAgentIdSandboxFilesReadPath, + zGetAgentByAgentIdSandboxFilesReadQuery, + zGetAgentByAgentIdSandboxFilesReadResponse, + zGetAgentByAgentIdSandboxFilesResponse, + zGetAgentByAgentIdVersionsByVersionIdPath, + zGetAgentByAgentIdVersionsByVersionIdResponse, + zGetAgentByAgentIdVersionsPath, + zGetAgentByAgentIdVersionsResponse, + zGetAgentInviteOptionsQuery, + zGetAgentInviteOptionsResponse, + zGetAgentQuery, + zGetAgentResponse, + zPostAgentBody, + zPostAgentByAgentIdChatMessagesByTaskIdStopPath, + zPostAgentByAgentIdChatMessagesByTaskIdStopResponse, + zPostAgentByAgentIdComposerValidateBody, + zPostAgentByAgentIdComposerValidatePath, + zPostAgentByAgentIdComposerValidateResponse, + zPostAgentByAgentIdFeaturesBody, + zPostAgentByAgentIdFeaturesPath, + zPostAgentByAgentIdFeaturesResponse, + zPostAgentByAgentIdFeedbacksBody, + zPostAgentByAgentIdFeedbacksPath, + zPostAgentByAgentIdFeedbacksResponse, + zPostAgentByAgentIdFilesBody, + zPostAgentByAgentIdFilesPath, + zPostAgentByAgentIdFilesResponse, + zPostAgentByAgentIdSandboxFilesUploadBody, + zPostAgentByAgentIdSandboxFilesUploadPath, + zPostAgentByAgentIdSandboxFilesUploadResponse, + zPostAgentByAgentIdSkillsBySlugInferToolsPath, + zPostAgentByAgentIdSkillsBySlugInferToolsResponse, + zPostAgentByAgentIdSkillsStandardizePath, + zPostAgentByAgentIdSkillsStandardizeResponse, + zPostAgentByAgentIdSkillsUploadPath, + zPostAgentByAgentIdSkillsUploadResponse, + zPostAgentResponse, + zPutAgentByAgentIdBody, + zPutAgentByAgentIdComposerBody, + zPutAgentByAgentIdComposerPath, + zPutAgentByAgentIdComposerResponse, + zPutAgentByAgentIdPath, + zPutAgentByAgentIdResponse, +} from './zod.gen' + +export const get = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentInviteOptions', + path: '/agent/invite-options', + tags: ['console'], + }) + .input(z.object({ query: zGetAgentInviteOptionsQuery.optional() })) + .output(zGetAgentInviteOptionsResponse) + +export const inviteOptions = { + get, +} + +/** + * Get suggested questions for an Agent App message + */ +export const get2 = oc + .route({ + description: 'Get suggested questions for an Agent App message', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdChatMessagesByMessageIdSuggestedQuestions', + path: '/agent/{agent_id}/chat-messages/{message_id}/suggested-questions', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath })) + .output(zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse) + +export const suggestedQuestions = { + get: get2, +} + +export const byMessageId = { + suggestedQuestions, +} + +/** + * Stop a running Agent App chat message generation + */ +export const post = oc + .route({ + description: 'Stop a running Agent App chat message generation', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdChatMessagesByTaskIdStop', + path: '/agent/{agent_id}/chat-messages/{task_id}/stop', + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdChatMessagesByTaskIdStopPath })) + .output(zPostAgentByAgentIdChatMessagesByTaskIdStopResponse) + +export const stop = { + post, +} + +export const byTaskId = { + stop, +} + +/** + * Get Agent App chat messages for a conversation with pagination + */ +export const get3 = oc + .route({ + description: 'Get Agent App chat messages for a conversation with pagination', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdChatMessages', + path: '/agent/{agent_id}/chat-messages', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdChatMessagesPath, + query: zGetAgentByAgentIdChatMessagesQuery, + }), + ) + .output(zGetAgentByAgentIdChatMessagesResponse) + +export const chatMessages = { + get: get3, + byMessageId, + byTaskId, +} + +export const get4 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdComposerCandidates', + path: '/agent/{agent_id}/composer/candidates', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdComposerCandidatesPath })) + .output(zGetAgentByAgentIdComposerCandidatesResponse) + +export const candidates = { + get: get4, +} + +export const post2 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdComposerValidate', + path: '/agent/{agent_id}/composer/validate', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAgentByAgentIdComposerValidateBody, + params: zPostAgentByAgentIdComposerValidatePath, + }), + ) + .output(zPostAgentByAgentIdComposerValidateResponse) + +export const validate = { + post: post2, +} + +export const get5 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdComposer', + path: '/agent/{agent_id}/composer', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdComposerPath })) + .output(zGetAgentByAgentIdComposerResponse) + +export const put = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAgentByAgentIdComposer', + path: '/agent/{agent_id}/composer', + tags: ['console'], + }) + .input(z.object({ body: zPutAgentByAgentIdComposerBody, params: zPutAgentByAgentIdComposerPath })) + .output(zPutAgentByAgentIdComposerResponse) + +export const composer = { + get: get5, + put, + candidates, + validate, +} + +/** + * Time-limited external signed URL for one Agent App drive value + */ +export const get6 = oc + .route({ + description: 'Time-limited external signed URL for one Agent App drive value', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdDriveFilesDownload', + path: '/agent/{agent_id}/drive/files/download', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdDriveFilesDownloadPath, + query: zGetAgentByAgentIdDriveFilesDownloadQuery, + }), + ) + .output(zGetAgentByAgentIdDriveFilesDownloadResponse) + +export const download = { + get: get6, +} + +/** + * Truncated text preview of one Agent App drive value + */ +export const get7 = oc + .route({ + description: 'Truncated text preview of one Agent App drive value', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdDriveFilesPreview', + path: '/agent/{agent_id}/drive/files/preview', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdDriveFilesPreviewPath, + query: zGetAgentByAgentIdDriveFilesPreviewQuery, + }), + ) + .output(zGetAgentByAgentIdDriveFilesPreviewResponse) + +export const preview = { + get: get7, +} + +/** + * List agent drive entries for an Agent App + */ +export const get8 = oc + .route({ + description: 'List agent drive entries for an Agent App', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdDriveFiles', + path: '/agent/{agent_id}/drive/files', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdDriveFilesPath, + query: zGetAgentByAgentIdDriveFilesQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdDriveFilesResponse) + +export const files = { + get: get8, + download, + preview, +} + +export const drive = { + files, +} + +/** + * Update an Agent App's presentation features (opener, follow-up, citations, ...) + */ +export const post3 = oc + .route({ + description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdFeatures', + path: '/agent/{agent_id}/features', + tags: ['console'], + }) + .input( + z.object({ body: zPostAgentByAgentIdFeaturesBody, params: zPostAgentByAgentIdFeaturesPath }), + ) + .output(zPostAgentByAgentIdFeaturesResponse) + +export const features = { + post: post3, +} + +/** + * Create or update Agent App message feedback + */ +export const post4 = oc + .route({ + description: 'Create or update Agent App message feedback', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdFeedbacks', + path: '/agent/{agent_id}/feedbacks', + tags: ['console'], + }) + .input( + z.object({ body: zPostAgentByAgentIdFeedbacksBody, params: zPostAgentByAgentIdFeedbacksPath }), + ) + .output(zPostAgentByAgentIdFeedbacksResponse) + +export const feedbacks = { + post: post4, +} + +/** + * Delete one Agent App drive file by key + */ +export const delete_ = oc + .route({ + description: 'Delete one Agent App drive file by key', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAgentByAgentIdFiles', + path: '/agent/{agent_id}/files', + tags: ['console'], + }) + .input( + z.object({ params: zDeleteAgentByAgentIdFilesPath, query: zDeleteAgentByAgentIdFilesQuery }), + ) + .output(zDeleteAgentByAgentIdFilesResponse) + +/** + * Commit an uploaded file into the Agent App drive under files/ + */ +export const post5 = oc + .route({ + description: 'Commit an uploaded file into the Agent App drive under files/', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdFiles', + path: '/agent/{agent_id}/files', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ body: zPostAgentByAgentIdFilesBody, params: zPostAgentByAgentIdFilesPath })) + .output(zPostAgentByAgentIdFilesResponse) + +export const files2 = { + delete: delete_, + post: post5, +} + +/** + * Get Agent App message details by ID + */ +export const get9 = oc + .route({ + description: 'Get Agent App message details by ID', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdMessagesByMessageId', + path: '/agent/{agent_id}/messages/{message_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdMessagesByMessageIdPath })) + .output(zGetAgentByAgentIdMessagesByMessageIdResponse) + +export const byMessageId2 = { + get: get9, +} + +export const messages = { + byMessageId: byMessageId2, +} + +/** + * List workflow apps that reference this Agent App's bound Agent (read-only) + */ +export const get10 = oc + .route({ + description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdReferencingWorkflows', + path: '/agent/{agent_id}/referencing-workflows', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdReferencingWorkflowsPath })) + .output(zGetAgentByAgentIdReferencingWorkflowsResponse) + +export const referencingWorkflows = { + get: get10, +} + +/** + * Read a text/binary preview file in an Agent App conversation sandbox + */ +export const get11 = oc + .route({ + description: 'Read a text/binary preview file in an Agent App conversation sandbox', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdSandboxFilesRead', + path: '/agent/{agent_id}/sandbox/files/read', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdSandboxFilesReadPath, + query: zGetAgentByAgentIdSandboxFilesReadQuery, + }), + ) + .output(zGetAgentByAgentIdSandboxFilesReadResponse) + +export const read = { + get: get11, +} + +/** + * Upload one Agent App sandbox file as a Dify ToolFile mapping + */ +export const post6 = oc + .route({ + description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdSandboxFilesUpload', + path: '/agent/{agent_id}/sandbox/files/upload', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAgentByAgentIdSandboxFilesUploadBody, + params: zPostAgentByAgentIdSandboxFilesUploadPath, + }), + ) + .output(zPostAgentByAgentIdSandboxFilesUploadResponse) + +export const upload = { + post: post6, +} + +/** + * List a directory in an Agent App conversation sandbox + */ +export const get12 = oc + .route({ + description: 'List a directory in an Agent App conversation sandbox', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdSandboxFiles', + path: '/agent/{agent_id}/sandbox/files', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdSandboxFilesPath, + query: zGetAgentByAgentIdSandboxFilesQuery, + }), + ) + .output(zGetAgentByAgentIdSandboxFilesResponse) + +export const files3 = { + get: get12, + read, + upload, +} + +export const sandbox = { + files: files3, +} + +/** + * Validate + standardize a Skill into an Agent App drive + */ +export const post7 = oc + .route({ + description: 'Validate + standardize a Skill into an Agent App drive', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdSkillsStandardize', + path: '/agent/{agent_id}/skills/standardize', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdSkillsStandardizePath })) + .output(zPostAgentByAgentIdSkillsStandardizeResponse) + +export const standardize = { + post: post7, +} + +/** + * Upload + validate a Skill package for an Agent App + */ +export const post8 = oc + .route({ + description: 'Upload + validate a Skill package for an Agent App', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdSkillsUpload', + path: '/agent/{agent_id}/skills/upload', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdSkillsUploadPath })) + .output(zPostAgentByAgentIdSkillsUploadResponse) + +export const upload2 = { + post: post8, +} + +/** + * Infer CLI tool + ENV suggestions from a standardized Agent App skill + */ +export const post9 = oc + .route({ + description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdSkillsBySlugInferTools', + path: '/agent/{agent_id}/skills/{slug}/infer-tools', + tags: ['console'], + }) + .input(z.object({ params: zPostAgentByAgentIdSkillsBySlugInferToolsPath })) + .output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse) + +export const inferTools = { + post: post9, +} + +/** + * Delete a standardized skill from an Agent App drive + */ +export const delete2 = oc + .route({ + description: 'Delete a standardized skill from an Agent App drive', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAgentByAgentIdSkillsBySlug', + path: '/agent/{agent_id}/skills/{slug}', + tags: ['console'], + }) + .input(z.object({ params: zDeleteAgentByAgentIdSkillsBySlugPath })) + .output(zDeleteAgentByAgentIdSkillsBySlugResponse) + +export const bySlug = { + delete: delete2, + inferTools, +} + +export const skills = { + standardize, + upload: upload2, + bySlug, +} + +export const get13 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdVersionsByVersionId', + path: '/agent/{agent_id}/versions/{version_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdVersionsByVersionIdPath })) + .output(zGetAgentByAgentIdVersionsByVersionIdResponse) + +export const byVersionId = { + get: get13, +} + +export const get14 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdVersions', + path: '/agent/{agent_id}/versions', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdVersionsPath })) + .output(zGetAgentByAgentIdVersionsResponse) + +export const versions = { + get: get14, + byVersionId, +} + +export const delete3 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAgentByAgentId', + path: '/agent/{agent_id}', + successStatus: 204, + tags: ['console'], + }) + .input(z.object({ params: zDeleteAgentByAgentIdPath })) + .output(zDeleteAgentByAgentIdResponse) + +export const get15 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentId', + path: '/agent/{agent_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdPath })) + .output(zGetAgentByAgentIdResponse) + +export const put2 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAgentByAgentId', + path: '/agent/{agent_id}', + tags: ['console'], + }) + .input(z.object({ body: zPutAgentByAgentIdBody, params: zPutAgentByAgentIdPath })) + .output(zPutAgentByAgentIdResponse) + +export const byAgentId = { + delete: delete3, + get: get15, + put: put2, + chatMessages, + composer, + drive, + features, + feedbacks, + files: files2, + messages, + referencingWorkflows, + sandbox, + skills, + versions, +} + +export const get16 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgent', + path: '/agent', + tags: ['console'], + }) + .input(z.object({ query: zGetAgentQuery.optional() })) + .output(zGetAgentResponse) + +export const post10 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgent', + path: '/agent', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ body: zPostAgentBody })) + .output(zPostAgentResponse) + +export const agent = { + get: get16, + post: post10, + inviteOptions, + byAgentId, +} + +export const contract = { + agent, +} diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts new file mode 100644 index 00000000000..2f7d2ee0af8 --- /dev/null +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -0,0 +1,1920 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}/console/api` | (string & {}) +} + +export type AppPagination = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + +export type AgentAppCreatePayload = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: IconType | null + name: string + role?: string +} + +export type AppDetailWithSite = { + access_mode?: string | null + active_config_is_published?: boolean + api_base_url?: string | null + app_id?: string | null + bound_agent_id?: string | null + created_at?: number | null + created_by?: string | null + deleted_tools?: Array + description?: string | null + enable_api: boolean + enable_site: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null + readonly icon_url: string | null + id: string + max_active_requests?: number | null + mode: string + model_config?: ModelConfig | null + name: string + role?: string | null + site?: Site | null + tags?: Array + tracing?: JsonValue | null + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null + workflow?: WorkflowPartial | null +} + +export type AgentInviteOptionsResponse = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + +export type AgentAppUpdatePayload = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: IconType | null + max_active_requests?: number | null + name: string + role?: string | null + use_icon_as_answer_icon?: boolean | null +} + +export type MessageInfiniteScrollPaginationResponse = { + data: Array + has_more: boolean + limit: number +} + +export type SuggestedQuestionsResponse = { + data: Array +} + +export type SimpleResultResponse = { + result: string +} + +export type AgentAppComposerResponse = { + active_config_snapshot: AgentConfigSnapshotSummaryResponse + agent: AgentComposerAgentResponse + agent_soul: AgentSoulConfig + save_options: Array + validation?: ComposerValidationFindingsResponse | null + variant: 'agent_app' +} + +export type ComposerSavePayload = { + agent_soul?: AgentSoulConfig | null + binding?: ComposerBindingPayload | null + client_revision_id?: string | null + idempotency_key?: string | null + new_agent_name?: string | null + node_job?: WorkflowNodeJobConfig | null + save_strategy: ComposerSaveStrategy + soul_lock?: ComposerSoulLockPayload + variant: ComposerVariant + version_note?: string | null +} + +export type AgentComposerCandidatesResponse = { + allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse + allowed_soul_candidates?: AgentComposerSoulCandidatesResponse + capabilities?: ComposerCandidateCapabilities + truncated?: boolean + variant: ComposerVariant +} + +export type AgentComposerValidateResponse = { + errors?: Array + knowledge_retrieval_placeholder?: Array + result: 'success' + warnings?: Array +} + +export type AgentDriveListResponse = { + items?: Array +} + +export type AgentDriveDownloadResponse = { + url: string +} + +export type AgentDrivePreviewResponse = { + binary: boolean + key: string + size?: number | null + text?: string | null + truncated: boolean +} + +export type AgentAppFeaturesPayload = { + opening_statement?: string | null + retriever_resource?: AgentFeatureToggleConfig | null + sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null + speech_to_text?: AgentFeatureToggleConfig | null + suggested_questions?: Array | null + suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig | null + text_to_speech?: AgentTextToSpeechFeatureConfig | null +} + +export type MessageFeedbackPayload = { + content?: string | null + message_id: string + rating?: 'dislike' | 'like' | null +} + +export type AgentDriveDeleteResponse = { + config_version_id?: string | null + removed_keys?: Array + result: string +} + +export type AgentDriveFilePayload = { + upload_file_id: string +} + +export type AgentDriveFileCommitResponse = { + config_version_id?: string | null + file: AgentDriveFileResponse +} + +export type MessageDetailResponse = { + agent_thoughts?: Array + annotation?: ConversationAnnotation | null + annotation_hit_history?: ConversationAnnotationHitHistory | null + answer_tokens?: number | null + conversation_id: string + created_at?: number | null + error?: string | null + extra_contents?: Array + feedbacks?: Array + from_account_id?: string | null + from_end_user_id?: string | null + from_source: string + id: string + inputs: { + [key: string]: JsonValue + } + message?: JsonValue | null + message_files?: Array + message_metadata_dict?: JsonValue | null + message_tokens?: number | null + parent_message_id?: string | null + provider_response_latency?: number | null + query: string + re_sign_file_url_answer: string + status: string + workflow_run_id?: string | null +} + +export type AgentReferencingWorkflowsResponse = { + data?: Array +} + +export type SandboxListResponse = { + entries?: Array + path: string + truncated?: boolean +} + +export type SandboxReadResponse = { + binary: boolean + path: string + size?: number | null + text?: string | null + truncated: boolean +} + +export type AgentSandboxUploadPayload = { + conversation_id: string + path: string +} + +export type SandboxUploadResponse = { + file: SandboxToolFileResponse + path: string +} + +export type AgentSkillStandardizeResponse = { + manifest: SkillManifest + skill: AgentSkillRefConfig +} + +export type AgentSkillUploadResponse = { + manifest: SkillManifest + skill: AgentSkillRefConfig +} + +export type SkillToolInferenceResult = { + cli_tools?: Array + inferable: boolean + reason?: string | null +} + +export type AgentConfigSnapshotListResponse = { + data: Array +} + +export type AgentConfigSnapshotDetailResponse = { + agent_id?: string | null + config_snapshot: AgentSoulConfig + created_at?: number | null + created_by?: string | null + id: string + revisions?: Array + summary?: string | null + version: number + version_note?: string | null +} + +export type AppPartial = { + access_mode?: string | null + active_config_is_published?: boolean + app_id?: string | null + author_name?: string | null + bound_agent_id?: string | null + create_user_name?: string | null + created_at?: number | null + created_by?: string | null + description?: string | null + has_draft_trigger?: boolean | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + readonly icon_url: string | null + id: string + is_starred?: boolean + max_active_requests?: number | null + mode: string + model_config?: ModelConfigPartial | null + name: string + role?: string | null + tags?: Array + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null + workflow?: WorkflowPartial | null +} + +export type IconType = 'emoji' | 'image' | 'link' + +export type DeletedTool = { + provider_id: string + tool_name: string + type: string +} + +export type ModelConfig = { + completion_params?: { + [key: string]: unknown + } + mode: LlmMode + name: string + provider: string +} + +export type Site = { + chat_color_theme?: string | null + chat_color_theme_inverted: boolean + copyright?: string | null + custom_disclaimer?: string | null + default_language: string + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + readonly icon_url: string | null + privacy_policy?: string | null + show_workflow_steps: boolean + title: string + use_icon_as_answer_icon: boolean +} + +export type Tag = { + id: string + name: string + type: string +} + +export type JsonValue + = | string + | number + | number + | boolean + | { + [key: string]: unknown + } + | Array + | null + +export type WorkflowPartial = { + created_at?: number | null + created_by?: string | null + id: string + updated_at?: number | null + updated_by?: string | null +} + +export type AgentInviteOptionResponse = { + active_config_is_published?: boolean + active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null + active_config_snapshot_id?: string | null + agent_kind: AgentKind + app_id?: string | null + archived_at?: number | null + archived_by?: string | null + created_at?: number | null + created_by?: string | null + description: string + existing_node_ids?: Array + icon?: string | null + icon_background?: string | null + icon_type?: AgentIconType | null + id: string + in_current_workflow_count?: number + is_in_current_workflow?: boolean + name: string + published_node_reference_count?: number + published_reference_count?: number + published_references?: Array + role?: string + scope: AgentScope + source: AgentSource + status: AgentStatus + updated_at?: number | null + updated_by?: string | null + workflow_id?: string | null + workflow_node_id?: string | null +} + +export type AgentConfigSnapshotSummaryResponse = { + agent_id?: string | null + created_at?: number | null + created_by?: string | null + id: string + summary?: string | null + version: number + version_note?: string | null +} + +export type AgentComposerAgentResponse = { + active_config_snapshot_id?: string | null + description: string + id: string + name: string + scope: AgentScope + status: AgentStatus +} + +export type AgentSoulConfig = { + app_features?: AgentSoulAppFeaturesConfig + app_variables?: Array + env?: AgentSoulEnvConfig + human?: AgentSoulHumanConfig + knowledge?: AgentSoulKnowledgeConfig + memory?: AgentSoulMemoryConfig + misc_legacy?: AgentSoulAppFeaturesConfig + model?: AgentSoulModelConfig | null + prompt?: AgentSoulPromptConfig + sandbox?: AgentSoulSandboxConfig + schema_version?: number + skills_files?: AgentSoulSkillsFilesConfig + tools?: AgentSoulToolsConfig +} + +export type ComposerSaveStrategy + = | 'node_job_only' + | 'save_as_new_agent' + | 'save_as_new_version' + | 'save_to_current_version' + | 'save_to_roster' + +export type ComposerValidationFindingsResponse = { + knowledge_retrieval_placeholder?: Array + warnings?: Array +} + +export type ComposerBindingPayload = { + agent_id?: string | null + binding_type: 'inline_agent' | 'roster_agent' + current_snapshot_id?: string | null +} + +export type WorkflowNodeJobConfig = { + declared_outputs?: Array + human_contacts?: Array + metadata?: WorkflowNodeJobMetadata + mode?: WorkflowNodeJobMode + previous_node_output_refs?: Array + schema_version?: number + workflow_prompt?: string +} + +export type ComposerSoulLockPayload = { + locked?: boolean + unlocked_from_version_id?: string | null +} + +export type ComposerVariant = 'agent_app' | 'workflow' + +export type AgentComposerNodeJobCandidatesResponse = { + declare_output_types?: Array + human_contacts?: Array + previous_node_outputs?: Array +} + +export type AgentComposerSoulCandidatesResponse = { + cli_tools?: Array + dify_tools?: Array + human_contacts?: Array + knowledge_datasets?: Array + skills_files?: Array< + | ({ + kind: 'skill' + } & AgentComposerSkillCandidateResponse) + | ({ + kind: 'file' + } & AgentComposerFileCandidateResponse) + > +} + +export type ComposerCandidateCapabilities = { + human_roster_available?: boolean +} + +export type ComposerKnowledgePlaceholderResponse = { + id: string + placeholder_name: string +} + +export type ComposerValidationWarningResponse = { + code: string + id?: string | null + kind?: string | null + message?: string | null + surface?: string | null +} + +export type AgentDriveItemResponse = { + created_at?: number | null + file_kind: string + hash?: string | null + key: string + mime_type?: string | null + size?: number | null +} + +export type AgentFeatureToggleConfig = { + enabled?: boolean + [key: string]: unknown +} + +export type AgentSensitiveWordAvoidanceFeatureConfig = { + config?: AgentModerationProviderConfig | null + enabled?: boolean + type?: string | null + [key: string]: unknown +} + +export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = { + enabled?: boolean + model?: AgentSoulModelConfig | null + prompt?: string | null + [key: string]: unknown +} + +export type AgentTextToSpeechFeatureConfig = { + autoPlay?: string | null + enabled?: boolean + language?: string | null + voice?: string | null + [key: string]: unknown +} + +export type AgentDriveFileResponse = { + drive_key: string + file_id: string + mime_type?: string | null + name: string + size?: number | null +} + +export type AgentThought = { + chain_id?: string | null + created_at?: number | null + files: Array + id: string + message_chain_id?: string | null + message_id: string + observation?: string | null + position: number + thought?: string | null + tool?: string | null + tool_input?: string | null + tool_labels: JsonValue +} + +export type ConversationAnnotation = { + account?: SimpleAccount | null + content: string + created_at?: number | null + id: string + question?: string | null +} + +export type ConversationAnnotationHitHistory = { + annotation_create_account?: SimpleAccount | null + created_at?: number | null + id: string +} + +export type HumanInputContent = { + form_definition?: HumanInputFormDefinition | null + form_submission_data?: HumanInputFormSubmissionData | null + submitted: boolean + type?: ExecutionContentType + workflow_run_id: string +} + +export type Feedback = { + content?: string | null + from_account?: SimpleAccount | null + from_end_user_id?: string | null + from_source: string + rating: string +} + +export type MessageFile = { + belongs_to?: string | null + filename: string + id: string + mime_type?: string | null + size?: number | null + transfer_method: string + type: string + upload_file_id?: string | null + url?: string | null +} + +export type AgentReferencingWorkflowResponse = { + app_icon?: string | null + app_icon_background?: string | null + app_icon_type?: string | null + app_id: string + app_mode: string + app_name: string + app_updated_at?: number | null + node_ids?: Array + workflow_id: string + workflow_version: string +} + +export type SandboxFileEntryResponse = { + mtime?: number | null + name: string + size?: number | null + type: 'dir' | 'file' | 'other' | 'symlink' +} + +export type SandboxToolFileResponse = { + reference: string + transfer_method?: 'tool_file' +} + +export type SkillManifest = { + description: string + entry_path: string + files: Array + hash: string + name: string + size: number +} + +export type AgentSkillRefConfig = { + description?: string | null + file_id?: string | null + full_archive_file_id?: string | null + full_archive_key?: string | null + id?: string | null + manifest_files?: Array | null + name?: string | null + path?: string | null + skill_md_file_id?: string | null + skill_md_key?: string | null + [key: string]: unknown +} + +export type CliToolSuggestion = { + command?: string + description?: string + env_suggestions?: Array + inferred_from?: string + install_commands?: Array + name: string +} + +export type AgentConfigRevisionResponse = { + created_at?: number | null + created_by?: string | null + current_snapshot_id: string + id: string + operation: AgentConfigRevisionOperation + previous_snapshot_id?: string | null + revision: number + summary?: string | null + version_note?: string | null +} + +export type ModelConfigPartial = { + created_at?: number | null + created_by?: string | null + model?: JsonValue | null + pre_prompt?: string | null + updated_at?: number | null + updated_by?: string | null +} + +export type LlmMode = 'chat' | 'completion' + +export type AgentKind = 'dify_agent' + +export type AgentIconType = 'emoji' | 'image' | 'link' + +export type AgentPublishedReferenceResponse = { + app_icon?: string | null + app_icon_background?: string | null + app_icon_type?: string | null + app_id: string + app_mode: string + app_name: string + app_updated_at?: number | null + node_ids?: Array + workflow_id: string + workflow_version: string +} + +export type AgentScope = 'roster' | 'workflow_only' + +export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'workflow' + +export type AgentStatus = 'active' | 'archived' + +export type AgentSoulAppFeaturesConfig = { + opening_statement?: string | null + retriever_resource?: AgentFeatureToggleConfig | null + sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null + speech_to_text?: AgentFeatureToggleConfig | null + suggested_questions?: Array | null + suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig | null + text_to_speech?: AgentTextToSpeechFeatureConfig | null + [key: string]: unknown +} + +export type AppVariableConfig = { + default?: unknown + name: string + required?: boolean + type: string +} + +export type AgentSoulEnvConfig = { + secret_refs?: Array + variables?: Array +} + +export type AgentSoulHumanConfig = { + contacts?: Array + tools?: Array +} + +export type AgentSoulKnowledgeConfig = { + datasets?: Array + query_config?: AgentKnowledgeQueryConfig + query_mode?: AgentKnowledgeQueryMode | null +} + +export type AgentSoulMemoryConfig = { + artifacts?: Array + budget?: string | null + scope?: string | null +} + +export type AgentSoulModelConfig = { + credential_ref?: AgentSoulModelCredentialRef | null + model: string + model_provider: string + model_settings?: AgentSoulModelSettings + plugin_id: string +} + +export type AgentSoulPromptConfig = { + system_prompt?: string +} + +export type AgentSoulSandboxConfig = { + config?: AgentSandboxProviderConfig + provider?: string | null +} + +export type AgentSoulSkillsFilesConfig = { + files?: Array + skills?: Array +} + +export type AgentSoulToolsConfig = { + cli_tools?: Array + dify_tools?: Array +} + +export type DeclaredOutputConfig = { + array_item?: DeclaredArrayItem | null + check?: DeclaredOutputCheckConfig | null + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> + description?: string | null + failure_strategy?: DeclaredOutputFailureStrategy + file?: DeclaredOutputFileConfig | null + id?: string | null + name: string + required?: boolean + type: DeclaredOutputType +} + +export type AgentHumanContactConfig = { + channel?: string | null + contact_id?: string | null + contact_method?: string | null + email?: string | null + human_id?: string | null + id?: string | null + method?: string | null + name?: string | null + tenant_id?: string | null + [key: string]: unknown +} + +export type WorkflowNodeJobMetadata = { + agent_soul?: { + [key: string]: unknown + } | null + file_refs?: Array | null +} + +export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do' + +export type WorkflowPreviousNodeOutputRef = { + key?: string | null + name?: string | null + node_id?: string | null + output?: string | null + selector?: Array | null + value_selector?: Array | null + variable?: string | null + variable_selector?: Array | null + [key: string]: unknown +} + +export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + +export type AgentCliToolConfig = { + approved?: boolean + authorization_status?: AgentCliToolAuthorizationStatus | null + command?: string | null + dangerous?: boolean + dangerous_accepted?: boolean + dangerous_acknowledged?: boolean + dangerous_command?: boolean + description?: string | null + enabled?: boolean + env?: AgentCliToolEnvConfig + id?: string | null + inferred_from?: string | null + install?: string | null + install_command?: string | null + install_commands?: Array + invoke_metadata?: { + [key: string]: unknown + } + label?: string | null + name?: string | null + permission?: AgentPermissionConfig | null + pre_authorized?: boolean | null + requires_confirmation?: boolean + risk_accepted?: boolean + risk_level?: AgentCliToolRiskLevel | null + setup_command?: string | null + tool_name?: string | null + [key: string]: unknown +} + +export type AgentComposerDifyToolCandidateResponse = { + description?: string | null + granularity?: string | null + id?: string | null + name?: string | null + plugin_id?: string | null + provider?: string | null + provider_id?: string | null + tools_count?: number | null +} + +export type AgentKnowledgeDatasetConfig = { + description?: string | null + id?: string | null + name?: string | null + [key: string]: unknown +} + +export type AgentComposerSkillCandidateResponse = { + description?: string | null + file_id?: string | null + full_archive_file_id?: string | null + full_archive_key?: string | null + id?: string | null + kind?: 'skill' + manifest_files?: Array | null + name?: string | null + path?: string | null + skill_md_file_id?: string | null + skill_md_key?: string | null + [key: string]: unknown +} + +export type AgentComposerFileCandidateResponse = { + drive_key?: string | null + file_id?: string | null + id?: string | null + kind?: 'file' + name?: string | null + reference?: string | null + remote_url?: string | null + tenant_id?: string | null + transfer_method?: string | null + type?: string | null + upload_file_id?: string | null + url?: string | null + [key: string]: unknown +} + +export type AgentModerationProviderConfig = { + api_based_extension_id?: string | null + inputs_config?: AgentModerationIoConfig | null + keywords?: string | null + outputs_config?: AgentModerationIoConfig | null + [key: string]: unknown +} + +export type SimpleAccount = { + email: string + id: string + name: string +} + +export type HumanInputFormDefinition = { + actions?: Array + display_in_ui?: boolean + expiration_time: number + form_content: string + form_id: string + form_token?: string | null + inputs?: Array + node_id: string + node_title: string + resolved_default_values?: { + [key: string]: unknown + } +} + +export type HumanInputFormSubmissionData = { + action_id: string + action_text: string + node_id: string + node_title: string + rendered_content: string + submitted_data?: { + [key: string]: JsonValue2 + } | null +} + +export type ExecutionContentType = 'human_input' + +export type EnvSuggestion = { + key: string + reason?: string + secret_likely?: boolean +} + +export type AgentConfigRevisionOperation + = | 'create_version' + | 'save_current_version' + | 'save_new_agent' + | 'save_new_version' + | 'save_to_roster' + +export type AgentSecretRefConfig = { + credential_id?: string | null + env_name?: string | null + id?: string | null + key?: string | null + name?: string | null + permission?: AgentPermissionConfig | null + permission_status?: string | null + provider?: string | null + provider_credential_id?: string | null + ref?: string | null + type?: string | null + value?: string | null + variable?: string | null + [key: string]: unknown +} + +export type AgentEnvVariableConfig = { + default?: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null + env_name?: string | null + key?: string | null + name?: string | null + required?: boolean + type?: string | null + value?: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null + variable?: string | null + [key: string]: unknown +} + +export type AgentHumanToolConfig = { + description?: string | null + enabled?: boolean + name?: string | null + [key: string]: unknown +} + +export type AgentKnowledgeQueryConfig = { + query?: string | null + score_threshold?: number | null + score_threshold_enabled?: boolean | null + top_k?: number | null + [key: string]: unknown +} + +export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' + +export type AgentMemoryArtifactConfig = { + id?: string | null + name?: string | null + type?: string | null + url?: string | null + [key: string]: unknown +} + +export type AgentSoulModelCredentialRef = { + id?: string | null + provider?: string | null + type: string +} + +export type AgentSoulModelSettings = { + frequency_penalty?: number | null + max_tokens?: number | null + presence_penalty?: number | null + response_format?: AgentModelResponseFormatConfig | null + stop?: Array | null + temperature?: number | null + top_p?: number | null +} + +export type AgentSandboxProviderConfig = { + cpu?: number | null + env?: Array + image?: string | null + working_dir?: string | null + [key: string]: unknown +} + +export type AgentFileRefConfig = { + drive_key?: string | null + file_id?: string | null + id?: string | null + name?: string | null + reference?: string | null + remote_url?: string | null + tenant_id?: string | null + transfer_method?: string | null + type?: string | null + upload_file_id?: string | null + url?: string | null + [key: string]: unknown +} + +export type AgentSoulDifyToolConfig = { + credential_ref?: AgentSoulDifyToolCredentialRef | null + credential_type?: 'api-key' | 'oauth2' | 'unauthorized' + description?: string | null + enabled?: boolean + name?: string | null + plugin_id?: string | null + provider?: string | null + provider_id?: string | null + provider_type?: string + runtime_parameters?: { + [key: string]: + | string + | number + | number + | boolean + | Array + | Array + | Array + | Array + | null + } + tool_name?: string | null +} + +export type DeclaredArrayItem = { + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> + description?: string | null + type: DeclaredOutputType +} + +export type DeclaredOutputCheckConfig = { + benchmark_file_ref?: AgentFileRefConfig | null + enabled?: boolean + model_ref?: AgentSoulModelConfig | null + prompt?: string | null +} + +export type DeclaredOutputFailureStrategy = { + default_value?: unknown + on_failure?: OutputErrorStrategy + retry?: DeclaredOutputRetryConfig +} + +export type DeclaredOutputFileConfig = { + extensions?: Array + mime_types?: Array +} + +export type AgentCliToolAuthorizationStatus + = | 'allowed' + | 'authorized' + | 'denied' + | 'forbidden' + | 'not_required' + | 'pending' + | 'pre_authorized' + | 'unauthorized' + +export type AgentCliToolEnvConfig = { + secret_refs?: Array + variables?: Array +} + +export type AgentPermissionConfig = { + allowed?: boolean | null + state?: string | null + status?: string | null +} + +export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown' + +export type AgentModerationIoConfig = { + enabled?: boolean + preset_response?: string | null + [key: string]: unknown +} + +export type UserActionConfig = { + button_style?: ButtonStyle + id: string + title: string +} + +export type FormInputConfig + = | ({ + type: 'paragraph' + } & ParagraphInputConfig) + | ({ + type: 'select' + } & SelectInputConfig) + | ({ + type: 'file' + } & FileInputConfig) + | ({ + type: 'file-list' + } & FileListInputConfig) + +export type JsonValue2 = unknown + +export type AgentModelResponseFormatConfig = { + type?: string | null + [key: string]: unknown +} + +export type AgentSoulDifyToolCredentialRef = { + id?: string | null + provider?: string | null + type?: 'provider' | 'tool' +} + +export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop' + +export type DeclaredOutputRetryConfig = { + enabled?: boolean + max_retries?: number + retry_interval_ms?: number +} + +export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' + +export type ParagraphInputConfig = { + default?: StringSource | null + output_variable_name: string + type?: 'paragraph' +} + +export type SelectInputConfig = { + option_source: StringListSource + output_variable_name: string + type?: 'select' +} + +export type FileInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + output_variable_name: string + type?: 'file' +} + +export type FileListInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + number_limits?: number + output_variable_name: string + type?: 'file-list' +} + +export type StringSource = { + selector?: Array + type: ValueSourceType + value?: string +} + +export type StringListSource = { + selector?: Array + type: ValueSourceType + value?: Array +} + +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type ValueSourceType = 'constant' | 'variable' + +export type AppPaginationWritable = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + +export type AppDetailWithSiteWritable = { + access_mode?: string | null + active_config_is_published?: boolean + api_base_url?: string | null + app_id?: string | null + bound_agent_id?: string | null + created_at?: number | null + created_by?: string | null + deleted_tools?: Array + description?: string | null + enable_api: boolean + enable_site: boolean + icon?: string | null + icon_background?: string | null + icon_type?: string | null + id: string + max_active_requests?: number | null + mode: string + model_config?: ModelConfig | null + name: string + role?: string | null + site?: SiteWritable | null + tags?: Array + tracing?: JsonValue | null + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null + workflow?: WorkflowPartial | null +} + +export type AppPartialWritable = { + access_mode?: string | null + active_config_is_published?: boolean + app_id?: string | null + author_name?: string | null + bound_agent_id?: string | null + create_user_name?: string | null + created_at?: number | null + created_by?: string | null + description?: string | null + has_draft_trigger?: boolean | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + id: string + is_starred?: boolean + max_active_requests?: number | null + mode: string + model_config?: ModelConfigPartial | null + name: string + role?: string | null + tags?: Array + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null + workflow?: WorkflowPartial | null +} + +export type SiteWritable = { + chat_color_theme?: string | null + chat_color_theme_inverted: boolean + copyright?: string | null + custom_disclaimer?: string | null + default_language: string + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + privacy_policy?: string | null + show_workflow_steps: boolean + title: string + use_icon_as_answer_icon: boolean +} + +export type GetAgentData = { + body?: never + path?: never + query?: { + creator_ids?: Array + is_created_by_me?: boolean + limit?: number + mode?: + | 'advanced-chat' + | 'agent' + | 'agent-chat' + | 'all' + | 'channel' + | 'chat' + | 'completion' + | 'workflow' + name?: string + page?: number + sort_by?: 'earliest_created' | 'last_modified' | 'recently_created' + tag_ids?: Array + } + url: '/agent' +} + +export type GetAgentResponses = { + 200: AppPagination +} + +export type GetAgentResponse = GetAgentResponses[keyof GetAgentResponses] + +export type PostAgentData = { + body: AgentAppCreatePayload + path?: never + query?: never + url: '/agent' +} + +export type PostAgentErrors = { + 400: unknown + 403: unknown +} + +export type PostAgentResponses = { + 201: AppDetailWithSite +} + +export type PostAgentResponse = PostAgentResponses[keyof PostAgentResponses] + +export type GetAgentInviteOptionsData = { + body?: never + path?: never + query?: { + app_id?: string + keyword?: string + limit?: number + page?: number + } + url: '/agent/invite-options' +} + +export type GetAgentInviteOptionsResponses = { + 200: AgentInviteOptionsResponse +} + +export type GetAgentInviteOptionsResponse + = GetAgentInviteOptionsResponses[keyof GetAgentInviteOptionsResponses] + +export type DeleteAgentByAgentIdData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}' +} + +export type DeleteAgentByAgentIdErrors = { + 403: unknown +} + +export type DeleteAgentByAgentIdResponses = { + 204: void +} + +export type DeleteAgentByAgentIdResponse + = DeleteAgentByAgentIdResponses[keyof DeleteAgentByAgentIdResponses] + +export type GetAgentByAgentIdData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}' +} + +export type GetAgentByAgentIdResponses = { + 200: AppDetailWithSite +} + +export type GetAgentByAgentIdResponse = GetAgentByAgentIdResponses[keyof GetAgentByAgentIdResponses] + +export type PutAgentByAgentIdData = { + body: AgentAppUpdatePayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}' +} + +export type PutAgentByAgentIdErrors = { + 400: unknown + 403: unknown +} + +export type PutAgentByAgentIdResponses = { + 200: AppDetailWithSite +} + +export type PutAgentByAgentIdResponse = PutAgentByAgentIdResponses[keyof PutAgentByAgentIdResponses] + +export type GetAgentByAgentIdChatMessagesData = { + body?: never + path: { + agent_id: string + } + query: { + conversation_id: string + first_id?: string + limit?: number + } + url: '/agent/{agent_id}/chat-messages' +} + +export type GetAgentByAgentIdChatMessagesErrors = { + 404: unknown +} + +export type GetAgentByAgentIdChatMessagesResponses = { + 200: MessageInfiniteScrollPaginationResponse +} + +export type GetAgentByAgentIdChatMessagesResponse + = GetAgentByAgentIdChatMessagesResponses[keyof GetAgentByAgentIdChatMessagesResponses] + +export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsData = { + body?: never + path: { + agent_id: string + message_id: string + } + query?: never + url: '/agent/{agent_id}/chat-messages/{message_id}/suggested-questions' +} + +export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsErrors = { + 404: unknown +} + +export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponses = { + 200: SuggestedQuestionsResponse +} + +export type GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse + = GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponses[keyof GetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponses] + +export type PostAgentByAgentIdChatMessagesByTaskIdStopData = { + body?: never + path: { + agent_id: string + task_id: string + } + query?: never + url: '/agent/{agent_id}/chat-messages/{task_id}/stop' +} + +export type PostAgentByAgentIdChatMessagesByTaskIdStopResponses = { + 200: SimpleResultResponse +} + +export type PostAgentByAgentIdChatMessagesByTaskIdStopResponse + = PostAgentByAgentIdChatMessagesByTaskIdStopResponses[keyof PostAgentByAgentIdChatMessagesByTaskIdStopResponses] + +export type GetAgentByAgentIdComposerData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/composer' +} + +export type GetAgentByAgentIdComposerResponses = { + 200: AgentAppComposerResponse +} + +export type GetAgentByAgentIdComposerResponse + = GetAgentByAgentIdComposerResponses[keyof GetAgentByAgentIdComposerResponses] + +export type PutAgentByAgentIdComposerData = { + body: ComposerSavePayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/composer' +} + +export type PutAgentByAgentIdComposerResponses = { + 200: AgentAppComposerResponse +} + +export type PutAgentByAgentIdComposerResponse + = PutAgentByAgentIdComposerResponses[keyof PutAgentByAgentIdComposerResponses] + +export type GetAgentByAgentIdComposerCandidatesData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/composer/candidates' +} + +export type GetAgentByAgentIdComposerCandidatesResponses = { + 200: AgentComposerCandidatesResponse +} + +export type GetAgentByAgentIdComposerCandidatesResponse + = GetAgentByAgentIdComposerCandidatesResponses[keyof GetAgentByAgentIdComposerCandidatesResponses] + +export type PostAgentByAgentIdComposerValidateData = { + body: ComposerSavePayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/composer/validate' +} + +export type PostAgentByAgentIdComposerValidateResponses = { + 200: AgentComposerValidateResponse +} + +export type PostAgentByAgentIdComposerValidateResponse + = PostAgentByAgentIdComposerValidateResponses[keyof PostAgentByAgentIdComposerValidateResponses] + +export type GetAgentByAgentIdDriveFilesData = { + body?: never + path: { + agent_id: string + } + query?: { + prefix?: string + } + url: '/agent/{agent_id}/drive/files' +} + +export type GetAgentByAgentIdDriveFilesResponses = { + 200: AgentDriveListResponse +} + +export type GetAgentByAgentIdDriveFilesResponse + = GetAgentByAgentIdDriveFilesResponses[keyof GetAgentByAgentIdDriveFilesResponses] + +export type GetAgentByAgentIdDriveFilesDownloadData = { + body?: never + path: { + agent_id: string + } + query: { + key: string + } + url: '/agent/{agent_id}/drive/files/download' +} + +export type GetAgentByAgentIdDriveFilesDownloadResponses = { + 200: AgentDriveDownloadResponse +} + +export type GetAgentByAgentIdDriveFilesDownloadResponse + = GetAgentByAgentIdDriveFilesDownloadResponses[keyof GetAgentByAgentIdDriveFilesDownloadResponses] + +export type GetAgentByAgentIdDriveFilesPreviewData = { + body?: never + path: { + agent_id: string + } + query: { + key: string + } + url: '/agent/{agent_id}/drive/files/preview' +} + +export type GetAgentByAgentIdDriveFilesPreviewResponses = { + 200: AgentDrivePreviewResponse +} + +export type GetAgentByAgentIdDriveFilesPreviewResponse + = GetAgentByAgentIdDriveFilesPreviewResponses[keyof GetAgentByAgentIdDriveFilesPreviewResponses] + +export type PostAgentByAgentIdFeaturesData = { + body: AgentAppFeaturesPayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/features' +} + +export type PostAgentByAgentIdFeaturesErrors = { + 400: unknown + 404: unknown +} + +export type PostAgentByAgentIdFeaturesResponses = { + 200: SimpleResultResponse +} + +export type PostAgentByAgentIdFeaturesResponse + = PostAgentByAgentIdFeaturesResponses[keyof PostAgentByAgentIdFeaturesResponses] + +export type PostAgentByAgentIdFeedbacksData = { + body: MessageFeedbackPayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/feedbacks' +} + +export type PostAgentByAgentIdFeedbacksErrors = { + 404: unknown +} + +export type PostAgentByAgentIdFeedbacksResponses = { + 200: SimpleResultResponse +} + +export type PostAgentByAgentIdFeedbacksResponse + = PostAgentByAgentIdFeedbacksResponses[keyof PostAgentByAgentIdFeedbacksResponses] + +export type DeleteAgentByAgentIdFilesData = { + body?: never + path: { + agent_id: string + } + query: { + key: string + } + url: '/agent/{agent_id}/files' +} + +export type DeleteAgentByAgentIdFilesResponses = { + 200: AgentDriveDeleteResponse +} + +export type DeleteAgentByAgentIdFilesResponse + = DeleteAgentByAgentIdFilesResponses[keyof DeleteAgentByAgentIdFilesResponses] + +export type PostAgentByAgentIdFilesData = { + body: AgentDriveFilePayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/files' +} + +export type PostAgentByAgentIdFilesResponses = { + 201: AgentDriveFileCommitResponse +} + +export type PostAgentByAgentIdFilesResponse + = PostAgentByAgentIdFilesResponses[keyof PostAgentByAgentIdFilesResponses] + +export type GetAgentByAgentIdMessagesByMessageIdData = { + body?: never + path: { + agent_id: string + message_id: string + } + query?: never + url: '/agent/{agent_id}/messages/{message_id}' +} + +export type GetAgentByAgentIdMessagesByMessageIdErrors = { + 404: unknown +} + +export type GetAgentByAgentIdMessagesByMessageIdResponses = { + 200: MessageDetailResponse +} + +export type GetAgentByAgentIdMessagesByMessageIdResponse + = GetAgentByAgentIdMessagesByMessageIdResponses[keyof GetAgentByAgentIdMessagesByMessageIdResponses] + +export type GetAgentByAgentIdReferencingWorkflowsData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/referencing-workflows' +} + +export type GetAgentByAgentIdReferencingWorkflowsErrors = { + 404: unknown +} + +export type GetAgentByAgentIdReferencingWorkflowsResponses = { + 200: AgentReferencingWorkflowsResponse +} + +export type GetAgentByAgentIdReferencingWorkflowsResponse + = GetAgentByAgentIdReferencingWorkflowsResponses[keyof GetAgentByAgentIdReferencingWorkflowsResponses] + +export type GetAgentByAgentIdSandboxFilesData = { + body?: never + path: { + agent_id: string + } + query: { + conversation_id: string + path?: string + } + url: '/agent/{agent_id}/sandbox/files' +} + +export type GetAgentByAgentIdSandboxFilesResponses = { + 200: SandboxListResponse +} + +export type GetAgentByAgentIdSandboxFilesResponse + = GetAgentByAgentIdSandboxFilesResponses[keyof GetAgentByAgentIdSandboxFilesResponses] + +export type GetAgentByAgentIdSandboxFilesReadData = { + body?: never + path: { + agent_id: string + } + query: { + conversation_id: string + path: string + } + url: '/agent/{agent_id}/sandbox/files/read' +} + +export type GetAgentByAgentIdSandboxFilesReadResponses = { + 200: SandboxReadResponse +} + +export type GetAgentByAgentIdSandboxFilesReadResponse + = GetAgentByAgentIdSandboxFilesReadResponses[keyof GetAgentByAgentIdSandboxFilesReadResponses] + +export type PostAgentByAgentIdSandboxFilesUploadData = { + body: AgentSandboxUploadPayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/sandbox/files/upload' +} + +export type PostAgentByAgentIdSandboxFilesUploadResponses = { + 200: SandboxUploadResponse +} + +export type PostAgentByAgentIdSandboxFilesUploadResponse + = PostAgentByAgentIdSandboxFilesUploadResponses[keyof PostAgentByAgentIdSandboxFilesUploadResponses] + +export type PostAgentByAgentIdSkillsStandardizeData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/skills/standardize' +} + +export type PostAgentByAgentIdSkillsStandardizeErrors = { + 400: unknown +} + +export type PostAgentByAgentIdSkillsStandardizeResponses = { + 201: AgentSkillStandardizeResponse +} + +export type PostAgentByAgentIdSkillsStandardizeResponse + = PostAgentByAgentIdSkillsStandardizeResponses[keyof PostAgentByAgentIdSkillsStandardizeResponses] + +export type PostAgentByAgentIdSkillsUploadData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/skills/upload' +} + +export type PostAgentByAgentIdSkillsUploadErrors = { + 400: unknown +} + +export type PostAgentByAgentIdSkillsUploadResponses = { + 201: AgentSkillUploadResponse +} + +export type PostAgentByAgentIdSkillsUploadResponse + = PostAgentByAgentIdSkillsUploadResponses[keyof PostAgentByAgentIdSkillsUploadResponses] + +export type DeleteAgentByAgentIdSkillsBySlugData = { + body?: never + path: { + agent_id: string + slug: string + } + query?: never + url: '/agent/{agent_id}/skills/{slug}' +} + +export type DeleteAgentByAgentIdSkillsBySlugResponses = { + 200: AgentDriveDeleteResponse +} + +export type DeleteAgentByAgentIdSkillsBySlugResponse + = DeleteAgentByAgentIdSkillsBySlugResponses[keyof DeleteAgentByAgentIdSkillsBySlugResponses] + +export type PostAgentByAgentIdSkillsBySlugInferToolsData = { + body?: never + path: { + agent_id: string + slug: string + } + query?: never + url: '/agent/{agent_id}/skills/{slug}/infer-tools' +} + +export type PostAgentByAgentIdSkillsBySlugInferToolsResponses = { + 200: SkillToolInferenceResult +} + +export type PostAgentByAgentIdSkillsBySlugInferToolsResponse + = PostAgentByAgentIdSkillsBySlugInferToolsResponses[keyof PostAgentByAgentIdSkillsBySlugInferToolsResponses] + +export type GetAgentByAgentIdVersionsData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/versions' +} + +export type GetAgentByAgentIdVersionsResponses = { + 200: AgentConfigSnapshotListResponse +} + +export type GetAgentByAgentIdVersionsResponse + = GetAgentByAgentIdVersionsResponses[keyof GetAgentByAgentIdVersionsResponses] + +export type GetAgentByAgentIdVersionsByVersionIdData = { + body?: never + path: { + agent_id: string + version_id: string + } + query?: never + url: '/agent/{agent_id}/versions/{version_id}' +} + +export type GetAgentByAgentIdVersionsByVersionIdResponses = { + 200: AgentConfigSnapshotDetailResponse +} + +export type GetAgentByAgentIdVersionsByVersionIdResponse + = GetAgentByAgentIdVersionsByVersionIdResponses[keyof GetAgentByAgentIdVersionsByVersionIdResponses] diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts new file mode 100644 index 00000000000..2e1ffadc4b5 --- /dev/null +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -0,0 +1,2222 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod' + +/** + * SuggestedQuestionsResponse + */ +export const zSuggestedQuestionsResponse = z.object({ + data: z.array(z.string()), +}) + +/** + * SimpleResultResponse + */ +export const zSimpleResultResponse = z.object({ + result: z.string(), +}) + +/** + * AgentDriveDownloadResponse + */ +export const zAgentDriveDownloadResponse = z.object({ + url: z.string(), +}) + +/** + * AgentDrivePreviewResponse + */ +export const zAgentDrivePreviewResponse = z.object({ + binary: z.boolean(), + key: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + +/** + * MessageFeedbackPayload + */ +export const zMessageFeedbackPayload = z.object({ + content: z.string().nullish(), + message_id: z.string(), + rating: z.enum(['dislike', 'like']).nullish(), +}) + +/** + * AgentDriveDeleteResponse + */ +export const zAgentDriveDeleteResponse = z.object({ + config_version_id: z.string().nullish(), + removed_keys: z.array(z.string()).optional(), + result: z.string(), +}) + +/** + * AgentDriveFilePayload + */ +export const zAgentDriveFilePayload = z.object({ + upload_file_id: z.string(), +}) + +/** + * SandboxReadResponse + */ +export const zSandboxReadResponse = z.object({ + binary: z.boolean(), + path: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + +/** + * AgentSandboxUploadPayload + */ +export const zAgentSandboxUploadPayload = z.object({ + conversation_id: z.string().min(1), + path: z.string().min(1), +}) + +/** + * IconType + */ +export const zIconType = z.enum(['emoji', 'image', 'link']) + +/** + * AgentAppCreatePayload + */ +export const zAgentAppCreatePayload = z.object({ + description: z.string().max(400).nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: zIconType.nullish(), + name: z.string().min(1), + role: z.string().max(255).optional().default(''), +}) + +/** + * AgentAppUpdatePayload + */ +export const zAgentAppUpdatePayload = z.object({ + description: z.string().max(400).nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: zIconType.nullish(), + max_active_requests: z.int().nullish(), + name: z.string().min(1), + role: z.string().max(255).nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), +}) + +/** + * DeletedTool + */ +export const zDeletedTool = z.object({ + provider_id: z.string(), + tool_name: z.string(), + type: z.string(), +}) + +/** + * Site + */ +export const zSite = z.object({ + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string(), + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean(), + title: z.string(), + use_icon_as_answer_icon: z.boolean(), +}) + +/** + * Tag + */ +export const zTag = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), +}) + +export const zJsonValue = z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + ]) + .nullable() + +/** + * WorkflowPartial + */ +export const zWorkflowPartial = z.object({ + created_at: z.int().nullish(), + created_by: z.string().nullish(), + id: z.string(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), +}) + +/** + * AgentConfigSnapshotSummaryResponse + */ +export const zAgentConfigSnapshotSummaryResponse = z.object({ + agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + id: z.string(), + summary: z.string().nullish(), + version: z.int(), + version_note: z.string().nullish(), +}) + +/** + * AgentConfigSnapshotListResponse + */ +export const zAgentConfigSnapshotListResponse = z.object({ + data: z.array(zAgentConfigSnapshotSummaryResponse), +}) + +/** + * ComposerSaveStrategy + */ +export const zComposerSaveStrategy = z.enum([ + 'node_job_only', + 'save_as_new_agent', + 'save_as_new_version', + 'save_to_current_version', + 'save_to_roster', +]) + +/** + * ComposerBindingPayload + */ +export const zComposerBindingPayload = z.object({ + agent_id: z.string().nullish(), + binding_type: z.enum(['inline_agent', 'roster_agent']), + current_snapshot_id: z.string().nullish(), +}) + +/** + * ComposerSoulLockPayload + */ +export const zComposerSoulLockPayload = z.object({ + locked: z.boolean().optional().default(true), + unlocked_from_version_id: z.string().nullish(), +}) + +/** + * ComposerVariant + */ +export const zComposerVariant = z.enum(['agent_app', 'workflow']) + +/** + * ComposerCandidateCapabilities + */ +export const zComposerCandidateCapabilities = z.object({ + human_roster_available: z.boolean().optional().default(false), +}) + +/** + * ComposerKnowledgePlaceholderResponse + */ +export const zComposerKnowledgePlaceholderResponse = z.object({ + id: z.string(), + placeholder_name: z.string(), +}) + +/** + * ComposerValidationWarningResponse + */ +export const zComposerValidationWarningResponse = z.object({ + code: z.string(), + id: z.string().nullish(), + kind: z.string().nullish(), + message: z.string().nullish(), + surface: z.string().nullish(), +}) + +/** + * AgentComposerValidateResponse + */ +export const zAgentComposerValidateResponse = z.object({ + errors: z.array(z.string()).optional(), + knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), + result: z.literal('success'), + warnings: z.array(zComposerValidationWarningResponse).optional(), +}) + +/** + * ComposerValidationFindingsResponse + */ +export const zComposerValidationFindingsResponse = z.object({ + knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), + warnings: z.array(zComposerValidationWarningResponse).optional(), +}) + +/** + * AgentDriveItemResponse + */ +export const zAgentDriveItemResponse = z.object({ + created_at: z.int().nullish(), + file_kind: z.string(), + hash: z.string().nullish(), + key: z.string(), + mime_type: z.string().nullish(), + size: z.int().nullish(), +}) + +/** + * AgentDriveListResponse + */ +export const zAgentDriveListResponse = z.object({ + items: z.array(zAgentDriveItemResponse).optional(), +}) + +/** + * AgentFeatureToggleConfig + */ +export const zAgentFeatureToggleConfig = z.object({ + enabled: z.boolean().optional().default(false), +}) + +/** + * AgentTextToSpeechFeatureConfig + */ +export const zAgentTextToSpeechFeatureConfig = z.object({ + autoPlay: z.string().nullish(), + enabled: z.boolean().optional().default(false), + language: z.string().nullish(), + voice: z.string().nullish(), +}) + +/** + * AgentDriveFileResponse + */ +export const zAgentDriveFileResponse = z.object({ + drive_key: z.string(), + file_id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int().nullish(), +}) + +/** + * AgentDriveFileCommitResponse + */ +export const zAgentDriveFileCommitResponse = z.object({ + config_version_id: z.string().nullish(), + file: zAgentDriveFileResponse, +}) + +/** + * AgentThought + */ +export const zAgentThought = z.object({ + chain_id: z.string().nullish(), + created_at: z.int().nullish(), + files: z.array(z.string()), + id: z.string(), + message_chain_id: z.string().nullish(), + message_id: z.string(), + observation: z.string().nullish(), + position: z.int(), + thought: z.string().nullish(), + tool: z.string().nullish(), + tool_input: z.string().nullish(), + tool_labels: zJsonValue, +}) + +/** + * MessageFile + */ +export const zMessageFile = z.object({ + belongs_to: z.string().nullish(), + filename: z.string(), + id: z.string(), + mime_type: z.string().nullish(), + size: z.int().nullish(), + transfer_method: z.string(), + type: z.string(), + upload_file_id: z.string().nullish(), + url: z.string().nullish(), +}) + +/** + * AgentReferencingWorkflowResponse + */ +export const zAgentReferencingWorkflowResponse = z.object({ + app_icon: z.string().nullish(), + app_icon_background: z.string().nullish(), + app_icon_type: z.string().nullish(), + app_id: z.string(), + app_mode: z.string(), + app_name: z.string(), + app_updated_at: z.int().nullish(), + node_ids: z.array(z.string()).optional(), + workflow_id: z.string(), + workflow_version: z.string(), +}) + +/** + * AgentReferencingWorkflowsResponse + */ +export const zAgentReferencingWorkflowsResponse = z.object({ + data: z.array(zAgentReferencingWorkflowResponse).optional(), +}) + +/** + * SandboxFileEntryResponse + */ +export const zSandboxFileEntryResponse = z.object({ + mtime: z.int().nullish(), + name: z.string(), + size: z.int().nullish(), + type: z.enum(['dir', 'file', 'other', 'symlink']), +}) + +/** + * SandboxListResponse + */ +export const zSandboxListResponse = z.object({ + entries: z.array(zSandboxFileEntryResponse).optional(), + path: z.string(), + truncated: z.boolean().optional().default(false), +}) + +/** + * SandboxToolFileResponse + */ +export const zSandboxToolFileResponse = z.object({ + reference: z.string(), + transfer_method: z.literal('tool_file').optional().default('tool_file'), +}) + +/** + * SandboxUploadResponse + */ +export const zSandboxUploadResponse = z.object({ + file: zSandboxToolFileResponse, + path: z.string(), +}) + +/** + * SkillManifest + * + * Validated metadata extracted from a Skill package. + */ +export const zSkillManifest = z.object({ + description: z.string(), + entry_path: z.string(), + files: z.array(z.string()), + hash: z.string(), + name: z.string(), + size: z.int(), +}) + +/** + * AgentSkillRefConfig + */ +export const zAgentSkillRefConfig = z.object({ + description: z.string().nullish(), + file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), + id: z.string().max(255).nullish(), + manifest_files: z.array(z.string()).nullish(), + name: z.string().max(255).nullish(), + path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), +}) + +/** + * AgentSkillStandardizeResponse + */ +export const zAgentSkillStandardizeResponse = z.object({ + manifest: zSkillManifest, + skill: zAgentSkillRefConfig, +}) + +/** + * AgentSkillUploadResponse + */ +export const zAgentSkillUploadResponse = z.object({ + manifest: zSkillManifest, + skill: zAgentSkillRefConfig, +}) + +/** + * ModelConfigPartial + */ +export const zModelConfigPartial = z.object({ + created_at: z.int().nullish(), + created_by: z.string().nullish(), + model: zJsonValue.nullish(), + pre_prompt: z.string().nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), +}) + +/** + * AppPartial + */ +export const zAppPartial = z.object({ + access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), + app_id: z.string().nullish(), + author_name: z.string().nullish(), + bound_agent_id: z.string().nullish(), + create_user_name: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + description: z.string().nullish(), + has_draft_trigger: z.boolean().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + id: z.string(), + is_starred: z.boolean().optional().default(false), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zModelConfigPartial.nullish(), + name: z.string(), + role: z.string().nullish(), + tags: z.array(zTag).optional(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + +/** + * AppPagination + */ +export const zAppPagination = z.object({ + data: z.array(zAppPartial), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * LLMMode + * + * Enum class for large language model mode. + */ +export const zLlmMode = z.enum(['chat', 'completion']) + +/** + * ModelConfig + */ +export const zModelConfig = z.object({ + completion_params: z.record(z.string(), z.unknown()).optional(), + mode: zLlmMode, + name: z.string(), + provider: z.string(), +}) + +/** + * AppDetailWithSite + */ +export const zAppDetailWithSite = z.object({ + access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), + api_base_url: z.string().nullish(), + app_id: z.string().nullish(), + bound_agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + deleted_tools: z.array(zDeletedTool).optional(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + icon_url: z.string().nullable(), + id: z.string(), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zModelConfig.nullish(), + name: z.string(), + role: z.string().nullish(), + site: zSite.nullish(), + tags: z.array(zTag).optional(), + tracing: zJsonValue.nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + +/** + * AgentKind + * + * Agent implementation family. + * + * This leaves room for future non-Dify agent implementations while keeping + * the current roster/workflow APIs scoped to Dify Agent. + */ +export const zAgentKind = z.enum(['dify_agent']) + +/** + * AgentIconType + * + * Supported icon storage formats for Agent roster entries. + */ +export const zAgentIconType = z.enum(['emoji', 'image', 'link']) + +/** + * AgentPublishedReferenceResponse + */ +export const zAgentPublishedReferenceResponse = z.object({ + app_icon: z.string().nullish(), + app_icon_background: z.string().nullish(), + app_icon_type: z.string().nullish(), + app_id: z.string(), + app_mode: z.string(), + app_name: z.string(), + app_updated_at: z.int().nullish(), + node_ids: z.array(z.string()).optional(), + workflow_id: z.string(), + workflow_version: z.string(), +}) + +/** + * AgentScope + * + * Visibility and lifecycle scope of an Agent record. + */ +export const zAgentScope = z.enum(['roster', 'workflow_only']) + +/** + * AgentSource + * + * Origin that created or imported the Agent. + */ +export const zAgentSource = z.enum(['agent_app', 'imported', 'roster', 'system', 'workflow']) + +/** + * AgentStatus + * + * Soft lifecycle state for Agent records. + */ +export const zAgentStatus = z.enum(['active', 'archived']) + +/** + * AgentInviteOptionResponse + */ +export const zAgentInviteOptionResponse = z.object({ + active_config_is_published: z.boolean().optional().default(false), + active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), + active_config_snapshot_id: z.string().nullish(), + agent_kind: zAgentKind, + app_id: z.string().nullish(), + archived_at: z.int().nullish(), + archived_by: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + description: z.string(), + existing_node_ids: z.array(z.string()).optional(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: zAgentIconType.nullish(), + id: z.string(), + in_current_workflow_count: z.int().optional().default(0), + is_in_current_workflow: z.boolean().optional().default(false), + name: z.string(), + published_node_reference_count: z.int().optional().default(0), + published_reference_count: z.int().optional().default(0), + published_references: z.array(zAgentPublishedReferenceResponse).optional(), + role: z.string().optional().default(''), + scope: zAgentScope, + source: zAgentSource, + status: zAgentStatus, + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + workflow_id: z.string().nullish(), + workflow_node_id: z.string().nullish(), +}) + +/** + * AgentInviteOptionsResponse + */ +export const zAgentInviteOptionsResponse = z.object({ + data: z.array(zAgentInviteOptionResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * AgentComposerAgentResponse + */ +export const zAgentComposerAgentResponse = z.object({ + active_config_snapshot_id: z.string().nullish(), + description: z.string(), + id: z.string(), + name: z.string(), + scope: zAgentScope, + status: zAgentStatus, +}) + +/** + * AppVariableConfig + */ +export const zAppVariableConfig = z.object({ + default: z.unknown().optional(), + name: z.string().min(1).max(255), + required: z.boolean().optional().default(false), + type: z.string().min(1).max(64), +}) + +/** + * AgentSoulPromptConfig + */ +export const zAgentSoulPromptConfig = z.object({ + system_prompt: z.string().optional().default(''), +}) + +/** + * AgentHumanContactConfig + */ +export const zAgentHumanContactConfig = z.object({ + channel: z.string().max(64).nullish(), + contact_id: z.string().max(255).nullish(), + contact_method: z.string().max(64).nullish(), + email: z.string().max(255).nullish(), + human_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + method: z.string().max(64).nullish(), + name: z.string().max(255).nullish(), + tenant_id: z.string().max(255).nullish(), +}) + +/** + * WorkflowNodeJobMode + */ +export const zWorkflowNodeJobMode = z.enum(['let_agent_figure_it_out', 'tell_agent_what_to_do']) + +/** + * WorkflowPreviousNodeOutputRef + */ +export const zWorkflowPreviousNodeOutputRef = z.object({ + key: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + node_id: z.string().max(255).nullish(), + output: z.string().max(255).nullish(), + selector: z.array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])).nullish(), + value_selector: z + .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) + .nullish(), + variable: z.string().max(255).nullish(), + variable_selector: z + .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) + .nullish(), +}) + +/** + * DeclaredOutputType + */ +export const zDeclaredOutputType = z.enum([ + 'array', + 'boolean', + 'file', + 'number', + 'object', + 'string', +]) + +/** + * AgentComposerNodeJobCandidatesResponse + */ +export const zAgentComposerNodeJobCandidatesResponse = z.object({ + declare_output_types: z.array(zDeclaredOutputType).optional(), + human_contacts: z.array(zAgentHumanContactConfig).optional(), + previous_node_outputs: z.array(zWorkflowPreviousNodeOutputRef).optional(), +}) + +/** + * AgentComposerDifyToolCandidateResponse + */ +export const zAgentComposerDifyToolCandidateResponse = z.object({ + description: z.string().nullish(), + granularity: z.string().nullish(), + id: z.string().nullish(), + name: z.string().nullish(), + plugin_id: z.string().nullish(), + provider: z.string().nullish(), + provider_id: z.string().nullish(), + tools_count: z.int().nullish(), +}) + +/** + * AgentKnowledgeDatasetConfig + */ +export const zAgentKnowledgeDatasetConfig = z.object({ + description: z.string().nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), +}) + +/** + * AgentComposerSkillCandidateResponse + */ +export const zAgentComposerSkillCandidateResponse = z.object({ + description: z.string().nullish(), + file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), + id: z.string().max(255).nullish(), + kind: z.literal('skill').optional().default('skill'), + manifest_files: z.array(z.string()).nullish(), + name: z.string().max(255).nullish(), + path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), +}) + +/** + * AgentComposerFileCandidateResponse + */ +export const zAgentComposerFileCandidateResponse = z.object({ + drive_key: z.string().max(512).nullish(), + file_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + kind: z.literal('file').optional().default('file'), + name: z.string().max(255).nullish(), + reference: z.string().max(255).nullish(), + remote_url: z.string().nullish(), + tenant_id: z.string().max(255).nullish(), + transfer_method: z.string().max(64).nullish(), + type: z.string().max(64).nullish(), + upload_file_id: z.string().max(255).nullish(), + url: z.string().nullish(), +}) + +/** + * SimpleAccount + */ +export const zSimpleAccount = z.object({ + email: z.string(), + id: z.string(), + name: z.string(), +}) + +/** + * ConversationAnnotation + */ +export const zConversationAnnotation = z.object({ + account: zSimpleAccount.nullish(), + content: z.string(), + created_at: z.int().nullish(), + id: z.string(), + question: z.string().nullish(), +}) + +/** + * ConversationAnnotationHitHistory + */ +export const zConversationAnnotationHitHistory = z.object({ + annotation_create_account: zSimpleAccount.nullish(), + created_at: z.int().nullish(), + id: z.string(), +}) + +/** + * Feedback + */ +export const zFeedback = z.object({ + content: z.string().nullish(), + from_account: zSimpleAccount.nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string(), + rating: z.string(), +}) + +/** + * ExecutionContentType + */ +export const zExecutionContentType = z.enum(['human_input']) + +/** + * EnvSuggestion + */ +export const zEnvSuggestion = z.object({ + key: z.string(), + reason: z.string().optional().default(''), + secret_likely: z.boolean().optional().default(false), +}) + +/** + * CliToolSuggestion + */ +export const zCliToolSuggestion = z.object({ + command: z.string().optional().default(''), + description: z.string().optional().default(''), + env_suggestions: z.array(zEnvSuggestion).optional(), + inferred_from: z.string().optional().default(''), + install_commands: z.array(z.string()).optional(), + name: z.string(), +}) + +/** + * SkillToolInferenceResult + */ +export const zSkillToolInferenceResult = z.object({ + cli_tools: z.array(zCliToolSuggestion).optional(), + inferable: z.boolean(), + reason: z.string().nullish(), +}) + +/** + * AgentConfigRevisionOperation + * + * Audit operation recorded for Agent Soul version/revision changes. + */ +export const zAgentConfigRevisionOperation = z.enum([ + 'create_version', + 'save_current_version', + 'save_new_agent', + 'save_new_version', + 'save_to_roster', +]) + +/** + * AgentConfigRevisionResponse + */ +export const zAgentConfigRevisionResponse = z.object({ + created_at: z.int().nullish(), + created_by: z.string().nullish(), + current_snapshot_id: z.string(), + id: z.string(), + operation: zAgentConfigRevisionOperation, + previous_snapshot_id: z.string().nullish(), + revision: z.int(), + summary: z.string().nullish(), + version_note: z.string().nullish(), +}) + +/** + * AgentEnvVariableConfig + */ +export const zAgentEnvVariableConfig = z.object({ + default: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullish(), + env_name: z.string().max(255).nullish(), + key: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + required: z.boolean().optional().default(false), + type: z.string().max(64).nullish(), + value: z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullish(), + variable: z.string().max(255).nullish(), +}) + +/** + * AgentHumanToolConfig + */ +export const zAgentHumanToolConfig = z.object({ + description: z.string().nullish(), + enabled: z.boolean().optional().default(true), + name: z.string().max(255).nullish(), +}) + +/** + * AgentSoulHumanConfig + */ +export const zAgentSoulHumanConfig = z.object({ + contacts: z.array(zAgentHumanContactConfig).optional(), + tools: z.array(zAgentHumanToolConfig).optional(), +}) + +/** + * AgentKnowledgeQueryConfig + */ +export const zAgentKnowledgeQueryConfig = z.object({ + query: z.string().nullish(), + score_threshold: z.number().gte(0).lte(1).nullish(), + score_threshold_enabled: z.boolean().nullish(), + top_k: z.int().gte(1).nullish(), +}) + +/** + * AgentKnowledgeQueryMode + */ +export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']) + +/** + * AgentSoulKnowledgeConfig + */ +export const zAgentSoulKnowledgeConfig = z.object({ + datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), + query_config: zAgentKnowledgeQueryConfig.optional(), + query_mode: zAgentKnowledgeQueryMode.nullish(), +}) + +/** + * AgentMemoryArtifactConfig + */ +export const zAgentMemoryArtifactConfig = z.object({ + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + type: z.string().max(64).nullish(), + url: z.string().nullish(), +}) + +/** + * AgentSoulMemoryConfig + */ +export const zAgentSoulMemoryConfig = z.object({ + artifacts: z.array(zAgentMemoryArtifactConfig).optional(), + budget: z.string().nullish(), + scope: z.string().nullish(), +}) + +/** + * AgentSoulModelCredentialRef + * + * Reference to model credentials resolved only at runtime. + */ +export const zAgentSoulModelCredentialRef = z.object({ + id: z.string().max(255).nullish(), + provider: z.string().max(255).nullish(), + type: z.string().min(1).max(64), +}) + +/** + * AgentSandboxProviderConfig + */ +export const zAgentSandboxProviderConfig = z.object({ + cpu: z.int().gte(1).nullish(), + env: z.array(zAgentEnvVariableConfig).optional(), + image: z.string().nullish(), + working_dir: z.string().nullish(), +}) + +/** + * AgentSoulSandboxConfig + */ +export const zAgentSoulSandboxConfig = z.object({ + config: zAgentSandboxProviderConfig.optional(), + provider: z.string().nullish(), +}) + +/** + * AgentFileRefConfig + */ +export const zAgentFileRefConfig = z.object({ + drive_key: z.string().max(512).nullish(), + file_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + reference: z.string().max(255).nullish(), + remote_url: z.string().nullish(), + tenant_id: z.string().max(255).nullish(), + transfer_method: z.string().max(64).nullish(), + type: z.string().max(64).nullish(), + upload_file_id: z.string().max(255).nullish(), + url: z.string().nullish(), +}) + +/** + * AgentSoulSkillsFilesConfig + */ +export const zAgentSoulSkillsFilesConfig = z.object({ + files: z.array(zAgentFileRefConfig).optional(), + skills: z.array(zAgentSkillRefConfig).optional(), +}) + +/** + * WorkflowNodeJobMetadata + */ +export const zWorkflowNodeJobMetadata = z.object({ + agent_soul: z.record(z.string(), z.unknown()).nullish(), + file_refs: z.array(zAgentFileRefConfig).nullish(), +}) + +/** + * DeclaredArrayItem + * + * Per-item shape for an ``array``-typed declared output. + * + * PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays + * are rejected so the runtime type checker and JSON Schema stay easy to reason + * about. Stage 4 §4.2. + */ +export const zDeclaredArrayItem = z.object({ + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), + description: z.string().nullish(), + type: zDeclaredOutputType, +}) + +/** + * DeclaredOutputFileConfig + * + * File-type output metadata. Both lists empty means "any file accepted". + */ +export const zDeclaredOutputFileConfig = z.object({ + extensions: z.array(z.string()).optional(), + mime_types: z.array(z.string()).optional(), +}) + +/** + * AgentCliToolAuthorizationStatus + * + * Authorization state for Agent-scoped CLI tools. + * + * Missing status keeps backward compatibility with draft rows and CLI tools that + * do not need pre-authorization. Explicit denied-like states are blocked by the + * composer/publish validators and skipped by runtime request builders. + */ +export const zAgentCliToolAuthorizationStatus = z.enum([ + 'allowed', + 'authorized', + 'denied', + 'forbidden', + 'not_required', + 'pending', + 'pre_authorized', + 'unauthorized', +]) + +/** + * AgentPermissionConfig + */ +export const zAgentPermissionConfig = z.object({ + allowed: z.boolean().nullish(), + state: z.string().max(64).nullish(), + status: z.string().max(64).nullish(), +}) + +/** + * AgentSecretRefConfig + */ +export const zAgentSecretRefConfig = z.object({ + credential_id: z.string().max(255).nullish(), + env_name: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + key: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + permission: zAgentPermissionConfig.nullish(), + permission_status: z.string().max(64).nullish(), + provider: z.string().max(255).nullish(), + provider_credential_id: z.string().max(255).nullish(), + ref: z.string().max(255).nullish(), + type: z.string().max(64).nullish(), + value: z.string().max(255).nullish(), + variable: z.string().max(255).nullish(), +}) + +/** + * AgentSoulEnvConfig + */ +export const zAgentSoulEnvConfig = z.object({ + secret_refs: z.array(zAgentSecretRefConfig).optional(), + variables: z.array(zAgentEnvVariableConfig).optional(), +}) + +/** + * AgentCliToolEnvConfig + */ +export const zAgentCliToolEnvConfig = z.object({ + secret_refs: z.array(zAgentSecretRefConfig).optional(), + variables: z.array(zAgentEnvVariableConfig).optional(), +}) + +/** + * AgentCliToolRiskLevel + * + * Risk marker for CLI tool bootstrap commands. + */ +export const zAgentCliToolRiskLevel = z.enum(['dangerous', 'safe', 'unknown']) + +/** + * AgentCliToolConfig + */ +export const zAgentCliToolConfig = z.object({ + approved: z.boolean().optional().default(false), + authorization_status: zAgentCliToolAuthorizationStatus.nullish(), + command: z.string().nullish(), + dangerous: z.boolean().optional().default(false), + dangerous_accepted: z.boolean().optional().default(false), + dangerous_acknowledged: z.boolean().optional().default(false), + dangerous_command: z.boolean().optional().default(false), + description: z.string().nullish(), + enabled: z.boolean().optional().default(true), + env: zAgentCliToolEnvConfig.optional(), + id: z.string().max(255).nullish(), + inferred_from: z.string().max(255).nullish(), + install: z.string().nullish(), + install_command: z.string().nullish(), + install_commands: z.array(z.string()).optional(), + invoke_metadata: z.record(z.string(), z.unknown()).optional(), + label: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + permission: zAgentPermissionConfig.nullish(), + pre_authorized: z.boolean().nullish(), + requires_confirmation: z.boolean().optional().default(false), + risk_accepted: z.boolean().optional().default(false), + risk_level: zAgentCliToolRiskLevel.nullish(), + setup_command: z.string().nullish(), + tool_name: z.string().max(255).nullish(), +}) + +/** + * AgentComposerSoulCandidatesResponse + */ +export const zAgentComposerSoulCandidatesResponse = z.object({ + cli_tools: z.array(zAgentCliToolConfig).optional(), + dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(), + human_contacts: z.array(zAgentHumanContactConfig).optional(), + knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), + skills_files: z + .array( + z.union([ + z + .object({ + kind: z.literal('skill'), + }) + .and(zAgentComposerSkillCandidateResponse), + z + .object({ + kind: z.literal('file'), + }) + .and(zAgentComposerFileCandidateResponse), + ]), + ) + .optional(), +}) + +/** + * AgentComposerCandidatesResponse + */ +export const zAgentComposerCandidatesResponse = z.object({ + allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(), + allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(), + capabilities: zComposerCandidateCapabilities.optional(), + truncated: z.boolean().optional().default(false), + variant: zComposerVariant, +}) + +/** + * AgentModerationIOConfig + */ +export const zAgentModerationIoConfig = z.object({ + enabled: z.boolean().optional().default(false), + preset_response: z.string().nullish(), +}) + +/** + * AgentModerationProviderConfig + */ +export const zAgentModerationProviderConfig = z.object({ + api_based_extension_id: z.string().nullish(), + inputs_config: zAgentModerationIoConfig.nullish(), + keywords: z.string().nullish(), + outputs_config: zAgentModerationIoConfig.nullish(), +}) + +/** + * AgentSensitiveWordAvoidanceFeatureConfig + */ +export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ + config: zAgentModerationProviderConfig.nullish(), + enabled: z.boolean().optional().default(false), + type: z.string().nullish(), +}) + +export const zJsonValue2 = z.unknown() + +/** + * HumanInputFormSubmissionData + */ +export const zHumanInputFormSubmissionData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), zJsonValue2).nullish(), +}) + +/** + * AgentModelResponseFormatConfig + */ +export const zAgentModelResponseFormatConfig = z.object({ + type: z.string().max(64).nullish(), +}) + +/** + * AgentSoulModelSettings + */ +export const zAgentSoulModelSettings = z.object({ + frequency_penalty: z.number().nullish(), + max_tokens: z.int().nullish(), + presence_penalty: z.number().nullish(), + response_format: zAgentModelResponseFormatConfig.nullish(), + stop: z.array(z.string()).nullish(), + temperature: z.number().nullish(), + top_p: z.number().nullish(), +}) + +/** + * AgentSoulModelConfig + * + * Stable model selection for Agent runtime without storing secret values. + */ +export const zAgentSoulModelConfig = z.object({ + credential_ref: zAgentSoulModelCredentialRef.nullish(), + model: z.string().min(1).max(255), + model_provider: z.string().min(1).max(255), + model_settings: zAgentSoulModelSettings.optional(), + plugin_id: z.string().min(1).max(255), +}) + +/** + * AgentSuggestedQuestionsAfterAnswerFeatureConfig + */ +export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ + enabled: z.boolean().optional().default(false), + model: zAgentSoulModelConfig.nullish(), + prompt: z.string().nullish(), +}) + +/** + * AgentAppFeaturesPayload + * + * Presentation features configurable on an Agent App. + * + * All fields are optional; an omitted field is reset to its disabled/empty + * default (the config form sends the full desired feature state on save). + */ +export const zAgentAppFeaturesPayload = z.object({ + opening_statement: z.string().nullish(), + retriever_resource: zAgentFeatureToggleConfig.nullish(), + sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), + speech_to_text: zAgentFeatureToggleConfig.nullish(), + suggested_questions: z.array(z.string()).nullish(), + suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), + text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), +}) + +/** + * AgentSoulAppFeaturesConfig + */ +export const zAgentSoulAppFeaturesConfig = z.object({ + opening_statement: z.string().nullish(), + retriever_resource: zAgentFeatureToggleConfig.nullish(), + sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), + speech_to_text: zAgentFeatureToggleConfig.nullish(), + suggested_questions: z.array(z.string()).nullish(), + suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), + text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), +}) + +/** + * DeclaredOutputCheckConfig + * + * File-output content check via a model-based comparison against a benchmark file. + * + * Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3. + */ +export const zDeclaredOutputCheckConfig = z.object({ + benchmark_file_ref: zAgentFileRefConfig.nullish(), + enabled: z.boolean().optional().default(false), + model_ref: zAgentSoulModelConfig.nullish(), + prompt: z.string().nullish(), +}) + +/** + * AgentSoulDifyToolCredentialRef + * + * Reference to a stored Dify Plugin Tool credential. + * + * Secret values are resolved only at runtime. The legacy ``credential_id`` + * field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so + * old Agent tool payloads can be read while new payloads stay explicit. + */ +export const zAgentSoulDifyToolCredentialRef = z.object({ + id: z.string().max(255).nullish(), + provider: z.string().max(255).nullish(), + type: z.enum(['provider', 'tool']).optional().default('tool'), +}) + +/** + * AgentSoulDifyToolConfig + * + * One Dify Plugin Tool configured on Agent Soul. + * + * The API backend prepares this persisted product shape into + * ``DifyPluginToolConfig`` before sending a run request to Agent backend. + * ``provider_id`` keeps compatibility with existing Agent tool config payloads; + * new callers should send ``plugin_id`` + ``provider`` when available. + */ +export const zAgentSoulDifyToolConfig = z.object({ + credential_ref: zAgentSoulDifyToolCredentialRef.nullish(), + credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'), + description: z.string().nullish(), + enabled: z.boolean().optional().default(true), + name: z.string().max(255).nullish(), + plugin_id: z.string().max(255).nullish(), + provider: z.string().max(255).nullish(), + provider_id: z.string().max(255).nullish(), + provider_type: z.string().optional().default('plugin'), + runtime_parameters: z + .record( + z.string(), + z + .union([ + z.string(), + z.int(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.int()), + z.array(z.number()), + z.array(z.boolean()), + ]) + .nullable(), + ) + .optional(), + tool_name: z.string().min(1).max(255).nullish(), +}) + +/** + * AgentSoulToolsConfig + */ +export const zAgentSoulToolsConfig = z.object({ + cli_tools: z.array(zAgentCliToolConfig).optional(), + dify_tools: z.array(zAgentSoulDifyToolConfig).optional(), +}) + +/** + * AgentSoulConfig + */ +export const zAgentSoulConfig = z.object({ + app_features: zAgentSoulAppFeaturesConfig.optional(), + app_variables: z.array(zAppVariableConfig).optional(), + env: zAgentSoulEnvConfig.optional(), + human: zAgentSoulHumanConfig.optional(), + knowledge: zAgentSoulKnowledgeConfig.optional(), + memory: zAgentSoulMemoryConfig.optional(), + misc_legacy: zAgentSoulAppFeaturesConfig.optional(), + model: zAgentSoulModelConfig.nullish(), + prompt: zAgentSoulPromptConfig.optional(), + sandbox: zAgentSoulSandboxConfig.optional(), + schema_version: z.int().optional().default(1), + skills_files: zAgentSoulSkillsFilesConfig.optional(), + tools: zAgentSoulToolsConfig.optional(), +}) + +/** + * AgentAppComposerResponse + */ +export const zAgentAppComposerResponse = z.object({ + active_config_snapshot: zAgentConfigSnapshotSummaryResponse, + agent: zAgentComposerAgentResponse, + agent_soul: zAgentSoulConfig, + save_options: z.array(zComposerSaveStrategy), + validation: zComposerValidationFindingsResponse.nullish(), + variant: z.literal('agent_app'), +}) + +/** + * AgentConfigSnapshotDetailResponse + */ +export const zAgentConfigSnapshotDetailResponse = z.object({ + agent_id: z.string().nullish(), + config_snapshot: zAgentSoulConfig, + created_at: z.int().nullish(), + created_by: z.string().nullish(), + id: z.string(), + revisions: z.array(zAgentConfigRevisionResponse).optional(), + summary: z.string().nullish(), + version: z.int(), + version_note: z.string().nullish(), +}) + +/** + * OutputErrorStrategy + * + * Per-output failure handling strategy. + * + * Mirrors ``graphon.ErrorStrategy`` but scoped to a single declared output of + * a Workflow Agent Node. The runtime applies the strategy after type check or + * output check fails and any configured retry attempts have been exhausted. + */ +export const zOutputErrorStrategy = z.enum(['default_value', 'fail_branch', 'stop']) + +/** + * DeclaredOutputRetryConfig + * + * Per-output retry configuration that mirrors ``graphon.RetryConfig`` shape. + */ +export const zDeclaredOutputRetryConfig = z.object({ + enabled: z.boolean().optional().default(false), + max_retries: z.int().gte(0).lte(10).optional().default(0), + retry_interval_ms: z.int().gte(0).lte(60000).optional().default(0), +}) + +/** + * DeclaredOutputFailureStrategy + * + * Per-output failure handling. + * + * A single strategy applies to both ``type_check`` and ``output_check`` failures + * (PRD does not distinguish them at the UX level). Stage 4 §4.4. + */ +export const zDeclaredOutputFailureStrategy = z.object({ + default_value: z.unknown().optional(), + on_failure: zOutputErrorStrategy.optional().default('stop'), + retry: zDeclaredOutputRetryConfig.optional(), +}) + +/** + * DeclaredOutputConfig + * + * One declared output of a Workflow Agent Node. + * + * Stage 4 normalizes the shape: ``check`` is singular (was ``checks: list`` in + * stage 3), and ``failure_strategy`` defaults to a populated value so runtime + * code can call ``output.failure_strategy.on_failure`` without None-guards. + */ +export const zDeclaredOutputConfig = z.object({ + array_item: zDeclaredArrayItem.nullish(), + check: zDeclaredOutputCheckConfig.nullish(), + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), + description: z.string().nullish(), + failure_strategy: zDeclaredOutputFailureStrategy.optional(), + file: zDeclaredOutputFileConfig.nullish(), + id: z.string().nullish(), + name: z.string().min(1).max(255), + required: z.boolean().optional().default(true), + type: zDeclaredOutputType, +}) + +/** + * WorkflowNodeJobConfig + */ +export const zWorkflowNodeJobConfig = z.object({ + declared_outputs: z.array(zDeclaredOutputConfig).optional(), + human_contacts: z.array(zAgentHumanContactConfig).optional(), + metadata: zWorkflowNodeJobMetadata.optional(), + mode: zWorkflowNodeJobMode.optional().default('tell_agent_what_to_do'), + previous_node_output_refs: z.array(zWorkflowPreviousNodeOutputRef).optional(), + schema_version: z.int().optional().default(1), + workflow_prompt: z.string().optional().default(''), +}) + +/** + * ComposerSavePayload + */ +export const zComposerSavePayload = z.object({ + agent_soul: zAgentSoulConfig.nullish(), + binding: zComposerBindingPayload.nullish(), + client_revision_id: z.string().nullish(), + idempotency_key: z.string().nullish(), + new_agent_name: z.string().min(1).max(255).nullish(), + node_job: zWorkflowNodeJobConfig.nullish(), + save_strategy: zComposerSaveStrategy, + soul_lock: zComposerSoulLockPayload.optional(), + variant: zComposerVariant, + version_note: z.string().nullish(), +}) + +/** + * ButtonStyle + * + * Button styles for user actions. + */ +export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) + +/** + * UserActionConfig + * + * User action configuration. + */ +export const zUserActionConfig = z.object({ + button_style: zButtonStyle.optional().default('default'), + id: z.string().max(20), + title: z.string().max(100), +}) + +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * FileInputConfig + */ +export const zFileInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + output_variable_name: z.string(), + type: z.literal('file').optional().default('file'), +}) + +/** + * FileListInputConfig + */ +export const zFileListInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + number_limits: z.int().gte(0).optional().default(0), + output_variable_name: z.string(), + type: z.literal('file-list').optional().default('file-list'), +}) + +/** + * ValueSourceType + * + * ValueSourceType records whether the value comes from a static setting + * in form definiton, or a variable while the workflow is running. + */ +export const zValueSourceType = z.enum(['constant', 'variable']) + +/** + * StringSource + * + * Default configuration for form inputs. + */ +export const zStringSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.string().optional().default(''), +}) + +/** + * ParagraphInputConfig + * + * Form input definition. + */ +export const zParagraphInputConfig = z.object({ + default: zStringSource.nullish(), + output_variable_name: z.string(), + type: z.literal('paragraph').optional().default('paragraph'), +}) + +/** + * StringListSource + */ +export const zStringListSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.array(z.string()).optional(), +}) + +/** + * SelectInputConfig + */ +export const zSelectInputConfig = z.object({ + option_source: zStringListSource, + output_variable_name: z.string(), + type: z.literal('select').optional().default('select'), +}) + +export const zFormInputConfig = z.discriminatedUnion('type', [ + zParagraphInputConfig.extend({ type: z.literal('paragraph') }), + zSelectInputConfig.extend({ type: z.literal('select') }), + zFileInputConfig.extend({ type: z.literal('file') }), + zFileListInputConfig.extend({ type: z.literal('file-list') }), +]) + +/** + * HumanInputFormDefinition + */ +export const zHumanInputFormDefinition = z.object({ + actions: z.array(zUserActionConfig).optional(), + display_in_ui: z.boolean().optional().default(false), + expiration_time: z.int(), + form_content: z.string(), + form_id: z.string(), + form_token: z.string().nullish(), + inputs: z.array(zFormInputConfig).optional(), + node_id: z.string(), + node_title: z.string(), + resolved_default_values: z.record(z.string(), z.unknown()).optional(), +}) + +/** + * HumanInputContent + */ +export const zHumanInputContent = z.object({ + form_definition: zHumanInputFormDefinition.nullish(), + form_submission_data: zHumanInputFormSubmissionData.nullish(), + submitted: z.boolean(), + type: zExecutionContentType.optional().default('human_input'), + workflow_run_id: z.string(), +}) + +/** + * MessageDetailResponse + */ +export const zMessageDetailResponse = z.object({ + agent_thoughts: z.array(zAgentThought).optional(), + annotation: zConversationAnnotation.nullish(), + annotation_hit_history: zConversationAnnotationHitHistory.nullish(), + answer_tokens: z.int().nullish(), + conversation_id: z.string(), + created_at: z.int().nullish(), + error: z.string().nullish(), + extra_contents: z.array(zHumanInputContent).optional(), + feedbacks: z.array(zFeedback).optional(), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string(), + id: z.string(), + inputs: z.record(z.string(), zJsonValue), + message: zJsonValue.nullish(), + message_files: z.array(zMessageFile).optional(), + message_metadata_dict: zJsonValue.nullish(), + message_tokens: z.int().nullish(), + parent_message_id: z.string().nullish(), + provider_response_latency: z.number().nullish(), + query: z.string(), + re_sign_file_url_answer: z.string(), + status: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * MessageInfiniteScrollPaginationResponse + */ +export const zMessageInfiniteScrollPaginationResponse = z.object({ + data: z.array(zMessageDetailResponse), + has_more: z.boolean(), + limit: z.int(), +}) + +/** + * AppPartial + */ +export const zAppPartialWritable = z.object({ + access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), + app_id: z.string().nullish(), + author_name: z.string().nullish(), + bound_agent_id: z.string().nullish(), + create_user_name: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + description: z.string().nullish(), + has_draft_trigger: z.boolean().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + id: z.string(), + is_starred: z.boolean().optional().default(false), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zModelConfigPartial.nullish(), + name: z.string(), + role: z.string().nullish(), + tags: z.array(zTag).optional(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + +/** + * AppPagination + */ +export const zAppPaginationWritable = z.object({ + data: z.array(zAppPartialWritable), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * Site + */ +export const zSiteWritable = z.object({ + chat_color_theme: z.string().nullish(), + chat_color_theme_inverted: z.boolean(), + copyright: z.string().nullish(), + custom_disclaimer: z.string().nullish(), + default_language: z.string(), + description: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + privacy_policy: z.string().nullish(), + show_workflow_steps: z.boolean(), + title: z.string(), + use_icon_as_answer_icon: z.boolean(), +}) + +/** + * AppDetailWithSite + */ +export const zAppDetailWithSiteWritable = z.object({ + access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), + api_base_url: z.string().nullish(), + app_id: z.string().nullish(), + bound_agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + deleted_tools: z.array(zDeletedTool).optional(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + id: z.string(), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zModelConfig.nullish(), + name: z.string(), + role: z.string().nullish(), + site: zSiteWritable.nullish(), + tags: z.array(zTag).optional(), + tracing: zJsonValue.nullish(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + +export const zGetAgentQuery = z.object({ + creator_ids: z.array(z.string()).optional(), + is_created_by_me: z.boolean().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + mode: z + .enum([ + 'advanced-chat', + 'agent', + 'agent-chat', + 'all', + 'channel', + 'chat', + 'completion', + 'workflow', + ]) + .optional() + .default('all'), + name: z.string().optional(), + page: z.int().gte(1).lte(99999).optional().default(1), + sort_by: z + .enum(['earliest_created', 'last_modified', 'recently_created']) + .optional() + .default('last_modified'), + tag_ids: z.array(z.string()).optional(), +}) + +/** + * Agent app list + */ +export const zGetAgentResponse = zAppPagination + +export const zPostAgentBody = zAgentAppCreatePayload + +/** + * Agent app created successfully + */ +export const zPostAgentResponse = zAppDetailWithSite + +export const zGetAgentInviteOptionsQuery = z.object({ + app_id: z.string().optional(), + keyword: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), +}) + +/** + * Agent invite options + */ +export const zGetAgentInviteOptionsResponse = zAgentInviteOptionsResponse + +export const zDeleteAgentByAgentIdPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app deleted successfully + */ +export const zDeleteAgentByAgentIdResponse = z.void() + +export const zGetAgentByAgentIdPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app detail + */ +export const zGetAgentByAgentIdResponse = zAppDetailWithSite + +export const zPutAgentByAgentIdBody = zAgentAppUpdatePayload + +export const zPutAgentByAgentIdPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app updated successfully + */ +export const zPutAgentByAgentIdResponse = zAppDetailWithSite + +export const zGetAgentByAgentIdChatMessagesPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdChatMessagesQuery = z.object({ + conversation_id: z.string(), + first_id: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), +}) + +/** + * Success + */ +export const zGetAgentByAgentIdChatMessagesResponse = zMessageInfiniteScrollPaginationResponse + +export const zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath = z.object({ + agent_id: z.string(), + message_id: z.string(), +}) + +/** + * Suggested questions retrieved successfully + */ +export const zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse + = zSuggestedQuestionsResponse + +export const zPostAgentByAgentIdChatMessagesByTaskIdStopPath = z.object({ + agent_id: z.string(), + task_id: z.string(), +}) + +/** + * Task stopped successfully + */ +export const zPostAgentByAgentIdChatMessagesByTaskIdStopResponse = zSimpleResultResponse + +export const zGetAgentByAgentIdComposerPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app composer state + */ +export const zGetAgentByAgentIdComposerResponse = zAgentAppComposerResponse + +export const zPutAgentByAgentIdComposerBody = zComposerSavePayload + +export const zPutAgentByAgentIdComposerPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app composer saved + */ +export const zPutAgentByAgentIdComposerResponse = zAgentAppComposerResponse + +export const zGetAgentByAgentIdComposerCandidatesPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app composer candidates + */ +export const zGetAgentByAgentIdComposerCandidatesResponse = zAgentComposerCandidatesResponse + +export const zPostAgentByAgentIdComposerValidateBody = zComposerSavePayload + +export const zPostAgentByAgentIdComposerValidatePath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent app composer validation result + */ +export const zPostAgentByAgentIdComposerValidateResponse = zAgentComposerValidateResponse + +export const zGetAgentByAgentIdDriveFilesPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdDriveFilesQuery = z.object({ + prefix: z.string().optional().default(''), +}) + +/** + * Drive entries + */ +export const zGetAgentByAgentIdDriveFilesResponse = zAgentDriveListResponse + +export const zGetAgentByAgentIdDriveFilesDownloadPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdDriveFilesDownloadQuery = z.object({ + key: z.string().min(1), +}) + +/** + * Signed URL + */ +export const zGetAgentByAgentIdDriveFilesDownloadResponse = zAgentDriveDownloadResponse + +export const zGetAgentByAgentIdDriveFilesPreviewPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdDriveFilesPreviewQuery = z.object({ + key: z.string().min(1), +}) + +/** + * Preview + */ +export const zGetAgentByAgentIdDriveFilesPreviewResponse = zAgentDrivePreviewResponse + +export const zPostAgentByAgentIdFeaturesBody = zAgentAppFeaturesPayload + +export const zPostAgentByAgentIdFeaturesPath = z.object({ + agent_id: z.string(), +}) + +/** + * Features updated successfully + */ +export const zPostAgentByAgentIdFeaturesResponse = zSimpleResultResponse + +export const zPostAgentByAgentIdFeedbacksBody = zMessageFeedbackPayload + +export const zPostAgentByAgentIdFeedbacksPath = z.object({ + agent_id: z.string(), +}) + +/** + * Feedback updated successfully + */ +export const zPostAgentByAgentIdFeedbacksResponse = zSimpleResultResponse + +export const zDeleteAgentByAgentIdFilesPath = z.object({ + agent_id: z.string(), +}) + +export const zDeleteAgentByAgentIdFilesQuery = z.object({ + key: z.string().min(1), +}) + +/** + * File removed + */ +export const zDeleteAgentByAgentIdFilesResponse = zAgentDriveDeleteResponse + +export const zPostAgentByAgentIdFilesBody = zAgentDriveFilePayload + +export const zPostAgentByAgentIdFilesPath = z.object({ + agent_id: z.string(), +}) + +/** + * File committed into the agent drive + */ +export const zPostAgentByAgentIdFilesResponse = zAgentDriveFileCommitResponse + +export const zGetAgentByAgentIdMessagesByMessageIdPath = z.object({ + agent_id: z.string(), + message_id: z.string(), +}) + +/** + * Message retrieved successfully + */ +export const zGetAgentByAgentIdMessagesByMessageIdResponse = zMessageDetailResponse + +export const zGetAgentByAgentIdReferencingWorkflowsPath = z.object({ + agent_id: z.string(), +}) + +/** + * Referencing workflows listed successfully + */ +export const zGetAgentByAgentIdReferencingWorkflowsResponse = zAgentReferencingWorkflowsResponse + +export const zGetAgentByAgentIdSandboxFilesPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdSandboxFilesQuery = z.object({ + conversation_id: z.string().min(1), + path: z.string().optional().default('.'), +}) + +/** + * Listing returned + */ +export const zGetAgentByAgentIdSandboxFilesResponse = zSandboxListResponse + +export const zGetAgentByAgentIdSandboxFilesReadPath = z.object({ + agent_id: z.string(), +}) + +export const zGetAgentByAgentIdSandboxFilesReadQuery = z.object({ + conversation_id: z.string().min(1), + path: z.string().min(1), +}) + +/** + * Preview returned + */ +export const zGetAgentByAgentIdSandboxFilesReadResponse = zSandboxReadResponse + +export const zPostAgentByAgentIdSandboxFilesUploadBody = zAgentSandboxUploadPayload + +export const zPostAgentByAgentIdSandboxFilesUploadPath = z.object({ + agent_id: z.string(), +}) + +/** + * Uploaded + */ +export const zPostAgentByAgentIdSandboxFilesUploadResponse = zSandboxUploadResponse + +export const zPostAgentByAgentIdSkillsStandardizePath = z.object({ + agent_id: z.string(), +}) + +/** + * Skill standardized into drive + */ +export const zPostAgentByAgentIdSkillsStandardizeResponse = zAgentSkillStandardizeResponse + +export const zPostAgentByAgentIdSkillsUploadPath = z.object({ + agent_id: z.string(), +}) + +/** + * Skill validated + */ +export const zPostAgentByAgentIdSkillsUploadResponse = zAgentSkillUploadResponse + +export const zDeleteAgentByAgentIdSkillsBySlugPath = z.object({ + agent_id: z.string(), + slug: z.string(), +}) + +/** + * Skill removed + */ +export const zDeleteAgentByAgentIdSkillsBySlugResponse = zAgentDriveDeleteResponse + +export const zPostAgentByAgentIdSkillsBySlugInferToolsPath = z.object({ + agent_id: z.string(), + slug: z.string(), +}) + +/** + * Inference result (draft suggestions, nothing persisted) + */ +export const zPostAgentByAgentIdSkillsBySlugInferToolsResponse = zSkillToolInferenceResult + +export const zGetAgentByAgentIdVersionsPath = z.object({ + agent_id: z.string(), +}) + +/** + * Agent versions + */ +export const zGetAgentByAgentIdVersionsResponse = zAgentConfigSnapshotListResponse + +export const zGetAgentByAgentIdVersionsByVersionIdPath = z.object({ + agent_id: z.string(), + version_id: z.string(), +}) + +/** + * Agent version detail + */ +export const zGetAgentByAgentIdVersionsByVersionIdResponse = zAgentConfigSnapshotDetailResponse diff --git a/packages/contracts/generated/api/console/agents/orpc.gen.ts b/packages/contracts/generated/api/console/agents/orpc.gen.ts deleted file mode 100644 index 0a01a8f3c97..00000000000 --- a/packages/contracts/generated/api/console/agents/orpc.gen.ts +++ /dev/null @@ -1,100 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { oc } from '@orpc/contract' -import * as z from 'zod' - -import { - zGetAgentsByAgentIdPath, - zGetAgentsByAgentIdResponse, - zGetAgentsByAgentIdVersionsByVersionIdPath, - zGetAgentsByAgentIdVersionsByVersionIdResponse, - zGetAgentsByAgentIdVersionsPath, - zGetAgentsByAgentIdVersionsResponse, - zGetAgentsInviteOptionsQuery, - zGetAgentsInviteOptionsResponse, - zGetAgentsQuery, - zGetAgentsResponse, -} from './zod.gen' - -export const get = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentsInviteOptions', - path: '/agents/invite-options', - tags: ['console'], - }) - .input(z.object({ query: zGetAgentsInviteOptionsQuery.optional() })) - .output(zGetAgentsInviteOptionsResponse) - -export const inviteOptions = { - get, -} - -export const get2 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentsByAgentIdVersionsByVersionId', - path: '/agents/{agent_id}/versions/{version_id}', - tags: ['console'], - }) - .input(z.object({ params: zGetAgentsByAgentIdVersionsByVersionIdPath })) - .output(zGetAgentsByAgentIdVersionsByVersionIdResponse) - -export const byVersionId = { - get: get2, -} - -export const get3 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentsByAgentIdVersions', - path: '/agents/{agent_id}/versions', - tags: ['console'], - }) - .input(z.object({ params: zGetAgentsByAgentIdVersionsPath })) - .output(zGetAgentsByAgentIdVersionsResponse) - -export const versions = { - get: get3, - byVersionId, -} - -export const get4 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgentsByAgentId', - path: '/agents/{agent_id}', - tags: ['console'], - }) - .input(z.object({ params: zGetAgentsByAgentIdPath })) - .output(zGetAgentsByAgentIdResponse) - -export const byAgentId = { - get: get4, - versions, -} - -export const get5 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAgents', - path: '/agents', - tags: ['console'], - }) - .input(z.object({ query: zGetAgentsQuery.optional() })) - .output(zGetAgentsResponse) - -export const agents = { - get: get5, - inviteOptions, - byAgentId, -} - -export const contract = { - agents, -} diff --git a/packages/contracts/generated/api/console/agents/types.gen.ts b/packages/contracts/generated/api/console/agents/types.gen.ts deleted file mode 100644 index eab4cbea186..00000000000 --- a/packages/contracts/generated/api/console/agents/types.gen.ts +++ /dev/null @@ -1,587 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type ClientOptions = { - baseUrl: `${string}://${string}/console/api` | (string & {}) -} - -export type AgentRosterListResponse = { - data: Array - has_more: boolean - limit: number - page: number - total: number -} - -export type AgentInviteOptionsResponse = { - data: Array - has_more: boolean - limit: number - page: number - total: number -} - -export type AgentRosterResponse = { - active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null - active_config_snapshot_id?: string | null - agent_kind: AgentKind - app_id?: string | null - archived_at?: number | null - archived_by?: string | null - created_at?: number | null - created_by?: string | null - description: string - icon?: string | null - icon_background?: string | null - icon_type?: AgentIconType | null - id: string - name: string - published_node_reference_count?: number - published_reference_count?: number - published_references?: Array - role?: string - scope: AgentScope - source: AgentSource - status: AgentStatus - updated_at?: number | null - updated_by?: string | null - workflow_id?: string | null - workflow_node_id?: string | null -} - -export type AgentConfigSnapshotListResponse = { - data: Array -} - -export type AgentConfigSnapshotDetailResponse = { - agent_id?: string | null - config_snapshot: AgentSoulConfig - created_at?: number | null - created_by?: string | null - id: string - revisions?: Array - summary?: string | null - version: number - version_note?: string | null -} - -export type AgentInviteOptionResponse = { - active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null - active_config_snapshot_id?: string | null - agent_kind: AgentKind - app_id?: string | null - archived_at?: number | null - archived_by?: string | null - created_at?: number | null - created_by?: string | null - description: string - existing_node_ids?: Array - icon?: string | null - icon_background?: string | null - icon_type?: AgentIconType | null - id: string - in_current_workflow_count?: number - is_in_current_workflow?: boolean - name: string - published_node_reference_count?: number - published_reference_count?: number - published_references?: Array - role?: string - scope: AgentScope - source: AgentSource - status: AgentStatus - updated_at?: number | null - updated_by?: string | null - workflow_id?: string | null - workflow_node_id?: string | null -} - -export type AgentConfigSnapshotSummaryResponse = { - agent_id?: string | null - created_at?: number | null - created_by?: string | null - id: string - summary?: string | null - version: number - version_note?: string | null -} - -export type AgentKind = 'dify_agent' - -export type AgentIconType = 'emoji' | 'image' | 'link' - -export type AgentPublishedReferenceResponse = { - app_id: string - app_mode: string - app_name: string - node_ids?: Array - workflow_id: string - workflow_version: string -} - -export type AgentScope = 'roster' | 'workflow_only' - -export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'workflow' - -export type AgentStatus = 'active' | 'archived' - -export type AgentSoulConfig = { - app_features?: AgentSoulAppFeaturesConfig - app_variables?: Array - env?: AgentSoulEnvConfig - human?: AgentSoulHumanConfig - knowledge?: AgentSoulKnowledgeConfig - memory?: AgentSoulMemoryConfig - misc_legacy?: AgentSoulAppFeaturesConfig - model?: AgentSoulModelConfig | null - prompt?: AgentSoulPromptConfig - sandbox?: AgentSoulSandboxConfig - schema_version?: number - skills_files?: AgentSoulSkillsFilesConfig - tools?: AgentSoulToolsConfig -} - -export type AgentConfigRevisionResponse = { - created_at?: number | null - created_by?: string | null - current_snapshot_id: string - id: string - operation: AgentConfigRevisionOperation - previous_snapshot_id?: string | null - revision: number - summary?: string | null - version_note?: string | null -} - -export type AgentSoulAppFeaturesConfig = { - opening_statement?: string | null - retriever_resource?: AgentFeatureToggleConfig | null - sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null - speech_to_text?: AgentFeatureToggleConfig | null - suggested_questions?: Array | null - suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig | null - text_to_speech?: AgentTextToSpeechFeatureConfig | null - [key: string]: unknown -} - -export type AppVariableConfig = { - default?: unknown - name: string - required?: boolean - type: string -} - -export type AgentSoulEnvConfig = { - secret_refs?: Array - variables?: Array -} - -export type AgentSoulHumanConfig = { - contacts?: Array - tools?: Array -} - -export type AgentSoulKnowledgeConfig = { - datasets?: Array - query_config?: AgentKnowledgeQueryConfig - query_mode?: AgentKnowledgeQueryMode | null -} - -export type AgentSoulMemoryConfig = { - artifacts?: Array - budget?: string | null - scope?: string | null -} - -export type AgentSoulModelConfig = { - credential_ref?: AgentSoulModelCredentialRef | null - model: string - model_provider: string - model_settings?: AgentSoulModelSettings - plugin_id: string -} - -export type AgentSoulPromptConfig = { - system_prompt?: string -} - -export type AgentSoulSandboxConfig = { - config?: AgentSandboxProviderConfig - provider?: string | null -} - -export type AgentSoulSkillsFilesConfig = { - files?: Array - skills?: Array -} - -export type AgentSoulToolsConfig = { - cli_tools?: Array - dify_tools?: Array -} - -export type AgentConfigRevisionOperation - = | 'create_version' - | 'save_current_version' - | 'save_new_agent' - | 'save_new_version' - | 'save_to_roster' - -export type AgentFeatureToggleConfig = { - enabled?: boolean - [key: string]: unknown -} - -export type AgentSensitiveWordAvoidanceFeatureConfig = { - config?: AgentModerationProviderConfig | null - enabled?: boolean - type?: string | null - [key: string]: unknown -} - -export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = { - enabled?: boolean - model?: AgentSoulModelConfig | null - prompt?: string | null - [key: string]: unknown -} - -export type AgentTextToSpeechFeatureConfig = { - autoPlay?: string | null - enabled?: boolean - language?: string | null - voice?: string | null - [key: string]: unknown -} - -export type AgentSecretRefConfig = { - credential_id?: string | null - env_name?: string | null - id?: string | null - key?: string | null - name?: string | null - permission?: AgentPermissionConfig | null - permission_status?: string | null - provider?: string | null - provider_credential_id?: string | null - ref?: string | null - type?: string | null - variable?: string | null - [key: string]: unknown -} - -export type AgentEnvVariableConfig = { - default?: - | string - | number - | number - | boolean - | Array - | Array - | Array - | Array - | null - env_name?: string | null - key?: string | null - name?: string | null - required?: boolean - type?: string | null - value?: - | string - | number - | number - | boolean - | Array - | Array - | Array - | Array - | null - variable?: string | null - [key: string]: unknown -} - -export type AgentHumanContactConfig = { - channel?: string | null - contact_id?: string | null - contact_method?: string | null - email?: string | null - human_id?: string | null - id?: string | null - method?: string | null - name?: string | null - tenant_id?: string | null - [key: string]: unknown -} - -export type AgentHumanToolConfig = { - description?: string | null - enabled?: boolean - name?: string | null - [key: string]: unknown -} - -export type AgentKnowledgeDatasetConfig = { - description?: string | null - id?: string | null - name?: string | null - [key: string]: unknown -} - -export type AgentKnowledgeQueryConfig = { - query?: string | null - score_threshold?: number | null - score_threshold_enabled?: boolean | null - top_k?: number | null - [key: string]: unknown -} - -export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query' - -export type AgentMemoryArtifactConfig = { - id?: string | null - name?: string | null - type?: string | null - url?: string | null - [key: string]: unknown -} - -export type AgentSoulModelCredentialRef = { - id?: string | null - provider?: string | null - type: string -} - -export type AgentSoulModelSettings = { - frequency_penalty?: number | null - max_tokens?: number | null - presence_penalty?: number | null - response_format?: AgentModelResponseFormatConfig | null - stop?: Array | null - temperature?: number | null - top_p?: number | null -} - -export type AgentSandboxProviderConfig = { - cpu?: number | null - env?: Array - image?: string | null - working_dir?: string | null - [key: string]: unknown -} - -export type AgentFileRefConfig = { - drive_key?: string | null - file_id?: string | null - id?: string | null - name?: string | null - reference?: string | null - remote_url?: string | null - tenant_id?: string | null - transfer_method?: string | null - type?: string | null - upload_file_id?: string | null - url?: string | null - [key: string]: unknown -} - -export type AgentSkillRefConfig = { - description?: string | null - file_id?: string | null - full_archive_file_id?: string | null - full_archive_key?: string | null - id?: string | null - manifest_files?: Array | null - name?: string | null - path?: string | null - skill_md_file_id?: string | null - skill_md_key?: string | null - [key: string]: unknown -} - -export type AgentCliToolConfig = { - approved?: boolean - authorization_status?: AgentCliToolAuthorizationStatus | null - command?: string | null - dangerous?: boolean - dangerous_accepted?: boolean - dangerous_acknowledged?: boolean - dangerous_command?: boolean - description?: string | null - enabled?: boolean - env?: AgentCliToolEnvConfig - id?: string | null - inferred_from?: string | null - install?: string | null - install_command?: string | null - install_commands?: Array - invoke_metadata?: { - [key: string]: unknown - } - label?: string | null - name?: string | null - permission?: AgentPermissionConfig | null - pre_authorized?: boolean | null - requires_confirmation?: boolean - risk_accepted?: boolean - risk_level?: AgentCliToolRiskLevel | null - setup_command?: string | null - tool_name?: string | null - [key: string]: unknown -} - -export type AgentSoulDifyToolConfig = { - credential_ref?: AgentSoulDifyToolCredentialRef | null - credential_type?: 'api-key' | 'oauth2' | 'unauthorized' - description?: string | null - enabled?: boolean - name?: string | null - plugin_id?: string | null - provider?: string | null - provider_id?: string | null - provider_type?: string - runtime_parameters?: { - [key: string]: - | string - | number - | number - | boolean - | Array - | Array - | Array - | Array - | null - } - tool_name?: string | null -} - -export type AgentModerationProviderConfig = { - api_based_extension_id?: string | null - inputs_config?: AgentModerationIoConfig | null - keywords?: string | null - outputs_config?: AgentModerationIoConfig | null - [key: string]: unknown -} - -export type AgentPermissionConfig = { - allowed?: boolean | null - state?: string | null - status?: string | null -} - -export type AgentModelResponseFormatConfig = { - type?: string | null - [key: string]: unknown -} - -export type AgentCliToolAuthorizationStatus - = | 'allowed' - | 'authorized' - | 'denied' - | 'forbidden' - | 'not_required' - | 'pending' - | 'pre_authorized' - | 'unauthorized' - -export type AgentCliToolEnvConfig = { - secret_refs?: Array - variables?: Array -} - -export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown' - -export type AgentSoulDifyToolCredentialRef = { - id?: string | null - provider?: string | null - type?: 'provider' | 'tool' -} - -export type AgentModerationIoConfig = { - enabled?: boolean - preset_response?: string | null - [key: string]: unknown -} - -export type GetAgentsData = { - body?: never - path?: never - query?: { - keyword?: string - limit?: number - page?: number - } - url: '/agents' -} - -export type GetAgentsResponses = { - 200: AgentRosterListResponse -} - -export type GetAgentsResponse = GetAgentsResponses[keyof GetAgentsResponses] - -export type GetAgentsInviteOptionsData = { - body?: never - path?: never - query?: { - app_id?: string - keyword?: string - limit?: number - page?: number - } - url: '/agents/invite-options' -} - -export type GetAgentsInviteOptionsResponses = { - 200: AgentInviteOptionsResponse -} - -export type GetAgentsInviteOptionsResponse - = GetAgentsInviteOptionsResponses[keyof GetAgentsInviteOptionsResponses] - -export type GetAgentsByAgentIdData = { - body?: never - path: { - agent_id: string - } - query?: never - url: '/agents/{agent_id}' -} - -export type GetAgentsByAgentIdResponses = { - 200: AgentRosterResponse -} - -export type GetAgentsByAgentIdResponse - = GetAgentsByAgentIdResponses[keyof GetAgentsByAgentIdResponses] - -export type GetAgentsByAgentIdVersionsData = { - body?: never - path: { - agent_id: string - } - query?: never - url: '/agents/{agent_id}/versions' -} - -export type GetAgentsByAgentIdVersionsResponses = { - 200: AgentConfigSnapshotListResponse -} - -export type GetAgentsByAgentIdVersionsResponse - = GetAgentsByAgentIdVersionsResponses[keyof GetAgentsByAgentIdVersionsResponses] - -export type GetAgentsByAgentIdVersionsByVersionIdData = { - body?: never - path: { - agent_id: string - version_id: string - } - query?: never - url: '/agents/{agent_id}/versions/{version_id}' -} - -export type GetAgentsByAgentIdVersionsByVersionIdResponses = { - 200: AgentConfigSnapshotDetailResponse -} - -export type GetAgentsByAgentIdVersionsByVersionIdResponse - = GetAgentsByAgentIdVersionsByVersionIdResponses[keyof GetAgentsByAgentIdVersionsByVersionIdResponses] diff --git a/packages/contracts/generated/api/console/agents/zod.gen.ts b/packages/contracts/generated/api/console/agents/zod.gen.ts deleted file mode 100644 index cee741afdb0..00000000000 --- a/packages/contracts/generated/api/console/agents/zod.gen.ts +++ /dev/null @@ -1,743 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import * as z from 'zod' - -/** - * AgentConfigSnapshotSummaryResponse - */ -export const zAgentConfigSnapshotSummaryResponse = z.object({ - agent_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - id: z.string(), - summary: z.string().nullish(), - version: z.int(), - version_note: z.string().nullish(), -}) - -/** - * AgentConfigSnapshotListResponse - */ -export const zAgentConfigSnapshotListResponse = z.object({ - data: z.array(zAgentConfigSnapshotSummaryResponse), -}) - -/** - * AgentKind - * - * Agent implementation family. - * - * This leaves room for future non-Dify agent implementations while keeping - * the current roster/workflow APIs scoped to Dify Agent. - */ -export const zAgentKind = z.enum(['dify_agent']) - -/** - * AgentIconType - * - * Supported icon storage formats for Agent roster entries. - */ -export const zAgentIconType = z.enum(['emoji', 'image', 'link']) - -/** - * AgentPublishedReferenceResponse - */ -export const zAgentPublishedReferenceResponse = z.object({ - app_id: z.string(), - app_mode: z.string(), - app_name: z.string(), - node_ids: z.array(z.string()).optional(), - workflow_id: z.string(), - workflow_version: z.string(), -}) - -/** - * AgentScope - * - * Visibility and lifecycle scope of an Agent record. - */ -export const zAgentScope = z.enum(['roster', 'workflow_only']) - -/** - * AgentSource - * - * Origin that created or imported the Agent. - */ -export const zAgentSource = z.enum(['agent_app', 'imported', 'roster', 'system', 'workflow']) - -/** - * AgentStatus - * - * Soft lifecycle state for Agent records. - */ -export const zAgentStatus = z.enum(['active', 'archived']) - -/** - * AgentRosterResponse - */ -export const zAgentRosterResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), - active_config_snapshot_id: z.string().nullish(), - agent_kind: zAgentKind, - app_id: z.string().nullish(), - archived_at: z.int().nullish(), - archived_by: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - description: z.string(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: zAgentIconType.nullish(), - id: z.string(), - name: z.string(), - published_node_reference_count: z.int().optional().default(0), - published_reference_count: z.int().optional().default(0), - published_references: z.array(zAgentPublishedReferenceResponse).optional(), - role: z.string().optional().default(''), - scope: zAgentScope, - source: zAgentSource, - status: zAgentStatus, - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - workflow_id: z.string().nullish(), - workflow_node_id: z.string().nullish(), -}) - -/** - * AgentRosterListResponse - */ -export const zAgentRosterListResponse = z.object({ - data: z.array(zAgentRosterResponse), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) - -/** - * AgentInviteOptionResponse - */ -export const zAgentInviteOptionResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), - active_config_snapshot_id: z.string().nullish(), - agent_kind: zAgentKind, - app_id: z.string().nullish(), - archived_at: z.int().nullish(), - archived_by: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - description: z.string(), - existing_node_ids: z.array(z.string()).optional(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: zAgentIconType.nullish(), - id: z.string(), - in_current_workflow_count: z.int().optional().default(0), - is_in_current_workflow: z.boolean().optional().default(false), - name: z.string(), - published_node_reference_count: z.int().optional().default(0), - published_reference_count: z.int().optional().default(0), - published_references: z.array(zAgentPublishedReferenceResponse).optional(), - role: z.string().optional().default(''), - scope: zAgentScope, - source: zAgentSource, - status: zAgentStatus, - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - workflow_id: z.string().nullish(), - workflow_node_id: z.string().nullish(), -}) - -/** - * AgentInviteOptionsResponse - */ -export const zAgentInviteOptionsResponse = z.object({ - data: z.array(zAgentInviteOptionResponse), - has_more: z.boolean(), - limit: z.int(), - page: z.int(), - total: z.int(), -}) - -/** - * AppVariableConfig - */ -export const zAppVariableConfig = z.object({ - default: z.unknown().optional(), - name: z.string().min(1).max(255), - required: z.boolean().optional().default(false), - type: z.string().min(1).max(64), -}) - -/** - * AgentSoulPromptConfig - */ -export const zAgentSoulPromptConfig = z.object({ - system_prompt: z.string().optional().default(''), -}) - -/** - * AgentConfigRevisionOperation - * - * Audit operation recorded for Agent Soul version/revision changes. - */ -export const zAgentConfigRevisionOperation = z.enum([ - 'create_version', - 'save_current_version', - 'save_new_agent', - 'save_new_version', - 'save_to_roster', -]) - -/** - * AgentConfigRevisionResponse - */ -export const zAgentConfigRevisionResponse = z.object({ - created_at: z.int().nullish(), - created_by: z.string().nullish(), - current_snapshot_id: z.string(), - id: z.string(), - operation: zAgentConfigRevisionOperation, - previous_snapshot_id: z.string().nullish(), - revision: z.int(), - summary: z.string().nullish(), - version_note: z.string().nullish(), -}) - -/** - * AgentFeatureToggleConfig - */ -export const zAgentFeatureToggleConfig = z.object({ - enabled: z.boolean().optional().default(false), -}) - -/** - * AgentTextToSpeechFeatureConfig - */ -export const zAgentTextToSpeechFeatureConfig = z.object({ - autoPlay: z.string().nullish(), - enabled: z.boolean().optional().default(false), - language: z.string().nullish(), - voice: z.string().nullish(), -}) - -/** - * AgentEnvVariableConfig - */ -export const zAgentEnvVariableConfig = z.object({ - default: z - .union([ - z.string(), - z.int(), - z.number(), - z.boolean(), - z.array(z.string()), - z.array(z.int()), - z.array(z.number()), - z.array(z.boolean()), - ]) - .nullish(), - env_name: z.string().max(255).nullish(), - key: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - required: z.boolean().optional().default(false), - type: z.string().max(64).nullish(), - value: z - .union([ - z.string(), - z.int(), - z.number(), - z.boolean(), - z.array(z.string()), - z.array(z.int()), - z.array(z.number()), - z.array(z.boolean()), - ]) - .nullish(), - variable: z.string().max(255).nullish(), -}) - -/** - * AgentHumanContactConfig - */ -export const zAgentHumanContactConfig = z.object({ - channel: z.string().max(64).nullish(), - contact_id: z.string().max(255).nullish(), - contact_method: z.string().max(64).nullish(), - email: z.string().max(255).nullish(), - human_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - method: z.string().max(64).nullish(), - name: z.string().max(255).nullish(), - tenant_id: z.string().max(255).nullish(), -}) - -/** - * AgentHumanToolConfig - */ -export const zAgentHumanToolConfig = z.object({ - description: z.string().nullish(), - enabled: z.boolean().optional().default(true), - name: z.string().max(255).nullish(), -}) - -/** - * AgentSoulHumanConfig - */ -export const zAgentSoulHumanConfig = z.object({ - contacts: z.array(zAgentHumanContactConfig).optional(), - tools: z.array(zAgentHumanToolConfig).optional(), -}) - -/** - * AgentKnowledgeDatasetConfig - */ -export const zAgentKnowledgeDatasetConfig = z.object({ - description: z.string().nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), -}) - -/** - * AgentKnowledgeQueryConfig - */ -export const zAgentKnowledgeQueryConfig = z.object({ - query: z.string().nullish(), - score_threshold: z.number().gte(0).lte(1).nullish(), - score_threshold_enabled: z.boolean().nullish(), - top_k: z.int().gte(1).nullish(), -}) - -/** - * AgentKnowledgeQueryMode - */ -export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']) - -/** - * AgentSoulKnowledgeConfig - */ -export const zAgentSoulKnowledgeConfig = z.object({ - datasets: z.array(zAgentKnowledgeDatasetConfig).optional(), - query_config: zAgentKnowledgeQueryConfig.optional(), - query_mode: zAgentKnowledgeQueryMode.nullish(), -}) - -/** - * AgentMemoryArtifactConfig - */ -export const zAgentMemoryArtifactConfig = z.object({ - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - type: z.string().max(64).nullish(), - url: z.string().nullish(), -}) - -/** - * AgentSoulMemoryConfig - */ -export const zAgentSoulMemoryConfig = z.object({ - artifacts: z.array(zAgentMemoryArtifactConfig).optional(), - budget: z.string().nullish(), - scope: z.string().nullish(), -}) - -/** - * AgentSoulModelCredentialRef - * - * Reference to model credentials resolved only at runtime. - */ -export const zAgentSoulModelCredentialRef = z.object({ - id: z.string().max(255).nullish(), - provider: z.string().max(255).nullish(), - type: z.string().min(1).max(64), -}) - -/** - * AgentSandboxProviderConfig - */ -export const zAgentSandboxProviderConfig = z.object({ - cpu: z.int().gte(1).nullish(), - env: z.array(zAgentEnvVariableConfig).optional(), - image: z.string().nullish(), - working_dir: z.string().nullish(), -}) - -/** - * AgentSoulSandboxConfig - */ -export const zAgentSoulSandboxConfig = z.object({ - config: zAgentSandboxProviderConfig.optional(), - provider: z.string().nullish(), -}) - -/** - * AgentFileRefConfig - */ -export const zAgentFileRefConfig = z.object({ - drive_key: z.string().max(512).nullish(), - file_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - reference: z.string().max(255).nullish(), - remote_url: z.string().nullish(), - tenant_id: z.string().max(255).nullish(), - transfer_method: z.string().max(64).nullish(), - type: z.string().max(64).nullish(), - upload_file_id: z.string().max(255).nullish(), - url: z.string().nullish(), -}) - -/** - * AgentSkillRefConfig - */ -export const zAgentSkillRefConfig = z.object({ - description: z.string().nullish(), - file_id: z.string().max(255).nullish(), - full_archive_file_id: z.string().max(255).nullish(), - full_archive_key: z.string().max(512).nullish(), - id: z.string().max(255).nullish(), - manifest_files: z.array(z.string()).nullish(), - name: z.string().max(255).nullish(), - path: z.string().nullish(), - skill_md_file_id: z.string().max(255).nullish(), - skill_md_key: z.string().max(512).nullish(), -}) - -/** - * AgentSoulSkillsFilesConfig - */ -export const zAgentSoulSkillsFilesConfig = z.object({ - files: z.array(zAgentFileRefConfig).optional(), - skills: z.array(zAgentSkillRefConfig).optional(), -}) - -/** - * AgentPermissionConfig - */ -export const zAgentPermissionConfig = z.object({ - allowed: z.boolean().nullish(), - state: z.string().max(64).nullish(), - status: z.string().max(64).nullish(), -}) - -/** - * AgentSecretRefConfig - */ -export const zAgentSecretRefConfig = z.object({ - credential_id: z.string().max(255).nullish(), - env_name: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - key: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - permission: zAgentPermissionConfig.nullish(), - permission_status: z.string().max(64).nullish(), - provider: z.string().max(255).nullish(), - provider_credential_id: z.string().max(255).nullish(), - ref: z.string().max(255).nullish(), - type: z.string().max(64).nullish(), - variable: z.string().max(255).nullish(), -}) - -/** - * AgentSoulEnvConfig - */ -export const zAgentSoulEnvConfig = z.object({ - secret_refs: z.array(zAgentSecretRefConfig).optional(), - variables: z.array(zAgentEnvVariableConfig).optional(), -}) - -/** - * AgentModelResponseFormatConfig - */ -export const zAgentModelResponseFormatConfig = z.object({ - type: z.string().max(64).nullish(), -}) - -/** - * AgentSoulModelSettings - */ -export const zAgentSoulModelSettings = z.object({ - frequency_penalty: z.number().nullish(), - max_tokens: z.int().nullish(), - presence_penalty: z.number().nullish(), - response_format: zAgentModelResponseFormatConfig.nullish(), - stop: z.array(z.string()).nullish(), - temperature: z.number().nullish(), - top_p: z.number().nullish(), -}) - -/** - * AgentSoulModelConfig - * - * Stable model selection for Agent runtime without storing secret values. - */ -export const zAgentSoulModelConfig = z.object({ - credential_ref: zAgentSoulModelCredentialRef.nullish(), - model: z.string().min(1).max(255), - model_provider: z.string().min(1).max(255), - model_settings: zAgentSoulModelSettings.optional(), - plugin_id: z.string().min(1).max(255), -}) - -/** - * AgentSuggestedQuestionsAfterAnswerFeatureConfig - */ -export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ - enabled: z.boolean().optional().default(false), - model: zAgentSoulModelConfig.nullish(), - prompt: z.string().nullish(), -}) - -/** - * AgentCliToolAuthorizationStatus - * - * Authorization state for Agent-scoped CLI tools. - * - * Missing status keeps backward compatibility with draft rows and CLI tools that - * do not need pre-authorization. Explicit denied-like states are blocked by the - * composer/publish validators and skipped by runtime request builders. - */ -export const zAgentCliToolAuthorizationStatus = z.enum([ - 'allowed', - 'authorized', - 'denied', - 'forbidden', - 'not_required', - 'pending', - 'pre_authorized', - 'unauthorized', -]) - -/** - * AgentCliToolEnvConfig - */ -export const zAgentCliToolEnvConfig = z.object({ - secret_refs: z.array(zAgentSecretRefConfig).optional(), - variables: z.array(zAgentEnvVariableConfig).optional(), -}) - -/** - * AgentCliToolRiskLevel - * - * Risk marker for CLI tool bootstrap commands. - */ -export const zAgentCliToolRiskLevel = z.enum(['dangerous', 'safe', 'unknown']) - -/** - * AgentCliToolConfig - */ -export const zAgentCliToolConfig = z.object({ - approved: z.boolean().optional().default(false), - authorization_status: zAgentCliToolAuthorizationStatus.nullish(), - command: z.string().nullish(), - dangerous: z.boolean().optional().default(false), - dangerous_accepted: z.boolean().optional().default(false), - dangerous_acknowledged: z.boolean().optional().default(false), - dangerous_command: z.boolean().optional().default(false), - description: z.string().nullish(), - enabled: z.boolean().optional().default(true), - env: zAgentCliToolEnvConfig.optional(), - id: z.string().max(255).nullish(), - inferred_from: z.string().max(255).nullish(), - install: z.string().nullish(), - install_command: z.string().nullish(), - install_commands: z.array(z.string()).optional(), - invoke_metadata: z.record(z.string(), z.unknown()).optional(), - label: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - permission: zAgentPermissionConfig.nullish(), - pre_authorized: z.boolean().nullish(), - requires_confirmation: z.boolean().optional().default(false), - risk_accepted: z.boolean().optional().default(false), - risk_level: zAgentCliToolRiskLevel.nullish(), - setup_command: z.string().nullish(), - tool_name: z.string().max(255).nullish(), -}) - -/** - * AgentSoulDifyToolCredentialRef - * - * Reference to a stored Dify Plugin Tool credential. - * - * Secret values are resolved only at runtime. The legacy ``credential_id`` - * field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so - * old Agent tool payloads can be read while new payloads stay explicit. - */ -export const zAgentSoulDifyToolCredentialRef = z.object({ - id: z.string().max(255).nullish(), - provider: z.string().max(255).nullish(), - type: z.enum(['provider', 'tool']).optional().default('tool'), -}) - -/** - * AgentSoulDifyToolConfig - * - * One Dify Plugin Tool configured on Agent Soul. - * - * The API backend prepares this persisted product shape into - * ``DifyPluginToolConfig`` before sending a run request to Agent backend. - * ``provider_id`` keeps compatibility with existing Agent tool config payloads; - * new callers should send ``plugin_id`` + ``provider`` when available. - */ -export const zAgentSoulDifyToolConfig = z.object({ - credential_ref: zAgentSoulDifyToolCredentialRef.nullish(), - credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'), - description: z.string().nullish(), - enabled: z.boolean().optional().default(true), - name: z.string().max(255).nullish(), - plugin_id: z.string().max(255).nullish(), - provider: z.string().max(255).nullish(), - provider_id: z.string().max(255).nullish(), - provider_type: z.string().optional().default('plugin'), - runtime_parameters: z - .record( - z.string(), - z - .union([ - z.string(), - z.int(), - z.number(), - z.boolean(), - z.array(z.string()), - z.array(z.int()), - z.array(z.number()), - z.array(z.boolean()), - ]) - .nullable(), - ) - .optional(), - tool_name: z.string().min(1).max(255).nullish(), -}) - -/** - * AgentSoulToolsConfig - */ -export const zAgentSoulToolsConfig = z.object({ - cli_tools: z.array(zAgentCliToolConfig).optional(), - dify_tools: z.array(zAgentSoulDifyToolConfig).optional(), -}) - -/** - * AgentModerationIOConfig - */ -export const zAgentModerationIoConfig = z.object({ - enabled: z.boolean().optional().default(false), - preset_response: z.string().nullish(), -}) - -/** - * AgentModerationProviderConfig - */ -export const zAgentModerationProviderConfig = z.object({ - api_based_extension_id: z.string().nullish(), - inputs_config: zAgentModerationIoConfig.nullish(), - keywords: z.string().nullish(), - outputs_config: zAgentModerationIoConfig.nullish(), -}) - -/** - * AgentSensitiveWordAvoidanceFeatureConfig - */ -export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ - config: zAgentModerationProviderConfig.nullish(), - enabled: z.boolean().optional().default(false), - type: z.string().nullish(), -}) - -/** - * AgentSoulAppFeaturesConfig - */ -export const zAgentSoulAppFeaturesConfig = z.object({ - opening_statement: z.string().nullish(), - retriever_resource: zAgentFeatureToggleConfig.nullish(), - sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), - speech_to_text: zAgentFeatureToggleConfig.nullish(), - suggested_questions: z.array(z.string()).nullish(), - suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), - text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), -}) - -/** - * AgentSoulConfig - */ -export const zAgentSoulConfig = z.object({ - app_features: zAgentSoulAppFeaturesConfig.optional(), - app_variables: z.array(zAppVariableConfig).optional(), - env: zAgentSoulEnvConfig.optional(), - human: zAgentSoulHumanConfig.optional(), - knowledge: zAgentSoulKnowledgeConfig.optional(), - memory: zAgentSoulMemoryConfig.optional(), - misc_legacy: zAgentSoulAppFeaturesConfig.optional(), - model: zAgentSoulModelConfig.nullish(), - prompt: zAgentSoulPromptConfig.optional(), - sandbox: zAgentSoulSandboxConfig.optional(), - schema_version: z.int().optional().default(1), - skills_files: zAgentSoulSkillsFilesConfig.optional(), - tools: zAgentSoulToolsConfig.optional(), -}) - -/** - * AgentConfigSnapshotDetailResponse - */ -export const zAgentConfigSnapshotDetailResponse = z.object({ - agent_id: z.string().nullish(), - config_snapshot: zAgentSoulConfig, - created_at: z.int().nullish(), - created_by: z.string().nullish(), - id: z.string(), - revisions: z.array(zAgentConfigRevisionResponse).optional(), - summary: z.string().nullish(), - version: z.int(), - version_note: z.string().nullish(), -}) - -export const zGetAgentsQuery = z.object({ - keyword: z.string().optional(), - limit: z.int().gte(1).lte(100).optional().default(20), - page: z.int().gte(1).optional().default(1), -}) - -/** - * Agent roster list - */ -export const zGetAgentsResponse = zAgentRosterListResponse - -export const zGetAgentsInviteOptionsQuery = z.object({ - app_id: z.string().optional(), - keyword: z.string().optional(), - limit: z.int().gte(1).lte(100).optional().default(20), - page: z.int().gte(1).optional().default(1), -}) - -/** - * Agent invite options - */ -export const zGetAgentsInviteOptionsResponse = zAgentInviteOptionsResponse - -export const zGetAgentsByAgentIdPath = z.object({ - agent_id: z.string(), -}) - -/** - * Agent detail - */ -export const zGetAgentsByAgentIdResponse = zAgentRosterResponse - -export const zGetAgentsByAgentIdVersionsPath = z.object({ - agent_id: z.string(), -}) - -/** - * Agent versions - */ -export const zGetAgentsByAgentIdVersionsResponse = zAgentConfigSnapshotListResponse - -export const zGetAgentsByAgentIdVersionsByVersionIdPath = z.object({ - agent_id: z.string(), - version_id: z.string(), -}) - -/** - * Agent version detail - */ -export const zGetAgentsByAgentIdVersionsByVersionIdResponse = zAgentConfigSnapshotDetailResponse diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 0d4d0ab7759..3952812a5a8 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -20,6 +20,8 @@ import { zDeleteAppsByAppIdCompletionConversationsByConversationIdResponse, zDeleteAppsByAppIdPath, zDeleteAppsByAppIdResponse, + zDeleteAppsByAppIdStarPath, + zDeleteAppsByAppIdStarResponse, zDeleteAppsByAppIdTraceConfigPath, zDeleteAppsByAppIdTraceConfigQuery, zDeleteAppsByAppIdTraceConfigResponse, @@ -43,10 +45,6 @@ import { zGetAppsByAppIdAdvancedChatWorkflowRunsPath, zGetAppsByAppIdAdvancedChatWorkflowRunsQuery, zGetAppsByAppIdAdvancedChatWorkflowRunsResponse, - zGetAppsByAppIdAgentComposerCandidatesPath, - zGetAppsByAppIdAgentComposerCandidatesResponse, - zGetAppsByAppIdAgentComposerPath, - zGetAppsByAppIdAgentComposerResponse, zGetAppsByAppIdAgentDriveFilesDownloadPath, zGetAppsByAppIdAgentDriveFilesDownloadQuery, zGetAppsByAppIdAgentDriveFilesDownloadResponse, @@ -59,14 +57,6 @@ import { zGetAppsByAppIdAgentLogsPath, zGetAppsByAppIdAgentLogsQuery, zGetAppsByAppIdAgentLogsResponse, - zGetAppsByAppIdAgentReferencingWorkflowsPath, - zGetAppsByAppIdAgentReferencingWorkflowsResponse, - zGetAppsByAppIdAgentSandboxFilesPath, - zGetAppsByAppIdAgentSandboxFilesQuery, - zGetAppsByAppIdAgentSandboxFilesReadPath, - zGetAppsByAppIdAgentSandboxFilesReadQuery, - zGetAppsByAppIdAgentSandboxFilesReadResponse, - zGetAppsByAppIdAgentSandboxFilesResponse, zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdPath, zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse, zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdPath, @@ -247,6 +237,8 @@ import { zGetAppsImportsByAppIdCheckDependenciesResponse, zGetAppsQuery, zGetAppsResponse, + zGetAppsStarredQuery, + zGetAppsStarredResponse, zPatchAppsByAppIdTraceConfigBody, zPatchAppsByAppIdTraceConfigPath, zPatchAppsByAppIdTraceConfigResponse, @@ -272,19 +264,10 @@ import { zPostAppsByAppIdAdvancedChatWorkflowsDraftRunBody, zPostAppsByAppIdAdvancedChatWorkflowsDraftRunPath, zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse, - zPostAppsByAppIdAgentComposerValidateBody, - zPostAppsByAppIdAgentComposerValidatePath, - zPostAppsByAppIdAgentComposerValidateResponse, - zPostAppsByAppIdAgentFeaturesBody, - zPostAppsByAppIdAgentFeaturesPath, - zPostAppsByAppIdAgentFeaturesResponse, zPostAppsByAppIdAgentFilesBody, zPostAppsByAppIdAgentFilesPath, zPostAppsByAppIdAgentFilesQuery, zPostAppsByAppIdAgentFilesResponse, - zPostAppsByAppIdAgentSandboxFilesUploadBody, - zPostAppsByAppIdAgentSandboxFilesUploadPath, - zPostAppsByAppIdAgentSandboxFilesUploadResponse, zPostAppsByAppIdAgentSkillsBySlugInferToolsPath, zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery, zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse, @@ -350,6 +333,8 @@ import { zPostAppsByAppIdSiteEnableResponse, zPostAppsByAppIdSitePath, zPostAppsByAppIdSiteResponse, + zPostAppsByAppIdStarPath, + zPostAppsByAppIdStarResponse, zPostAppsByAppIdTextToAudioBody, zPostAppsByAppIdTextToAudioPath, zPostAppsByAppIdTextToAudioResponse, @@ -439,9 +424,6 @@ import { zPostAppsResponse, zPostAppsWorkflowsOnlineUsersBody, zPostAppsWorkflowsOnlineUsersResponse, - zPutAppsByAppIdAgentComposerBody, - zPutAppsByAppIdAgentComposerPath, - zPutAppsByAppIdAgentComposerResponse, zPutAppsByAppIdBody, zPutAppsByAppIdPath, zPutAppsByAppIdResponse, @@ -516,6 +498,25 @@ export const imports = { byImportId, } +/** + * Get applications starred by the current account + */ +export const get2 = oc + .route({ + description: 'Get applications starred by the current account', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsStarred', + path: '/apps/starred', + tags: ['console'], + }) + .input(z.object({ query: zGetAppsStarredQuery.optional() })) + .output(zGetAppsStarredResponse) + +export const starred = { + get: get2, +} + /** * Get workflow online users */ @@ -544,7 +545,7 @@ export const workflows = { * * Get advanced chat workflow runs count statistics */ -export const get2 = oc +export const get3 = oc .route({ description: 'Get advanced chat workflow runs count statistics', inputStructure: 'detailed', @@ -563,7 +564,7 @@ export const get2 = oc .output(zGetAppsByAppIdAdvancedChatWorkflowRunsCountResponse) export const count = { - get: get2, + get: get3, } /** @@ -571,7 +572,7 @@ export const count = { * * Get advanced chat workflow run list */ -export const get3 = oc +export const get4 = oc .route({ description: 'Get advanced chat workflow run list', inputStructure: 'detailed', @@ -590,7 +591,7 @@ export const get3 = oc .output(zGetAppsByAppIdAdvancedChatWorkflowRunsResponse) export const workflowRuns = { - get: get3, + get: get4, count, } @@ -786,197 +787,10 @@ export const advancedChat = { workflows: workflows2, } -export const get4 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentComposerCandidates', - path: '/apps/{app_id}/agent-composer/candidates', - tags: ['console'], - }) - .input(z.object({ params: zGetAppsByAppIdAgentComposerCandidatesPath })) - .output(zGetAppsByAppIdAgentComposerCandidatesResponse) - -export const candidates = { - get: get4, -} - -export const post9 = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAgentComposerValidate', - path: '/apps/{app_id}/agent-composer/validate', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdAgentComposerValidateBody, - params: zPostAppsByAppIdAgentComposerValidatePath, - }), - ) - .output(zPostAppsByAppIdAgentComposerValidateResponse) - -export const validate = { - post: post9, -} - -export const get5 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentComposer', - path: '/apps/{app_id}/agent-composer', - tags: ['console'], - }) - .input(z.object({ params: zGetAppsByAppIdAgentComposerPath })) - .output(zGetAppsByAppIdAgentComposerResponse) - -export const put = oc - .route({ - inputStructure: 'detailed', - method: 'PUT', - operationId: 'putAppsByAppIdAgentComposer', - path: '/apps/{app_id}/agent-composer', - tags: ['console'], - }) - .input( - z.object({ body: zPutAppsByAppIdAgentComposerBody, params: zPutAppsByAppIdAgentComposerPath }), - ) - .output(zPutAppsByAppIdAgentComposerResponse) - -export const agentComposer = { - get: get5, - put, - candidates, - validate, -} - -/** - * Update an Agent App's presentation features (opener, follow-up, citations, ...) - */ -export const post10 = oc - .route({ - description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAgentFeatures', - path: '/apps/{app_id}/agent-features', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdAgentFeaturesBody, - params: zPostAppsByAppIdAgentFeaturesPath, - }), - ) - .output(zPostAppsByAppIdAgentFeaturesResponse) - -export const agentFeatures = { - post: post10, -} - -/** - * List workflow apps that reference this Agent App's bound Agent (read-only) - */ -export const get6 = oc - .route({ - description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentReferencingWorkflows', - path: '/apps/{app_id}/agent-referencing-workflows', - tags: ['console'], - }) - .input(z.object({ params: zGetAppsByAppIdAgentReferencingWorkflowsPath })) - .output(zGetAppsByAppIdAgentReferencingWorkflowsResponse) - -export const agentReferencingWorkflows = { - get: get6, -} - -/** - * Read a text/binary preview file in an Agent App conversation sandbox - */ -export const get7 = oc - .route({ - description: 'Read a text/binary preview file in an Agent App conversation sandbox', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentSandboxFilesRead', - path: '/apps/{app_id}/agent-sandbox/files/read', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdAgentSandboxFilesReadPath, - query: zGetAppsByAppIdAgentSandboxFilesReadQuery, - }), - ) - .output(zGetAppsByAppIdAgentSandboxFilesReadResponse) - -export const read = { - get: get7, -} - -/** - * Upload one Agent App sandbox file as a Dify ToolFile mapping - */ -export const post11 = oc - .route({ - description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAgentSandboxFilesUpload', - path: '/apps/{app_id}/agent-sandbox/files/upload', - tags: ['console'], - }) - .input( - z.object({ - body: zPostAppsByAppIdAgentSandboxFilesUploadBody, - params: zPostAppsByAppIdAgentSandboxFilesUploadPath, - }), - ) - .output(zPostAppsByAppIdAgentSandboxFilesUploadResponse) - -export const upload = { - post: post11, -} - -/** - * List a directory in an Agent App conversation sandbox - */ -export const get8 = oc - .route({ - description: 'List a directory in an Agent App conversation sandbox', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsByAppIdAgentSandboxFiles', - path: '/apps/{app_id}/agent-sandbox/files', - tags: ['console'], - }) - .input( - z.object({ - params: zGetAppsByAppIdAgentSandboxFilesPath, - query: zGetAppsByAppIdAgentSandboxFilesQuery, - }), - ) - .output(zGetAppsByAppIdAgentSandboxFilesResponse) - -export const files = { - get: get8, - read, - upload, -} - -export const agentSandbox = { - files, -} - /** * Time-limited external signed URL for one drive value (no streaming proxy) */ -export const get9 = oc +export const get5 = oc .route({ description: 'Time-limited external signed URL for one drive value (no streaming proxy)', inputStructure: 'detailed', @@ -994,13 +808,13 @@ export const get9 = oc .output(zGetAppsByAppIdAgentDriveFilesDownloadResponse) export const download = { - get: get9, + get: get5, } /** * Truncated text preview of one drive value (binary-safe; SKILL.md is the main case) */ -export const get10 = oc +export const get6 = oc .route({ description: 'Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)', @@ -1019,13 +833,13 @@ export const get10 = oc .output(zGetAppsByAppIdAgentDriveFilesPreviewResponse) export const preview2 = { - get: get10, + get: get6, } /** * List agent drive entries (read-only inspector; one endpoint for both tabs) */ -export const get11 = oc +export const get7 = oc .route({ description: 'List agent drive entries (read-only inspector; one endpoint for both tabs)', inputStructure: 'detailed', @@ -1042,14 +856,14 @@ export const get11 = oc ) .output(zGetAppsByAppIdAgentDriveFilesResponse) -export const files2 = { - get: get11, +export const files = { + get: get7, download, preview: preview2, } export const drive = { - files: files2, + files, } /** @@ -1077,7 +891,7 @@ export const delete_ = oc * * Commit an uploaded file into the agent drive under files/ (ENG-625 D3) */ -export const post12 = oc +export const post9 = oc .route({ description: 'Commit an uploaded file into the agent drive under files/ (ENG-625 D3)', inputStructure: 'detailed', @@ -1097,9 +911,9 @@ export const post12 = oc ) .output(zPostAppsByAppIdAgentFilesResponse) -export const files3 = { +export const files2 = { delete: delete_, - post: post12, + post: post9, } /** @@ -1107,7 +921,7 @@ export const files3 = { * * Get agent execution logs for an application */ -export const get12 = oc +export const get8 = oc .route({ description: 'Get agent execution logs for an application', inputStructure: 'detailed', @@ -1121,7 +935,7 @@ export const get12 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get12, + get: get8, } /** @@ -1129,7 +943,7 @@ export const logs = { * * Validate + standardize a Skill into the agent drive (ENG-594) */ -export const post13 = oc +export const post10 = oc .route({ description: 'Validate + standardize a Skill into the agent drive (ENG-594)', inputStructure: 'detailed', @@ -1149,7 +963,7 @@ export const post13 = oc .output(zPostAppsByAppIdAgentSkillsStandardizeResponse) export const standardize = { - post: post13, + post: post10, } /** @@ -1159,7 +973,7 @@ export const standardize = { * Returns a validated skill ref (to bind into the Agent soul config on save) * plus its manifest. Standardizing into the agent drive is ENG-594. */ -export const post14 = oc +export const post11 = oc .route({ description: 'Upload + validate a Skill package (.zip/.skill) and extract its manifest\nReturns a validated skill ref (to bind into the Agent soul config on save)\nplus its manifest. Standardizing into the agent drive is ENG-594.', @@ -1174,8 +988,8 @@ export const post14 = oc .input(z.object({ params: zPostAppsByAppIdAgentSkillsUploadPath })) .output(zPostAppsByAppIdAgentSkillsUploadResponse) -export const upload2 = { - post: post14, +export const upload = { + post: post11, } /** @@ -1184,7 +998,7 @@ export const upload2 = { * Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371) * Saving still goes through composer validation. */ -export const post15 = oc +export const post12 = oc .route({ description: 'Infer CLI tool + ENV suggestions from a standardized skill\'s SKILL.md (draft only, ENG-371)\nSaving still goes through composer validation.', @@ -1204,7 +1018,7 @@ export const post15 = oc .output(zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse) export const inferTools = { - post: post15, + post: post12, } /** @@ -1235,13 +1049,13 @@ export const bySlug = { export const skills = { standardize, - upload: upload2, + upload, bySlug, } export const agent = { drive, - files: files3, + files: files2, logs, skills, } @@ -1249,7 +1063,7 @@ export const agent = { /** * Get status of annotation reply action job */ -export const get13 = oc +export const get9 = oc .route({ description: 'Get status of annotation reply action job', inputStructure: 'detailed', @@ -1262,7 +1076,7 @@ export const get13 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get13, + get: get9, } export const status = { @@ -1272,7 +1086,7 @@ export const status = { /** * Enable or disable annotation reply for an app */ -export const post16 = oc +export const post13 = oc .route({ description: 'Enable or disable annotation reply for an app', inputStructure: 'detailed', @@ -1290,7 +1104,7 @@ export const post16 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post16, + post: post13, status, } @@ -1301,7 +1115,7 @@ export const annotationReply = { /** * Get annotation settings for an app */ -export const get14 = oc +export const get10 = oc .route({ description: 'Get annotation settings for an app', inputStructure: 'detailed', @@ -1314,13 +1128,13 @@ export const get14 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get14, + get: get10, } /** * Update annotation settings for an app */ -export const post17 = oc +export const post14 = oc .route({ description: 'Update annotation settings for an app', inputStructure: 'detailed', @@ -1338,7 +1152,7 @@ export const post17 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post17, + post: post14, } export const annotationSettings = { @@ -1348,7 +1162,7 @@ export const annotationSettings = { /** * Batch import annotations from CSV file with rate limiting and security checks */ -export const post18 = oc +export const post15 = oc .route({ description: 'Batch import annotations from CSV file with rate limiting and security checks', inputStructure: 'detailed', @@ -1361,13 +1175,13 @@ export const post18 = oc .output(zPostAppsByAppIdAnnotationsBatchImportResponse) export const batchImport = { - post: post18, + post: post15, } /** * Get status of batch import job */ -export const get15 = oc +export const get11 = oc .route({ description: 'Get status of batch import job', inputStructure: 'detailed', @@ -1380,7 +1194,7 @@ export const get15 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get15, + get: get11, } export const batchImportStatus = { @@ -1390,7 +1204,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get16 = oc +export const get12 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -1403,13 +1217,13 @@ export const get16 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get16, + get: get12, } /** * Export all annotations for an app with CSV injection protection */ -export const get17 = oc +export const get13 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -1422,13 +1236,13 @@ export const get17 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get17, + get: get13, } /** * Get hit histories for an annotation */ -export const get18 = oc +export const get14 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -1446,7 +1260,7 @@ export const get18 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get18, + get: get14, } export const delete3 = oc @@ -1464,7 +1278,7 @@ export const delete3 = oc /** * Update or delete an annotation */ -export const post19 = oc +export const post16 = oc .route({ description: 'Update or delete an annotation', inputStructure: 'detailed', @@ -1483,7 +1297,7 @@ export const post19 = oc export const byAnnotationId = { delete: delete3, - post: post19, + post: post16, hitHistories, } @@ -1502,7 +1316,7 @@ export const delete4 = oc /** * Get annotations for an app with pagination */ -export const get19 = oc +export const get15 = oc .route({ description: 'Get annotations for an app with pagination', inputStructure: 'detailed', @@ -1522,7 +1336,7 @@ export const get19 = oc /** * Create a new annotation for an app */ -export const post20 = oc +export const post17 = oc .route({ description: 'Create a new annotation for an app', inputStructure: 'detailed', @@ -1539,8 +1353,8 @@ export const post20 = oc export const annotations = { delete: delete4, - get: get19, - post: post20, + get: get15, + post: post17, batchImport, batchImportStatus, count: count2, @@ -1551,7 +1365,7 @@ export const annotations = { /** * Enable or disable app API */ -export const post21 = oc +export const post18 = oc .route({ description: 'Enable or disable app API', inputStructure: 'detailed', @@ -1564,13 +1378,13 @@ export const post21 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post21, + post: post18, } /** * Transcript audio to text for chat messages */ -export const post22 = oc +export const post19 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1583,7 +1397,7 @@ export const post22 = oc .output(zPostAppsByAppIdAudioToTextResponse) export const audioToText = { - post: post22, + post: post19, } /** @@ -1605,7 +1419,7 @@ export const delete5 = oc /** * Get chat conversation details */ -export const get20 = oc +export const get16 = oc .route({ description: 'Get chat conversation details', inputStructure: 'detailed', @@ -1619,13 +1433,13 @@ export const get20 = oc export const byConversationId = { delete: delete5, - get: get20, + get: get16, } /** * Get chat conversations with pagination, filtering and summary */ -export const get21 = oc +export const get17 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1643,14 +1457,14 @@ export const get21 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get21, + get: get17, byConversationId, } /** * Get suggested questions for a message */ -export const get22 = oc +export const get18 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1663,7 +1477,7 @@ export const get22 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get22, + get: get18, } export const byMessageId = { @@ -1673,7 +1487,7 @@ export const byMessageId = { /** * Stop a running chat message generation */ -export const post23 = oc +export const post20 = oc .route({ description: 'Stop a running chat message generation', inputStructure: 'detailed', @@ -1686,7 +1500,7 @@ export const post23 = oc .output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post23, + post: post20, } export const byTaskId = { @@ -1696,7 +1510,7 @@ export const byTaskId = { /** * Get chat messages for a conversation with pagination */ -export const get23 = oc +export const get19 = oc .route({ description: 'Get chat messages for a conversation with pagination', inputStructure: 'detailed', @@ -1711,7 +1525,7 @@ export const get23 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get23, + get: get19, byMessageId, byTaskId, } @@ -1735,7 +1549,7 @@ export const delete6 = oc /** * Get completion conversation details with messages */ -export const get24 = oc +export const get20 = oc .route({ description: 'Get completion conversation details with messages', inputStructure: 'detailed', @@ -1749,13 +1563,13 @@ export const get24 = oc export const byConversationId2 = { delete: delete6, - get: get24, + get: get20, } /** * Get completion conversations with pagination and filtering */ -export const get25 = oc +export const get21 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1773,14 +1587,14 @@ export const get25 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get25, + get: get21, byConversationId: byConversationId2, } /** * Stop a running completion message generation */ -export const post24 = oc +export const post21 = oc .route({ description: 'Stop a running completion message generation', inputStructure: 'detailed', @@ -1793,7 +1607,7 @@ export const post24 = oc .output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post24, + post: post21, } export const byTaskId2 = { @@ -1803,7 +1617,7 @@ export const byTaskId2 = { /** * Generate completion message for debugging */ -export const post25 = oc +export const post22 = oc .route({ description: 'Generate completion message for debugging', inputStructure: 'detailed', @@ -1821,14 +1635,14 @@ export const post25 = oc .output(zPostAppsByAppIdCompletionMessagesResponse) export const completionMessages = { - post: post25, + post: post22, byTaskId: byTaskId2, } /** * Get conversation variables for an application */ -export const get26 = oc +export const get22 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -1846,7 +1660,7 @@ export const get26 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get26, + get: get22, } /** @@ -1856,7 +1670,7 @@ export const conversationVariables = { * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App */ -export const post26 = oc +export const post23 = oc .route({ description: 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', @@ -1876,7 +1690,7 @@ export const post26 = oc .output(zPostAppsByAppIdConvertToWorkflowResponse) export const convertToWorkflow = { - post: post26, + post: post23, } /** @@ -1884,7 +1698,7 @@ export const convertToWorkflow = { * * Create a copy of an existing application */ -export const post27 = oc +export const post24 = oc .route({ description: 'Create a copy of an existing application', inputStructure: 'detailed', @@ -1899,7 +1713,7 @@ export const post27 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post27, + post: post24, } /** @@ -1907,7 +1721,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get27 = oc +export const get23 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -1923,13 +1737,13 @@ export const get27 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get27, + get: get23, } /** * Export user feedback data for Google Sheets */ -export const get28 = oc +export const get24 = oc .route({ description: 'Export user feedback data for Google Sheets', inputStructure: 'detailed', @@ -1947,13 +1761,13 @@ export const get28 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get28, + get: get24, } /** * Create or update message feedback (like/dislike) */ -export const post28 = oc +export const post25 = oc .route({ description: 'Create or update message feedback (like/dislike)', inputStructure: 'detailed', @@ -1966,14 +1780,14 @@ export const post28 = oc .output(zPostAppsByAppIdFeedbacksResponse) export const feedbacks = { - post: post28, + post: post25, export: export3, } /** * Update application icon */ -export const post29 = oc +export const post26 = oc .route({ description: 'Update application icon', inputStructure: 'detailed', @@ -1986,13 +1800,13 @@ export const post29 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post29, + post: post26, } /** * Get message details by ID */ -export const get29 = oc +export const get25 = oc .route({ description: 'Get message details by ID', inputStructure: 'detailed', @@ -2005,7 +1819,7 @@ export const get29 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get29, + get: get25, } export const messages = { @@ -2017,7 +1831,7 @@ export const messages = { * * Update application model configuration */ -export const post30 = oc +export const post27 = oc .route({ description: 'Update application model configuration', inputStructure: 'detailed', @@ -2033,13 +1847,13 @@ export const post30 = oc .output(zPostAppsByAppIdModelConfigResponse) export const modelConfig = { - post: post30, + post: post27, } /** * Check if app name is available */ -export const post31 = oc +export const post28 = oc .route({ description: 'Check if app name is available', inputStructure: 'detailed', @@ -2052,13 +1866,13 @@ export const post31 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post31, + post: post28, } /** * Publish app to Creators Platform */ -export const post32 = oc +export const post29 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2071,13 +1885,13 @@ export const post32 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post32, + post: post29, } /** * Get MCP server configuration for an application */ -export const get30 = oc +export const get26 = oc .route({ description: 'Get MCP server configuration for an application', inputStructure: 'detailed', @@ -2092,7 +1906,7 @@ export const get30 = oc /** * Create MCP server configuration for an application */ -export const post33 = oc +export const post30 = oc .route({ description: 'Create MCP server configuration for an application', inputStructure: 'detailed', @@ -2108,7 +1922,7 @@ export const post33 = oc /** * Update MCP server configuration for an application */ -export const put2 = oc +export const put = oc .route({ description: 'Update MCP server configuration for an application', inputStructure: 'detailed', @@ -2121,15 +1935,15 @@ export const put2 = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get30, - post: post33, - put: put2, + get: get26, + post: post30, + put, } /** * Reset access token for application site */ -export const post34 = oc +export const post31 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -2142,13 +1956,13 @@ export const post34 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post34, + post: post31, } /** * Update application site configuration */ -export const post35 = oc +export const post32 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -2161,14 +1975,14 @@ export const post35 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post35, + post: post32, accessTokenReset, } /** * Enable or disable app site */ -export const post36 = oc +export const post33 = oc .route({ description: 'Enable or disable app site', inputStructure: 'detailed', @@ -2181,13 +1995,48 @@ export const post36 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post36, + post: post33, +} + +/** + * Remove the current account's star from an application + */ +export const delete7 = oc + .route({ + description: 'Remove the current account\'s star from an application', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAppsByAppIdStar', + path: '/apps/{app_id}/star', + tags: ['console'], + }) + .input(z.object({ params: zDeleteAppsByAppIdStarPath })) + .output(zDeleteAppsByAppIdStarResponse) + +/** + * Star an application for the current account + */ +export const post34 = oc + .route({ + description: 'Star an application for the current account', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdStar', + path: '/apps/{app_id}/star', + tags: ['console'], + }) + .input(z.object({ params: zPostAppsByAppIdStarPath })) + .output(zPostAppsByAppIdStarResponse) + +export const star = { + delete: delete7, + post: post34, } /** * Get average response time statistics for an application */ -export const get31 = oc +export const get27 = oc .route({ description: 'Get average response time statistics for an application', inputStructure: 'detailed', @@ -2205,13 +2054,13 @@ export const get31 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get31, + get: get27, } /** * Get average session interaction statistics for an application */ -export const get32 = oc +export const get28 = oc .route({ description: 'Get average session interaction statistics for an application', inputStructure: 'detailed', @@ -2229,13 +2078,13 @@ export const get32 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get32, + get: get28, } /** * Get daily conversation statistics for an application */ -export const get33 = oc +export const get29 = oc .route({ description: 'Get daily conversation statistics for an application', inputStructure: 'detailed', @@ -2253,13 +2102,13 @@ export const get33 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get33, + get: get29, } /** * Get daily terminal/end-user statistics for an application */ -export const get34 = oc +export const get30 = oc .route({ description: 'Get daily terminal/end-user statistics for an application', inputStructure: 'detailed', @@ -2277,13 +2126,13 @@ export const get34 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get34, + get: get30, } /** * Get daily message statistics for an application */ -export const get35 = oc +export const get31 = oc .route({ description: 'Get daily message statistics for an application', inputStructure: 'detailed', @@ -2301,13 +2150,13 @@ export const get35 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get35, + get: get31, } /** * Get daily token cost statistics for an application */ -export const get36 = oc +export const get32 = oc .route({ description: 'Get daily token cost statistics for an application', inputStructure: 'detailed', @@ -2325,13 +2174,13 @@ export const get36 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get36, + get: get32, } /** * Get tokens per second statistics for an application */ -export const get37 = oc +export const get33 = oc .route({ description: 'Get tokens per second statistics for an application', inputStructure: 'detailed', @@ -2349,13 +2198,13 @@ export const get37 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get37, + get: get33, } /** * Get user satisfaction rate statistics for an application */ -export const get38 = oc +export const get34 = oc .route({ description: 'Get user satisfaction rate statistics for an application', inputStructure: 'detailed', @@ -2373,7 +2222,7 @@ export const get38 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get38, + get: get34, } export const statistics = { @@ -2390,7 +2239,7 @@ export const statistics = { /** * Get available TTS voices for a specific language */ -export const get39 = oc +export const get35 = oc .route({ description: 'Get available TTS voices for a specific language', inputStructure: 'detailed', @@ -2408,13 +2257,13 @@ export const get39 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get39, + get: get35, } /** * Convert text to speech for chat messages */ -export const post37 = oc +export const post35 = oc .route({ description: 'Convert text to speech for chat messages', inputStructure: 'detailed', @@ -2429,7 +2278,7 @@ export const post37 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post37, + post: post35, voices, } @@ -2438,7 +2287,7 @@ export const textToAudio = { * * Get app tracing configuration */ -export const get40 = oc +export const get36 = oc .route({ description: 'Get app tracing configuration', inputStructure: 'detailed', @@ -2454,7 +2303,7 @@ export const get40 = oc /** * Update app tracing configuration */ -export const post38 = oc +export const post36 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2467,8 +2316,8 @@ export const post38 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get40, - post: post38, + get: get36, + post: post36, } /** @@ -2476,7 +2325,7 @@ export const trace = { * * Delete an existing tracing configuration for an application */ -export const delete7 = oc +export const delete8 = oc .route({ description: 'Delete an existing tracing configuration for an application', inputStructure: 'detailed', @@ -2498,7 +2347,7 @@ export const delete7 = oc /** * Get tracing configuration for an application */ -export const get41 = oc +export const get37 = oc .route({ description: 'Get tracing configuration for an application', inputStructure: 'detailed', @@ -2537,7 +2386,7 @@ export const patch = oc * * Create a new tracing configuration for an application */ -export const post39 = oc +export const post37 = oc .route({ description: 'Create a new tracing configuration for an application', inputStructure: 'detailed', @@ -2554,16 +2403,16 @@ export const post39 = oc .output(zPostAppsByAppIdTraceConfigResponse) export const traceConfig = { - delete: delete7, - get: get41, + delete: delete8, + get: get37, patch, - post: post39, + post: post37, } /** * Update app trigger (enable/disable) */ -export const post40 = oc +export const post38 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2581,13 +2430,13 @@ export const post40 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post40, + post: post38, } /** * Get app triggers list */ -export const get42 = oc +export const get38 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2600,7 +2449,7 @@ export const get42 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get42, + get: get38, } /** @@ -2608,7 +2457,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get43 = oc +export const get39 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2627,7 +2476,7 @@ export const get43 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get43, + get: get39, } /** @@ -2635,7 +2484,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get44 = oc +export const get40 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2654,7 +2503,7 @@ export const get44 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get44, + get: get40, } /** @@ -2662,7 +2511,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get45 = oc +export const get41 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2681,7 +2530,7 @@ export const get45 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get45, + get: get41, } /** @@ -2689,7 +2538,7 @@ export const count3 = { * * Stop running workflow task */ -export const post41 = oc +export const post39 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2703,7 +2552,7 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post41, + post: post39, } export const byTaskId3 = { @@ -2717,7 +2566,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get46 = oc +export const get42 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2730,7 +2579,7 @@ export const get46 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get46, + get: get42, } /** @@ -2738,7 +2587,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get47 = oc +export const get43 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2752,7 +2601,7 @@ export const get47 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get47, + get: get43, } /** @@ -2760,7 +2609,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get48 = oc +export const get44 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2774,7 +2623,7 @@ export const get48 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get48, + get: get44, export: export4, nodeExecutions, } @@ -2782,7 +2631,7 @@ export const byRunId = { /** * Read a text/binary preview file in a workflow Agent node sandbox */ -export const get49 = oc +export const get45 = oc .route({ description: 'Read a text/binary preview file in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -2799,14 +2648,14 @@ export const get49 = oc ) .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse) -export const read2 = { - get: get49, +export const read = { + get: get45, } /** * Upload one workflow Agent sandbox file as a Dify ToolFile mapping */ -export const post42 = oc +export const post40 = oc .route({ description: 'Upload one workflow Agent sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', @@ -2823,14 +2672,14 @@ export const post42 = oc ) .output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse) -export const upload3 = { - post: post42, +export const upload2 = { + post: post40, } /** * List a directory in a workflow Agent node sandbox */ -export const get50 = oc +export const get46 = oc .route({ description: 'List a directory in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -2848,14 +2697,14 @@ export const get50 = oc ) .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse) -export const files4 = { - get: get50, - read: read2, - upload: upload3, +export const files3 = { + get: get46, + read, + upload: upload2, } export const sandbox = { - files: files4, + files: files3, } export const byNodeId4 = { @@ -2875,7 +2724,7 @@ export const byWorkflowRunId = { * * Get workflow run list */ -export const get51 = oc +export const get47 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -2894,7 +2743,7 @@ export const get51 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get51, + get: get47, count: count3, tasks, byRunId, @@ -2906,7 +2755,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get52 = oc +export const get48 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -2920,7 +2769,7 @@ export const get52 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get52, + get: get48, } /** @@ -2928,7 +2777,7 @@ export const mentionUsers = { * * Delete a comment reply */ -export const delete8 = oc +export const delete9 = oc .route({ description: 'Delete a comment reply', inputStructure: 'detailed', @@ -2947,7 +2796,7 @@ export const delete8 = oc * * Update a comment reply */ -export const put3 = oc +export const put2 = oc .route({ description: 'Update a comment reply', inputStructure: 'detailed', @@ -2966,8 +2815,8 @@ export const put3 = oc .output(zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse) export const byReplyId = { - delete: delete8, - put: put3, + delete: delete9, + put: put2, } /** @@ -2975,7 +2824,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post43 = oc +export const post41 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -2995,7 +2844,7 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post43, + post: post41, byReplyId, } @@ -3004,7 +2853,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post44 = oc +export const post42 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -3018,7 +2867,7 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post44, + post: post42, } /** @@ -3026,7 +2875,7 @@ export const resolve = { * * Delete a workflow comment */ -export const delete9 = oc +export const delete10 = oc .route({ description: 'Delete a workflow comment', inputStructure: 'detailed', @@ -3045,7 +2894,7 @@ export const delete9 = oc * * Get a specific workflow comment */ -export const get53 = oc +export const get49 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -3063,7 +2912,7 @@ export const get53 = oc * * Update a workflow comment */ -export const put4 = oc +export const put3 = oc .route({ description: 'Update a workflow comment', inputStructure: 'detailed', @@ -3082,9 +2931,9 @@ export const put4 = oc .output(zPutAppsByAppIdWorkflowCommentsByCommentIdResponse) export const byCommentId = { - delete: delete9, - get: get53, - put: put4, + delete: delete10, + get: get49, + put: put3, replies, resolve, } @@ -3094,7 +2943,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get54 = oc +export const get50 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -3112,7 +2961,7 @@ export const get54 = oc * * Create a new workflow comment */ -export const post45 = oc +export const post43 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -3132,8 +2981,8 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get54, - post: post45, + get: get50, + post: post43, mentionUsers, byCommentId, } @@ -3141,7 +2990,7 @@ export const comments = { /** * Get workflow average app interaction statistics */ -export const get55 = oc +export const get51 = oc .route({ description: 'Get workflow average app interaction statistics', inputStructure: 'detailed', @@ -3159,13 +3008,13 @@ export const get55 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get55, + get: get51, } /** * Get workflow daily runs statistics */ -export const get56 = oc +export const get52 = oc .route({ description: 'Get workflow daily runs statistics', inputStructure: 'detailed', @@ -3183,13 +3032,13 @@ export const get56 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get56, + get: get52, } /** * Get workflow daily terminals statistics */ -export const get57 = oc +export const get53 = oc .route({ description: 'Get workflow daily terminals statistics', inputStructure: 'detailed', @@ -3207,13 +3056,13 @@ export const get57 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get57, + get: get53, } /** * Get workflow daily token cost statistics */ -export const get58 = oc +export const get54 = oc .route({ description: 'Get workflow daily token cost statistics', inputStructure: 'detailed', @@ -3231,7 +3080,7 @@ export const get58 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get58, + get: get54, } export const statistics2 = { @@ -3251,7 +3100,7 @@ export const workflow = { * * Get default block configuration by type */ -export const get59 = oc +export const get55 = oc .route({ description: 'Get default block configuration by type', inputStructure: 'detailed', @@ -3270,7 +3119,7 @@ export const get59 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get59, + get: get55, } /** @@ -3278,7 +3127,7 @@ export const byBlockType = { * * Get default block configurations for workflow */ -export const get60 = oc +export const get56 = oc .route({ description: 'Get default block configurations for workflow', inputStructure: 'detailed', @@ -3292,14 +3141,14 @@ export const get60 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get60, + get: get56, byBlockType, } /** * Get conversation variables for workflow */ -export const get61 = oc +export const get57 = oc .route({ description: 'Get conversation variables for workflow', inputStructure: 'detailed', @@ -3314,7 +3163,7 @@ export const get61 = oc /** * Update conversation variables for workflow draft */ -export const post46 = oc +export const post44 = oc .route({ description: 'Update conversation variables for workflow draft', inputStructure: 'detailed', @@ -3332,8 +3181,8 @@ export const post46 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get61, - post: post46, + get: get57, + post: post44, } /** @@ -3341,7 +3190,7 @@ export const conversationVariables2 = { * * Get environment variables for workflow */ -export const get62 = oc +export const get58 = oc .route({ description: 'Get environment variables for workflow', inputStructure: 'detailed', @@ -3357,7 +3206,7 @@ export const get62 = oc /** * Update environment variables for workflow draft */ -export const post47 = oc +export const post45 = oc .route({ description: 'Update environment variables for workflow draft', inputStructure: 'detailed', @@ -3375,14 +3224,14 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get62, - post: post47, + get: get58, + post: post45, } /** * Update draft workflow features */ -export const post48 = oc +export const post46 = oc .route({ description: 'Update draft workflow features', inputStructure: 'detailed', @@ -3400,7 +3249,7 @@ export const post48 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post48, + post: post46, } /** @@ -3408,7 +3257,7 @@ export const features = { * * Test human input delivery for workflow */ -export const post49 = oc +export const post47 = oc .route({ description: 'Test human input delivery for workflow', inputStructure: 'detailed', @@ -3427,7 +3276,7 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post49, + post: post47, } /** @@ -3435,7 +3284,7 @@ export const deliveryTest = { * * Get human input form preview for workflow */ -export const post50 = oc +export const post48 = oc .route({ description: 'Get human input form preview for workflow', inputStructure: 'detailed', @@ -3454,7 +3303,7 @@ export const post50 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) export const preview3 = { - post: post50, + post: post48, } /** @@ -3462,7 +3311,7 @@ export const preview3 = { * * Submit human input form preview for workflow */ -export const post51 = oc +export const post49 = oc .route({ description: 'Submit human input form preview for workflow', inputStructure: 'detailed', @@ -3481,7 +3330,7 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post51, + post: post49, } export const form2 = { @@ -3507,7 +3356,7 @@ export const humanInput2 = { * * Run draft workflow iteration node */ -export const post52 = oc +export const post50 = oc .route({ description: 'Run draft workflow iteration node', inputStructure: 'detailed', @@ -3526,7 +3375,7 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post52, + post: post50, } export const byNodeId6 = { @@ -3546,7 +3395,7 @@ export const iteration2 = { * * Run draft workflow loop node */ -export const post53 = oc +export const post51 = oc .route({ description: 'Run draft workflow loop node', inputStructure: 'detailed', @@ -3565,7 +3414,7 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post53, + post: post51, } export const byNodeId7 = { @@ -3580,7 +3429,7 @@ export const loop2 = { nodes: nodes6, } -export const get63 = oc +export const get59 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3593,11 +3442,11 @@ export const get63 = oc ) .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) -export const candidates2 = { - get: get63, +export const candidates = { + get: get59, } -export const post54 = oc +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3614,10 +3463,10 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post54, + post: post52, } -export const post55 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3634,10 +3483,10 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post55, + post: post53, } -export const post56 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3653,11 +3502,11 @@ export const post56 = oc ) .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) -export const validate2 = { - post: post56, +export const validate = { + post: post54, } -export const get64 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3668,7 +3517,7 @@ export const get64 = oc .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath })) .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) -export const put5 = oc +export const put4 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3684,19 +3533,19 @@ export const put5 = oc ) .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) -export const agentComposer2 = { - get: get64, - put: put5, - candidates: candidates2, +export const agentComposer = { + get: get60, + put: put4, + candidates, impact, saveToRoster, - validate: validate2, + validate, } /** * Get last run result for draft workflow node */ -export const get65 = oc +export const get61 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3709,7 +3558,7 @@ export const get65 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get65, + get: get61, } /** @@ -3717,7 +3566,7 @@ export const lastRun = { * * Run draft workflow node */ -export const post57 = oc +export const post55 = oc .route({ description: 'Run draft workflow node', inputStructure: 'detailed', @@ -3736,7 +3585,7 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post57, + post: post55, } /** @@ -3744,7 +3593,7 @@ export const run8 = { * * Poll for trigger events and execute single node when event arrives */ -export const post58 = oc +export const post56 = oc .route({ description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', @@ -3758,7 +3607,7 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post58, + post: post56, } export const trigger = { @@ -3768,7 +3617,7 @@ export const trigger = { /** * Delete all variables for a specific node */ -export const delete10 = oc +export const delete11 = oc .route({ description: 'Delete all variables for a specific node', inputStructure: 'detailed', @@ -3784,7 +3633,7 @@ export const delete10 = oc /** * Get variables for a specific node */ -export const get66 = oc +export const get62 = oc .route({ description: 'Get variables for a specific node', inputStructure: 'detailed', @@ -3797,12 +3646,12 @@ export const get66 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse) export const variables = { - delete: delete10, - get: get66, + delete: delete11, + get: get62, } export const byNodeId8 = { - agentComposer: agentComposer2, + agentComposer, lastRun, run: run8, trigger, @@ -3818,7 +3667,7 @@ export const nodes7 = { * * Run draft workflow */ -export const post59 = oc +export const post57 = oc .route({ description: 'Run draft workflow', inputStructure: 'detailed', @@ -3837,13 +3686,13 @@ export const post59 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post59, + post: post57, } /** * Server-Sent Events stream of inspector deltas for a draft workflow run. */ -export const get67 = oc +export const get63 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a draft workflow run.', inputStructure: 'detailed', @@ -3856,13 +3705,13 @@ export const get67 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get67, + get: get63, } /** * Full value for one declared output, including signed download URL for files. */ -export const get68 = oc +export const get64 = oc .route({ description: 'Full value for one declared output, including signed download URL for files.', inputStructure: 'detailed', @@ -3879,7 +3728,7 @@ export const get68 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) export const preview4 = { - get: get68, + get: get64, } export const byOutputName = { @@ -3889,7 +3738,7 @@ export const byOutputName = { /** * One node's declared outputs for a draft workflow run. */ -export const get69 = oc +export const get65 = oc .route({ description: 'One node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -3902,14 +3751,14 @@ export const get69 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId9 = { - get: get69, + get: get65, byOutputName, } /** * Snapshot of every node's declared outputs for a draft workflow run. */ -export const get70 = oc +export const get66 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -3922,7 +3771,7 @@ export const get70 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get70, + get: get66, events, byNodeId: byNodeId9, } @@ -3938,7 +3787,7 @@ export const runs = { /** * Get system variables for workflow */ -export const get71 = oc +export const get67 = oc .route({ description: 'Get system variables for workflow', inputStructure: 'detailed', @@ -3951,7 +3800,7 @@ export const get71 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get71, + get: get67, } /** @@ -3959,7 +3808,7 @@ export const systemVariables = { * * Poll for trigger events and execute full workflow when event arrives */ -export const post60 = oc +export const post58 = oc .route({ description: 'Poll for trigger events and execute full workflow when event arrives', inputStructure: 'detailed', @@ -3978,7 +3827,7 @@ export const post60 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post60, + post: post58, } /** @@ -3986,7 +3835,7 @@ export const run11 = { * * Full workflow debug when the start node is a trigger */ -export const post61 = oc +export const post59 = oc .route({ description: 'Full workflow debug when the start node is a trigger', inputStructure: 'detailed', @@ -4005,7 +3854,7 @@ export const post61 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post61, + post: post59, } export const trigger2 = { @@ -4016,7 +3865,7 @@ export const trigger2 = { /** * Reset a workflow variable to its default value */ -export const put6 = oc +export const put5 = oc .route({ description: 'Reset a workflow variable to its default value', inputStructure: 'detailed', @@ -4029,13 +3878,13 @@ export const put6 = oc .output(zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse) export const reset = { - put: put6, + put: put5, } /** * Delete a workflow variable */ -export const delete11 = oc +export const delete12 = oc .route({ description: 'Delete a workflow variable', inputStructure: 'detailed', @@ -4051,7 +3900,7 @@ export const delete11 = oc /** * Get a specific workflow variable */ -export const get72 = oc +export const get68 = oc .route({ description: 'Get a specific workflow variable', inputStructure: 'detailed', @@ -4084,8 +3933,8 @@ export const patch2 = oc .output(zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse) export const byVariableId = { - delete: delete11, - get: get72, + delete: delete12, + get: get68, patch: patch2, reset, } @@ -4093,7 +3942,7 @@ export const byVariableId = { /** * Delete all draft workflow variables */ -export const delete12 = oc +export const delete13 = oc .route({ description: 'Delete all draft workflow variables', inputStructure: 'detailed', @@ -4111,7 +3960,7 @@ export const delete12 = oc * * Get draft workflow variables */ -export const get73 = oc +export const get69 = oc .route({ description: 'Get draft workflow variables', inputStructure: 'detailed', @@ -4130,8 +3979,8 @@ export const get73 = oc .output(zGetAppsByAppIdWorkflowsDraftVariablesResponse) export const variables2 = { - delete: delete12, - get: get73, + delete: delete13, + get: get69, byVariableId, } @@ -4140,7 +3989,7 @@ export const variables2 = { * * Get draft workflow for an application */ -export const get74 = oc +export const get70 = oc .route({ description: 'Get draft workflow for an application', inputStructure: 'detailed', @@ -4158,7 +4007,7 @@ export const get74 = oc * * Sync draft workflow configuration */ -export const post62 = oc +export const post60 = oc .route({ description: 'Sync draft workflow configuration', inputStructure: 'detailed', @@ -4177,8 +4026,8 @@ export const post62 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get74, - post: post62, + get: get70, + post: post60, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4198,7 +4047,7 @@ export const draft2 = { * * Get published workflow for an application */ -export const get75 = oc +export const get71 = oc .route({ description: 'Get published workflow for an application', inputStructure: 'detailed', @@ -4214,7 +4063,7 @@ export const get75 = oc /** * Publish workflow */ -export const post63 = oc +export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4232,14 +4081,14 @@ export const post63 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get75, - post: post63, + get: get71, + post: post61, } /** * Server-Sent Events stream of inspector deltas for a published workflow run. */ -export const get76 = oc +export const get72 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a published workflow run.', inputStructure: 'detailed', @@ -4252,13 +4101,13 @@ export const get76 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get76, + get: get72, } /** * Full value for one declared output of a published run. */ -export const get77 = oc +export const get73 = oc .route({ description: 'Full value for one declared output of a published run.', inputStructure: 'detailed', @@ -4279,7 +4128,7 @@ export const get77 = oc ) export const preview5 = { - get: get77, + get: get73, } export const byOutputName2 = { @@ -4289,7 +4138,7 @@ export const byOutputName2 = { /** * One node's declared outputs for a published workflow run. */ -export const get78 = oc +export const get74 = oc .route({ description: 'One node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4302,14 +4151,14 @@ export const get78 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId10 = { - get: get78, + get: get74, byOutputName: byOutputName2, } /** * Snapshot of every node's declared outputs for a published workflow run. */ -export const get79 = oc +export const get75 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4322,7 +4171,7 @@ export const get79 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get79, + get: get75, events: events2, byNodeId: byNodeId10, } @@ -4342,7 +4191,7 @@ export const published = { /** * Get webhook trigger for a node */ -export const get80 = oc +export const get76 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4360,7 +4209,7 @@ export const get80 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get80, + get: get76, } export const triggers2 = { @@ -4370,7 +4219,7 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow */ -export const post64 = oc +export const post62 = oc .route({ description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', @@ -4383,13 +4232,13 @@ export const post64 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post64, + post: post62, } /** * Delete workflow */ -export const delete13 = oc +export const delete14 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4426,7 +4275,7 @@ export const patch3 = oc .output(zPatchAppsByAppIdWorkflowsByWorkflowIdResponse) export const byWorkflowId = { - delete: delete13, + delete: delete14, patch: patch3, restore, } @@ -4436,7 +4285,7 @@ export const byWorkflowId = { * * Get all published workflows for an application */ -export const get81 = oc +export const get77 = oc .route({ description: 'Get all published workflows for an application', inputStructure: 'detailed', @@ -4455,7 +4304,7 @@ export const get81 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get81, + get: get77, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4469,7 +4318,7 @@ export const workflows3 = { * * Delete application */ -export const delete14 = oc +export const delete15 = oc .route({ description: 'Delete application', inputStructure: 'detailed', @@ -4488,7 +4337,7 @@ export const delete14 = oc * * Get application details */ -export const get82 = oc +export const get78 = oc .route({ description: 'Get application details', inputStructure: 'detailed', @@ -4506,7 +4355,7 @@ export const get82 = oc * * Update application details */ -export const put7 = oc +export const put6 = oc .route({ description: 'Update application details', inputStructure: 'detailed', @@ -4520,14 +4369,10 @@ export const put7 = oc .output(zPutAppsByAppIdResponse) export const byAppId2 = { - delete: delete14, - get: get82, - put: put7, + delete: delete15, + get: get78, + put: put6, advancedChat, - agentComposer, - agentFeatures, - agentReferencingWorkflows, - agentSandbox, agent, annotationReply, annotationSetting, @@ -4552,6 +4397,7 @@ export const byAppId2 = { server, site, siteEnable, + star, statistics, textToAudio, trace, @@ -4570,7 +4416,7 @@ export const byAppId2 = { * * Delete an API key for an app */ -export const delete15 = oc +export const delete16 = oc .route({ description: 'Delete an API key for an app', inputStructure: 'detailed', @@ -4585,7 +4431,7 @@ export const delete15 = oc .output(zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse) export const byApiKeyId = { - delete: delete15, + delete: delete16, } /** @@ -4593,7 +4439,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get83 = oc +export const get79 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4611,7 +4457,7 @@ export const get83 = oc * * Create a new API key for an app */ -export const post65 = oc +export const post63 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4626,8 +4472,8 @@ export const post65 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get83, - post: post65, + get: get79, + post: post63, byApiKeyId, } @@ -4638,7 +4484,7 @@ export const byResourceId = { /** * Refresh MCP server configuration and regenerate server code */ -export const get84 = oc +export const get80 = oc .route({ description: 'Refresh MCP server configuration and regenerate server code', inputStructure: 'detailed', @@ -4651,7 +4497,7 @@ export const get84 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get84, + get: get80, } export const server2 = { @@ -4667,7 +4513,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get85 = oc +export const get81 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4685,7 +4531,7 @@ export const get85 = oc * * Create a new application */ -export const post66 = oc +export const post64 = oc .route({ description: 'Create a new application', inputStructure: 'detailed', @@ -4700,9 +4546,10 @@ export const post66 = oc .output(zPostAppsResponse) export const apps = { - get: get85, - post: post66, + get: get81, + post: post64, imports, + starred, workflows, byAppId: byAppId2, byResourceId, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 5ba8675ee3e..0c7633ba7ec 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -5,10 +5,10 @@ export type ClientOptions = { } export type AppPagination = { - has_next: boolean - items: Array + data: Array + has_more: boolean + limit: number page: number - per_page: number total: number } @@ -17,14 +17,15 @@ export type CreateAppPayload = { icon?: string | null icon_background?: string | null icon_type?: IconType | null - mode: 'advanced-chat' | 'agent' | 'agent-chat' | 'chat' | 'completion' | 'workflow' + mode: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow' name: string } export type AppDetailWithSite = { access_mode?: string | null + active_config_is_published?: boolean api_base_url?: string | null - app_model_config?: ModelConfig | null + app_id?: string | null bound_agent_id?: string | null created_at?: number | null created_by?: string | null @@ -35,10 +36,13 @@ export type AppDetailWithSite = { icon?: string | null icon_background?: string | null icon_type?: string | null + readonly icon_url: string | null id: string max_active_requests?: number | null - mode_compatible_with_agent: string + mode: string + model_config?: ModelConfig | null name: string + role?: string | null site?: Site | null tags?: Array tracing?: JsonValue | null @@ -172,85 +176,6 @@ export type AdvancedChatWorkflowRunPayload = { query?: string } -export type AgentAppComposerResponse = { - active_config_snapshot: AgentConfigSnapshotSummaryResponse - agent: AgentComposerAgentResponse - agent_soul: AgentSoulConfig - save_options: Array - validation?: ComposerValidationFindingsResponse | null - variant: 'agent_app' -} - -export type ComposerSavePayload = { - agent_soul?: AgentSoulConfig | null - binding?: ComposerBindingPayload | null - client_revision_id?: string | null - idempotency_key?: string | null - new_agent_name?: string | null - node_job?: WorkflowNodeJobConfig | null - save_strategy: ComposerSaveStrategy - soul_lock?: ComposerSoulLockPayload - variant: ComposerVariant - version_note?: string | null -} - -export type AgentComposerCandidatesResponse = { - allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse - allowed_soul_candidates?: AgentComposerSoulCandidatesResponse - capabilities?: ComposerCandidateCapabilities - truncated?: boolean - variant: ComposerVariant -} - -export type AgentComposerValidateResponse = { - errors?: Array - knowledge_retrieval_placeholder?: Array - result: 'success' - warnings?: Array -} - -export type AgentAppFeaturesPayload = { - opening_statement?: string | null - retriever_resource?: AgentFeatureToggleConfig | null - sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null - speech_to_text?: AgentFeatureToggleConfig | null - suggested_questions?: Array | null - suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig | null - text_to_speech?: AgentTextToSpeechFeatureConfig | null -} - -export type SimpleResultResponse = { - result: string -} - -export type AgentReferencingWorkflowsResponse = { - data?: Array -} - -export type SandboxListResponse = { - entries?: Array - path: string - truncated?: boolean -} - -export type SandboxReadResponse = { - binary: boolean - path: string - size?: number | null - text?: string | null - truncated: boolean -} - -export type AgentSandboxUploadPayload = { - conversation_id: string - path: string -} - -export type SandboxUploadResponse = { - file: SandboxToolFileResponse - path: string -} - export type AgentDriveListResponse = { items?: Array } @@ -442,6 +367,10 @@ export type SuggestedQuestionsResponse = { data: Array } +export type SimpleResultResponse = { + result: string +} + export type ConversationPagination = { has_next: boolean items: Array @@ -809,11 +738,30 @@ export type WorkflowRunNodeExecutionListResponse = { data: Array } +export type SandboxListResponse = { + entries?: Array + path: string + truncated?: boolean +} + +export type SandboxReadResponse = { + binary: boolean + path: string + size?: number | null + text?: string | null + truncated: boolean +} + export type WorkflowAgentSandboxUploadPayload = { node_execution_id?: string | null path: string } +export type SandboxUploadResponse = { + file: SandboxToolFileResponse + path: string +} + export type WorkflowCommentBasicList = { data: Array } @@ -1014,12 +962,40 @@ export type WorkflowAgentComposerResponse = { workflow_id?: string | null } +export type ComposerSavePayload = { + agent_soul?: AgentSoulConfig | null + binding?: ComposerBindingPayload | null + client_revision_id?: string | null + idempotency_key?: string | null + new_agent_name?: string | null + node_job?: WorkflowNodeJobConfig | null + save_strategy: ComposerSaveStrategy + soul_lock?: ComposerSoulLockPayload + variant: ComposerVariant + version_note?: string | null +} + +export type AgentComposerCandidatesResponse = { + allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse + allowed_soul_candidates?: AgentComposerSoulCandidatesResponse + capabilities?: ComposerCandidateCapabilities + truncated?: boolean + variant: ComposerVariant +} + export type AgentComposerImpactResponse = { bindings?: Array current_snapshot_id?: string | null workflow_node_count: number } +export type AgentComposerValidateResponse = { + errors?: Array + knowledge_retrieval_placeholder?: Array + result: 'success' + warnings?: Array +} + export type WorkflowRunNodeExecutionResponse = { created_at?: number | null created_by_account?: SimpleAccount | null @@ -1180,20 +1156,26 @@ export type ApiKeyItem = { export type AppPartial = { access_mode?: string | null - app_model_config?: ModelConfigPartial | null + active_config_is_published?: boolean + app_id?: string | null author_name?: string | null + bound_agent_id?: string | null create_user_name?: string | null created_at?: number | null created_by?: string | null - desc_or_prompt?: string | null + description?: string | null has_draft_trigger?: boolean | null icon?: string | null icon_background?: string | null icon_type?: string | null + readonly icon_url: string | null id: string + is_starred?: boolean max_active_requests?: number | null - mode_compatible_with_agent: string + mode: string + model_config?: ModelConfigPartial | null name: string + role?: string | null tags?: Array updated_at?: number | null updated_by?: string | null @@ -1203,6 +1185,12 @@ export type AppPartial = { export type IconType = 'emoji' | 'image' | 'link' +export type DeletedTool = { + provider_id: string + tool_name: string + type: string +} + export type ModelConfig = { completion_params?: { [key: string]: unknown @@ -1212,12 +1200,6 @@ export type ModelConfig = { provider: string } -export type DeletedTool = { - provider_id: string - tool_name: string - type: string -} - export type Site = { chat_color_theme?: string | null chat_color_theme_inverted: boolean @@ -1289,161 +1271,6 @@ export type AdvancedChatWorkflowRunForListResponse = { version?: string | null } -export type AgentConfigSnapshotSummaryResponse = { - agent_id?: string | null - created_at?: number | null - created_by?: string | null - id: string - summary?: string | null - version: number - version_note?: string | null -} - -export type AgentComposerAgentResponse = { - active_config_snapshot_id?: string | null - description: string - id: string - name: string - scope: AgentScope - status: AgentStatus -} - -export type AgentSoulConfig = { - app_features?: AgentSoulAppFeaturesConfig - app_variables?: Array - env?: AgentSoulEnvConfig - human?: AgentSoulHumanConfig - knowledge?: AgentSoulKnowledgeConfig - memory?: AgentSoulMemoryConfig - misc_legacy?: AgentSoulAppFeaturesConfig - model?: AgentSoulModelConfig | null - prompt?: AgentSoulPromptConfig - sandbox?: AgentSoulSandboxConfig - schema_version?: number - skills_files?: AgentSoulSkillsFilesConfig - tools?: AgentSoulToolsConfig -} - -export type ComposerSaveStrategy - = | 'node_job_only' - | 'save_as_new_agent' - | 'save_as_new_version' - | 'save_to_current_version' - | 'save_to_roster' - -export type ComposerValidationFindingsResponse = { - knowledge_retrieval_placeholder?: Array - warnings?: Array -} - -export type ComposerBindingPayload = { - agent_id?: string | null - binding_type: 'inline_agent' | 'roster_agent' - current_snapshot_id?: string | null -} - -export type WorkflowNodeJobConfig = { - declared_outputs?: Array - human_contacts?: Array - metadata?: WorkflowNodeJobMetadata - mode?: WorkflowNodeJobMode - previous_node_output_refs?: Array - schema_version?: number - workflow_prompt?: string -} - -export type ComposerSoulLockPayload = { - locked?: boolean - unlocked_from_version_id?: string | null -} - -export type ComposerVariant = 'agent_app' | 'workflow' - -export type AgentComposerNodeJobCandidatesResponse = { - declare_output_types?: Array - human_contacts?: Array - previous_node_outputs?: Array -} - -export type AgentComposerSoulCandidatesResponse = { - cli_tools?: Array - dify_tools?: Array - human_contacts?: Array - knowledge_datasets?: Array - skills_files?: Array< - | ({ - kind: 'skill' - } & AgentComposerSkillCandidateResponse) - | ({ - kind: 'file' - } & AgentComposerFileCandidateResponse) - > -} - -export type ComposerCandidateCapabilities = { - human_roster_available?: boolean -} - -export type ComposerKnowledgePlaceholderResponse = { - id: string - placeholder_name: string -} - -export type ComposerValidationWarningResponse = { - code: string - id?: string | null - kind?: string | null - message?: string | null - surface?: string | null -} - -export type AgentFeatureToggleConfig = { - enabled?: boolean - [key: string]: unknown -} - -export type AgentSensitiveWordAvoidanceFeatureConfig = { - config?: AgentModerationProviderConfig | null - enabled?: boolean - type?: string | null - [key: string]: unknown -} - -export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = { - enabled?: boolean - model?: AgentSoulModelConfig | null - prompt?: string | null - [key: string]: unknown -} - -export type AgentTextToSpeechFeatureConfig = { - autoPlay?: string | null - enabled?: boolean - language?: string | null - voice?: string | null - [key: string]: unknown -} - -export type AgentReferencingWorkflowResponse = { - app_id: string - app_mode: string - app_name: string - node_ids?: Array - workflow_id: string -} - -export type SandboxFileEntryResponse = { - mtime?: number | null - name: string - size?: number | null - type: 'dir' | 'file' | 'other' | 'symlink' -} - -export type SandboxToolFileResponse = { - reference: string - transfer_method?: 'tool_file' -} - export type AgentDriveItemResponse = { created_at?: number | null file_kind: string @@ -1759,6 +1586,18 @@ export type SimpleEndUser = { type: string } +export type SandboxFileEntryResponse = { + mtime?: number | null + name: string + size?: number | null + type: 'dir' | 'file' | 'other' | 'symlink' +} + +export type SandboxToolFileResponse = { + reference: string + transfer_method?: 'tool_file' +} + export type WorkflowCommentBasic = { content: string created_at?: number | null @@ -1876,6 +1715,41 @@ export type EnvironmentVariableItemResponse = { visible: boolean } +export type AgentConfigSnapshotSummaryResponse = { + agent_id?: string | null + created_at?: number | null + created_by?: string | null + id: string + summary?: string | null + version: number + version_note?: string | null +} + +export type AgentComposerAgentResponse = { + active_config_snapshot_id?: string | null + description: string + id: string + name: string + scope: AgentScope + status: AgentStatus +} + +export type AgentSoulConfig = { + app_features?: AgentSoulAppFeaturesConfig + app_variables?: Array + env?: AgentSoulEnvConfig + human?: AgentSoulHumanConfig + knowledge?: AgentSoulKnowledgeConfig + memory?: AgentSoulMemoryConfig + misc_legacy?: AgentSoulAppFeaturesConfig + model?: AgentSoulModelConfig | null + prompt?: AgentSoulPromptConfig + sandbox?: AgentSoulSandboxConfig + schema_version?: number + skills_files?: AgentSoulSkillsFilesConfig + tools?: AgentSoulToolsConfig +} + export type AgentComposerBindingResponse = { agent_id?: string | null binding_type: WorkflowAgentBindingType @@ -1888,6 +1762,26 @@ export type AgentComposerBindingResponse = { export type DeclaredOutputConfig = { array_item?: DeclaredArrayItem | null check?: DeclaredOutputCheckConfig | null + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> description?: string | null failure_strategy?: DeclaredOutputFailureStrategy file?: DeclaredOutputFileConfig | null @@ -1897,18 +1791,91 @@ export type DeclaredOutputConfig = { type: DeclaredOutputType } +export type WorkflowNodeJobConfig = { + declared_outputs?: Array + human_contacts?: Array + metadata?: WorkflowNodeJobMetadata + mode?: WorkflowNodeJobMode + previous_node_output_refs?: Array + schema_version?: number + workflow_prompt?: string +} + +export type ComposerSaveStrategy + = | 'node_job_only' + | 'save_as_new_agent' + | 'save_as_new_version' + | 'save_to_current_version' + | 'save_to_roster' + export type AgentComposerSoulLockResponse = { can_unlock?: boolean locked: boolean reason?: string | null } +export type ComposerValidationFindingsResponse = { + knowledge_retrieval_placeholder?: Array + warnings?: Array +} + +export type ComposerBindingPayload = { + agent_id?: string | null + binding_type: 'inline_agent' | 'roster_agent' + current_snapshot_id?: string | null +} + +export type ComposerSoulLockPayload = { + locked?: boolean + unlocked_from_version_id?: string | null +} + +export type ComposerVariant = 'agent_app' | 'workflow' + +export type AgentComposerNodeJobCandidatesResponse = { + declare_output_types?: Array + human_contacts?: Array + previous_node_outputs?: Array +} + +export type AgentComposerSoulCandidatesResponse = { + cli_tools?: Array + dify_tools?: Array + human_contacts?: Array + knowledge_datasets?: Array + skills_files?: Array< + | ({ + kind: 'skill' + } & AgentComposerSkillCandidateResponse) + | ({ + kind: 'file' + } & AgentComposerFileCandidateResponse) + > +} + +export type ComposerCandidateCapabilities = { + human_roster_available?: boolean +} + export type AgentComposerImpactBindingResponse = { app_id: string node_id: string workflow_id: string } +export type ComposerKnowledgePlaceholderResponse = { + id: string + placeholder_name: string +} + +export type ComposerValidationWarningResponse = { + code: string + id?: string | null + kind?: string | null + message?: string | null + surface?: string | null +} + export type WorkflowExecutionStatus = | 'failed' | 'partial-succeeded' @@ -1956,7 +1923,7 @@ export type WorkflowDraftVariableWithoutValue = { export type ModelConfigPartial = { created_at?: number | null created_by?: string | null - model_dict?: JsonValue | null + model?: JsonValue | null pre_prompt?: string | null updated_at?: number | null updated_by?: string | null @@ -1989,6 +1956,101 @@ export type WorkflowOnlineUser = { username: string } +export type AgentToolCallResponse = { + error?: string | null + status: string + time_cost: number | number + tool_icon?: unknown + tool_input: { + [key: string]: unknown + } + tool_label: string + tool_name: string + tool_output: { + [key: string]: unknown + } + tool_parameters: { + [key: string]: unknown + } +} + +export type EnvSuggestion = { + key: string + reason?: string + secret_likely?: boolean +} + +export type SimpleModelConfig = { + model_dict?: JsonValue | null + pre_prompt?: string | null +} + +export type StatusCount = { + failed: number + partial_success: number + paused: number + success: number +} + +export type SimpleMessageDetail = { + answer: string + inputs: { + [key: string]: JsonValue + } + message: string + query: string +} + +export type HumanInputFormDefinition = { + actions?: Array + display_in_ui?: boolean + expiration_time: number + form_content: string + form_id: string + form_token?: string | null + inputs?: Array + node_id: string + node_title: string + resolved_default_values?: { + [key: string]: unknown + } +} + +export type HumanInputFormSubmissionData = { + action_id: string + action_text: string + node_id: string + node_title: string + rendered_content: string + submitted_data?: { + [key: string]: JsonValue2 + } | null +} + +export type ExecutionContentType = 'human_input' + +export type WorkflowRunForLogResponse = { + created_at?: number | null + elapsed_time?: number | null + error?: string | null + exceptions_count?: number | null + finished_at?: number | null + id: string + status?: string | null + total_steps?: number | null + total_tokens?: number | null + triggered_from?: string | null + version?: string | null +} + +export type WorkflowRunForArchivedLogResponse = { + elapsed_time?: number | null + id: string + status?: string | null + total_tokens?: number | null + triggered_from?: string | null +} + export type AgentScope = 'roster' | 'workflow_only' export type AgentStatus = 'active' | 'archived' @@ -2060,6 +2122,51 @@ export type AgentSoulToolsConfig = { dify_tools?: Array } +export type WorkflowAgentBindingType = 'inline_agent' | 'roster_agent' + +export type DeclaredArrayItem = { + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> + description?: string | null + type: DeclaredOutputType +} + +export type DeclaredOutputCheckConfig = { + benchmark_file_ref?: AgentFileRefConfig | null + enabled?: boolean + model_ref?: AgentSoulModelConfig | null + prompt?: string | null +} + +export type DeclaredOutputFailureStrategy = { + default_value?: unknown + on_failure?: OutputErrorStrategy + retry?: DeclaredOutputRetryConfig +} + +export type DeclaredOutputFileConfig = { + extensions?: Array + mime_types?: Array +} + export type AgentHumanContactConfig = { channel?: string | null contact_id?: string | null @@ -2174,139 +2281,60 @@ export type AgentComposerFileCandidateResponse = { [key: string]: unknown } -export type AgentModerationProviderConfig = { - api_based_extension_id?: string | null - inputs_config?: AgentModerationIoConfig | null - keywords?: string | null - outputs_config?: AgentModerationIoConfig | null - [key: string]: unknown -} - -export type AgentToolCallResponse = { - error?: string | null - status: string - time_cost: number | number - tool_icon?: unknown - tool_input: { - [key: string]: unknown - } - tool_label: string - tool_name: string - tool_output: { - [key: string]: unknown - } - tool_parameters: { - [key: string]: unknown - } -} - -export type EnvSuggestion = { - key: string - reason?: string - secret_likely?: boolean -} - -export type SimpleModelConfig = { - model_dict?: JsonValue | null - pre_prompt?: string | null -} - -export type StatusCount = { - failed: number - partial_success: number - paused: number - success: number -} - -export type SimpleMessageDetail = { - answer: string - inputs: { - [key: string]: JsonValue - } - message: string - query: string -} - -export type HumanInputFormDefinition = { - actions?: Array - display_in_ui?: boolean - expiration_time: number - form_content: string - form_id: string - form_token?: string | null - inputs?: Array - node_id: string - node_title: string - resolved_default_values?: { - [key: string]: unknown - } -} - -export type HumanInputFormSubmissionData = { - action_id: string - action_text: string - node_id: string - node_title: string - rendered_content: string - submitted_data?: { - [key: string]: JsonValue2 - } | null -} - -export type ExecutionContentType = 'human_input' - -export type WorkflowRunForLogResponse = { - created_at?: number | null - elapsed_time?: number | null - error?: string | null - exceptions_count?: number | null - finished_at?: number | null - id: string - status?: string | null - total_steps?: number | null - total_tokens?: number | null - triggered_from?: string | null - version?: string | null -} - -export type WorkflowRunForArchivedLogResponse = { - elapsed_time?: number | null - id: string - status?: string | null - total_tokens?: number | null - triggered_from?: string | null -} - -export type WorkflowAgentBindingType = 'inline_agent' | 'roster_agent' - -export type DeclaredArrayItem = { - description?: string | null - type: DeclaredOutputType -} - -export type DeclaredOutputCheckConfig = { - benchmark_file_ref?: AgentFileRefConfig | null - enabled?: boolean - model_ref?: AgentSoulModelConfig | null - prompt?: string | null -} - -export type DeclaredOutputFailureStrategy = { - default_value?: unknown - on_failure?: OutputErrorStrategy - retry?: DeclaredOutputRetryConfig -} - -export type DeclaredOutputFileConfig = { - extensions?: Array - mime_types?: Array -} - export type CheckResultView = { passed: boolean reason?: string | null } +export type UserActionConfig = { + button_style?: ButtonStyle + id: string + title: string +} + +export type FormInputConfig + = | ({ + type: 'paragraph' + } & ParagraphInputConfig) + | ({ + type: 'select' + } & SelectInputConfig) + | ({ + type: 'file' + } & FileInputConfig) + | ({ + type: 'file-list' + } & FileListInputConfig) + +export type JsonValue2 = unknown + +export type AgentFeatureToggleConfig = { + enabled?: boolean + [key: string]: unknown +} + +export type AgentSensitiveWordAvoidanceFeatureConfig = { + config?: AgentModerationProviderConfig | null + enabled?: boolean + type?: string | null + [key: string]: unknown +} + +export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = { + enabled?: boolean + model?: AgentSoulModelConfig | null + prompt?: string | null + [key: string]: unknown +} + +export type AgentTextToSpeechFeatureConfig = { + autoPlay?: string | null + enabled?: boolean + language?: string | null + voice?: string | null + [key: string]: unknown +} + export type AgentSecretRefConfig = { credential_id?: string | null env_name?: string | null @@ -2319,6 +2347,7 @@ export type AgentSecretRefConfig = { provider_credential_id?: string | null ref?: string | null type?: string | null + value?: string | null variable?: string | null [key: string]: unknown } @@ -2442,6 +2471,14 @@ export type AgentSoulDifyToolConfig = { tool_name?: string | null } +export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop' + +export type DeclaredOutputRetryConfig = { + enabled?: boolean + max_retries?: number + retry_interval_ms?: number +} + export type AgentCliToolAuthorizationStatus = | 'allowed' | 'authorized' @@ -2465,53 +2502,6 @@ export type AgentPermissionConfig = { export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown' -export type AgentModerationIoConfig = { - enabled?: boolean - preset_response?: string | null - [key: string]: unknown -} - -export type UserActionConfig = { - button_style?: ButtonStyle - id: string - title: string -} - -export type FormInputConfig - = | ({ - type: 'paragraph' - } & ParagraphInputConfig) - | ({ - type: 'select' - } & SelectInputConfig) - | ({ - type: 'file' - } & FileInputConfig) - | ({ - type: 'file-list' - } & FileListInputConfig) - -export type JsonValue2 = unknown - -export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop' - -export type DeclaredOutputRetryConfig = { - enabled?: boolean - max_retries?: number - retry_interval_ms?: number -} - -export type AgentModelResponseFormatConfig = { - type?: string | null - [key: string]: unknown -} - -export type AgentSoulDifyToolCredentialRef = { - id?: string | null - provider?: string | null - type?: 'provider' | 'tool' -} - export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' export type ParagraphInputConfig = { @@ -2543,6 +2533,25 @@ export type FileListInputConfig = { type?: 'file-list' } +export type AgentModerationProviderConfig = { + api_based_extension_id?: string | null + inputs_config?: AgentModerationIoConfig | null + keywords?: string | null + outputs_config?: AgentModerationIoConfig | null + [key: string]: unknown +} + +export type AgentModelResponseFormatConfig = { + type?: string | null + [key: string]: unknown +} + +export type AgentSoulDifyToolCredentialRef = { + id?: string | null + provider?: string | null + type?: 'provider' | 'tool' +} + export type StringSource = { selector?: Array type: ValueSourceType @@ -2559,12 +2568,27 @@ export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' +export type AgentModerationIoConfig = { + enabled?: boolean + preset_response?: string | null + [key: string]: unknown +} + export type ValueSourceType = 'constant' | 'variable' +export type AppPaginationWritable = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + export type AppDetailWithSiteWritable = { access_mode?: string | null + active_config_is_published?: boolean api_base_url?: string | null - app_model_config?: ModelConfig | null + app_id?: string | null bound_agent_id?: string | null created_at?: number | null created_by?: string | null @@ -2577,8 +2601,10 @@ export type AppDetailWithSiteWritable = { icon_type?: string | null id: string max_active_requests?: number | null - mode_compatible_with_agent: string + mode: string + model_config?: ModelConfig | null name: string + role?: string | null site?: SiteWritable | null tags?: Array tracing?: JsonValue | null @@ -2611,6 +2637,34 @@ export type WorkflowCommentDetailWritable = { updated_at?: number | null } +export type AppPartialWritable = { + access_mode?: string | null + active_config_is_published?: boolean + app_id?: string | null + author_name?: string | null + bound_agent_id?: string | null + create_user_name?: string | null + created_at?: number | null + created_by?: string | null + description?: string | null + has_draft_trigger?: boolean | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + id: string + is_starred?: boolean + max_active_requests?: number | null + mode: string + model_config?: ModelConfigPartial | null + name: string + role?: string | null + tags?: Array + updated_at?: number | null + updated_by?: string | null + use_icon_as_answer_icon?: boolean | null + workflow?: WorkflowPartial | null +} + export type SiteWritable = { chat_color_theme?: string | null chat_color_theme_inverted: boolean @@ -2683,6 +2737,7 @@ export type GetAppsData = { | 'workflow' name?: string page?: number + sort_by?: 'earliest_created' | 'last_modified' | 'recently_created' tag_ids?: Array } url: '/apps' @@ -2771,6 +2826,36 @@ export type PostAppsImportsByImportIdConfirmResponses = { export type PostAppsImportsByImportIdConfirmResponse = PostAppsImportsByImportIdConfirmResponses[keyof PostAppsImportsByImportIdConfirmResponses] +export type GetAppsStarredData = { + body?: never + path?: never + query?: { + creator_ids?: Array + is_created_by_me?: boolean + limit?: number + mode?: + | 'advanced-chat' + | 'agent' + | 'agent-chat' + | 'all' + | 'channel' + | 'chat' + | 'completion' + | 'workflow' + name?: string + page?: number + sort_by?: 'earliest_created' | 'last_modified' | 'recently_created' + tag_ids?: Array + } + url: '/apps/starred' +} + +export type GetAppsStarredResponses = { + 200: AppPagination +} + +export type GetAppsStarredResponse = GetAppsStarredResponses[keyof GetAppsStarredResponses] + export type PostAppsWorkflowsOnlineUsersData = { body: WorkflowOnlineUsersPayload path?: never @@ -2979,165 +3064,6 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses = { export type PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse = PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftRunResponses] -export type GetAppsByAppIdAgentComposerData = { - body?: never - path: { - app_id: string - } - query?: never - url: '/apps/{app_id}/agent-composer' -} - -export type GetAppsByAppIdAgentComposerResponses = { - 200: AgentAppComposerResponse -} - -export type GetAppsByAppIdAgentComposerResponse - = GetAppsByAppIdAgentComposerResponses[keyof GetAppsByAppIdAgentComposerResponses] - -export type PutAppsByAppIdAgentComposerData = { - body: ComposerSavePayload - path: { - app_id: string - } - query?: never - url: '/apps/{app_id}/agent-composer' -} - -export type PutAppsByAppIdAgentComposerResponses = { - 200: AgentAppComposerResponse -} - -export type PutAppsByAppIdAgentComposerResponse - = PutAppsByAppIdAgentComposerResponses[keyof PutAppsByAppIdAgentComposerResponses] - -export type GetAppsByAppIdAgentComposerCandidatesData = { - body?: never - path: { - app_id: string - } - query?: never - url: '/apps/{app_id}/agent-composer/candidates' -} - -export type GetAppsByAppIdAgentComposerCandidatesResponses = { - 200: AgentComposerCandidatesResponse -} - -export type GetAppsByAppIdAgentComposerCandidatesResponse - = GetAppsByAppIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdAgentComposerCandidatesResponses] - -export type PostAppsByAppIdAgentComposerValidateData = { - body: ComposerSavePayload - path: { - app_id: string - } - query?: never - url: '/apps/{app_id}/agent-composer/validate' -} - -export type PostAppsByAppIdAgentComposerValidateResponses = { - 200: AgentComposerValidateResponse -} - -export type PostAppsByAppIdAgentComposerValidateResponse - = PostAppsByAppIdAgentComposerValidateResponses[keyof PostAppsByAppIdAgentComposerValidateResponses] - -export type PostAppsByAppIdAgentFeaturesData = { - body: AgentAppFeaturesPayload - path: { - app_id: string - } - query?: never - url: '/apps/{app_id}/agent-features' -} - -export type PostAppsByAppIdAgentFeaturesErrors = { - 400: unknown - 404: unknown -} - -export type PostAppsByAppIdAgentFeaturesResponses = { - 200: SimpleResultResponse -} - -export type PostAppsByAppIdAgentFeaturesResponse - = PostAppsByAppIdAgentFeaturesResponses[keyof PostAppsByAppIdAgentFeaturesResponses] - -export type GetAppsByAppIdAgentReferencingWorkflowsData = { - body?: never - path: { - app_id: string - } - query?: never - url: '/apps/{app_id}/agent-referencing-workflows' -} - -export type GetAppsByAppIdAgentReferencingWorkflowsErrors = { - 404: unknown -} - -export type GetAppsByAppIdAgentReferencingWorkflowsResponses = { - 200: AgentReferencingWorkflowsResponse -} - -export type GetAppsByAppIdAgentReferencingWorkflowsResponse - = GetAppsByAppIdAgentReferencingWorkflowsResponses[keyof GetAppsByAppIdAgentReferencingWorkflowsResponses] - -export type GetAppsByAppIdAgentSandboxFilesData = { - body?: never - path: { - app_id: string - } - query: { - conversation_id: string - path?: string - } - url: '/apps/{app_id}/agent-sandbox/files' -} - -export type GetAppsByAppIdAgentSandboxFilesResponses = { - 200: SandboxListResponse -} - -export type GetAppsByAppIdAgentSandboxFilesResponse - = GetAppsByAppIdAgentSandboxFilesResponses[keyof GetAppsByAppIdAgentSandboxFilesResponses] - -export type GetAppsByAppIdAgentSandboxFilesReadData = { - body?: never - path: { - app_id: string - } - query: { - conversation_id: string - path: string - } - url: '/apps/{app_id}/agent-sandbox/files/read' -} - -export type GetAppsByAppIdAgentSandboxFilesReadResponses = { - 200: SandboxReadResponse -} - -export type GetAppsByAppIdAgentSandboxFilesReadResponse - = GetAppsByAppIdAgentSandboxFilesReadResponses[keyof GetAppsByAppIdAgentSandboxFilesReadResponses] - -export type PostAppsByAppIdAgentSandboxFilesUploadData = { - body: AgentSandboxUploadPayload - path: { - app_id: string - } - query?: never - url: '/apps/{app_id}/agent-sandbox/files/upload' -} - -export type PostAppsByAppIdAgentSandboxFilesUploadResponses = { - 200: SandboxUploadResponse -} - -export type PostAppsByAppIdAgentSandboxFilesUploadResponse - = PostAppsByAppIdAgentSandboxFilesUploadResponses[keyof PostAppsByAppIdAgentSandboxFilesUploadResponses] - export type GetAppsByAppIdAgentDriveFilesData = { body?: never path: { @@ -4250,6 +4176,46 @@ export type PostAppsByAppIdSiteAccessTokenResetResponses = { export type PostAppsByAppIdSiteAccessTokenResetResponse = PostAppsByAppIdSiteAccessTokenResetResponses[keyof PostAppsByAppIdSiteAccessTokenResetResponses] +export type DeleteAppsByAppIdStarData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/star' +} + +export type DeleteAppsByAppIdStarErrors = { + 404: unknown +} + +export type DeleteAppsByAppIdStarResponses = { + 200: SimpleResultResponse +} + +export type DeleteAppsByAppIdStarResponse + = DeleteAppsByAppIdStarResponses[keyof DeleteAppsByAppIdStarResponses] + +export type PostAppsByAppIdStarData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/star' +} + +export type PostAppsByAppIdStarErrors = { + 404: unknown +} + +export type PostAppsByAppIdStarResponses = { + 200: SimpleResultResponse +} + +export type PostAppsByAppIdStarResponse + = PostAppsByAppIdStarResponses[keyof PostAppsByAppIdStarResponses] + export type GetAppsByAppIdStatisticsAverageResponseTimeData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 397fcd5cce0..475823246b7 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -98,32 +98,6 @@ export const zAdvancedChatWorkflowRunPayload = z.object({ query: z.string().optional().default(''), }) -/** - * SimpleResultResponse - */ -export const zSimpleResultResponse = z.object({ - result: z.string(), -}) - -/** - * SandboxReadResponse - */ -export const zSandboxReadResponse = z.object({ - binary: z.boolean(), - path: z.string(), - size: z.int().nullish(), - text: z.string().nullish(), - truncated: z.boolean(), -}) - -/** - * AgentSandboxUploadPayload - */ -export const zAgentSandboxUploadPayload = z.object({ - conversation_id: z.string().min(1), - path: z.string().min(1), -}) - /** * AgentDriveDownloadResponse */ @@ -262,6 +236,13 @@ export const zSuggestedQuestionsResponse = z.object({ data: z.array(z.string()), }) +/** + * SimpleResultResponse + */ +export const zSimpleResultResponse = z.object({ + result: z.string(), +}) + /** * CompletionMessagePayload */ @@ -511,6 +492,17 @@ export const zWorkflowRunExportResponse = z.object({ status: z.string(), }) +/** + * SandboxReadResponse + */ +export const zSandboxReadResponse = z.object({ + binary: z.boolean(), + path: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + /** * WorkflowAgentSandboxUploadPayload */ @@ -797,7 +789,7 @@ export const zCreateAppPayload = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: zIconType.nullish(), - mode: z.enum(['advanced-chat', 'agent', 'agent-chat', 'chat', 'completion', 'workflow']), + mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']), name: z.string().min(1), }) @@ -917,166 +909,6 @@ export const zImport = z.object({ status: zImportStatus, }) -/** - * AgentConfigSnapshotSummaryResponse - */ -export const zAgentConfigSnapshotSummaryResponse = z.object({ - agent_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - id: z.string(), - summary: z.string().nullish(), - version: z.int(), - version_note: z.string().nullish(), -}) - -/** - * ComposerSaveStrategy - */ -export const zComposerSaveStrategy = z.enum([ - 'node_job_only', - 'save_as_new_agent', - 'save_as_new_version', - 'save_to_current_version', - 'save_to_roster', -]) - -/** - * ComposerBindingPayload - */ -export const zComposerBindingPayload = z.object({ - agent_id: z.string().nullish(), - binding_type: z.enum(['inline_agent', 'roster_agent']), - current_snapshot_id: z.string().nullish(), -}) - -/** - * ComposerSoulLockPayload - */ -export const zComposerSoulLockPayload = z.object({ - locked: z.boolean().optional().default(true), - unlocked_from_version_id: z.string().nullish(), -}) - -/** - * ComposerVariant - */ -export const zComposerVariant = z.enum(['agent_app', 'workflow']) - -/** - * ComposerCandidateCapabilities - */ -export const zComposerCandidateCapabilities = z.object({ - human_roster_available: z.boolean().optional().default(false), -}) - -/** - * ComposerKnowledgePlaceholderResponse - */ -export const zComposerKnowledgePlaceholderResponse = z.object({ - id: z.string(), - placeholder_name: z.string(), -}) - -/** - * ComposerValidationWarningResponse - */ -export const zComposerValidationWarningResponse = z.object({ - code: z.string(), - id: z.string().nullish(), - kind: z.string().nullish(), - message: z.string().nullish(), - surface: z.string().nullish(), -}) - -/** - * AgentComposerValidateResponse - */ -export const zAgentComposerValidateResponse = z.object({ - errors: z.array(z.string()).optional(), - knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), - result: z.literal('success'), - warnings: z.array(zComposerValidationWarningResponse).optional(), -}) - -/** - * ComposerValidationFindingsResponse - */ -export const zComposerValidationFindingsResponse = z.object({ - knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), - warnings: z.array(zComposerValidationWarningResponse).optional(), -}) - -/** - * AgentFeatureToggleConfig - */ -export const zAgentFeatureToggleConfig = z.object({ - enabled: z.boolean().optional().default(false), -}) - -/** - * AgentTextToSpeechFeatureConfig - */ -export const zAgentTextToSpeechFeatureConfig = z.object({ - autoPlay: z.string().nullish(), - enabled: z.boolean().optional().default(false), - language: z.string().nullish(), - voice: z.string().nullish(), -}) - -/** - * AgentReferencingWorkflowResponse - */ -export const zAgentReferencingWorkflowResponse = z.object({ - app_id: z.string(), - app_mode: z.string(), - app_name: z.string(), - node_ids: z.array(z.string()).optional(), - workflow_id: z.string(), -}) - -/** - * AgentReferencingWorkflowsResponse - */ -export const zAgentReferencingWorkflowsResponse = z.object({ - data: z.array(zAgentReferencingWorkflowResponse).optional(), -}) - -/** - * SandboxFileEntryResponse - */ -export const zSandboxFileEntryResponse = z.object({ - mtime: z.int().nullish(), - name: z.string(), - size: z.int().nullish(), - type: z.enum(['dir', 'file', 'other', 'symlink']), -}) - -/** - * SandboxListResponse - */ -export const zSandboxListResponse = z.object({ - entries: z.array(zSandboxFileEntryResponse).optional(), - path: z.string(), - truncated: z.boolean().optional().default(false), -}) - -/** - * SandboxToolFileResponse - */ -export const zSandboxToolFileResponse = z.object({ - reference: z.string(), - transfer_method: z.literal('tool_file').optional().default('tool_file'), -}) - -/** - * SandboxUploadResponse - */ -export const zSandboxUploadResponse = z.object({ - file: zSandboxToolFileResponse, - path: z.string(), -}) - /** * AgentDriveItemResponse */ @@ -1614,6 +1446,41 @@ export const zWorkflowRunNodeExecutionListResponse = z.object({ data: z.array(zWorkflowRunNodeExecutionResponse), }) +/** + * SandboxFileEntryResponse + */ +export const zSandboxFileEntryResponse = z.object({ + mtime: z.int().nullish(), + name: z.string(), + size: z.int().nullish(), + type: z.enum(['dir', 'file', 'other', 'symlink']), +}) + +/** + * SandboxListResponse + */ +export const zSandboxListResponse = z.object({ + entries: z.array(zSandboxFileEntryResponse).optional(), + path: z.string(), + truncated: z.boolean().optional().default(false), +}) + +/** + * SandboxToolFileResponse + */ +export const zSandboxToolFileResponse = z.object({ + reference: z.string(), + transfer_method: z.literal('tool_file').optional().default('tool_file'), +}) + +/** + * SandboxUploadResponse + */ +export const zSandboxUploadResponse = z.object({ + file: zSandboxToolFileResponse, + path: z.string(), +}) + /** * AccountWithRole */ @@ -1870,6 +1737,30 @@ export const zEnvironmentVariableListResponse = z.object({ items: z.array(zEnvironmentVariableItemResponse), }) +/** + * AgentConfigSnapshotSummaryResponse + */ +export const zAgentConfigSnapshotSummaryResponse = z.object({ + agent_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + id: z.string(), + summary: z.string().nullish(), + version: z.int(), + version_note: z.string().nullish(), +}) + +/** + * ComposerSaveStrategy + */ +export const zComposerSaveStrategy = z.enum([ + 'node_job_only', + 'save_as_new_agent', + 'save_as_new_version', + 'save_to_current_version', + 'save_to_roster', +]) + /** * AgentComposerSoulLockResponse */ @@ -1879,6 +1770,35 @@ export const zAgentComposerSoulLockResponse = z.object({ reason: z.string().nullish(), }) +/** + * ComposerBindingPayload + */ +export const zComposerBindingPayload = z.object({ + agent_id: z.string().nullish(), + binding_type: z.enum(['inline_agent', 'roster_agent']), + current_snapshot_id: z.string().nullish(), +}) + +/** + * ComposerSoulLockPayload + */ +export const zComposerSoulLockPayload = z.object({ + locked: z.boolean().optional().default(true), + unlocked_from_version_id: z.string().nullish(), +}) + +/** + * ComposerVariant + */ +export const zComposerVariant = z.enum(['agent_app', 'workflow']) + +/** + * ComposerCandidateCapabilities + */ +export const zComposerCandidateCapabilities = z.object({ + human_roster_available: z.boolean().optional().default(false), +}) + /** * AgentComposerImpactBindingResponse */ @@ -1897,6 +1817,43 @@ export const zAgentComposerImpactResponse = z.object({ workflow_node_count: z.int(), }) +/** + * ComposerKnowledgePlaceholderResponse + */ +export const zComposerKnowledgePlaceholderResponse = z.object({ + id: z.string(), + placeholder_name: z.string(), +}) + +/** + * ComposerValidationWarningResponse + */ +export const zComposerValidationWarningResponse = z.object({ + code: z.string(), + id: z.string().nullish(), + kind: z.string().nullish(), + message: z.string().nullish(), + surface: z.string().nullish(), +}) + +/** + * AgentComposerValidateResponse + */ +export const zAgentComposerValidateResponse = z.object({ + errors: z.array(z.string()).optional(), + knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), + result: z.literal('success'), + warnings: z.array(zComposerValidationWarningResponse).optional(), +}) + +/** + * ComposerValidationFindingsResponse + */ +export const zComposerValidationFindingsResponse = z.object({ + knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(), + warnings: z.array(zComposerValidationWarningResponse).optional(), +}) + /** * WorkflowExecutionStatus */ @@ -1978,7 +1935,7 @@ export const zWorkflowDraftVariableListWithoutValue = z.object({ export const zModelConfigPartial = z.object({ created_at: z.int().nullish(), created_by: z.string().nullish(), - model_dict: zJsonValue.nullish(), + model: zJsonValue.nullish(), pre_prompt: z.string().nullish(), updated_at: z.int().nullish(), updated_by: z.string().nullish(), @@ -1989,20 +1946,26 @@ export const zModelConfigPartial = z.object({ */ export const zAppPartial = z.object({ access_mode: z.string().nullish(), - app_model_config: zModelConfigPartial.nullish(), + active_config_is_published: z.boolean().optional().default(false), + app_id: z.string().nullish(), author_name: z.string().nullish(), + bound_agent_id: z.string().nullish(), create_user_name: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), - desc_or_prompt: z.string().nullish(), + description: z.string().nullish(), has_draft_trigger: z.boolean().nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), + icon_url: z.string().nullable(), id: z.string(), + is_starred: z.boolean().optional().default(false), max_active_requests: z.int().nullish(), - mode_compatible_with_agent: z.string(), + mode: z.string(), + model_config: zModelConfigPartial.nullish(), name: z.string(), + role: z.string().nullish(), tags: z.array(zTag).optional(), updated_at: z.int().nullish(), updated_by: z.string().nullish(), @@ -2014,10 +1977,10 @@ export const zAppPartial = z.object({ * AppPagination */ export const zAppPagination = z.object({ - has_next: z.boolean(), - items: z.array(zAppPartial), + data: z.array(zAppPartial), + has_more: z.boolean(), + limit: z.int(), page: z.int(), - per_page: z.int(), total: z.int(), }) @@ -2043,8 +2006,9 @@ export const zModelConfig = z.object({ */ export const zAppDetailWithSite = z.object({ access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), api_base_url: z.string().nullish(), - app_model_config: zModelConfig.nullish(), + app_id: z.string().nullish(), bound_agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), @@ -2055,10 +2019,13 @@ export const zAppDetailWithSite = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), + icon_url: z.string().nullable(), id: z.string(), max_active_requests: z.int().nullish(), - mode_compatible_with_agent: z.string(), + mode: z.string(), + model_config: zModelConfig.nullish(), name: z.string(), + role: z.string().nullish(), site: zSite.nullish(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), @@ -2196,154 +2163,6 @@ export const zWorkflowOnlineUsersResponse = z.object({ data: z.array(zWorkflowOnlineUsersByApp), }) -/** - * AgentScope - * - * Visibility and lifecycle scope of an Agent record. - */ -export const zAgentScope = z.enum(['roster', 'workflow_only']) - -/** - * AgentStatus - * - * Soft lifecycle state for Agent records. - */ -export const zAgentStatus = z.enum(['active', 'archived']) - -/** - * AgentComposerAgentResponse - */ -export const zAgentComposerAgentResponse = z.object({ - active_config_snapshot_id: z.string().nullish(), - description: z.string(), - id: z.string(), - name: z.string(), - scope: zAgentScope, - status: zAgentStatus, -}) - -/** - * AppVariableConfig - */ -export const zAppVariableConfig = z.object({ - default: z.unknown().optional(), - name: z.string().min(1).max(255), - required: z.boolean().optional().default(false), - type: z.string().min(1).max(64), -}) - -/** - * AgentSoulPromptConfig - */ -export const zAgentSoulPromptConfig = z.object({ - system_prompt: z.string().optional().default(''), -}) - -/** - * AgentHumanContactConfig - */ -export const zAgentHumanContactConfig = z.object({ - channel: z.string().max(64).nullish(), - contact_id: z.string().max(255).nullish(), - contact_method: z.string().max(64).nullish(), - email: z.string().max(255).nullish(), - human_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - method: z.string().max(64).nullish(), - name: z.string().max(255).nullish(), - tenant_id: z.string().max(255).nullish(), -}) - -/** - * WorkflowNodeJobMode - */ -export const zWorkflowNodeJobMode = z.enum(['let_agent_figure_it_out', 'tell_agent_what_to_do']) - -/** - * WorkflowPreviousNodeOutputRef - */ -export const zWorkflowPreviousNodeOutputRef = z.object({ - key: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - node_id: z.string().max(255).nullish(), - output: z.string().max(255).nullish(), - selector: z.array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])).nullish(), - value_selector: z - .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) - .nullish(), - variable: z.string().max(255).nullish(), - variable_selector: z - .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) - .nullish(), -}) - -/** - * AgentComposerNodeJobCandidatesResponse - */ -export const zAgentComposerNodeJobCandidatesResponse = z.object({ - declare_output_types: z.array(zDeclaredOutputType).optional(), - human_contacts: z.array(zAgentHumanContactConfig).optional(), - previous_node_outputs: z.array(zWorkflowPreviousNodeOutputRef).optional(), -}) - -/** - * AgentComposerDifyToolCandidateResponse - */ -export const zAgentComposerDifyToolCandidateResponse = z.object({ - description: z.string().nullish(), - granularity: z.string().nullish(), - id: z.string().nullish(), - name: z.string().nullish(), - plugin_id: z.string().nullish(), - provider: z.string().nullish(), - provider_id: z.string().nullish(), - tools_count: z.int().nullish(), -}) - -/** - * AgentKnowledgeDatasetConfig - */ -export const zAgentKnowledgeDatasetConfig = z.object({ - description: z.string().nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), -}) - -/** - * AgentComposerSkillCandidateResponse - */ -export const zAgentComposerSkillCandidateResponse = z.object({ - description: z.string().nullish(), - file_id: z.string().max(255).nullish(), - full_archive_file_id: z.string().max(255).nullish(), - full_archive_key: z.string().max(512).nullish(), - id: z.string().max(255).nullish(), - kind: z.literal('skill').optional().default('skill'), - manifest_files: z.array(z.string()).nullish(), - name: z.string().max(255).nullish(), - path: z.string().nullish(), - skill_md_file_id: z.string().max(255).nullish(), - skill_md_key: z.string().max(512).nullish(), -}) - -/** - * AgentComposerFileCandidateResponse - */ -export const zAgentComposerFileCandidateResponse = z.object({ - drive_key: z.string().max(512).nullish(), - file_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - kind: z.literal('file').optional().default('file'), - name: z.string().max(255).nullish(), - reference: z.string().max(255).nullish(), - remote_url: z.string().nullish(), - tenant_id: z.string().max(255).nullish(), - transfer_method: z.string().max(64).nullish(), - type: z.string().max(64).nullish(), - upload_file_id: z.string().max(255).nullish(), - url: z.string().nullish(), -}) - /** * AgentToolCallResponse */ @@ -2586,6 +2405,49 @@ export const zWorkflowArchivedLogPaginationResponse = z.object({ total: z.int(), }) +/** + * AgentScope + * + * Visibility and lifecycle scope of an Agent record. + */ +export const zAgentScope = z.enum(['roster', 'workflow_only']) + +/** + * AgentStatus + * + * Soft lifecycle state for Agent records. + */ +export const zAgentStatus = z.enum(['active', 'archived']) + +/** + * AgentComposerAgentResponse + */ +export const zAgentComposerAgentResponse = z.object({ + active_config_snapshot_id: z.string().nullish(), + description: z.string(), + id: z.string(), + name: z.string(), + scope: zAgentScope, + status: zAgentStatus, +}) + +/** + * AppVariableConfig + */ +export const zAppVariableConfig = z.object({ + default: z.unknown().optional(), + name: z.string().min(1).max(255), + required: z.boolean().optional().default(false), + type: z.string().min(1).max(64), +}) + +/** + * AgentSoulPromptConfig + */ +export const zAgentSoulPromptConfig = z.object({ + system_prompt: z.string().optional().default(''), +}) + /** * WorkflowAgentBindingType * @@ -2615,6 +2477,25 @@ export const zAgentComposerBindingResponse = z.object({ * about. Stage 4 §4.2. */ export const zDeclaredArrayItem = z.object({ + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), description: z.string().nullish(), type: zDeclaredOutputType, }) @@ -2629,6 +2510,111 @@ export const zDeclaredOutputFileConfig = z.object({ mime_types: z.array(z.string()).optional(), }) +/** + * AgentHumanContactConfig + */ +export const zAgentHumanContactConfig = z.object({ + channel: z.string().max(64).nullish(), + contact_id: z.string().max(255).nullish(), + contact_method: z.string().max(64).nullish(), + email: z.string().max(255).nullish(), + human_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + method: z.string().max(64).nullish(), + name: z.string().max(255).nullish(), + tenant_id: z.string().max(255).nullish(), +}) + +/** + * WorkflowNodeJobMode + */ +export const zWorkflowNodeJobMode = z.enum(['let_agent_figure_it_out', 'tell_agent_what_to_do']) + +/** + * WorkflowPreviousNodeOutputRef + */ +export const zWorkflowPreviousNodeOutputRef = z.object({ + key: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), + node_id: z.string().max(255).nullish(), + output: z.string().max(255).nullish(), + selector: z.array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])).nullish(), + value_selector: z + .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) + .nullish(), + variable: z.string().max(255).nullish(), + variable_selector: z + .array(z.union([z.string(), z.int(), z.number(), z.boolean(), z.null()])) + .nullish(), +}) + +/** + * AgentComposerNodeJobCandidatesResponse + */ +export const zAgentComposerNodeJobCandidatesResponse = z.object({ + declare_output_types: z.array(zDeclaredOutputType).optional(), + human_contacts: z.array(zAgentHumanContactConfig).optional(), + previous_node_outputs: z.array(zWorkflowPreviousNodeOutputRef).optional(), +}) + +/** + * AgentComposerDifyToolCandidateResponse + */ +export const zAgentComposerDifyToolCandidateResponse = z.object({ + description: z.string().nullish(), + granularity: z.string().nullish(), + id: z.string().nullish(), + name: z.string().nullish(), + plugin_id: z.string().nullish(), + provider: z.string().nullish(), + provider_id: z.string().nullish(), + tools_count: z.int().nullish(), +}) + +/** + * AgentKnowledgeDatasetConfig + */ +export const zAgentKnowledgeDatasetConfig = z.object({ + description: z.string().nullish(), + id: z.string().max(255).nullish(), + name: z.string().max(255).nullish(), +}) + +/** + * AgentComposerSkillCandidateResponse + */ +export const zAgentComposerSkillCandidateResponse = z.object({ + description: z.string().nullish(), + file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), + id: z.string().max(255).nullish(), + kind: z.literal('skill').optional().default('skill'), + manifest_files: z.array(z.string()).nullish(), + name: z.string().max(255).nullish(), + path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), +}) + +/** + * AgentComposerFileCandidateResponse + */ +export const zAgentComposerFileCandidateResponse = z.object({ + drive_key: z.string().max(512).nullish(), + file_id: z.string().max(255).nullish(), + id: z.string().max(255).nullish(), + kind: z.literal('file').optional().default('file'), + name: z.string().max(255).nullish(), + reference: z.string().max(255).nullish(), + remote_url: z.string().nullish(), + tenant_id: z.string().max(255).nullish(), + transfer_method: z.string().max(64).nullish(), + type: z.string().max(64).nullish(), + upload_file_id: z.string().max(255).nullish(), + url: z.string().nullish(), +}) + /** * CheckResultView * @@ -2674,6 +2660,37 @@ export const zWorkflowRunSnapshotView = z.object({ workflow_run_status: zWorkflowExecutionStatus, }) +export const zJsonValue2 = z.unknown() + +/** + * HumanInputFormSubmissionData + */ +export const zHumanInputFormSubmissionData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), zJsonValue2).nullish(), +}) + +/** + * AgentFeatureToggleConfig + */ +export const zAgentFeatureToggleConfig = z.object({ + enabled: z.boolean().optional().default(false), +}) + +/** + * AgentTextToSpeechFeatureConfig + */ +export const zAgentTextToSpeechFeatureConfig = z.object({ + autoPlay: z.string().nullish(), + enabled: z.boolean().optional().default(false), + language: z.string().nullish(), + voice: z.string().nullish(), +}) + /** * AgentEnvVariableConfig */ @@ -2832,6 +2849,42 @@ export const zWorkflowNodeJobMetadata = z.object({ file_refs: z.array(zAgentFileRefConfig).nullish(), }) +/** + * OutputErrorStrategy + * + * Per-output failure handling strategy. + * + * Mirrors ``graphon.ErrorStrategy`` but scoped to a single declared output of + * a Workflow Agent Node. The runtime applies the strategy after type check or + * output check fails and any configured retry attempts have been exhausted. + */ +export const zOutputErrorStrategy = z.enum(['default_value', 'fail_branch', 'stop']) + +/** + * DeclaredOutputRetryConfig + * + * Per-output retry configuration that mirrors ``graphon.RetryConfig`` shape. + */ +export const zDeclaredOutputRetryConfig = z.object({ + enabled: z.boolean().optional().default(false), + max_retries: z.int().gte(0).lte(10).optional().default(0), + retry_interval_ms: z.int().gte(0).lte(60000).optional().default(0), +}) + +/** + * DeclaredOutputFailureStrategy + * + * Per-output failure handling. + * + * A single strategy applies to both ``type_check`` and ``output_check`` failures + * (PRD does not distinguish them at the UX level). Stage 4 §4.4. + */ +export const zDeclaredOutputFailureStrategy = z.object({ + default_value: z.unknown().optional(), + on_failure: zOutputErrorStrategy.optional().default('stop'), + retry: zDeclaredOutputRetryConfig.optional(), +}) + /** * AgentCliToolAuthorizationStatus * @@ -2876,6 +2929,7 @@ export const zAgentSecretRefConfig = z.object({ provider_credential_id: z.string().max(255).nullish(), ref: z.string().max(255).nullish(), type: z.string().max(64).nullish(), + value: z.string().max(255).nullish(), variable: z.string().max(255).nullish(), }) @@ -2971,80 +3025,21 @@ export const zAgentComposerCandidatesResponse = z.object({ }) /** - * AgentModerationIOConfig - */ -export const zAgentModerationIoConfig = z.object({ - enabled: z.boolean().optional().default(false), - preset_response: z.string().nullish(), -}) - -/** - * AgentModerationProviderConfig - */ -export const zAgentModerationProviderConfig = z.object({ - api_based_extension_id: z.string().nullish(), - inputs_config: zAgentModerationIoConfig.nullish(), - keywords: z.string().nullish(), - outputs_config: zAgentModerationIoConfig.nullish(), -}) - -/** - * AgentSensitiveWordAvoidanceFeatureConfig - */ -export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ - config: zAgentModerationProviderConfig.nullish(), - enabled: z.boolean().optional().default(false), - type: z.string().nullish(), -}) - -export const zJsonValue2 = z.unknown() - -/** - * HumanInputFormSubmissionData - */ -export const zHumanInputFormSubmissionData = z.object({ - action_id: z.string(), - action_text: z.string(), - node_id: z.string(), - node_title: z.string(), - rendered_content: z.string(), - submitted_data: z.record(z.string(), zJsonValue2).nullish(), -}) - -/** - * OutputErrorStrategy + * ButtonStyle * - * Per-output failure handling strategy. - * - * Mirrors ``graphon.ErrorStrategy`` but scoped to a single declared output of - * a Workflow Agent Node. The runtime applies the strategy after type check or - * output check fails and any configured retry attempts have been exhausted. + * Button styles for user actions. */ -export const zOutputErrorStrategy = z.enum(['default_value', 'fail_branch', 'stop']) +export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) /** - * DeclaredOutputRetryConfig + * UserActionConfig * - * Per-output retry configuration that mirrors ``graphon.RetryConfig`` shape. + * User action configuration. */ -export const zDeclaredOutputRetryConfig = z.object({ - enabled: z.boolean().optional().default(false), - max_retries: z.int().gte(0).lte(10).optional().default(0), - retry_interval_ms: z.int().gte(0).lte(60000).optional().default(0), -}) - -/** - * DeclaredOutputFailureStrategy - * - * Per-output failure handling. - * - * A single strategy applies to both ``type_check`` and ``output_check`` failures - * (PRD does not distinguish them at the UX level). Stage 4 §4.4. - */ -export const zDeclaredOutputFailureStrategy = z.object({ - default_value: z.unknown().optional(), - on_failure: zOutputErrorStrategy.optional().default('stop'), - retry: zDeclaredOutputRetryConfig.optional(), +export const zUserActionConfig = z.object({ + button_style: zButtonStyle.optional().default('default'), + id: z.string().max(20), + title: z.string().max(100), }) /** @@ -3080,46 +3075,6 @@ export const zAgentSoulModelConfig = z.object({ plugin_id: z.string().min(1).max(255), }) -/** - * AgentSuggestedQuestionsAfterAnswerFeatureConfig - */ -export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ - enabled: z.boolean().optional().default(false), - model: zAgentSoulModelConfig.nullish(), - prompt: z.string().nullish(), -}) - -/** - * AgentAppFeaturesPayload - * - * Presentation features configurable on an Agent App. - * - * All fields are optional; an omitted field is reset to its disabled/empty - * default (the config form sends the full desired feature state on save). - */ -export const zAgentAppFeaturesPayload = z.object({ - opening_statement: z.string().nullish(), - retriever_resource: zAgentFeatureToggleConfig.nullish(), - sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), - speech_to_text: zAgentFeatureToggleConfig.nullish(), - suggested_questions: z.array(z.string()).nullish(), - suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), - text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), -}) - -/** - * AgentSoulAppFeaturesConfig - */ -export const zAgentSoulAppFeaturesConfig = z.object({ - opening_statement: z.string().nullish(), - retriever_resource: zAgentFeatureToggleConfig.nullish(), - sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), - speech_to_text: zAgentFeatureToggleConfig.nullish(), - suggested_questions: z.array(z.string()).nullish(), - suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), - text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), -}) - /** * DeclaredOutputCheckConfig * @@ -3146,6 +3101,25 @@ export const zDeclaredOutputCheckConfig = z.object({ export const zDeclaredOutputConfig = z.object({ array_item: zDeclaredArrayItem.nullish(), check: zDeclaredOutputCheckConfig.nullish(), + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), description: z.string().nullish(), failure_strategy: zDeclaredOutputFailureStrategy.optional(), file: zDeclaredOutputFileConfig.nullish(), @@ -3168,6 +3142,15 @@ export const zWorkflowNodeJobConfig = z.object({ workflow_prompt: z.string().optional().default(''), }) +/** + * AgentSuggestedQuestionsAfterAnswerFeatureConfig + */ +export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ + enabled: z.boolean().optional().default(false), + model: zAgentSoulModelConfig.nullish(), + prompt: z.string().nullish(), +}) + /** * AgentSoulDifyToolCredentialRef * @@ -3231,91 +3214,6 @@ export const zAgentSoulToolsConfig = z.object({ dify_tools: z.array(zAgentSoulDifyToolConfig).optional(), }) -/** - * AgentSoulConfig - */ -export const zAgentSoulConfig = z.object({ - app_features: zAgentSoulAppFeaturesConfig.optional(), - app_variables: z.array(zAppVariableConfig).optional(), - env: zAgentSoulEnvConfig.optional(), - human: zAgentSoulHumanConfig.optional(), - knowledge: zAgentSoulKnowledgeConfig.optional(), - memory: zAgentSoulMemoryConfig.optional(), - misc_legacy: zAgentSoulAppFeaturesConfig.optional(), - model: zAgentSoulModelConfig.nullish(), - prompt: zAgentSoulPromptConfig.optional(), - sandbox: zAgentSoulSandboxConfig.optional(), - schema_version: z.int().optional().default(1), - skills_files: zAgentSoulSkillsFilesConfig.optional(), - tools: zAgentSoulToolsConfig.optional(), -}) - -/** - * AgentAppComposerResponse - */ -export const zAgentAppComposerResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse, - agent: zAgentComposerAgentResponse, - agent_soul: zAgentSoulConfig, - save_options: z.array(zComposerSaveStrategy), - validation: zComposerValidationFindingsResponse.nullish(), - variant: z.literal('agent_app'), -}) - -/** - * ComposerSavePayload - */ -export const zComposerSavePayload = z.object({ - agent_soul: zAgentSoulConfig.nullish(), - binding: zComposerBindingPayload.nullish(), - client_revision_id: z.string().nullish(), - idempotency_key: z.string().nullish(), - new_agent_name: z.string().min(1).max(255).nullish(), - node_job: zWorkflowNodeJobConfig.nullish(), - save_strategy: zComposerSaveStrategy, - soul_lock: zComposerSoulLockPayload.optional(), - variant: zComposerVariant, - version_note: z.string().nullish(), -}) - -/** - * WorkflowAgentComposerResponse - */ -export const zWorkflowAgentComposerResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), - agent: zAgentComposerAgentResponse.nullish(), - agent_soul: zAgentSoulConfig, - app_id: z.string().nullish(), - binding: zAgentComposerBindingResponse.nullish(), - effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(), - impact_summary: zAgentComposerImpactResponse.nullish(), - node_id: z.string().nullish(), - node_job: zWorkflowNodeJobConfig, - save_options: z.array(zComposerSaveStrategy), - soul_lock: zAgentComposerSoulLockResponse, - validation: zComposerValidationFindingsResponse.nullish(), - variant: z.literal('workflow'), - workflow_id: z.string().nullish(), -}) - -/** - * ButtonStyle - * - * Button styles for user actions. - */ -export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) - -/** - * UserActionConfig - * - * User action configuration. - */ -export const zUserActionConfig = z.object({ - button_style: zButtonStyle.optional().default('default'), - id: z.string().max(20), - title: z.string().max(100), -}) - /** * FileType */ @@ -3354,6 +3252,101 @@ export const zFileListInputConfig = z.object({ type: z.literal('file-list').optional().default('file-list'), }) +/** + * AgentModerationIOConfig + */ +export const zAgentModerationIoConfig = z.object({ + enabled: z.boolean().optional().default(false), + preset_response: z.string().nullish(), +}) + +/** + * AgentModerationProviderConfig + */ +export const zAgentModerationProviderConfig = z.object({ + api_based_extension_id: z.string().nullish(), + inputs_config: zAgentModerationIoConfig.nullish(), + keywords: z.string().nullish(), + outputs_config: zAgentModerationIoConfig.nullish(), +}) + +/** + * AgentSensitiveWordAvoidanceFeatureConfig + */ +export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ + config: zAgentModerationProviderConfig.nullish(), + enabled: z.boolean().optional().default(false), + type: z.string().nullish(), +}) + +/** + * AgentSoulAppFeaturesConfig + */ +export const zAgentSoulAppFeaturesConfig = z.object({ + opening_statement: z.string().nullish(), + retriever_resource: zAgentFeatureToggleConfig.nullish(), + sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), + speech_to_text: zAgentFeatureToggleConfig.nullish(), + suggested_questions: z.array(z.string()).nullish(), + suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), + text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), +}) + +/** + * AgentSoulConfig + */ +export const zAgentSoulConfig = z.object({ + app_features: zAgentSoulAppFeaturesConfig.optional(), + app_variables: z.array(zAppVariableConfig).optional(), + env: zAgentSoulEnvConfig.optional(), + human: zAgentSoulHumanConfig.optional(), + knowledge: zAgentSoulKnowledgeConfig.optional(), + memory: zAgentSoulMemoryConfig.optional(), + misc_legacy: zAgentSoulAppFeaturesConfig.optional(), + model: zAgentSoulModelConfig.nullish(), + prompt: zAgentSoulPromptConfig.optional(), + sandbox: zAgentSoulSandboxConfig.optional(), + schema_version: z.int().optional().default(1), + skills_files: zAgentSoulSkillsFilesConfig.optional(), + tools: zAgentSoulToolsConfig.optional(), +}) + +/** + * WorkflowAgentComposerResponse + */ +export const zWorkflowAgentComposerResponse = z.object({ + active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), + agent: zAgentComposerAgentResponse.nullish(), + agent_soul: zAgentSoulConfig, + app_id: z.string().nullish(), + binding: zAgentComposerBindingResponse.nullish(), + effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(), + impact_summary: zAgentComposerImpactResponse.nullish(), + node_id: z.string().nullish(), + node_job: zWorkflowNodeJobConfig, + save_options: z.array(zComposerSaveStrategy), + soul_lock: zAgentComposerSoulLockResponse, + validation: zComposerValidationFindingsResponse.nullish(), + variant: z.literal('workflow'), + workflow_id: z.string().nullish(), +}) + +/** + * ComposerSavePayload + */ +export const zComposerSavePayload = z.object({ + agent_soul: zAgentSoulConfig.nullish(), + binding: zComposerBindingPayload.nullish(), + client_revision_id: z.string().nullish(), + idempotency_key: z.string().nullish(), + new_agent_name: z.string().min(1).max(255).nullish(), + node_job: zWorkflowNodeJobConfig.nullish(), + save_strategy: zComposerSaveStrategy, + soul_lock: zComposerSoulLockPayload.optional(), + variant: zComposerVariant, + version_note: z.string().nullish(), +}) + /** * ValueSourceType * @@ -3480,6 +3473,48 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({ */ export const zGeneratedAppResponseWritable = zJsonValue +/** + * AppPartial + */ +export const zAppPartialWritable = z.object({ + access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), + app_id: z.string().nullish(), + author_name: z.string().nullish(), + bound_agent_id: z.string().nullish(), + create_user_name: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + description: z.string().nullish(), + has_draft_trigger: z.boolean().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + id: z.string(), + is_starred: z.boolean().optional().default(false), + max_active_requests: z.int().nullish(), + mode: z.string(), + model_config: zModelConfigPartial.nullish(), + name: z.string(), + role: z.string().nullish(), + tags: z.array(zTag).optional(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.nullish(), +}) + +/** + * AppPagination + */ +export const zAppPaginationWritable = z.object({ + data: z.array(zAppPartialWritable), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * Site */ @@ -3504,8 +3539,9 @@ export const zSiteWritable = z.object({ */ export const zAppDetailWithSiteWritable = z.object({ access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), api_base_url: z.string().nullish(), - app_model_config: zModelConfig.nullish(), + app_id: z.string().nullish(), bound_agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), @@ -3518,8 +3554,10 @@ export const zAppDetailWithSiteWritable = z.object({ icon_type: z.string().nullish(), id: z.string(), max_active_requests: z.int().nullish(), - mode_compatible_with_agent: z.string(), + mode: z.string(), + model_config: zModelConfig.nullish(), name: z.string(), + role: z.string().nullish(), site: zSiteWritable.nullish(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), @@ -3625,6 +3663,10 @@ export const zGetAppsQuery = z.object({ .default('all'), name: z.string().optional(), page: z.int().gte(1).lte(99999).optional().default(1), + sort_by: z + .enum(['earliest_created', 'last_modified', 'recently_created']) + .optional() + .default('last_modified'), tag_ids: z.array(z.string()).optional(), }) @@ -3665,6 +3707,37 @@ export const zPostAppsImportsByImportIdConfirmPath = z.object({ */ export const zPostAppsImportsByImportIdConfirmResponse = zImport +export const zGetAppsStarredQuery = z.object({ + creator_ids: z.array(z.string()).optional(), + is_created_by_me: z.boolean().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + mode: z + .enum([ + 'advanced-chat', + 'agent', + 'agent-chat', + 'all', + 'channel', + 'chat', + 'completion', + 'workflow', + ]) + .optional() + .default('all'), + name: z.string().optional(), + page: z.int().gte(1).lte(99999).optional().default(1), + sort_by: z + .enum(['earliest_created', 'last_modified', 'recently_created']) + .optional() + .default('last_modified'), + tag_ids: z.array(z.string()).optional(), +}) + +/** + * Success + */ +export const zGetAppsStarredResponse = zAppPagination + export const zPostAppsWorkflowsOnlineUsersBody = zWorkflowOnlineUsersPayload /** @@ -3802,105 +3875,6 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunPath = z.object({ */ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse = zGeneratedAppResponse -export const zGetAppsByAppIdAgentComposerPath = z.object({ - app_id: z.string(), -}) - -/** - * Agent app composer state - */ -export const zGetAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse - -export const zPutAppsByAppIdAgentComposerBody = zComposerSavePayload - -export const zPutAppsByAppIdAgentComposerPath = z.object({ - app_id: z.string(), -}) - -/** - * Agent app composer saved - */ -export const zPutAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse - -export const zGetAppsByAppIdAgentComposerCandidatesPath = z.object({ - app_id: z.string(), -}) - -/** - * Agent app composer candidates - */ -export const zGetAppsByAppIdAgentComposerCandidatesResponse = zAgentComposerCandidatesResponse - -export const zPostAppsByAppIdAgentComposerValidateBody = zComposerSavePayload - -export const zPostAppsByAppIdAgentComposerValidatePath = z.object({ - app_id: z.string(), -}) - -/** - * Agent app composer validation result - */ -export const zPostAppsByAppIdAgentComposerValidateResponse = zAgentComposerValidateResponse - -export const zPostAppsByAppIdAgentFeaturesBody = zAgentAppFeaturesPayload - -export const zPostAppsByAppIdAgentFeaturesPath = z.object({ - app_id: z.string(), -}) - -/** - * Features updated successfully - */ -export const zPostAppsByAppIdAgentFeaturesResponse = zSimpleResultResponse - -export const zGetAppsByAppIdAgentReferencingWorkflowsPath = z.object({ - app_id: z.string(), -}) - -/** - * Referencing workflows listed successfully - */ -export const zGetAppsByAppIdAgentReferencingWorkflowsResponse = zAgentReferencingWorkflowsResponse - -export const zGetAppsByAppIdAgentSandboxFilesPath = z.object({ - app_id: z.string(), -}) - -export const zGetAppsByAppIdAgentSandboxFilesQuery = z.object({ - conversation_id: z.string().min(1), - path: z.string().optional().default('.'), -}) - -/** - * Listing returned - */ -export const zGetAppsByAppIdAgentSandboxFilesResponse = zSandboxListResponse - -export const zGetAppsByAppIdAgentSandboxFilesReadPath = z.object({ - app_id: z.string(), -}) - -export const zGetAppsByAppIdAgentSandboxFilesReadQuery = z.object({ - conversation_id: z.string().min(1), - path: z.string().min(1), -}) - -/** - * Preview returned - */ -export const zGetAppsByAppIdAgentSandboxFilesReadResponse = zSandboxReadResponse - -export const zPostAppsByAppIdAgentSandboxFilesUploadBody = zAgentSandboxUploadPayload - -export const zPostAppsByAppIdAgentSandboxFilesUploadPath = z.object({ - app_id: z.string(), -}) - -/** - * Uploaded - */ -export const zPostAppsByAppIdAgentSandboxFilesUploadResponse = zSandboxUploadResponse - export const zGetAppsByAppIdAgentDriveFilesPath = z.object({ app_id: z.string(), }) @@ -4541,6 +4515,24 @@ export const zPostAppsByAppIdSiteAccessTokenResetPath = z.object({ */ export const zPostAppsByAppIdSiteAccessTokenResetResponse = zAppSiteResponse +export const zDeleteAppsByAppIdStarPath = z.object({ + app_id: z.string(), +}) + +/** + * Success + */ +export const zDeleteAppsByAppIdStarResponse = zSimpleResultResponse + +export const zPostAppsByAppIdStarPath = z.object({ + app_id: z.string(), +}) + +/** + * Success + */ +export const zPostAppsByAppIdStarResponse = zSimpleResultResponse + export const zGetAppsByAppIdStatisticsAverageResponseTimePath = z.object({ app_id: z.string(), }) diff --git a/packages/contracts/generated/api/console/explore/orpc.gen.ts b/packages/contracts/generated/api/console/explore/orpc.gen.ts index db163ae92ad..23a7ef8bc42 100644 --- a/packages/contracts/generated/api/console/explore/orpc.gen.ts +++ b/packages/contracts/generated/api/console/explore/orpc.gen.ts @@ -6,6 +6,8 @@ import * as z from 'zod' import { zGetExploreAppsByAppIdPath, zGetExploreAppsByAppIdResponse, + zGetExploreAppsLearnDifyQuery, + zGetExploreAppsLearnDifyResponse, zGetExploreAppsQuery, zGetExploreAppsResponse, zGetExploreBannersQuery, @@ -13,6 +15,21 @@ import { } from './zod.gen' export const get = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getExploreAppsLearnDify', + path: '/explore/apps/learn-dify', + tags: ['console'], + }) + .input(z.object({ query: zGetExploreAppsLearnDifyQuery.optional() })) + .output(zGetExploreAppsLearnDifyResponse) + +export const learnDify = { + get, +} + +export const get2 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -24,10 +41,10 @@ export const get = oc .output(zGetExploreAppsByAppIdResponse) export const byAppId = { - get, + get: get2, } -export const get2 = oc +export const get3 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -39,14 +56,15 @@ export const get2 = oc .output(zGetExploreAppsResponse) export const apps = { - get: get2, + get: get3, + learnDify, byAppId, } /** * Get banner list */ -export const get3 = oc +export const get4 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -59,7 +77,7 @@ export const get3 = oc .output(zGetExploreBannersResponse) export const banners = { - get: get3, + get: get4, } export const explore = { diff --git a/packages/contracts/generated/api/console/explore/types.gen.ts b/packages/contracts/generated/api/console/explore/types.gen.ts index 72920964add..e5980e3c54d 100644 --- a/packages/contracts/generated/api/console/explore/types.gen.ts +++ b/packages/contracts/generated/api/console/explore/types.gen.ts @@ -9,6 +9,10 @@ export type RecommendedAppListResponse = { recommended_apps: Array } +export type LearnDifyAppListResponse = { + recommended_apps: Array +} + export type RecommendedAppDetailResponse = { [key: string]: unknown } @@ -61,6 +65,22 @@ export type GetExploreAppsResponses = { export type GetExploreAppsResponse = GetExploreAppsResponses[keyof GetExploreAppsResponses] +export type GetExploreAppsLearnDifyData = { + body?: never + path?: never + query?: { + language?: string + } + url: '/explore/apps/learn-dify' +} + +export type GetExploreAppsLearnDifyResponses = { + 200: LearnDifyAppListResponse +} + +export type GetExploreAppsLearnDifyResponse + = GetExploreAppsLearnDifyResponses[keyof GetExploreAppsLearnDifyResponses] + export type GetExploreAppsByAppIdData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/explore/zod.gen.ts b/packages/contracts/generated/api/console/explore/zod.gen.ts index 913338afdfe..9346796a86b 100644 --- a/packages/contracts/generated/api/console/explore/zod.gen.ts +++ b/packages/contracts/generated/api/console/explore/zod.gen.ts @@ -60,6 +60,13 @@ export const zRecommendedAppListResponse = z.object({ recommended_apps: z.array(zRecommendedAppResponse), }) +/** + * LearnDifyAppListResponse + */ +export const zLearnDifyAppListResponse = z.object({ + recommended_apps: z.array(zRecommendedAppResponse), +}) + export const zGetExploreAppsQuery = z.object({ language: z.string().optional(), }) @@ -69,6 +76,15 @@ export const zGetExploreAppsQuery = z.object({ */ export const zGetExploreAppsResponse = zRecommendedAppListResponse +export const zGetExploreAppsLearnDifyQuery = z.object({ + language: z.string().optional(), +}) + +/** + * Success + */ +export const zGetExploreAppsLearnDifyResponse = zLearnDifyAppListResponse + export const zGetExploreAppsByAppIdPath = z.object({ app_id: z.string(), }) diff --git a/packages/contracts/generated/api/console/orpc.gen.ts b/packages/contracts/generated/api/console/orpc.gen.ts index ec59d8cccdc..1d25989f247 100644 --- a/packages/contracts/generated/api/console/orpc.gen.ts +++ b/packages/contracts/generated/api/console/orpc.gen.ts @@ -2,7 +2,7 @@ import { account } from './account/orpc.gen' import { activate } from './activate/orpc.gen' -import { agents } from './agents/orpc.gen' +import { agent } from './agent/orpc.gen' import { allWorkspaces } from './all-workspaces/orpc.gen' import { apiBasedExtension } from './api-based-extension/orpc.gen' import { apiKeyAuth } from './api-key-auth/orpc.gen' @@ -53,7 +53,7 @@ import { workspaces } from './workspaces/orpc.gen' export const contract = { account, activate, - agents, + agent, allWorkspaces, apiBasedExtension, apiKeyAuth, diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index ecfc3d44eff..630e8f6b354 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -67,6 +67,11 @@ import { zGetWorkspacesCurrentPermissionResponse, zGetWorkspacesCurrentPluginAssetQuery, zGetWorkspacesCurrentPluginAssetResponse, + zGetWorkspacesCurrentPluginAutoUpgradeFetchQuery, + zGetWorkspacesCurrentPluginAutoUpgradeFetchResponse, + zGetWorkspacesCurrentPluginByCategoryListPath, + zGetWorkspacesCurrentPluginByCategoryListQuery, + zGetWorkspacesCurrentPluginByCategoryListResponse, zGetWorkspacesCurrentPluginDebuggingKeyResponse, zGetWorkspacesCurrentPluginFetchManifestQuery, zGetWorkspacesCurrentPluginFetchManifestResponse, @@ -79,7 +84,6 @@ import { zGetWorkspacesCurrentPluginParametersDynamicOptionsQuery, zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse, zGetWorkspacesCurrentPluginPermissionFetchResponse, - zGetWorkspacesCurrentPluginPreferencesFetchResponse, zGetWorkspacesCurrentPluginReadmeQuery, zGetWorkspacesCurrentPluginReadmeResponse, zGetWorkspacesCurrentPluginTasksByTaskIdPath, @@ -214,6 +218,10 @@ import { zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeBody, zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypePath, zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponse, + zPostWorkspacesCurrentPluginAutoUpgradeChangeBody, + zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse, + zPostWorkspacesCurrentPluginAutoUpgradeExcludeBody, + zPostWorkspacesCurrentPluginAutoUpgradeExcludeResponse, zPostWorkspacesCurrentPluginInstallGithubBody, zPostWorkspacesCurrentPluginInstallGithubResponse, zPostWorkspacesCurrentPluginInstallMarketplaceBody, @@ -228,10 +236,6 @@ import { zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse, zPostWorkspacesCurrentPluginPermissionChangeBody, zPostWorkspacesCurrentPluginPermissionChangeResponse, - zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeBody, - zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponse, - zPostWorkspacesCurrentPluginPreferencesChangeBody, - zPostWorkspacesCurrentPluginPreferencesChangeResponse, zPostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierPath, zPostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponse, zPostWorkspacesCurrentPluginTasksByTaskIdDeletePath, @@ -1487,7 +1491,58 @@ export const asset = { get: get20, } +export const post26 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginAutoUpgradeChange', + path: '/workspaces/current/plugin/auto-upgrade/change', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginAutoUpgradeChangeBody })) + .output(zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse) + +export const change = { + post: post26, +} + +export const post27 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginAutoUpgradeExclude', + path: '/workspaces/current/plugin/auto-upgrade/exclude', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginAutoUpgradeExcludeBody })) + .output(zPostWorkspacesCurrentPluginAutoUpgradeExcludeResponse) + +export const exclude = { + post: post27, +} + export const get21 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginAutoUpgradeFetch', + path: '/workspaces/current/plugin/auto-upgrade/fetch', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentPluginAutoUpgradeFetchQuery })) + .output(zGetWorkspacesCurrentPluginAutoUpgradeFetchResponse) + +export const fetch_ = { + get: get21, +} + +export const autoUpgrade = { + change, + exclude, + fetch: fetch_, +} + +export const get22 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1498,10 +1553,10 @@ export const get21 = oc .output(zGetWorkspacesCurrentPluginDebuggingKeyResponse) export const debuggingKey = { - get: get21, + get: get22, } -export const get22 = oc +export const get23 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1513,10 +1568,10 @@ export const get22 = oc .output(zGetWorkspacesCurrentPluginFetchManifestResponse) export const fetchManifest = { - get: get22, + get: get23, } -export const get23 = oc +export const get24 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1528,10 +1583,10 @@ export const get23 = oc .output(zGetWorkspacesCurrentPluginIconResponse) export const icon = { - get: get23, + get: get24, } -export const post26 = oc +export const post28 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1543,10 +1598,10 @@ export const post26 = oc .output(zPostWorkspacesCurrentPluginInstallGithubResponse) export const github = { - post: post26, + post: post28, } -export const post27 = oc +export const post29 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1558,10 +1613,10 @@ export const post27 = oc .output(zPostWorkspacesCurrentPluginInstallMarketplaceResponse) export const marketplace = { - post: post27, + post: post29, } -export const post28 = oc +export const post30 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1573,7 +1628,7 @@ export const post28 = oc .output(zPostWorkspacesCurrentPluginInstallPkgResponse) export const pkg = { - post: post28, + post: post30, } export const install = { @@ -1582,7 +1637,7 @@ export const install = { pkg, } -export const post29 = oc +export const post31 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1594,14 +1649,14 @@ export const post29 = oc .output(zPostWorkspacesCurrentPluginListInstallationsIdsResponse) export const ids = { - post: post29, + post: post31, } export const installations = { ids, } -export const post30 = oc +export const post32 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1613,10 +1668,10 @@ export const post30 = oc .output(zPostWorkspacesCurrentPluginListLatestVersionsResponse) export const latestVersions = { - post: post30, + post: post32, } -export const get24 = oc +export const get25 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1628,12 +1683,12 @@ export const get24 = oc .output(zGetWorkspacesCurrentPluginListResponse) export const list2 = { - get: get24, + get: get25, installations, latestVersions, } -export const get25 = oc +export const get26 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1645,14 +1700,14 @@ export const get25 = oc .output(zGetWorkspacesCurrentPluginMarketplacePkgResponse) export const pkg2 = { - get: get25, + get: get26, } export const marketplace2 = { pkg: pkg2, } -export const get26 = oc +export const get27 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1664,13 +1719,13 @@ export const get26 = oc .output(zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse) export const dynamicOptions = { - get: get26, + get: get27, } /** * Fetch dynamic options using credentials directly (for edit mode) */ -export const post31 = oc +export const post33 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1685,7 +1740,7 @@ export const post31 = oc .output(zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse) export const dynamicOptionsWithCredentials = { - post: post31, + post: post33, } export const parameters = { @@ -1693,7 +1748,7 @@ export const parameters = { dynamicOptionsWithCredentials, } -export const post32 = oc +export const post34 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1704,11 +1759,11 @@ export const post32 = oc .input(z.object({ body: zPostWorkspacesCurrentPluginPermissionChangeBody })) .output(zPostWorkspacesCurrentPluginPermissionChangeResponse) -export const change = { - post: post32, +export const change2 = { + post: post34, } -export const get27 = oc +export const get28 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1718,65 +1773,11 @@ export const get27 = oc }) .output(zGetWorkspacesCurrentPluginPermissionFetchResponse) -export const fetch_ = { - get: get27, -} - -export const permission2 = { - change, - fetch: fetch_, -} - -export const post33 = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginPreferencesAutoupgradeExclude', - path: '/workspaces/current/plugin/preferences/autoupgrade/exclude', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeBody })) - .output(zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponse) - -export const exclude = { - post: post33, -} - -export const autoupgrade = { - exclude, -} - -export const post34 = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginPreferencesChange', - path: '/workspaces/current/plugin/preferences/change', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginPreferencesChangeBody })) - .output(zPostWorkspacesCurrentPluginPreferencesChangeResponse) - -export const change2 = { - post: post34, -} - -export const get28 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentPluginPreferencesFetch', - path: '/workspaces/current/plugin/preferences/fetch', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentPluginPreferencesFetchResponse) - export const fetch2 = { get: get28, } -export const preferences = { - autoupgrade, +export const permission2 = { change: change2, fetch: fetch2, } @@ -1973,8 +1974,33 @@ export const upload = { pkg: pkg3, } +export const get32 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginByCategoryList', + path: '/workspaces/current/plugin/{category}/list', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentPluginByCategoryListPath, + query: zGetWorkspacesCurrentPluginByCategoryListQuery.optional(), + }), + ) + .output(zGetWorkspacesCurrentPluginByCategoryListResponse) + +export const list3 = { + get: get32, +} + +export const byCategory = { + list: list3, +} + export const plugin2 = { asset, + autoUpgrade, debuggingKey, fetchManifest, icon, @@ -1983,15 +2009,15 @@ export const plugin2 = { marketplace: marketplace2, parameters, permission: permission2, - preferences, readme, tasks, uninstall, upgrade, upload, + byCategory, } -export const get32 = oc +export const get33 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2002,7 +2028,7 @@ export const get32 = oc .output(zGetWorkspacesCurrentToolLabelsResponse) export const toolLabels = { - get: get32, + get: get33, } export const post44 = oc @@ -2035,7 +2061,7 @@ export const delete9 = { post: post45, } -export const get33 = oc +export const get34 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2046,11 +2072,11 @@ export const get33 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiGetQuery })) .output(zGetWorkspacesCurrentToolProviderApiGetResponse) -export const get34 = { - get: get33, +export const get35 = { + get: get34, } -export const get35 = oc +export const get36 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2062,7 +2088,7 @@ export const get35 = oc .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) export const remote = { - get: get35, + get: get36, } export const post46 = oc @@ -2099,7 +2125,7 @@ export const test = { pre, } -export const get36 = oc +export const get37 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2111,7 +2137,7 @@ export const get36 = oc .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) export const tools = { - get: get36, + get: get37, } export const post48 = oc @@ -2132,7 +2158,7 @@ export const update2 = { export const api = { add, delete: delete9, - get: get34, + get: get35, remote, schema, test, @@ -2160,7 +2186,7 @@ export const add2 = { post: post49, } -export const get37 = oc +export const get38 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2177,10 +2203,10 @@ export const get37 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse) export const info = { - get: get37, + get: get38, } -export const get38 = oc +export const get39 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2200,7 +2226,7 @@ export const get38 = oc ) export const byCredentialType = { - get: get38, + get: get39, } export const schema2 = { @@ -2212,7 +2238,7 @@ export const credential = { schema: schema2, } -export const get39 = oc +export const get40 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2229,7 +2255,7 @@ export const get39 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse) export const credentials3 = { - get: get39, + get: get40, } export const post50 = oc @@ -2272,7 +2298,7 @@ export const delete10 = { post: post51, } -export const get40 = oc +export const get41 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2284,10 +2310,10 @@ export const get40 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse) export const icon2 = { - get: get40, + get: get41, } -export const get41 = oc +export const get42 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2299,10 +2325,10 @@ export const get41 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse) export const info2 = { - get: get41, + get: get42, } -export const get42 = oc +export const get43 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2316,7 +2342,7 @@ export const get42 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse) export const clientSchema = { - get: get42, + get: get43, } export const delete11 = oc @@ -2334,7 +2360,7 @@ export const delete11 = oc ) .output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const get43 = oc +export const get44 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2365,7 +2391,7 @@ export const post52 = oc export const customClient = { delete: delete11, - get: get43, + get: get44, post: post52, } @@ -2374,7 +2400,7 @@ export const oauth = { customClient, } -export const get44 = oc +export const get45 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2386,7 +2412,7 @@ export const get44 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse) export const tools2 = { - get: get44, + get: get45, } export const post53 = oc @@ -2441,7 +2467,7 @@ export const auth = { post: post54, } -export const get45 = oc +export const get46 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2453,14 +2479,14 @@ export const get45 = oc .output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse) export const byProviderId = { - get: get45, + get: get46, } export const tools3 = { byProviderId, } -export const get46 = oc +export const get47 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2472,7 +2498,7 @@ export const get46 = oc .output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse) export const byProviderId2 = { - get: get46, + get: get47, } export const update4 = { @@ -2551,7 +2577,7 @@ export const delete13 = { post: post57, } -export const get47 = oc +export const get48 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2562,11 +2588,11 @@ export const get47 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowGetQuery.optional() })) .output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse) -export const get48 = { - get: get47, +export const get49 = { + get: get48, } -export const get49 = oc +export const get50 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2578,7 +2604,7 @@ export const get49 = oc .output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse) export const tools4 = { - get: get49, + get: get50, } export const post58 = oc @@ -2599,7 +2625,7 @@ export const update5 = { export const workflow = { create: create2, delete: delete13, - get: get48, + get: get49, tools: tools4, update: update5, } @@ -2611,7 +2637,7 @@ export const toolProvider = { workflow, } -export const get50 = oc +export const get51 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2623,10 +2649,10 @@ export const get50 = oc .output(zGetWorkspacesCurrentToolProvidersResponse) export const toolProviders = { - get: get50, + get: get51, } -export const get51 = oc +export const get52 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2637,10 +2663,10 @@ export const get51 = oc .output(zGetWorkspacesCurrentToolsApiResponse) export const api2 = { - get: get51, + get: get52, } -export const get52 = oc +export const get53 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2651,10 +2677,10 @@ export const get52 = oc .output(zGetWorkspacesCurrentToolsBuiltinResponse) export const builtin2 = { - get: get52, + get: get53, } -export const get53 = oc +export const get54 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2665,10 +2691,10 @@ export const get53 = oc .output(zGetWorkspacesCurrentToolsMcpResponse) export const mcp2 = { - get: get53, + get: get54, } -export const get54 = oc +export const get55 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2679,7 +2705,7 @@ export const get54 = oc .output(zGetWorkspacesCurrentToolsWorkflowResponse) export const workflow2 = { - get: get54, + get: get55, } export const tools5 = { @@ -2689,7 +2715,7 @@ export const tools5 = { workflow: workflow2, } -export const get55 = oc +export const get56 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2701,13 +2727,13 @@ export const get55 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse) export const icon3 = { - get: get55, + get: get56, } /** * Get info for a trigger provider */ -export const get56 = oc +export const get57 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2720,7 +2746,7 @@ export const get56 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse) export const info3 = { - get: get56, + get: get57, } /** @@ -2741,7 +2767,7 @@ export const delete14 = oc /** * Get OAuth client configuration for a provider */ -export const get57 = oc +export const get58 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2775,7 +2801,7 @@ export const post59 = oc export const client = { delete: delete14, - get: get57, + get: get58, post: post59, } @@ -2842,7 +2868,7 @@ export const create3 = { /** * Get the request logs for a subscription instance for a trigger provider */ -export const get58 = oc +export const get59 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2863,7 +2889,7 @@ export const get58 = oc ) export const bySubscriptionBuilderId2 = { - get: get58, + get: get59, } export const logs = { @@ -2937,7 +2963,7 @@ export const verifyAndUpdate = { /** * Get a subscription instance for a trigger provider */ -export const get59 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2958,7 +2984,7 @@ export const get59 = oc ) export const bySubscriptionBuilderId5 = { - get: get59, + get: get60, } export const builder = { @@ -2973,7 +2999,7 @@ export const builder = { /** * List all trigger subscriptions for the current tenant's provider */ -export const get60 = oc +export const get61 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2985,14 +3011,14 @@ export const get60 = oc .input(z.object({ params: zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListPath })) .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse) -export const list3 = { - get: get60, +export const list4 = { + get: get61, } /** * Initiate OAuth authorization flow for a trigger provider */ -export const get61 = oc +export const get62 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3009,7 +3035,7 @@ export const get61 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse) export const authorize = { - get: get61, + get: get62, } export const oauth3 = { @@ -3050,7 +3076,7 @@ export const verify = { export const subscriptions = { builder, - list: list3, + list: list4, oauth: oauth3, verify, } @@ -3126,7 +3152,7 @@ export const triggerProvider = { /** * List all trigger providers for the current tenant */ -export const get62 = oc +export const get63 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3138,7 +3164,7 @@ export const get62 = oc .output(zGetWorkspacesCurrentTriggersResponse) export const triggers = { - get: get62, + get: get63, } export const post67 = oc @@ -3237,7 +3263,7 @@ export const switch3 = { post: post71, } -export const get63 = oc +export const get64 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3249,7 +3275,7 @@ export const get63 = oc .output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse) export const byLang = { - get: get63, + get: get64, } export const byIconType = { @@ -3268,7 +3294,7 @@ export const byTenantId = { modelProviders: modelProviders2, } -export const get64 = oc +export const get65 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3279,7 +3305,7 @@ export const get64 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get64, + get: get65, current, customConfig, info: info4, diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index a3c207dd222..8b6f34b18ff 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -376,6 +376,30 @@ export type WorkspacePermissionResponse = { export type BinaryFileResponse = Blob | File +export type ParserAutoUpgradeChange = { + auto_upgrade: PluginAutoUpgradeSettingsPayload + category: PluginCategory +} + +export type PluginAutoUpgradeChangeResponse = { + message?: string | null + success: boolean +} + +export type ParserExcludePlugin = { + category: PluginCategory + plugin_id: string +} + +export type SuccessResponse = { + success: boolean +} + +export type PluginAutoUpgradeFetchResponse = { + auto_upgrade: PluginAutoUpgradeSettingsResponseModel + category: PluginCategory +} + export type PluginDebuggingKeyResponse = { host: string key: string @@ -432,12 +456,8 @@ export type ParserDynamicOptionsWithCredentials = { } export type ParserPermissionChange = { - debug_permission: DebugPermission - install_permission: InstallPermission -} - -export type SuccessResponse = { - success: boolean + debug_permission?: DebugPermission + install_permission?: InstallPermission } export type PluginPermissionResponse = { @@ -445,25 +465,6 @@ export type PluginPermissionResponse = { install_permission: InstallPermission } -export type ParserExcludePlugin = { - plugin_id: string -} - -export type PluginOperationSuccessResponse = { - message?: string | null - success: boolean -} - -export type ParserPreferencesChange = { - auto_upgrade: PluginAutoUpgradeSettingsPayload - permission: PluginPermissionSettingsPayload -} - -export type PluginPreferencesResponse = { - auto_upgrade: PluginAutoUpgradeSettingsPayload - permission: PluginPermissionSettingsPayload -} - export type PluginReadmeResponse = { readme: string } @@ -499,6 +500,12 @@ export type ParserGithubUpload = { version: string } +export type PluginCategoryListResponse = { + builtin_tools: Array + has_more: boolean + plugins: Array +} + export type ToolProviderOpaqueResponse = unknown export type ApiToolProviderAddPayload = { @@ -901,8 +908,8 @@ export type ModelCredentialLoadBalancingResponse = { export type ParameterRule = { default?: unknown | null - help?: I18nObject | null - label: I18nObject + help?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject max?: number | null min?: number | null name: string @@ -923,10 +930,6 @@ export type ProviderWithModelsResponse = { tenant_id: string } -export type DebugPermission = 'admins' | 'everyone' | 'noone' - -export type InstallPermission = 'admins' | 'everyone' | 'noone' - export type PluginAutoUpgradeSettingsPayload = { exclude_plugins?: Array include_plugins?: Array @@ -935,9 +938,75 @@ export type PluginAutoUpgradeSettingsPayload = { upgrade_time_of_day?: number } -export type PluginPermissionSettingsPayload = { - debug_permission?: DebugPermission - install_permission?: InstallPermission +export type PluginCategory + = | 'agent-strategy' + | 'datasource' + | 'extension' + | 'model' + | 'tool' + | 'trigger' + +export type PluginAutoUpgradeSettingsResponseModel = { + exclude_plugins: Array + include_plugins: Array + strategy_setting: StrategySetting + upgrade_mode: UpgradeMode + upgrade_time_of_day: number +} + +export type DebugPermission = 'admins' | 'everyone' | 'noone' + +export type InstallPermission = 'admins' | 'everyone' | 'noone' + +export type PluginCategoryBuiltinToolProviderResponse = { + allow_delete: boolean + author: string + description: CoreToolsEntitiesCommonEntitiesI18nObject + icon: + | string + | { + [key: string]: string + } + icon_dark: + | string + | { + [key: string]: string + } + | null + id: string + is_team_authorization: boolean + label: CoreToolsEntitiesCommonEntitiesI18nObject + labels: Array + name: string + plugin_id: string | null + plugin_unique_identifier: string | null + team_credentials: { + [key: string]: unknown + } + tools: Array + type: ToolProviderType + [key: string]: unknown +} + +export type PluginCategoryInstalledPluginResponse = { + checksum: string + created_at: string + declaration: PluginDeclarationResponse + endpoints_active: number + endpoints_setups: number + id: string + installation_id: string + meta: { + [key: string]: unknown + } + name: string + plugin_id: string + plugin_unique_identifier: string + runtime_type: string + source: PluginInstallationSource + tenant_id: string + updated_at: string + version: string } export type ApiProviderSchemaType = 'openai_actions' | 'openai_plugin' | 'openapi' | 'swagger' @@ -976,12 +1045,14 @@ export type CustomConfigurationResponse = { export type I18nObject = { en_US: string + ja_JP?: string | null + pt_BR?: string | null zh_Hans?: string | null } export type ProviderHelpEntity = { - title: I18nObject - url: I18nObject + title: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + url: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject } export type ModelCredentialSchema = { @@ -1036,6 +1107,11 @@ export type ModelStatus | 'no-permission' | 'quota-exceeded' +export type GraphonModelRuntimeEntitiesCommonEntitiesI18nObject = { + en_US: string + zh_Hans?: string | null +} + export type ParameterType = 'boolean' | 'float' | 'int' | 'string' | 'text' export type ProviderModelWithStatusEntity = { @@ -1059,13 +1135,86 @@ export type StrategySetting = 'disabled' | 'fix_only' | 'latest' export type UpgradeMode = 'all' | 'exclude' | 'partial' +export type CoreToolsEntitiesCommonEntitiesI18nObject = { + en_US: string + ja_JP?: string | null + pt_BR?: string | null + zh_Hans?: string | null +} + +export type PluginCategoryBuiltinToolResponse = { + author: string + description: CoreToolsEntitiesCommonEntitiesI18nObject + label: CoreToolsEntitiesCommonEntitiesI18nObject + labels: Array + name: string + output_schema: { + [key: string]: unknown + } + parameters?: Array<{ + [key: string]: unknown + }> | null + [key: string]: unknown +} + +export type ToolProviderType + = | 'api' + | 'app' + | 'builtin' + | 'dataset-retrieval' + | 'mcp' + | 'plugin' + | 'workflow' + +export type PluginDeclarationResponse = { + agent_strategy?: { + [key: string]: unknown + } | null + author: string | null + category: PluginCategory + created_at: string + datasource?: { + [key: string]: unknown + } | null + description: CoreToolsEntitiesCommonEntitiesI18nObject + endpoint?: { + [key: string]: unknown + } | null + icon: string + icon_dark?: string | null + label: CoreToolsEntitiesCommonEntitiesI18nObject + meta: { + [key: string]: unknown + } + model?: ProviderEntityResponse | null + name: string + plugins: { + [key: string]: Array | null + } + repo?: string | null + resource: { + [key: string]: unknown + } + tags?: Array + tool?: { + [key: string]: unknown + } | null + trigger?: { + [key: string]: unknown + } | null + verified?: boolean + version: string +} + +export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote' + export type ToolParameterForm = 'form' | 'llm' | 'schema' export type AiModelEntityResponse = { deprecated?: boolean features?: Array | null fetch_from: FetchFrom - label: I18nObject + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject model: string model_properties: { [key in ModelPropertyKey]?: unknown @@ -1094,10 +1243,10 @@ export type CustomModelConfiguration = { export type CredentialFormSchema = { default?: string | null - label: I18nObject + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject max_length?: number options?: Array | null - placeholder?: I18nObject | null + placeholder?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null required?: boolean show_on?: Array type: FormType @@ -1105,8 +1254,8 @@ export type CredentialFormSchema = { } export type FieldModelSchema = { - label: I18nObject - placeholder?: I18nObject | null + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + placeholder?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null } export type ProviderQuotaType = 'free' | 'paid' | 'trial' @@ -1120,6 +1269,25 @@ export type QuotaConfiguration = { restrict_models?: Array } +export type ProviderEntityResponse = { + background?: string | null + configurate_methods: Array + description?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + help?: ProviderHelpEntity | null + icon_small?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + icon_small_dark?: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject | null + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject + model_credential_schema?: ModelCredentialSchema | null + models?: Array + position?: { + [key: string]: Array + } | null + provider: string + provider_credential_schema?: ProviderCredentialSchema | null + provider_name?: string + supported_model_types: Array +} + export type PriceConfigResponse = { currency: string input: string @@ -1128,7 +1296,7 @@ export type PriceConfigResponse = { } export type FormOption = { - label: I18nObject + label: GraphonModelRuntimeEntitiesCommonEntitiesI18nObject show_on?: Array value: string } @@ -2166,6 +2334,50 @@ export type GetWorkspacesCurrentPluginAssetResponses = { export type GetWorkspacesCurrentPluginAssetResponse = GetWorkspacesCurrentPluginAssetResponses[keyof GetWorkspacesCurrentPluginAssetResponses] +export type PostWorkspacesCurrentPluginAutoUpgradeChangeData = { + body: ParserAutoUpgradeChange + path?: never + query?: never + url: '/workspaces/current/plugin/auto-upgrade/change' +} + +export type PostWorkspacesCurrentPluginAutoUpgradeChangeResponses = { + 200: PluginAutoUpgradeChangeResponse +} + +export type PostWorkspacesCurrentPluginAutoUpgradeChangeResponse + = PostWorkspacesCurrentPluginAutoUpgradeChangeResponses[keyof PostWorkspacesCurrentPluginAutoUpgradeChangeResponses] + +export type PostWorkspacesCurrentPluginAutoUpgradeExcludeData = { + body: ParserExcludePlugin + path?: never + query?: never + url: '/workspaces/current/plugin/auto-upgrade/exclude' +} + +export type PostWorkspacesCurrentPluginAutoUpgradeExcludeResponses = { + 200: SuccessResponse +} + +export type PostWorkspacesCurrentPluginAutoUpgradeExcludeResponse + = PostWorkspacesCurrentPluginAutoUpgradeExcludeResponses[keyof PostWorkspacesCurrentPluginAutoUpgradeExcludeResponses] + +export type GetWorkspacesCurrentPluginAutoUpgradeFetchData = { + body?: never + path?: never + query: { + category: 'agent-strategy' | 'datasource' | 'extension' | 'model' | 'tool' | 'trigger' + } + url: '/workspaces/current/plugin/auto-upgrade/fetch' +} + +export type GetWorkspacesCurrentPluginAutoUpgradeFetchResponses = { + 200: PluginAutoUpgradeFetchResponse +} + +export type GetWorkspacesCurrentPluginAutoUpgradeFetchResponse + = GetWorkspacesCurrentPluginAutoUpgradeFetchResponses[keyof GetWorkspacesCurrentPluginAutoUpgradeFetchResponses] + export type GetWorkspacesCurrentPluginDebuggingKeyData = { body?: never path?: never @@ -2379,48 +2591,6 @@ export type GetWorkspacesCurrentPluginPermissionFetchResponses = { export type GetWorkspacesCurrentPluginPermissionFetchResponse = GetWorkspacesCurrentPluginPermissionFetchResponses[keyof GetWorkspacesCurrentPluginPermissionFetchResponses] -export type PostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeData = { - body: ParserExcludePlugin - path?: never - query?: never - url: '/workspaces/current/plugin/preferences/autoupgrade/exclude' -} - -export type PostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponses = { - 200: PluginOperationSuccessResponse -} - -export type PostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponse - = PostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponses[keyof PostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponses] - -export type PostWorkspacesCurrentPluginPreferencesChangeData = { - body: ParserPreferencesChange - path?: never - query?: never - url: '/workspaces/current/plugin/preferences/change' -} - -export type PostWorkspacesCurrentPluginPreferencesChangeResponses = { - 200: PluginOperationSuccessResponse -} - -export type PostWorkspacesCurrentPluginPreferencesChangeResponse - = PostWorkspacesCurrentPluginPreferencesChangeResponses[keyof PostWorkspacesCurrentPluginPreferencesChangeResponses] - -export type GetWorkspacesCurrentPluginPreferencesFetchData = { - body?: never - path?: never - query?: never - url: '/workspaces/current/plugin/preferences/fetch' -} - -export type GetWorkspacesCurrentPluginPreferencesFetchResponses = { - 200: PluginPreferencesResponse -} - -export type GetWorkspacesCurrentPluginPreferencesFetchResponse - = GetWorkspacesCurrentPluginPreferencesFetchResponses[keyof GetWorkspacesCurrentPluginPreferencesFetchResponses] - export type GetWorkspacesCurrentPluginReadmeData = { body?: never path?: never @@ -2602,6 +2772,25 @@ export type PostWorkspacesCurrentPluginUploadPkgResponses = { export type PostWorkspacesCurrentPluginUploadPkgResponse = PostWorkspacesCurrentPluginUploadPkgResponses[keyof PostWorkspacesCurrentPluginUploadPkgResponses] +export type GetWorkspacesCurrentPluginByCategoryListData = { + body?: never + path: { + category: string + } + query?: { + page?: number + page_size?: number + } + url: '/workspaces/current/plugin/{category}/list' +} + +export type GetWorkspacesCurrentPluginByCategoryListResponses = { + 200: PluginCategoryListResponse +} + +export type GetWorkspacesCurrentPluginByCategoryListResponse + = GetWorkspacesCurrentPluginByCategoryListResponses[keyof GetWorkspacesCurrentPluginByCategoryListResponses] + export type GetWorkspacesCurrentToolLabelsData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index f35dd939918..b342c586594 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -289,6 +289,21 @@ export const zWorkspacePermissionResponse = z.object({ */ export const zBinaryFileResponse = z.custom() +/** + * PluginAutoUpgradeChangeResponse + */ +export const zPluginAutoUpgradeChangeResponse = z.object({ + message: z.string().nullish(), + success: z.boolean(), +}) + +/** + * SuccessResponse + */ +export const zSuccessResponse = z.object({ + success: z.boolean(), +}) + /** * PluginDebuggingKeyResponse */ @@ -375,28 +390,6 @@ export const zParserDynamicOptionsWithCredentials = z.object({ provider: z.string(), }) -/** - * SuccessResponse - */ -export const zSuccessResponse = z.object({ - success: z.boolean(), -}) - -/** - * ParserExcludePlugin - */ -export const zParserExcludePlugin = z.object({ - plugin_id: z.string(), -}) - -/** - * PluginOperationSuccessResponse - */ -export const zPluginOperationSuccessResponse = z.object({ - message: z.string().nullish(), - success: z.boolean(), -}) - /** * PluginReadmeResponse */ @@ -1022,6 +1015,26 @@ export const zModelCredentialResponse = z.object({ load_balancing: zModelCredentialLoadBalancingResponse, }) +/** + * PluginCategory + */ +export const zPluginCategory = z.enum([ + 'agent-strategy', + 'datasource', + 'extension', + 'model', + 'tool', + 'trigger', +]) + +/** + * ParserExcludePlugin + */ +export const zParserExcludePlugin = z.object({ + category: zPluginCategory, + plugin_id: z.string(), +}) + /** * DebugPermission */ @@ -1036,8 +1049,8 @@ export const zInstallPermission = z.enum(['admins', 'everyone', 'noone']) * ParserPermissionChange */ export const zParserPermissionChange = z.object({ - debug_permission: zDebugPermission, - install_permission: zInstallPermission, + debug_permission: zDebugPermission.optional().default('everyone'), + install_permission: zInstallPermission.optional().default('everyone'), }) /** @@ -1048,14 +1061,6 @@ export const zPluginPermissionResponse = z.object({ install_permission: zInstallPermission, }) -/** - * PluginPermissionSettingsPayload - */ -export const zPluginPermissionSettingsPayload = z.object({ - debug_permission: zDebugPermission.optional().default('everyone'), - install_permission: zInstallPermission.optional().default('everyone'), -}) - /** * ApiProviderSchemaType * @@ -1178,19 +1183,11 @@ export const zConfigurateMethod = z.enum(['customizable-model', 'predefined-mode */ export const zI18nObject = z.object({ en_US: z.string(), + ja_JP: z.string().nullish(), + pt_BR: z.string().nullish(), zh_Hans: z.string().nullish(), }) -/** - * ProviderHelpEntity - * - * Model class for provider help. - */ -export const zProviderHelpEntity = z.object({ - title: zI18nObject, - url: zI18nObject, -}) - /** * ProviderType */ @@ -1254,6 +1251,26 @@ export const zModelStatus = z.enum([ 'quota-exceeded', ]) +/** + * I18nObject + * + * Model class for i18n object. + */ +export const zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject = z.object({ + en_US: z.string(), + zh_Hans: z.string().nullish(), +}) + +/** + * ProviderHelpEntity + * + * Model class for provider help. + */ +export const zProviderHelpEntity = z.object({ + title: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + url: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, +}) + /** * ParameterType * @@ -1268,8 +1285,8 @@ export const zParameterType = z.enum(['boolean', 'float', 'int', 'string', 'text */ export const zParameterRule = z.object({ default: z.unknown().nullish(), - help: zI18nObject.nullish(), - label: zI18nObject, + help: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, max: z.number().nullish(), min: z.number().nullish(), name: z.string(), @@ -1356,21 +1373,98 @@ export const zPluginAutoUpgradeSettingsPayload = z.object({ }) /** - * ParserPreferencesChange + * ParserAutoUpgradeChange */ -export const zParserPreferencesChange = z.object({ +export const zParserAutoUpgradeChange = z.object({ auto_upgrade: zPluginAutoUpgradeSettingsPayload, - permission: zPluginPermissionSettingsPayload, + category: zPluginCategory, }) /** - * PluginPreferencesResponse + * PluginAutoUpgradeSettingsResponseModel */ -export const zPluginPreferencesResponse = z.object({ - auto_upgrade: zPluginAutoUpgradeSettingsPayload, - permission: zPluginPermissionSettingsPayload, +export const zPluginAutoUpgradeSettingsResponseModel = z.object({ + exclude_plugins: z.array(z.string()), + include_plugins: z.array(z.string()), + strategy_setting: zStrategySetting, + upgrade_mode: zUpgradeMode, + upgrade_time_of_day: z.int(), }) +/** + * PluginAutoUpgradeFetchResponse + */ +export const zPluginAutoUpgradeFetchResponse = z.object({ + auto_upgrade: zPluginAutoUpgradeSettingsResponseModel, + category: zPluginCategory, +}) + +/** + * I18nObject + * + * Model class for i18n object. + */ +export const zCoreToolsEntitiesCommonEntitiesI18nObject = z.object({ + en_US: z.string(), + ja_JP: z.string().nullish(), + pt_BR: z.string().nullish(), + zh_Hans: z.string().nullish(), +}) + +/** + * PluginCategoryBuiltinToolResponse + */ +export const zPluginCategoryBuiltinToolResponse = z.object({ + author: z.string(), + description: zCoreToolsEntitiesCommonEntitiesI18nObject, + label: zCoreToolsEntitiesCommonEntitiesI18nObject, + labels: z.array(z.string()), + name: z.string(), + output_schema: z.record(z.string(), z.unknown()), + parameters: z.array(z.record(z.string(), z.unknown())).nullish(), +}) + +/** + * ToolProviderType + * + * Enum class for tool provider + */ +export const zToolProviderType = z.enum([ + 'api', + 'app', + 'builtin', + 'dataset-retrieval', + 'mcp', + 'plugin', + 'workflow', +]) + +/** + * PluginCategoryBuiltinToolProviderResponse + */ +export const zPluginCategoryBuiltinToolProviderResponse = z.object({ + allow_delete: z.boolean(), + author: z.string(), + description: zCoreToolsEntitiesCommonEntitiesI18nObject, + icon: z.union([z.string(), z.record(z.string(), z.string())]), + icon_dark: z.union([z.string(), z.record(z.string(), z.string())]).nullable(), + id: z.string(), + is_team_authorization: z.boolean(), + label: zCoreToolsEntitiesCommonEntitiesI18nObject, + labels: z.array(z.string()), + name: z.string(), + plugin_id: z.string().nullable(), + plugin_unique_identifier: z.string().nullable(), + team_credentials: z.record(z.string(), z.unknown()), + tools: z.array(zPluginCategoryBuiltinToolResponse), + type: zToolProviderType, +}) + +/** + * PluginInstallationSource + */ +export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote']) + /** * ToolParameterForm */ @@ -1458,8 +1552,8 @@ export const zCustomConfigurationResponse = z.object({ * FieldModelSchema */ export const zFieldModelSchema = z.object({ - label: zI18nObject, - placeholder: zI18nObject.nullish(), + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + placeholder: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), }) /** @@ -1489,7 +1583,7 @@ export const zAiModelEntityResponse = z.object({ deprecated: z.boolean().optional().default(false), features: z.array(zModelFeature).nullish(), fetch_from: zFetchFrom, - label: zI18nObject, + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, model: z.string(), model_properties: z.record(z.string(), z.unknown()), model_type: zModelType, @@ -1573,7 +1667,7 @@ export const zFormShowOnObject = z.object({ * Model class for form option. */ export const zFormOption = z.object({ - label: zI18nObject, + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, show_on: z.array(zFormShowOnObject).optional().default([]), value: z.string(), }) @@ -1592,10 +1686,10 @@ export const zFormType = z.enum(['radio', 'secret-input', 'select', 'switch', 't */ export const zCredentialFormSchema = z.object({ default: z.string().nullish(), - label: zI18nObject, + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, max_length: z.int().optional().default(0), options: z.array(zFormOption).nullish(), - placeholder: zI18nObject.nullish(), + placeholder: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), required: z.boolean().optional().default(true), show_on: z.array(zFormShowOnObject).optional().default([]), type: zFormType, @@ -1621,6 +1715,86 @@ export const zProviderCredentialSchema = z.object({ credential_form_schemas: z.array(zCredentialFormSchema), }) +/** + * ProviderEntityResponse + * + * Runtime provider response with codegen-safe model pricing schemas. + */ +export const zProviderEntityResponse = z.object({ + background: z.string().nullish(), + configurate_methods: z.array(zConfigurateMethod), + description: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + help: zProviderHelpEntity.nullish(), + icon_small: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + icon_small_dark: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject.nullish(), + label: zGraphonModelRuntimeEntitiesCommonEntitiesI18nObject, + model_credential_schema: zModelCredentialSchema.nullish(), + models: z.array(zAiModelEntityResponse).optional().default([]), + position: z.record(z.string(), z.array(z.string())).nullish().default({}), + provider: z.string(), + provider_credential_schema: zProviderCredentialSchema.nullish(), + provider_name: z.string().optional().default(''), + supported_model_types: z.array(zModelType), +}) + +/** + * PluginDeclarationResponse + */ +export const zPluginDeclarationResponse = z.object({ + agent_strategy: z.record(z.string(), z.unknown()).nullish(), + author: z.string().nullable(), + category: zPluginCategory, + created_at: z.iso.datetime(), + datasource: z.record(z.string(), z.unknown()).nullish(), + description: zCoreToolsEntitiesCommonEntitiesI18nObject, + endpoint: z.record(z.string(), z.unknown()).nullish(), + icon: z.string(), + icon_dark: z.string().nullish(), + label: zCoreToolsEntitiesCommonEntitiesI18nObject, + meta: z.record(z.string(), z.unknown()), + model: zProviderEntityResponse.nullish(), + name: z.string(), + plugins: z.record(z.string(), z.array(z.string()).nullable()), + repo: z.string().nullish(), + resource: z.record(z.string(), z.unknown()), + tags: z.array(z.string()).optional(), + tool: z.record(z.string(), z.unknown()).nullish(), + trigger: z.record(z.string(), z.unknown()).nullish(), + verified: z.boolean().optional().default(false), + version: z.string(), +}) + +/** + * PluginCategoryInstalledPluginResponse + */ +export const zPluginCategoryInstalledPluginResponse = z.object({ + checksum: z.string(), + created_at: z.iso.datetime(), + declaration: zPluginDeclarationResponse, + endpoints_active: z.int(), + endpoints_setups: z.int(), + id: z.string(), + installation_id: z.string(), + meta: z.record(z.string(), z.unknown()), + name: z.string(), + plugin_id: z.string(), + plugin_unique_identifier: z.string(), + runtime_type: z.string(), + source: zPluginInstallationSource, + tenant_id: z.string(), + updated_at: z.iso.datetime(), + version: z.string(), +}) + +/** + * PluginCategoryListResponse + */ +export const zPluginCategoryListResponse = z.object({ + builtin_tools: z.array(zPluginCategoryBuiltinToolProviderResponse), + has_more: z.boolean(), + plugins: z.array(zPluginCategoryInstalledPluginResponse), +}) + /** * QuotaUnit */ @@ -2294,6 +2468,30 @@ export const zGetWorkspacesCurrentPluginAssetQuery = z.object({ */ export const zGetWorkspacesCurrentPluginAssetResponse = zBinaryFileResponse +export const zPostWorkspacesCurrentPluginAutoUpgradeChangeBody = zParserAutoUpgradeChange + +/** + * Success + */ +export const zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse + = zPluginAutoUpgradeChangeResponse + +export const zPostWorkspacesCurrentPluginAutoUpgradeExcludeBody = zParserExcludePlugin + +/** + * Success + */ +export const zPostWorkspacesCurrentPluginAutoUpgradeExcludeResponse = zSuccessResponse + +export const zGetWorkspacesCurrentPluginAutoUpgradeFetchQuery = z.object({ + category: z.enum(['agent-strategy', 'datasource', 'extension', 'model', 'tool', 'trigger']), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentPluginAutoUpgradeFetchResponse = zPluginAutoUpgradeFetchResponse + /** * Success */ @@ -2408,26 +2606,6 @@ export const zPostWorkspacesCurrentPluginPermissionChangeResponse = zSuccessResp */ export const zGetWorkspacesCurrentPluginPermissionFetchResponse = zPluginPermissionResponse -export const zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeBody = zParserExcludePlugin - -/** - * Success - */ -export const zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponse - = zPluginOperationSuccessResponse - -export const zPostWorkspacesCurrentPluginPreferencesChangeBody = zParserPreferencesChange - -/** - * Success - */ -export const zPostWorkspacesCurrentPluginPreferencesChangeResponse = zPluginOperationSuccessResponse - -/** - * Success - */ -export const zGetWorkspacesCurrentPluginPreferencesFetchResponse = zPluginPreferencesResponse - export const zGetWorkspacesCurrentPluginReadmeQuery = z.object({ language: z.string().optional().default('en-US'), plugin_unique_identifier: z.string(), @@ -2519,6 +2697,20 @@ export const zPostWorkspacesCurrentPluginUploadGithubResponse = zPluginDaemonOpe */ export const zPostWorkspacesCurrentPluginUploadPkgResponse = zPluginDaemonOperationResponse +export const zGetWorkspacesCurrentPluginByCategoryListPath = z.object({ + category: z.string(), +}) + +export const zGetWorkspacesCurrentPluginByCategoryListQuery = z.object({ + page: z.int().gte(1).optional().default(1), + page_size: z.int().gte(1).lte(256).optional().default(256), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentPluginByCategoryListResponse = zPluginCategoryListResponse + /** * Success */ diff --git a/packages/dify-ui/.storybook/main.ts b/packages/dify-ui/.storybook/main.ts index c8b7ee8e3f5..2dcdf5afe28 100644 --- a/packages/dify-ui/.storybook/main.ts +++ b/packages/dify-ui/.storybook/main.ts @@ -8,6 +8,8 @@ const config: StorybookConfig = { '@storybook/addon-links', '@storybook/addon-docs', '@storybook/addon-themes', + '@storybook/addon-a11y', + '@storybook/addon-vitest', '@chromatic-com/storybook', ], framework: '@storybook/react-vite', diff --git a/packages/dify-ui/.storybook/preview.tsx b/packages/dify-ui/.storybook/preview.tsx index a5bfc5d8af6..18bf768e4fb 100644 --- a/packages/dify-ui/.storybook/preview.tsx +++ b/packages/dify-ui/.storybook/preview.tsx @@ -24,6 +24,9 @@ const preview: Preview = { docs: { toc: true, }, + a11y: { + test: 'error', + }, }, tags: ['autodocs'], } diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 06db2d0f418..1833eb4ecdf 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -166,8 +166,23 @@ See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay b - `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 test:storybook` — Storybook component tests in Vitest browser mode. Stories without `play` are render and a11y smoke tests; stories with `play` should cover public UI contracts such as opening overlays, keyboard navigation, disabled/loading guards, form submission, and controlled state updates. - `pnpm -C packages/dify-ui type-check` — `tsgo --noEmit` for this package only. +### Test Boundary + +Use Storybook tests for behavior that belongs to the documented component example: +visible state changes, user interaction, keyboard paths, overlay open/close flows, +and accessibility-facing semantics. Keep regular Vitest unit tests for lower-level +wrapper contracts such as class variants, Base UI passthrough props, hidden input +serialization, data attribute hooks, store behavior, and edge cases that do not +need a full story. + +Storybook accessibility testing stays enabled globally with `a11y.test = 'error'`. +If a story is temporarily marked `todo`, keep the exception local to that story +and do not treat an interaction `play` test as a replacement for fixing the +underlying accessibility issue. + ### Disabling Animations In Tests Base UI can wait for `element.getAnimations()` to finish before it unmounts overlays, panels, and transition-driven components. Browser-based test runners can make that timing unstable, especially when tests assert final DOM state rather than animation behavior. diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 5758e3541bf..0d2e4d47359 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -157,8 +157,9 @@ "scripts": { "storybook": "storybook dev -p 6006", "storybook:build": "storybook build", - "test": "vp test", - "test:watch": "vp test --watch", + "test": "vp test --project unit", + "test:storybook": "vp test --project storybook --run", + "test:watch": "vp test --project unit --watch", "type-check": "tsgo" }, "peerDependencies": { @@ -178,9 +179,11 @@ "@dify/tsconfig": "workspace:*", "@egoist/tailwindcss-icons": "catalog:", "@iconify-json/ri": "catalog:", + "@storybook/addon-a11y": "catalog:", "@storybook/addon-docs": "catalog:", "@storybook/addon-links": "catalog:", "@storybook/addon-themes": "catalog:", + "@storybook/addon-vitest": "catalog:", "@storybook/react-vite": "catalog:", "@tailwindcss/vite": "catalog:", "@tanstack/react-hotkeys": "catalog:", diff --git a/packages/dify-ui/src/alert-dialog/index.stories.tsx b/packages/dify-ui/src/alert-dialog/index.stories.tsx index e56d6b942fd..c8dcc6ac533 100644 --- a/packages/dify-ui/src/alert-dialog/index.stories.tsx +++ b/packages/dify-ui/src/alert-dialog/index.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' +import { expect, waitFor, within } from 'storybook/test' import { AlertDialog, AlertDialogActions, @@ -55,6 +56,21 @@ export const Default: Story = { ), + play: async ({ canvas, canvasElement, userEvent }) => { + const body = within(canvasElement.ownerDocument.body) + + await userEvent.click(canvas.getByRole('button', { name: 'Delete project' })) + + const dialog = body.getByRole('alertdialog', { name: 'Delete project?' }) + await waitFor(async () => { + await expect(dialog).toBeVisible() + }) + + await userEvent.click(body.getByRole('button', { name: 'Cancel' })) + await waitFor(async () => { + await expect(body.queryByRole('alertdialog', { name: 'Delete project?' })).not.toBeInTheDocument() + }) + }, } export const NonDestructive: Story = { diff --git a/packages/dify-ui/src/autocomplete/index.stories.tsx b/packages/dify-ui/src/autocomplete/index.stories.tsx index e9f707abe2b..bf96cd1ee17 100644 --- a/packages/dify-ui/src/autocomplete/index.stories.tsx +++ b/packages/dify-ui/src/autocomplete/index.stories.tsx @@ -297,8 +297,9 @@ const CommandPaletteList = () => { {groups.map((group, groupIndex) => ( - {groupIndex > 0 && } - {group.label} + 0 ? 'mt-1 border-t border-divider-subtle pt-2' : undefined}> + {group.label} + {(item: Suggestion) => ( @@ -691,7 +692,7 @@ export const CommandPalette: Story = { > diff --git a/packages/dify-ui/src/button/index.stories.tsx b/packages/dify-ui/src/button/index.stories.tsx index f8397d95991..91a1d38c2d3 100644 --- a/packages/dify-ui/src/button/index.stories.tsx +++ b/packages/dify-ui/src/button/index.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' +import { expect, fn } from 'storybook/test' import { Button } from '.' @@ -90,8 +91,21 @@ export const Loading: Story = { args: { variant: 'primary', loading: true, + onClick: fn(), children: 'Loading Button', }, + play: async ({ args, canvas, userEvent }) => { + const button = canvas.getByRole('button', { name: 'Loading Button' }) + + await expect(button).toHaveAttribute('aria-disabled', 'true') + await expect(button).toHaveAttribute('aria-busy', 'true') + + button.focus() + await expect(button).toHaveFocus() + + await userEvent.click(button) + await expect(args.onClick).not.toHaveBeenCalled() + }, parameters: { docs: { description: { @@ -141,6 +155,7 @@ export const AsLink: Story = { args: { variant: 'ghost-accent', render: , + nativeButton: false, children: 'Link Button', }, } diff --git a/packages/dify-ui/src/combobox/index.stories.tsx b/packages/dify-ui/src/combobox/index.stories.tsx index a1c1badff9f..1c8021811cd 100644 --- a/packages/dify-ui/src/combobox/index.stories.tsx +++ b/packages/dify-ui/src/combobox/index.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { Virtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual' import * as React from 'react' +import { expect } from 'storybook/test' import { Combobox, ComboboxChip, @@ -768,6 +769,15 @@ const MultipleChipsDemo = () => { export const MultipleChips: Story = { render: () => , + play: async ({ canvas, userEvent }) => { + await expect(canvas.getByText('Maya Chen')).toBeVisible() + await expect(canvas.getByText('Liam Brooks')).toBeVisible() + + await userEvent.click(canvas.getByRole('button', { name: 'Remove Maya Chen' })) + + await expect(canvas.queryByText('Maya Chen')).not.toBeInTheDocument() + await expect(canvas.getByText('Liam Brooks')).toBeVisible() + }, } export const VirtualizedLongList: Story = { diff --git a/packages/dify-ui/src/dialog/index.stories.tsx b/packages/dify-ui/src/dialog/index.stories.tsx index cd101017f23..e70cc2465db 100644 --- a/packages/dify-ui/src/dialog/index.stories.tsx +++ b/packages/dify-ui/src/dialog/index.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' +import { expect, waitFor, within } from 'storybook/test' import { Dialog, DialogCloseButton, @@ -66,6 +67,22 @@ export const Default: Story = { ), + play: async ({ canvas, canvasElement, userEvent }) => { + const body = within(canvasElement.ownerDocument.body) + + await userEvent.click(canvas.getByRole('button', { name: 'Open dialog' })) + + const dialog = body.getByRole('dialog', { name: 'Invite collaborators' }) + await waitFor(async () => { + await expect(dialog).toBeVisible() + }) + await expect(body.getByRole('textbox', { name: 'Email address' })).toBeVisible() + + await userEvent.click(body.getByRole('button', { name: 'Close' })) + await waitFor(async () => { + await expect(body.queryByRole('dialog', { name: 'Invite collaborators' })).not.toBeInTheDocument() + }) + }, } export const WithoutCloseButton: Story = { diff --git a/packages/dify-ui/src/file-tree/index.stories.tsx b/packages/dify-ui/src/file-tree/index.stories.tsx index 5545ad9b8ef..a75e40aa65c 100644 --- a/packages/dify-ui/src/file-tree/index.stories.tsx +++ b/packages/dify-ui/src/file-tree/index.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { FileTreeIconType } from '.' import * as React from 'react' +import { expect } from 'storybook/test' import { FileTreeBadge, FileTreeFile, @@ -330,6 +331,19 @@ function VisualStates() { export const Default: Story = { render: () => , + play: async ({ canvas, userEvent }) => { + const srcFolder = canvas.getByRole('button', { name: 'src' }) + + await expect(canvas.getByRole('button', { name: 'components' })).toBeVisible() + + await userEvent.click(srcFolder) + await expect(srcFolder).toHaveAttribute('aria-expanded', 'false') + await expect(canvas.queryByRole('button', { name: 'components' })).not.toBeInTheDocument() + + await userEvent.click(srcFolder) + await expect(srcFolder).toHaveAttribute('aria-expanded', 'true') + await expect(canvas.getByRole('button', { name: 'components' })).toBeVisible() + }, } export const DataDriven: Story = { diff --git a/packages/dify-ui/src/number-field/index.stories.tsx b/packages/dify-ui/src/number-field/index.stories.tsx index 284148c56ff..3d85b42c096 100644 --- a/packages/dify-ui/src/number-field/index.stories.tsx +++ b/packages/dify-ui/src/number-field/index.stories.tsx @@ -207,6 +207,11 @@ export const States: Story = { ), + parameters: { + a11y: { + test: 'todo', + }, + }, } function ControlledDemo() { diff --git a/packages/dify-ui/src/pagination/index.stories.tsx b/packages/dify-ui/src/pagination/index.stories.tsx index 0045a421aaf..6ffab6bfee9 100644 --- a/packages/dify-ui/src/pagination/index.stories.tsx +++ b/packages/dify-ui/src/pagination/index.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' +import { expect } from 'storybook/test' import { Pagination, PaginationSkeleton, @@ -9,10 +10,12 @@ function PaginationExample({ initialPage = 2, initialPageSize = 25, totalPages = 200, + label = 'Pagination', }: { initialPage?: number initialPageSize?: number totalPages?: number + label?: string }) { const [page, setPage] = React.useState(initialPage) const [pageSize, setPageSize] = React.useState(initialPageSize) @@ -21,6 +24,7 @@ function PaginationExample({ ) { function DesignSpecDemo() { return (
- - - - + + + +
) } @@ -74,11 +78,28 @@ type Story = StoryObj export const Playground: Story = { render: () => , + play: async ({ canvas, userEvent }) => { + await expect(canvas.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeVisible() + + await userEvent.click(canvas.getByRole('button', { name: 'Next page' })) + await expect(canvas.getByRole('button', { name: 'Edit page number, current page 3 of 200' })).toBeVisible() + + await userEvent.click(canvas.getByRole('button', { name: '50' })) + await expect(canvas.getByRole('button', { name: '50' })).toHaveAttribute('aria-pressed', 'true') + }, + parameters: { + a11y: { + test: 'todo', + }, + }, } export const DesignSpec: Story = { render: () => , parameters: { + a11y: { + test: 'todo', + }, docs: { description: { story: 'Pagination rows with default, hover-like, focused, page-size, and skeleton examples.', diff --git a/packages/dify-ui/src/popover/index.stories.tsx b/packages/dify-ui/src/popover/index.stories.tsx index b7b1a6c3861..21df9296764 100644 --- a/packages/dify-ui/src/popover/index.stories.tsx +++ b/packages/dify-ui/src/popover/index.stories.tsx @@ -127,9 +127,9 @@ export const Infotip: Story = { closeDelay={200} aria-label="Set which resource to use first when running models." render={( - + )} /> { await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass( 'data-pressed:bg-components-segmented-control-item-active-bg', 'data-pressed:text-text-accent-light-mode-only', - 'focus-visible:z-10', + 'focus-visible:ring-inset', ) }) diff --git a/packages/dify-ui/src/segmented-control/index.stories.tsx b/packages/dify-ui/src/segmented-control/index.stories.tsx index f2b6f9cc1f5..ead9931898b 100644 --- a/packages/dify-ui/src/segmented-control/index.stories.tsx +++ b/packages/dify-ui/src/segmented-control/index.stories.tsx @@ -113,6 +113,9 @@ export const DesignSpec: Story = { ), parameters: { + a11y: { + test: 'todo', + }, docs: { description: { story: 'Figma node 2473:9851: segmented control examples with text+icon and icon-only rows, with and without outer padding.', @@ -168,6 +171,9 @@ export const DataAttributeStates: Story = { ), parameters: { + a11y: { + test: 'todo', + }, docs: { description: { story: '`SegmentedControlItem` gets `data-pressed` and `data-disabled` from Base UI Toggle. Accent, neutral, and multiple-selection examples are composed through props and className.', diff --git a/packages/dify-ui/src/segmented-control/index.tsx b/packages/dify-ui/src/segmented-control/index.tsx index ceb3454cc6c..ba347b58f18 100644 --- a/packages/dify-ui/src/segmented-control/index.tsx +++ b/packages/dify-ui/src/segmented-control/index.tsx @@ -33,7 +33,7 @@ export function SegmentedControlItem({ }: SegmentedControlItemProps) { return ( ) diff --git a/packages/dify-ui/src/select/index.stories.tsx b/packages/dify-ui/src/select/index.stories.tsx index 64cd2b8649d..75602572019 100644 --- a/packages/dify-ui/src/select/index.stories.tsx +++ b/packages/dify-ui/src/select/index.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' +import { expect, waitFor, within } from 'storybook/test' import { Select, SelectContent, @@ -19,6 +20,8 @@ const triggerWidth = 'w-64' const cityItems = [ { label: 'Seattle', value: 'seattle' }, { label: 'New York', value: 'new-york' }, + { label: 'Tokyo', value: 'tokyo' }, + { label: 'Paris', value: 'paris' }, ] const meta = { @@ -41,11 +44,11 @@ type Story = StoryObj export const Default: Story = { render: () => (
- - + Seattle @@ -66,6 +69,27 @@ export const Default: Story = {
), + play: async ({ canvas, canvasElement, userEvent }) => { + const trigger = canvas.getByRole('combobox', { name: 'City' }) + const body = within(canvasElement.ownerDocument.body) + + await expect(trigger).toHaveTextContent('Seattle') + + trigger.focus() + await userEvent.keyboard('{ArrowDown}') + + await waitFor(async () => { + await expect(body.getByRole('option', { name: 'Tokyo' })).toBeVisible() + }) + + await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}') + await expect(trigger).toHaveTextContent('Tokyo') + + await userEvent.keyboard('{Escape}') + await waitFor(async () => { + await expect(body.queryByRole('listbox', { name: 'City options' })).not.toBeInTheDocument() + }) + }, } export const WithVisibleLabel: Story = { diff --git a/packages/dify-ui/src/styles/utilities.css b/packages/dify-ui/src/styles/utilities.css index 69b15d4c106..312d6052bd8 100644 --- a/packages/dify-ui/src/styles/utilities.css +++ b/packages/dify-ui/src/styles/utilities.css @@ -265,6 +265,12 @@ line-height: 1.2; } +@utility title-5xl-semi-bold { + font-size: 30px; + font-weight: 600; + line-height: 1.2; +} + @utility title-5xl-bold { font-size: 30px; font-weight: 700; diff --git a/packages/dify-ui/src/switch/index.stories.tsx b/packages/dify-ui/src/switch/index.stories.tsx index a7537b50b82..40e9ca6e519 100644 --- a/packages/dify-ui/src/switch/index.stories.tsx +++ b/packages/dify-ui/src/switch/index.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import * as React from 'react' +import { expect } from 'storybook/test' import { Switch, SwitchSkeleton } from '.' import { FieldDescription, @@ -77,6 +78,17 @@ export const Default: Story = { checked: false, disabled: false, }, + play: async ({ canvas, userEvent }) => { + const switchControl = canvas.getByRole('switch', { name: 'Enable auto retry' }) + + await expect(switchControl).toHaveAttribute('aria-checked', 'false') + await expect(canvas.getByText('Failures require manual retry.')).toBeVisible() + + await userEvent.click(switchControl) + + await expect(switchControl).toHaveAttribute('aria-checked', 'true') + await expect(canvas.getByText('Failures will retry automatically.')).toBeVisible() + }, } export const DefaultOn: Story = { diff --git a/packages/dify-ui/src/themes/dark.css b/packages/dify-ui/src/themes/dark.css index 4c005983ef1..1b24e8fb489 100644 --- a/packages/dify-ui/src/themes/dark.css +++ b/packages/dify-ui/src/themes/dark.css @@ -156,6 +156,19 @@ html[data-theme="dark"] { --color-components-main-nav-nav-button-bg-active: rgb(200 206 218 / 0.14); --color-components-main-nav-nav-button-border: rgb(255 255 255 / 0.08); --color-components-main-nav-nav-button-bg-hover: rgb(200 206 218 / 0.04); + --color-components-main-nav-glass-text-glow: #3146ff2e; + --color-components-main-nav-glass-surface-first: #0033ff14; + --color-components-main-nav-glass-surface-middle-1: #0033ff1f; + --color-components-main-nav-glass-surface-middle-2: #0033ff1a; + --color-components-main-nav-glass-surface-end: #0033ff14; + --color-components-main-nav-glass-edge-highlight-first: #fffffffa; + --color-components-main-nav-glass-edge-highlight-end: #ffffff6b; + --color-components-main-nav-glass-edge-reflection-first: #0033ff00; + --color-components-main-nav-glass-edge-reflection-middle: #0033ff99; + --color-components-main-nav-glass-edge-reflection-end: #0033ff00; + --color-components-main-nav-glass-inner-glow: #ffffff4d; + --color-components-main-nav-glass-shadow-reflection: #0033ff0a; + --color-components-main-nav-glass-shadow-reflection-glow: #ffffff00; --color-components-main-nav-nav-user-border: rgb(255 255 255 / 0.05); diff --git a/packages/dify-ui/src/themes/light.css b/packages/dify-ui/src/themes/light.css index 16e5e898ee5..3feb4afb47f 100644 --- a/packages/dify-ui/src/themes/light.css +++ b/packages/dify-ui/src/themes/light.css @@ -156,6 +156,19 @@ html[data-theme="light"] { --color-components-main-nav-nav-button-bg-active: #fcfcfd; --color-components-main-nav-nav-button-border: rgb(255 255 255 / 0.95); --color-components-main-nav-nav-button-bg-hover: rgb(16 24 40 / 0.04); + --color-components-main-nav-glass-text-glow: #3146ff2e; + --color-components-main-nav-glass-surface-first: #0033ff14; + --color-components-main-nav-glass-surface-middle-1: #0033ff1f; + --color-components-main-nav-glass-surface-middle-2: #0033ff1a; + --color-components-main-nav-glass-surface-end: #0033ff14; + --color-components-main-nav-glass-edge-highlight-first: #fffffffa; + --color-components-main-nav-glass-edge-highlight-end: #ffffff6b; + --color-components-main-nav-glass-edge-reflection-first: #0033ff00; + --color-components-main-nav-glass-edge-reflection-middle: #0033ff99; + --color-components-main-nav-glass-edge-reflection-end: #0033ff00; + --color-components-main-nav-glass-inner-glow: #ffffff4d; + --color-components-main-nav-glass-shadow-reflection: #0033ff0a; + --color-components-main-nav-glass-shadow-reflection-glow: #ffffff00; --color-components-main-nav-nav-user-border: #ffffff; diff --git a/packages/dify-ui/src/themes/theme.css b/packages/dify-ui/src/themes/theme.css index 04e02d852ce..3e35feb8eb8 100644 --- a/packages/dify-ui/src/themes/theme.css +++ b/packages/dify-ui/src/themes/theme.css @@ -163,6 +163,19 @@ --color-components-main-nav-nav-button-bg-active: var(--color-components-main-nav-nav-button-bg-active); --color-components-main-nav-nav-button-border: var(--color-components-main-nav-nav-button-border); --color-components-main-nav-nav-button-bg-hover: var(--color-components-main-nav-nav-button-bg-hover); + --color-components-main-nav-glass-text-glow: var(--color-components-main-nav-glass-text-glow); + --color-components-main-nav-glass-surface-first: var(--color-components-main-nav-glass-surface-first); + --color-components-main-nav-glass-surface-middle-1: var(--color-components-main-nav-glass-surface-middle-1); + --color-components-main-nav-glass-surface-middle-2: var(--color-components-main-nav-glass-surface-middle-2); + --color-components-main-nav-glass-surface-end: var(--color-components-main-nav-glass-surface-end); + --color-components-main-nav-glass-edge-highlight-first: var(--color-components-main-nav-glass-edge-highlight-first); + --color-components-main-nav-glass-edge-highlight-end: var(--color-components-main-nav-glass-edge-highlight-end); + --color-components-main-nav-glass-edge-reflection-first: var(--color-components-main-nav-glass-edge-reflection-first); + --color-components-main-nav-glass-edge-reflection-middle: var(--color-components-main-nav-glass-edge-reflection-middle); + --color-components-main-nav-glass-edge-reflection-end: var(--color-components-main-nav-glass-edge-reflection-end); + --color-components-main-nav-glass-inner-glow: var(--color-components-main-nav-glass-inner-glow); + --color-components-main-nav-glass-shadow-reflection: var(--color-components-main-nav-glass-shadow-reflection); + --color-components-main-nav-glass-shadow-reflection-glow: var(--color-components-main-nav-glass-shadow-reflection-glow); --color-components-main-nav-nav-user-border: var(--color-components-main-nav-nav-user-border); diff --git a/packages/dify-ui/vite.config.ts b/packages/dify-ui/vite.config.ts index f564ca769ce..ef6b439f75f 100644 --- a/packages/dify-ui/vite.config.ts +++ b/packages/dify-ui/vite.config.ts @@ -1,8 +1,5 @@ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite-plus' -import { playwright } from 'vite-plus/test/browser-playwright' - -const isCI = !!process.env.CI export default defineConfig({ plugins: [react()], @@ -16,25 +13,4 @@ export default defineConfig({ '@base-ui/react/use-render', ], }, - test: { - globals: true, - setupFiles: ['./vitest.setup.ts'], - browser: { - enabled: true, - provider: playwright(), - instances: [{ browser: 'chromium' }], - headless: true, - }, - coverage: { - provider: 'v8', - include: ['src/**/*.{ts,tsx}'], - exclude: [ - 'src/**/*.stories.{ts,tsx}', - 'src/**/__tests__/**', - 'src/themes/**', - 'src/styles/**', - ], - reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'], - }, - }, }) diff --git a/packages/dify-ui/vitest.config.ts b/packages/dify-ui/vitest.config.ts new file mode 100644 index 00000000000..dfda908c563 --- /dev/null +++ b/packages/dify-ui/vitest.config.ts @@ -0,0 +1,71 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite-plus' +import { playwright } from 'vite-plus/test/browser-playwright' + +const dirname = path.dirname(fileURLToPath(import.meta.url)) +const configDir = path.join(dirname, '.storybook') +const isCI = !!process.env.CI + +export default defineConfig({ + plugins: [react()], + resolve: { + tsconfigPaths: true, + }, + optimizeDeps: { + include: [ + '@base-ui/react/form', + '@base-ui/react/merge-props', + '@base-ui/react/use-render', + ], + }, + test: { + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.stories.{ts,tsx}', + 'src/**/__tests__/**', + 'src/themes/**', + 'src/styles/**', + ], + reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'], + }, + projects: [ + { + extends: true, + test: { + name: 'unit', + globals: true, + setupFiles: ['./vitest.setup.ts'], + include: ['src/**/__tests__/**/*.spec.{ts,tsx}'], + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + headless: true, + }, + }, + }, + { + extends: true, + plugins: [ + storybookTest({ + configDir, + }), + ], + test: { + name: 'storybook', + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + headless: true, + }, + }, + }, + ], + }, +}) diff --git a/packages/iconify-collections/README.md b/packages/iconify-collections/README.md new file mode 100644 index 00000000000..f36b63d03b8 --- /dev/null +++ b/packages/iconify-collections/README.md @@ -0,0 +1,43 @@ +# @dify/iconify-collections + +Pre-generated Iconify collections for Dify custom SVG icons. The web app imports these collections from this package so Tailwind does not need to scan and build custom SVG icon data from the old `web/app/components/base/icons/src` tree during dev startup. + +## Adding Custom SVG Icons + +Add new SVG source files under one of these directories: + +- `assets/public/...` for multi-color or public brand-like icons. +- `assets/vender/...` for UI vendor icons that should render with `currentColor`. + +After adding or changing SVG files, regenerate the packaged collections: + +```bash +pnpm --filter @dify/iconify-collections generate +``` + +Then run the dimension guard: + +```bash +pnpm --filter @dify/iconify-collections check:dimensions +``` + +This protects existing icon groups with layout-sensitive intrinsic sizes, such as the `main-nav-*` icons that must remain `20x20` after collection flattening. + +Commit both the SVG source files and the generated package files under `custom-public/` or `custom-vender/`. +Restart the web dev server after regenerating icons. Tailwind loads this plugin collection at startup, so an already-running dev server may not render newly-added `i-custom-*` classes until it restarts. + +Use the generated icons through Tailwind icon classes in frontend code. For example: + +```text +assets/vender/integrations/mcp.svg +``` + +becomes: + +```tsx + +``` + +Do not add new generated React icon components or JSON files under `web/app/components/base/icons/src/...` for new custom SVG icons. That path is legacy; new custom icons should flow through this package and be consumed as `i-custom-*` classes. + +When reviewing generated `icons.json` diffs, check that unrelated existing icon groups did not lose or change their intrinsic `width` and `height`. If a group is layout-sensitive, add it to `scripts/check-icon-dimensions.ts`. diff --git a/packages/iconify-collections/assets/vender/integrations/agent-strategy-active.svg b/packages/iconify-collections/assets/vender/integrations/agent-strategy-active.svg new file mode 100644 index 00000000000..85b5cede98f --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/agent-strategy-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/agent-strategy.svg b/packages/iconify-collections/assets/vender/integrations/agent-strategy.svg new file mode 100644 index 00000000000..8dd5385fdef --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/agent-strategy.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/api-extension-active.svg b/packages/iconify-collections/assets/vender/integrations/api-extension-active.svg new file mode 100644 index 00000000000..631a2c6adc3 --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/api-extension-active.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/iconify-collections/assets/vender/integrations/api-extension.svg b/packages/iconify-collections/assets/vender/integrations/api-extension.svg new file mode 100644 index 00000000000..97dd93a77ba --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/api-extension.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/custom-tool-active.svg b/packages/iconify-collections/assets/vender/integrations/custom-tool-active.svg new file mode 100644 index 00000000000..a4dd7d6f841 --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/custom-tool-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/custom-tool.svg b/packages/iconify-collections/assets/vender/integrations/custom-tool.svg new file mode 100644 index 00000000000..15c6324fbfc --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/custom-tool.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/extension-active.svg b/packages/iconify-collections/assets/vender/integrations/extension-active.svg new file mode 100644 index 00000000000..41793c99e14 --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/extension-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/extension.svg b/packages/iconify-collections/assets/vender/integrations/extension.svg new file mode 100644 index 00000000000..ea3735aff66 --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/extension.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/install-drop.svg b/packages/iconify-collections/assets/vender/integrations/install-drop.svg new file mode 100644 index 00000000000..c82a9fc4813 --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/install-drop.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/install-github.svg b/packages/iconify-collections/assets/vender/integrations/install-github.svg new file mode 100644 index 00000000000..b11c3255c36 --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/install-github.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/install-local-package.svg b/packages/iconify-collections/assets/vender/integrations/install-local-package.svg new file mode 100644 index 00000000000..5167a2fe03f --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/install-local-package.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/install-marketplace.svg b/packages/iconify-collections/assets/vender/integrations/install-marketplace.svg new file mode 100644 index 00000000000..a1649a4a09c --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/install-marketplace.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/mcp.svg b/packages/iconify-collections/assets/vender/integrations/mcp.svg new file mode 100644 index 00000000000..a4a15f99f77 --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/mcp.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/iconify-collections/assets/vender/integrations/panel-left.svg b/packages/iconify-collections/assets/vender/integrations/panel-left.svg new file mode 100644 index 00000000000..fb0378e5576 --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/panel-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/tools-active.svg b/packages/iconify-collections/assets/vender/integrations/tools-active.svg new file mode 100644 index 00000000000..39617977443 --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/tools-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/tools.svg b/packages/iconify-collections/assets/vender/integrations/tools.svg new file mode 100644 index 00000000000..d88b285bce2 --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/tools.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/integrations/trigger-active.svg b/packages/iconify-collections/assets/vender/integrations/trigger-active.svg new file mode 100644 index 00000000000..018048390ef --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/trigger-active.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/iconify-collections/assets/vender/integrations/trigger.svg b/packages/iconify-collections/assets/vender/integrations/trigger.svg new file mode 100644 index 00000000000..80b9bba35fa --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/trigger.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/iconify-collections/assets/vender/integrations/workflow-as-tool-active.svg b/packages/iconify-collections/assets/vender/integrations/workflow-as-tool-active.svg new file mode 100644 index 00000000000..110a9cb7a04 --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/workflow-as-tool-active.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/iconify-collections/assets/vender/integrations/workflow-as-tool.svg b/packages/iconify-collections/assets/vender/integrations/workflow-as-tool.svg new file mode 100644 index 00000000000..6a25ba0411c --- /dev/null +++ b/packages/iconify-collections/assets/vender/integrations/workflow-as-tool.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/app-home.svg b/packages/iconify-collections/assets/vender/main-nav/app-home.svg new file mode 100644 index 00000000000..157d8dcd52c --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/app-home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/credits.svg b/packages/iconify-collections/assets/vender/main-nav/credits.svg new file mode 100644 index 00000000000..e956861d724 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/credits.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/help.svg b/packages/iconify-collections/assets/vender/main-nav/help.svg new file mode 100644 index 00000000000..58d0a92dd6d --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/help.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/home-active.svg b/packages/iconify-collections/assets/vender/main-nav/home-active.svg new file mode 100644 index 00000000000..13dc13e0947 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/home-active.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/home.svg b/packages/iconify-collections/assets/vender/main-nav/home.svg new file mode 100644 index 00000000000..cf685e7f234 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/integrations-active.svg b/packages/iconify-collections/assets/vender/main-nav/integrations-active.svg new file mode 100644 index 00000000000..351802502ff --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/integrations-active.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/integrations.svg b/packages/iconify-collections/assets/vender/main-nav/integrations.svg new file mode 100644 index 00000000000..af030453f91 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/integrations.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/knowledge-active.svg b/packages/iconify-collections/assets/vender/main-nav/knowledge-active.svg new file mode 100644 index 00000000000..869982f1177 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/knowledge-active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/knowledge.svg b/packages/iconify-collections/assets/vender/main-nav/knowledge.svg new file mode 100644 index 00000000000..c54812ffc21 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/knowledge.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/marketplace-active.svg b/packages/iconify-collections/assets/vender/main-nav/marketplace-active.svg new file mode 100644 index 00000000000..8a2d7a7911a --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/marketplace-active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/marketplace.svg b/packages/iconify-collections/assets/vender/main-nav/marketplace.svg new file mode 100644 index 00000000000..cbec531fe9b --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/marketplace.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/quick-search.svg b/packages/iconify-collections/assets/vender/main-nav/quick-search.svg new file mode 100644 index 00000000000..f96f09d4e4f --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/quick-search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/studio-active.svg b/packages/iconify-collections/assets/vender/main-nav/studio-active.svg new file mode 100644 index 00000000000..2441d4760c6 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/studio-active.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/studio.svg b/packages/iconify-collections/assets/vender/main-nav/studio.svg new file mode 100644 index 00000000000..ac64f1100a2 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/studio.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/iconify-collections/assets/vender/main-nav/workspace-settings.svg b/packages/iconify-collections/assets/vender/main-nav/workspace-settings.svg new file mode 100644 index 00000000000..0eba74b8743 --- /dev/null +++ b/packages/iconify-collections/assets/vender/main-nav/workspace-settings.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/custom-public/icons.json b/packages/iconify-collections/custom-public/icons.json index 13d8400b890..7a4ea8bdaf1 100644 --- a/packages/iconify-collections/custom-public/icons.json +++ b/packages/iconify-collections/custom-public/icons.json @@ -1,6 +1,6 @@ { "prefix": "custom-public", - "lastModified": 1776670621, + "lastModified": 1781246368, "icons": { "avatar-user": { "body": "", @@ -9,11 +9,13 @@ }, "billing-ar-cube-1": { "body": "", - "width": 28 + "width": 28, + "height": 28 }, "billing-asterisk": { "body": "", - "width": 28 + "width": 28, + "height": 28 }, "billing-aws-marketplace-dark": { "body": "", @@ -31,10 +33,14 @@ "height": 20 }, "billing-buildings": { - "body": "" + "body": "", + "width": 29, + "height": 28 }, "billing-diamond": { - "body": "" + "body": "", + "width": 29, + "height": 28 }, "billing-google-cloud": { "body": "", @@ -42,10 +48,14 @@ "height": 18 }, "billing-group-2": { - "body": "" + "body": "", + "width": 29, + "height": 28 }, "billing-keyframe": { - "body": "" + "body": "", + "width": 29, + "height": 28 }, "billing-sparkles-soft": { "body": "", @@ -132,31 +142,49 @@ "height": 22 }, "files-csv": { - "body": "" + "body": "", + "width": 32, + "height": 34 }, "files-doc": { - "body": "" + "body": "", + "width": 32, + "height": 34 }, "files-docx": { - "body": "" + "body": "", + "width": 32, + "height": 34 }, "files-html": { - "body": "" + "body": "", + "width": 32, + "height": 34 }, "files-json": { - "body": "" + "body": "", + "width": 32, + "height": 34 }, "files-md": { - "body": "" + "body": "", + "width": 32, + "height": 34 }, "files-pdf": { - "body": "" + "body": "", + "width": 32, + "height": 34 }, "files-txt": { - "body": "" + "body": "", + "width": 32, + "height": 34 }, "files-unknown": { - "body": "" + "body": "", + "width": 32, + "height": 34 }, "files-xlsx": { "body": "", @@ -184,10 +212,14 @@ "height": 74 }, "knowledge-option-card-effect-orange": { - "body": "" + "body": "", + "width": 220, + "height": 220 }, "knowledge-option-card-effect-purple": { - "body": "" + "body": "", + "width": 220, + "height": 220 }, "knowledge-option-card-effect-teal": { "body": "", @@ -205,33 +237,49 @@ "height": 500 }, "knowledge-dataset-card-external-knowledge-base": { - "body": "" + "body": "", + "width": 18, + "height": 18 }, "knowledge-dataset-card-general": { - "body": "" + "body": "", + "width": 18, + "height": 18 }, "knowledge-dataset-card-graph": { - "body": "" + "body": "", + "width": 18, + "height": 18 }, "knowledge-dataset-card-parent-child": { - "body": "" + "body": "", + "width": 18, + "height": 18 }, "knowledge-dataset-card-qa": { - "body": "" + "body": "", + "width": 18, + "height": 18 }, "knowledge-online-drive-buckets-blue": { "body": "", - "height": 21 + "height": 21, + "width": 20 }, "knowledge-online-drive-buckets-gray": { "body": "", - "width": 18 + "width": 18, + "height": 19 }, "knowledge-online-drive-folder": { - "body": "" + "body": "", + "width": 20, + "height": 19 }, "llm-anthropic": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-anthropic-dark": { "body": "", @@ -255,32 +303,43 @@ }, "llm-azure-openai-service": { "body": "", - "width": 56 + "width": 56, + "height": 24 }, "llm-azure-openai-service-text": { "body": "", - "width": 212 + "width": 212, + "height": 24 }, "llm-azureai": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-azureai-text": { "body": "", - "width": 92 + "width": 92, + "height": 24 }, "llm-baichuan": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-baichuan-text": { "body": "", - "width": 130 + "width": 130, + "height": 24 }, "llm-chatglm": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-chatglm-text": { "body": "", - "width": 100 + "width": 100, + "height": 24 }, "llm-cohere": { "body": "", @@ -289,7 +348,8 @@ }, "llm-cohere-text": { "body": "", - "width": 120 + "width": 120, + "height": 24 }, "llm-deepseek": { "body": "", @@ -302,10 +362,14 @@ "height": 40 }, "llm-gpt-3": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-gpt-4": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-grok": { "body": "", @@ -313,33 +377,44 @@ "height": 40 }, "llm-huggingface": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-huggingface-text": { "body": "", - "width": 120 + "width": 120, + "height": 24 }, "llm-huggingface-text-hub": { "body": "", - "width": 151 + "width": 151, + "height": 24 }, "llm-iflytek-spark": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-iflytek-spark-text": { "body": "", - "width": 150 + "width": 150, + "height": 24 }, "llm-iflytek-spark-text-cn": { "body": "", - "width": 84 + "width": 84, + "height": 24 }, "llm-jina": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-jina-text": { "body": "", - "width": 58 + "width": 58, + "height": 24 }, "llm-microsoft": { "body": "", @@ -347,16 +422,24 @@ "height": 22 }, "llm-openai-black": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-openai-blue": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-openai-green": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-openai-teal": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-openai-text": { "body": "", @@ -364,16 +447,24 @@ "height": 20 }, "llm-openai-transparent": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-openai-violet": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-openai-yellow": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-openllm": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-openllm-text": { "body": "", @@ -381,21 +472,29 @@ "height": 25 }, "llm-replicate": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-replicate-text": { "body": "", - "width": 92 + "width": 92, + "height": 24 }, "llm-xorbits-inference": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-xorbits-inference-text": { "body": "", - "width": 152 + "width": 152, + "height": 24 }, "llm-zhipuai": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "llm-zhipuai-text": { "body": "", @@ -416,7 +515,9 @@ "height": 12 }, "other-default-tool-icon": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "other-icon-3-dots": { "body": "", @@ -424,7 +525,9 @@ "height": 16 }, "other-message-3-fill": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "other-row-struct": { "body": "", @@ -447,16 +550,24 @@ "height": 24 }, "plugins-partner-dark": { - "body": "" + "body": "", + "width": 20, + "height": 20 }, "plugins-partner-light": { - "body": "" + "body": "", + "width": 20, + "height": 20 }, "plugins-verified-dark": { - "body": "" + "body": "", + "width": 20, + "height": 20 }, "plugins-verified-light": { - "body": "" + "body": "", + "width": 20, + "height": 20 }, "plugins-web-reader": { "body": "", @@ -469,19 +580,29 @@ "height": 24 }, "thought-data-set": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "thought-loading": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "thought-search": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "thought-thought-list": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "thought-web-reader": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "tracing-aliyun-icon": { "body": "", @@ -493,7 +614,8 @@ "height": 24 }, "tracing-arize-icon": { - "body": "" + "body": "", + "width": 74 }, "tracing-arize-icon-big": { "body": "", @@ -510,7 +632,8 @@ "height": 24 }, "tracing-langfuse-icon": { - "body": "" + "body": "", + "width": 74 }, "tracing-langfuse-icon-big": { "body": "", @@ -546,7 +669,8 @@ "height": 24 }, "tracing-phoenix-icon": { - "body": "" + "body": "", + "width": 74 }, "tracing-phoenix-icon-big": { "body": "", diff --git a/packages/iconify-collections/custom-vender/icons.json b/packages/iconify-collections/custom-vender/icons.json index f85c44d912d..bf08a795057 100644 --- a/packages/iconify-collections/custom-vender/icons.json +++ b/packages/iconify-collections/custom-vender/icons.json @@ -1,36 +1,156 @@ { "prefix": "custom-vender", - "lastModified": 1781036531, + "lastModified": 1781246368, "icons": { "features-citations": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "features-content-moderation": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "features-document": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "features-folder-upload": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "features-love-message": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "features-message-fast": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "features-microphone-01": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "features-text-to-audio": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "features-virtual-assistant": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "features-vision": { - "body": "" + "body": "", + "width": 24, + "height": 24 + }, + "integrations-agent-strategy": { + "body": "", + "width": 15.3333, + "height": 14.6667 + }, + "integrations-agent-strategy-active": { + "body": "", + "width": 15.3333, + "height": 14.6667 + }, + "integrations-api-extension": { + "body": "", + "width": 14, + "height": 12.9447 + }, + "integrations-api-extension-active": { + "body": "", + "width": 16, + "height": 16 + }, + "integrations-custom-tool": { + "body": "", + "width": 12.6667, + "height": 14.6667 + }, + "integrations-custom-tool-active": { + "body": "", + "width": 12.6667, + "height": 14.2807 + }, + "integrations-extension": { + "body": "", + "width": 12, + "height": 13.3333 + }, + "integrations-extension-active": { + "body": "", + "width": 12, + "height": 13.3333 + }, + "integrations-install-drop": { + "body": "", + "width": 13.3333, + "height": 13.3333 + }, + "integrations-install-github": { + "body": "", + "width": 11.6416, + "height": 13.086 + }, + "integrations-install-local-package": { + "body": "", + "width": 12, + "height": 13.3333 + }, + "integrations-install-marketplace": { + "body": "", + "width": 14.6667, + "height": 13.3333 + }, + "integrations-mcp": { + "body": "", + "width": 13.4445, + "height": 14.6667 + }, + "integrations-panel-left": { + "body": "", + "width": 14.5, + "height": 14.5 + }, + "integrations-tools": { + "body": "", + "width": 12.3333, + "height": 14 + }, + "integrations-tools-active": { + "body": "", + "width": 12.3333, + "height": 14 + }, + "integrations-trigger": { + "body": "", + "width": 13.325, + "height": 13.325 + }, + "integrations-trigger-active": { + "body": "", + "width": 13.325, + "height": 13.325 + }, + "integrations-workflow-as-tool": { + "body": "", + "width": 16, + "height": 16 + }, + "integrations-workflow-as-tool-active": { + "body": "", + "width": 12.1, + "height": 11.4333 }, "knowledge-add-chunks": { "body": "", @@ -62,7 +182,8 @@ }, "knowledge-economic": { "body": "", - "height": 18 + "height": 18, + "width": 18 }, "knowledge-full-text-search": { "body": "", @@ -70,11 +191,13 @@ }, "knowledge-general-chunk": { "body": "", - "height": 18 + "height": 18, + "width": 18 }, "knowledge-high-quality": { "body": "", - "height": 18 + "height": 18, + "width": 18 }, "knowledge-hybrid-search": { "body": "", @@ -82,11 +205,13 @@ }, "knowledge-parent-child-chunk": { "body": "", - "height": 18 + "height": 18, + "width": 18 }, "knowledge-question-and-answer": { "body": "", - "height": 18 + "height": 18, + "width": 18 }, "knowledge-search-lines-sparkle": { "body": "", @@ -121,7 +246,9 @@ "height": 16 }, "line-arrows-arrow-up-right": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "line-arrows-chevron-down-double": { "body": "", @@ -129,7 +256,9 @@ "height": 13 }, "line-arrows-chevron-right": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "line-arrows-chevron-selector-vertical": { "body": "", @@ -137,7 +266,9 @@ "height": 24 }, "line-arrows-iconr": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "line-arrows-refresh-ccw-01": { "body": "", @@ -155,10 +286,14 @@ "height": 16 }, "line-communication-ai-text": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "line-communication-chat-bot": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "line-communication-chat-bot-slim": { "body": "", @@ -166,7 +301,9 @@ "height": 48 }, "line-communication-cute-robot": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "line-communication-message-check-remove": { "body": "", @@ -230,7 +367,9 @@ "body": "" }, "line-editor-align-left": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "line-editor-bezier-curve-03": { "body": "", @@ -243,16 +382,24 @@ "height": 16 }, "line-editor-colors": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "line-editor-image-indent-left": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "line-editor-left-indent-02": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "line-editor-letter-spacing-01": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "line-editor-type-square": { "body": "", @@ -306,10 +453,14 @@ "height": 14 }, "line-financeAndECommerce-balance": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "line-financeAndECommerce-coins-stacked-01": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "line-financeAndECommerce-credits-coin": { "body": "", @@ -322,7 +473,9 @@ "height": 16 }, "line-financeAndECommerce-receipt-list": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "line-financeAndECommerce-tag-01": { "body": "", @@ -469,10 +622,14 @@ "body": "" }, "line-layout-align-left-01": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "line-layout-align-right-01": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "line-layout-grid-01": { "body": "", @@ -480,7 +637,9 @@ "height": 16 }, "line-layout-layout-grid-02": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "line-mediaAndDevices-microphone-01": { "body": "" @@ -514,7 +673,7 @@ "height": 14 }, "line-others-dhs": { - "body": "", + "body": "", "width": 18, "height": 18 }, @@ -522,7 +681,7 @@ "body": "" }, "line-others-dvs": { - "body": "", + "body": "", "width": 18, "height": 18 }, @@ -530,7 +689,7 @@ "body": "" }, "line-others-evaluation": { - "body": "", + "body": "", "width": 18, "height": 18 }, @@ -592,21 +751,100 @@ "width": 24, "height": 24 }, + "main-nav-app-home": { + "body": "", + "width": 16, + "height": 16 + }, + "main-nav-credits": { + "body": "", + "width": 24, + "height": 24 + }, + "main-nav-help": { + "body": "", + "width": 24, + "height": 24 + }, + "main-nav-home": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-home-active": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-integrations": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-integrations-active": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-knowledge": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-knowledge-active": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-marketplace": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-marketplace-active": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-quick-search": { + "body": "", + "width": 24, + "height": 24 + }, + "main-nav-studio": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-studio-active": { + "body": "", + "width": 20, + "height": 20 + }, + "main-nav-workspace-settings": { + "body": "", + "width": 16, + "height": 16 + }, "other-anthropic-text": { "body": "", "width": 90, "height": 20 }, "other-generator": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "other-group": { "body": "", - "height": 16 + "height": 16, + "width": 14 }, "other-hourglass-shape": { "body": "", - "width": 8 + "width": 8, + "height": 14 }, "other-mcp": { "body": "", @@ -639,10 +877,14 @@ "height": 16 }, "pipeline-pipeline-fill": { - "body": "" + "body": "", + "width": 18, + "height": 18 }, "pipeline-pipeline-line": { - "body": "" + "body": "", + "width": 18, + "height": 18 }, "plugin-box-sparkle-fill": { "body": "", @@ -658,10 +900,14 @@ "body": "" }, "solid-FinanceAndECommerce-gold-coin": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-FinanceAndECommerce-scales-02": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-alertsAndFeedback-alert-triangle": { "body": "", @@ -688,10 +934,14 @@ "height": 24 }, "solid-communication-ai-text": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-communication-bubble-text-mod": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-communication-chat-bot": { "body": "", @@ -699,22 +949,34 @@ "height": 12 }, "solid-communication-cute-robot": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-communication-edit-list": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-communication-list-sparkle": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-communication-logic": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-communication-message-dots-circle": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-communication-message-fast": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-communication-message-heart-circle": { "body": "", @@ -784,7 +1046,9 @@ "height": 24 }, "solid-editor-brush-01": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-editor-citations": { "body": "", @@ -792,13 +1056,19 @@ "height": 16 }, "solid-editor-colors": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-editor-paragraph": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-editor-type-square": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-education-beaker-02": { "body": "", @@ -806,13 +1076,19 @@ "height": 12 }, "solid-education-bubble-text": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-education-heart-02": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-education-unblur": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-files-file-05": { "body": "" @@ -842,10 +1118,14 @@ "height": 16 }, "solid-general-check-done-01": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-general-download-02": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-general-edit-03": { "body": "", @@ -853,10 +1133,14 @@ "height": 12 }, "solid-general-edit-04": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-general-eye": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-general-github": { "body": "", @@ -869,7 +1153,9 @@ "height": 16 }, "solid-general-plus-circle": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-general-question-triangle": { "body": "", @@ -877,10 +1163,14 @@ "height": 12 }, "solid-general-search-md": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-general-target-04": { - "body": "" + "body": "", + "width": 24, + "height": 24 }, "solid-general-tool-03": { "body": "", @@ -906,19 +1196,29 @@ "body": "" }, "solid-mediaAndDevices-audio-support-icon": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "solid-mediaAndDevices-document-support-icon": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "solid-mediaAndDevices-magic-box": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "solid-mediaAndDevices-magic-eyes": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "solid-mediaAndDevices-magic-wand": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "solid-mediaAndDevices-microphone-01": { "body": "", @@ -926,10 +1226,14 @@ "height": 16 }, "solid-mediaAndDevices-play": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "solid-mediaAndDevices-robot": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "solid-mediaAndDevices-sliders-02": { "body": "", @@ -947,7 +1251,9 @@ "height": 20 }, "solid-mediaAndDevices-video-support-icon": { - "body": "" + "body": "", + "width": 12, + "height": 12 }, "solid-security-lock-01": { "body": "", @@ -994,7 +1300,9 @@ "height": 16 }, "workflow-answer": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-api-aggregate": { "body": "", @@ -1007,16 +1315,24 @@ "height": 16 }, "workflow-asterisk": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-calendar-check-line": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-code": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-datasource": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-docs-extractor": { "body": "", @@ -1024,13 +1340,19 @@ "height": 16 }, "workflow-end": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-home": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-http": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-human-in-loop": { "body": "", @@ -1038,15 +1360,19 @@ "height": 16 }, "workflow-if-else": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-input-field": { - "body": "", + "body": "", "width": 16, "height": 16 }, "workflow-iteration": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-iteration-start": { "body": "", @@ -1059,7 +1385,9 @@ "height": 12 }, "workflow-knowledge-base": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-knowledge-retrieval": { "body": "", @@ -1072,7 +1400,9 @@ "height": 16 }, "workflow-llm": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-loop": { "body": "", @@ -1090,10 +1420,14 @@ "height": 12 }, "workflow-parameter-extractor": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-question-classifier": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-schedule": { "body": "", @@ -1106,10 +1440,14 @@ "height": 13.3333 }, "workflow-templating-transform": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-trigger-all": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-user-input": { "body": "", @@ -1117,7 +1455,9 @@ "height": 16 }, "workflow-variable-x": { - "body": "" + "body": "", + "width": 14, + "height": 14 }, "workflow-webhook-line": { "body": "", diff --git a/packages/iconify-collections/custom-vender/info.json b/packages/iconify-collections/custom-vender/info.json index f08b18fcad6..cef352ac549 100644 --- a/packages/iconify-collections/custom-vender/info.json +++ b/packages/iconify-collections/custom-vender/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-vender", "name": "Dify Custom Vender", - "total": 284, + "total": 319, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/packages/iconify-collections/package.json b/packages/iconify-collections/package.json index 752b7ce4371..eeec58e1fab 100644 --- a/packages/iconify-collections/package.json +++ b/packages/iconify-collections/package.json @@ -23,6 +23,7 @@ "./custom-vender/chars.json": "./custom-vender/chars.json" }, "scripts": { + "check:dimensions": "tsx ./scripts/check-icon-dimensions.ts", "generate": "tsx ./scripts/generate-collections.ts" }, "devDependencies": { diff --git a/packages/iconify-collections/scripts/check-icon-dimensions.ts b/packages/iconify-collections/scripts/check-icon-dimensions.ts new file mode 100644 index 00000000000..ab933fdf290 --- /dev/null +++ b/packages/iconify-collections/scripts/check-icon-dimensions.ts @@ -0,0 +1,94 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +type IconData = { + width?: number + height?: number +} + +type IconCollection = { + icons: Record + width?: number + height?: number +} + +type DimensionRule = { + collection: 'custom-vender' + icons: string[] + width: number + height: number +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const packageDir = path.resolve(__dirname, '..') + +const dimensionRules: DimensionRule[] = [ + { + collection: 'custom-vender', + icons: [ + 'main-nav-home', + 'main-nav-home-active', + 'main-nav-integrations', + 'main-nav-integrations-active', + 'main-nav-knowledge', + 'main-nav-knowledge-active', + 'main-nav-marketplace', + 'main-nav-marketplace-active', + 'main-nav-studio', + 'main-nav-studio-active', + ], + width: 20, + height: 20, + }, +] + +const readCollection = async (collection: DimensionRule['collection']): Promise => { + return JSON.parse( + await readFile(path.resolve(packageDir, collection, 'icons.json'), 'utf8'), + ) as IconCollection +} + +const main = async () => { + const collections = new Map() + const failures: string[] = [] + + for (const rule of dimensionRules) { + if (!collections.has(rule.collection)) + collections.set(rule.collection, await readCollection(rule.collection)) + + const collection = collections.get(rule.collection)! + + for (const iconName of rule.icons) { + const icon = collection.icons[iconName] + const width = icon?.width ?? collection.width ?? 16 + const height = icon?.height ?? collection.height ?? 16 + + if (!icon) { + failures.push(`${rule.collection}:${iconName} is missing`) + continue + } + + if (width !== rule.width || height !== rule.height) { + failures.push( + `${rule.collection}:${iconName} expected ${rule.width}x${rule.height}, got ${width}x${height}`, + ) + } + } + } + + if (failures.length) { + console.error('Icon dimension check failed:') + for (const failure of failures) + console.error(`- ${failure}`) + process.exitCode = 1 + return + } + + console.log('Icon dimension check passed.') +} + +main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/iconify-collections/scripts/generate-collections.ts b/packages/iconify-collections/scripts/generate-collections.ts index 5cc67dd5884..0a4d969fff3 100644 --- a/packages/iconify-collections/scripts/generate-collections.ts +++ b/packages/iconify-collections/scripts/generate-collections.ts @@ -21,6 +21,8 @@ type AliasData = Omit & { type ImportedCollection = { icons?: Record aliases?: Record + width?: number + height?: number lastModified?: number } @@ -60,11 +62,17 @@ const flattenCollections = (collections: ImportedCollections, prefix: string) => const segment = collectionKey.slice(prefix.length + 1) const namePrefix = segment ? `${segment}-` : '' + const applyCollectionSize = (iconData: T): T => ({ + ...iconData, + ...(iconData.width === undefined && collection.width !== undefined ? { width: collection.width } : {}), + ...(iconData.height === undefined && collection.height !== undefined ? { height: collection.height } : {}), + }) + for (const [iconName, iconData] of Object.entries(collection.icons ?? {})) - icons[`${namePrefix}${iconName}`] = iconData + icons[`${namePrefix}${iconName}`] = applyCollectionSize(iconData) for (const [aliasName, aliasData] of Object.entries(collection.aliases ?? {})) - aliases[`${namePrefix}${aliasName}`] = aliasData + aliases[`${namePrefix}${aliasName}`] = applyCollectionSize(aliasData) if (typeof collection.lastModified === 'number') lastModified = Math.max(lastModified, collection.lastModified) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 044c3cb391f..d189883bd0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ catalogs: '@sentry/react': specifier: 10.57.0 version: 10.57.0 + '@storybook/addon-a11y': + specifier: 10.4.4 + version: 10.4.4 '@storybook/addon-docs': specifier: 10.4.4 version: 10.4.4 @@ -132,6 +135,9 @@ catalogs: '@storybook/addon-themes': specifier: 10.4.4 version: 10.4.4 + '@storybook/addon-vitest': + specifier: 10.4.4 + version: 10.4.4 '@storybook/nextjs-vite': specifier: 10.4.4 version: 10.4.4 @@ -303,6 +309,9 @@ catalogs: embla-carousel-autoplay: specifier: 8.6.0 version: 8.6.0 + embla-carousel-fade: + specifier: 8.6.0 + version: 8.6.0 embla-carousel-react: specifier: 8.6.0 version: 8.6.0 @@ -426,6 +435,9 @@ catalogs: mitt: specifier: 3.0.1 version: 3.0.1 + motion: + specifier: 12.40.0 + version: 12.40.0 negotiator: specifier: 1.0.0 version: 1.0.0 @@ -595,6 +607,8 @@ overrides: is-core-module: npm:@nolyfill/is-core-module@^1.0.39 picomatch@>=4.0.0 <4.0.4: 4.0.4 postcss@<8.5.10: ^8.5.10 + postcss-selector-parser@>=6.0.0 <6.1.3: 6.1.4 + postcss-selector-parser@>=7.0.0 <7.1.3: 7.1.4 rollup@>=4.0.0 <4.59.0: 4.61.1 safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 side-channel: npm:@nolyfill/side-channel@^1.0.44 @@ -605,6 +619,9 @@ overrides: ws@>=8.0.0 <8.20.1: ^8.21.0 yaml@>=2.0.0 <2.8.3: 2.9.0 yauzl@<3.2.1: 3.2.1 + '@babel/core@<=7.29.0': ^7.29.1 + js-yaml@<=4.1.1: ^4.1.2 + tar@<=7.5.15: ^7.5.16 importers: @@ -612,22 +629,22 @@ importers: devDependencies: '@antfu/eslint-config': specifier: 'catalog:' - version: 9.0.0(77a654f2a6b8944547bd98887d4bebdc) + version: 9.0.0(b376e15be293d4e014f0f69f32d1fb4a) concurrently: specifier: 'catalog:' version: 10.0.3 eslint: specifier: 'catalog:' - version: 10.5.0(jiti@2.7.0) + version: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) eslint-markdown: specifier: 'catalog:' - version: 0.11.0(eslint@10.5.0(jiti@2.7.0)) + version: 0.11.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-markdown-preferences: specifier: 'catalog:' - version: 0.41.1(@eslint/markdown@8.0.1)(eslint@10.5.0(jiti@2.7.0)) + version: 0.41.1(@eslint/markdown@8.0.1)(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-no-barrel-files: specifier: 'catalog:' - version: 1.3.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + version: 1.3.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) node: specifier: runtime:^22.22.1 version: runtime:22.22.3 @@ -851,6 +868,9 @@ importers: '@iconify-json/ri': specifier: 'catalog:' version: 1.2.10 + '@storybook/addon-a11y': + specifier: 'catalog:' + version: 10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-docs': specifier: 'catalog:' version: 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) @@ -860,6 +880,9 @@ importers: '@storybook/addon-themes': specifier: 'catalog:' version: 10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + '@storybook/addon-vitest': + specifier: 'catalog:' + version: 10.4.4(@vitest/browser@4.1.8)(@voidzero-dev/vite-plus-test@0.1.24)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/react-vite': specifier: 'catalog:' version: 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) @@ -928,7 +951,7 @@ importers: devDependencies: iconify-import-svg: specifier: 'catalog:' - version: 0.2.0 + version: 0.2.0(supports-color@10.2.2) tsx: specifier: 'catalog:' version: 4.22.4 @@ -970,10 +993,10 @@ importers: version: 25.9.3 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260613.1 @@ -1124,6 +1147,9 @@ importers: embla-carousel-autoplay: specifier: 'catalog:' version: 8.6.0(embla-carousel@8.6.0) + embla-carousel-fade: + specifier: 'catalog:' + version: 8.6.0(embla-carousel@8.6.0) embla-carousel-react: specifier: 'catalog:' version: 8.6.0(react@19.2.7) @@ -1162,7 +1188,7 @@ importers: version: 11.1.8 jotai: specifier: 'catalog:' - version: 2.20.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.17)(react@19.2.7) + version: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) js-audio-recorder: specifier: 'catalog:' version: 1.0.7 @@ -1199,18 +1225,21 @@ importers: mitt: specifier: 'catalog:' version: 3.0.1 + motion: + specifier: 'catalog:' + version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) negotiator: specifier: 'catalog:' version: 1.0.0 next: specifier: 'catalog:' - version: 16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) nuqs: specifier: 'catalog:' - version: 2.8.9(next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 2.8.9(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) pinyin-pro: specifier: 'catalog:' version: 3.28.1 @@ -1270,7 +1299,7 @@ importers: version: 4.2.0 socket.io-client: specifier: 'catalog:' - version: 4.8.3 + version: 4.8.3(supports-color@10.2.2) sortablejs: specifier: 'catalog:' version: 1.15.7 @@ -1370,7 +1399,7 @@ importers: version: 10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/nextjs-vite': specifier: 'catalog:' - version: 10.4.4(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + version: 10.4.4(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3) '@storybook/react': specifier: 'catalog:' version: 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) @@ -1430,7 +1459,7 @@ importers: version: 1.15.9 '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260613.1 @@ -1448,7 +1477,7 @@ importers: version: 3.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) code-inspector-plugin: specifier: 'catalog:' - version: 1.6.0 + version: 1.6.0(supports-color@10.2.2) eslint: specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) @@ -1508,7 +1537,7 @@ importers: version: 3.19.3 vinext: specifier: 'catalog:' - version: 0.1.2(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.2(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + version: 0.1.2(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.2(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(typescript@6.0.3) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' @@ -1679,55 +1708,79 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': ^7.29.1 '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.2': resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} hasBin: true '@babel/runtime@7.29.2': @@ -1738,14 +1791,26 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@base-ui/react@1.5.0': resolution: {integrity: sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A==} engines: {node: '>=14.0.0'} @@ -1843,7 +1908,6 @@ packages: '@cucumber/gherkin-utils@11.0.0': resolution: {integrity: sha512-LJ+s4+TepHTgdKWDR4zbPyT7rQjmYIcukTwNbwNwgqr6i8Gjcmzf6NmtbYDA19m1ZFg6kWbFsmHnj37ZuX+kZA==} - hasBin: true '@cucumber/gherkin@38.0.0': resolution: {integrity: sha512-duEXK+KDfQUzu3vsSzXjkxQ2tirF5PRsc1Xrts6THKHJO6mjw4RjM8RV+vliuDasmhhrmdLcOcM7d9nurNTJKw==} @@ -3784,7 +3848,6 @@ packages: '@playwright/test@1.60.0': resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} - hasBin: true '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -4119,7 +4182,6 @@ packages: '@shuding/opentype.js@1.4.0-beta.0': resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} engines: {node: '>= 8.0.0'} - hasBin: true '@sindresorhus/base62@1.0.0': resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} @@ -4134,6 +4196,11 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@storybook/addon-a11y@10.4.4': + resolution: {integrity: sha512-/eUCx/6Ozq5grauwm/NqKtlW0oJ26b6GNesXrMuFID8WLg/qLEKf79Awfz9XrmyWxe7loD40K952r7AA5Oc23A==} + peerDependencies: + storybook: ^10.4.4 + '@storybook/addon-docs@10.4.4': resolution: {integrity: sha512-yPshCvtmQTq52T2sXuXgjy7B/QbhA/WIZxLYggptNjBL8BJMvbOfp9bAfCKh7+KpRWGqDZ6Y6tWL1Q48Wj3vtw==} peerDependencies: @@ -4165,6 +4232,24 @@ packages: peerDependencies: storybook: ^10.4.4 + '@storybook/addon-vitest@10.4.4': + resolution: {integrity: sha512-VPpBwf1Elr+0g33am8ZE6aHhLB+r1TPxUsnDuCVNhxGjRxMFyQkAE8+jPJFPvS/YIUGMbVXarzaV7PcI/sJuVQ==} + peerDependencies: + '@vitest/browser': ^3.0.0 || ^4.0.0 + '@vitest/browser-playwright': ^4.0.0 + '@vitest/runner': ^3.0.0 || ^4.0.0 + storybook: ^10.4.4 + vitest: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/runner': + optional: true + vitest: + optional: true + '@storybook/builder-vite@10.4.4': resolution: {integrity: sha512-VyuZ4mEvhhVXjJa1qXMWKH8ohnas0rgEuJDf6u4aJ54XeENFebPUEAHde1Qo2PflJ4rUdVdXieOZzKbYwP5RAQ==} peerDependencies: @@ -4412,7 +4497,6 @@ packages: '@tanstack/devtools-event-client@0.4.3': resolution: {integrity: sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==} engines: {node: '>=18'} - hasBin: true '@tanstack/eslint-plugin-query@5.101.0': resolution: {integrity: sha512-wsfg821y4yw21J7nKI2oM5yyGSz3vASXqgWbmWCXZpnyY9ObLrBCcXivwZKj4YHF2fUWiqoOIRX2pbE79cf6gQ==} @@ -5167,7 +5251,6 @@ packages: acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} - hasBin: true agentation@3.0.2: resolution: {integrity: sha512-iGzBxFVTuZEIKzLY6AExSLAQH6i6SwxV4pAu7v7m3X6bInZ7qlZXAwrEqyc4+EfP4gM7z2RXBF6SF4DeH0f2lA==} @@ -5274,7 +5357,6 @@ packages: astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} - hasBin: true async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} @@ -5319,7 +5401,6 @@ packages: baseline-browser-mapping@2.10.12: resolution: {integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==} engines: {node: '>=6.0.0'} - hasBin: true birecord@0.1.1: resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==} @@ -5347,7 +5428,6 @@ packages: browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -5567,7 +5647,6 @@ packages: color-support@1.1.3: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} - hasBin: true comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -5746,7 +5825,6 @@ packages: d3-dsv@3.0.1: resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} engines: {node: '>=12'} - hasBin: true d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} @@ -6011,6 +6089,11 @@ packages: peerDependencies: embla-carousel: 8.6.0 + embla-carousel-fade@8.6.0: + resolution: {integrity: sha512-qaYsx5mwCz72ZrjlsXgs1nKejSrW+UhkbOMwLgfRT7w2LtdEB03nPRI06GHuHv5ac2USvbEiX2/nAHctcDwvpg==} + peerDependencies: + embla-carousel: 8.6.0 + embla-carousel-react@8.6.0: resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} peerDependencies: @@ -6123,7 +6206,6 @@ packages: esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} - hasBin: true escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} @@ -6494,7 +6576,6 @@ packages: extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} - hasBin: true fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -6597,6 +6678,20 @@ packages: react-dom: optional: true + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -6668,7 +6763,6 @@ packages: giget@3.2.0: resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} - hasBin: true github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -6878,7 +6972,6 @@ packages: image-size@2.0.2: resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} engines: {node: '>=16.x'} - hasBin: true immer@11.1.8: resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} @@ -6975,7 +7068,6 @@ packages: is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} @@ -7003,7 +7095,6 @@ packages: is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} - hasBin: true is-installed-globally@1.0.0: resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} @@ -7111,7 +7202,7 @@ packages: resolution: {integrity: sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==} engines: {node: '>=12.20.0'} peerDependencies: - '@babel/core': '>=7.0.0' + '@babel/core': ^7.29.1 '@babel/template': '>=7.0.0' '@types/react': '>=17.0.0' react: '>=17.0.0' @@ -7143,13 +7234,8 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} - hasBin: true jsdoc-type-pratt-parser@7.1.1: resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} @@ -7162,7 +7248,6 @@ packages: jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} - hasBin: true json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -7198,11 +7283,9 @@ packages: katex@0.16.47: resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} - hasBin: true katex@0.17.0: resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==} - hasBin: true keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -7371,7 +7454,6 @@ packages: loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true loro-crdt@1.13.2: resolution: {integrity: sha512-Br9tZZk9x/HP83By9RvOCqzWh8v8tnOhVlR6/ibYNtLSmysO7ZgwzjNpqsCABqaSOcGC7TBkx5sG8tfosdJMQA==} @@ -7392,7 +7474,6 @@ packages: lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -7414,12 +7495,10 @@ packages: marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} - hasBin: true marked@17.0.5: resolution: {integrity: sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==} engines: {node: '>= 20'} - hasBin: true math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} @@ -7622,12 +7701,10 @@ packages: mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} - hasBin: true mime@4.1.0: resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} engines: {node: '>=16'} - hasBin: true mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} @@ -7668,7 +7745,6 @@ packages: mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} - hasBin: true mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -7682,6 +7758,26 @@ packages: moo-color@1.0.3: resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==} + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.40.0: + resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -7692,7 +7788,6 @@ packages: nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -7888,7 +7983,6 @@ packages: os: linux libc: musl version: 22.22.3 - hasBin: true normalize-package-data@8.0.0: resolution: {integrity: sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==} @@ -8023,7 +8117,6 @@ packages: oxlint-tsgolint@0.23.0: resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==} - hasBin: true oxlint@1.67.0: resolution: {integrity: sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==} @@ -8160,12 +8253,10 @@ packages: playwright-core@1.60.0: resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} - hasBin: true playwright@1.60.0: resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} - hasBin: true pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} @@ -8192,12 +8283,12 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss-selector-parser@6.0.10: - resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} engines: {node: '>=4'} - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + postcss-selector-parser@7.1.4: + resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} postcss-value-parser@4.2.0: @@ -8215,7 +8306,6 @@ packages: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} @@ -8265,7 +8355,6 @@ packages: rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true re-resizable@6.11.2: resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==} @@ -8480,7 +8569,6 @@ packages: regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} - hasBin: true regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} @@ -8488,7 +8576,6 @@ packages: regjsparser@0.13.0: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} - hasBin: true rehype-harden@1.1.8: resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==} @@ -8756,7 +8843,6 @@ packages: srvx@0.11.15: resolution: {integrity: sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==} engines: {node: '>=20.16.0'} - hasBin: true stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -8900,7 +8986,6 @@ packages: svgo@3.3.3: resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==} engines: {node: '>=14.0.0'} - hasBin: true synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} @@ -8939,8 +9024,8 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar@7.5.11: - resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} + tar@7.5.16: + resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} engines: {node: '>=18'} tiny-case@1.0.3: @@ -8987,7 +9072,6 @@ packages: tldts@7.4.2: resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} - hasBin: true to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} @@ -9010,7 +9094,6 @@ packages: tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -9064,7 +9147,6 @@ packages: tsx@4.22.4: resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} engines: {node: '>=18.0.0'} - hasBin: true tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -9107,7 +9189,6 @@ packages: typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} - hasBin: true ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} @@ -9115,7 +9196,6 @@ packages: uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} - hasBin: true unbash@3.0.0: resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} @@ -9128,6 +9208,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.26.0: + resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==} + engines: {node: '>=20.18.1'} + undici@7.27.2: resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} engines: {node: '>=20.18.1'} @@ -9263,7 +9347,6 @@ packages: uuid@14.0.0: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} - hasBin: true valibot@1.4.1: resolution: {integrity: sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==} @@ -9331,7 +9414,6 @@ packages: vite-plus@0.1.24: resolution: {integrity: sha512-b3fr6WtCiEhetjuzW/4KcEMOAMuZxoxZATWaXKmPzOLf1upG+pzKJOFZTb94D6wiPBlwcjxoaUtF7C3uAN+VjQ==} engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} @@ -9438,7 +9520,6 @@ packages: which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} - hasBin: true word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} @@ -9735,16 +9816,16 @@ snapshots: idb: 8.0.0 tslib: 2.8.1 - '@antfu/eslint-config@9.0.0(77a654f2a6b8944547bd98887d4bebdc)': + '@antfu/eslint-config@9.0.0(@eslint-react/eslint-plugin@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.9)(@types/node@25.9.3)(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.5.0(jiti@2.7.0)))(eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)))(eslint@10.5.0(jiti@2.7.0))(happy-dom@20.10.3)(jiti@2.7.0)(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.4.0 - '@e18e/eslint-plugin': 0.4.1(eslint@10.5.0(jiti@2.7.0))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + '@e18e/eslint-plugin': 0.4.1(eslint@10.5.0(jiti@2.7.0))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.5.0(jiti@2.7.0)) '@eslint/markdown': 8.0.1 '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.7.0)) - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) '@vitest/eslint-plugin': 1.6.17(@types/node@25.9.3)(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint@10.5.0(jiti@2.7.0))(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) ansis: 4.3.0 cac: 7.0.0 @@ -9818,50 +9899,50 @@ snapshots: - vite - yaml - '@antfu/eslint-config@9.0.0(@eslint-react/eslint-plugin@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.9)(@types/node@25.9.3)(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.5.0(jiti@2.7.0)))(eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)))(eslint@10.5.0(jiti@2.7.0))(happy-dom@20.10.3)(jiti@2.7.0)(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)': + '@antfu/eslint-config@9.0.0(b376e15be293d4e014f0f69f32d1fb4a)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.4.0 - '@e18e/eslint-plugin': 0.4.1(eslint@10.5.0(jiti@2.7.0))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) - '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.5.0(jiti@2.7.0)) + '@e18e/eslint-plugin': 0.4.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) + '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) '@eslint/markdown': 8.0.1 - '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.7.0)) - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@vitest/eslint-plugin': 1.6.17(@types/node@25.9.3)(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint@10.5.0(jiti@2.7.0))(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@vitest/eslint-plugin': 1.6.17(@types/node@25.9.3)(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0) ansis: 4.3.0 cac: 7.0.0 - eslint: 10.5.0(jiti@2.7.0) - eslint-config-flat-gitignore: 2.3.0(eslint@10.5.0(jiti@2.7.0)) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-config-flat-gitignore: 2.3.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-flat-config-utils: 3.2.0 - eslint-merge-processors: 2.0.0(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-antfu: 3.2.3(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-command: 3.5.2(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-import-lite: 0.6.0(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-jsdoc: 62.9.0(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-jsonc: 3.1.2(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-n: 18.0.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + eslint-merge-processors: 2.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-antfu: 3.2.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-command: 3.5.2(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-import-lite: 0.6.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-jsdoc: 62.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + eslint-plugin-jsonc: 3.1.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-n: 18.0.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint-plugin-no-only-tests: 3.4.0 - eslint-plugin-perfectionist: 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-pnpm: 1.6.0(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-regexp: 3.1.0(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-toml: 1.3.1(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-unicorn: 64.0.0(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-vue: 10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.7.0)))(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.7.0))) - eslint-plugin-yml: 3.3.2(eslint@10.5.0(jiti@2.7.0)) - eslint-processor-vue-blocks: 2.0.0(eslint@10.5.0(jiti@2.7.0)) + eslint-plugin-perfectionist: 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint-plugin-pnpm: 1.6.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-regexp: 3.1.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-toml: 1.3.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + eslint-plugin-unicorn: 64.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-vue: 10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)) + eslint-plugin-yml: 3.3.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-processor-vue-blocks: 2.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) globals: 17.6.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.5.0(jiti@2.7.0)) + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) yaml-eslint-parser: 2.0.0 optionalDependencies: - '@eslint-react/eslint-plugin': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/eslint-plugin': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@next/eslint-plugin-next': 16.2.9 - eslint-plugin-jsx-a11y: 6.10.2(eslint@10.5.0(jiti@2.7.0)) - eslint-plugin-react-refresh: 0.5.3(eslint@10.5.0(jiti@2.7.0)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-react-refresh: 0.5.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -9914,19 +9995,25 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@10.2.2) @@ -9944,47 +10031,65 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/generator@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.28.6': + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helpers@7.29.2': + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/parser@7.29.2': dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -9993,6 +10098,12 @@ snapshots: '@babel/parser': 7.29.2 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -10005,11 +10116,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@base-ui/react@1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@babel/runtime': 7.29.2 @@ -10065,45 +10193,45 @@ snapshots: fast-wrap-ansi: 0.2.0 sisteransi: 1.0.5 - '@code-inspector/core@1.6.0': + '@code-inspector/core@1.6.0(supports-color@10.2.2)': dependencies: '@vue/compiler-dom': 3.5.31 chalk: 4.1.2 dotenv: 16.6.1 launch-ide: 1.4.3 - portfinder: 1.0.38 + portfinder: 1.0.38(supports-color@10.2.2) transitivePeerDependencies: - supports-color '@code-inspector/esbuild@1.6.0': dependencies: - '@code-inspector/core': 1.6.0 + '@code-inspector/core': 1.6.0(supports-color@10.2.2) transitivePeerDependencies: - supports-color '@code-inspector/mako@1.6.0': dependencies: - '@code-inspector/core': 1.6.0 + '@code-inspector/core': 1.6.0(supports-color@10.2.2) transitivePeerDependencies: - supports-color '@code-inspector/turbopack@1.6.0': dependencies: - '@code-inspector/core': 1.6.0 + '@code-inspector/core': 1.6.0(supports-color@10.2.2) '@code-inspector/webpack': 1.6.0 transitivePeerDependencies: - supports-color '@code-inspector/vite@1.6.0': dependencies: - '@code-inspector/core': 1.6.0 + '@code-inspector/core': 1.6.0(supports-color@10.2.2) chalk: 4.1.1 transitivePeerDependencies: - supports-color '@code-inspector/webpack@1.6.0': dependencies: - '@code-inspector/core': 1.6.0 + '@code-inspector/core': 1.6.0(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -10222,13 +10350,13 @@ snapshots: perfect-debounce: 2.1.0 tinyexec: 1.2.3 - '@e18e/eslint-plugin@0.4.1(eslint@10.5.0(jiti@2.7.0))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + '@e18e/eslint-plugin@0.4.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: empathic: 2.0.0 module-replacements: 3.0.0-beta.7 semver: 7.8.4 optionalDependencies: - eslint: 10.5.0(jiti@2.7.0) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) '@e18e/eslint-plugin@0.4.1(eslint@10.5.0(jiti@2.7.0))(oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': @@ -10375,12 +10503,23 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + ignore: 7.0.5 + '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.5.0(jiti@2.7.0))': dependencies: escape-string-regexp: 4.0.0 eslint: 10.5.0(jiti@2.7.0) ignore: 7.0.5 + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))': + dependencies: + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0))': dependencies: eslint: 10.5.0(jiti@2.7.0) @@ -10388,6 +10527,18 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} + '@eslint-react/ast@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + string-ts: 2.3.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + '@eslint-react/ast@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.61.0 @@ -10399,6 +10550,23 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint-react/core@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + ts-pattern: 5.9.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + '@eslint-react/core@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -10415,6 +10583,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint-react/eslint-plugin@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-plugin-react-dom: 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint-plugin-react-jsx: 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint-plugin-react-naming-convention: 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint-plugin-react-rsc: 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint-plugin-react-web-api: 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint-plugin-react-x: 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + '@eslint-react/eslint-plugin@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -10429,6 +10612,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint-react/eslint@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + '@eslint-react/eslint@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -10437,6 +10629,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint-react/jsx@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + ts-pattern: 5.9.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + '@eslint-react/jsx@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -10451,6 +10658,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint-react/shared@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + ts-pattern: 5.9.0 + typescript: 6.0.3 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + '@eslint-react/shared@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -10462,6 +10681,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint-react/var@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + ts-pattern: 5.9.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + '@eslint-react/var@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -10475,13 +10708,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/compat@2.0.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))': + dependencies: + '@eslint/core': 1.2.1 + optionalDependencies: + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + '@eslint/compat@2.0.3(eslint@10.5.0(jiti@2.7.0))': dependencies: '@eslint/core': 1.2.1 optionalDependencies: eslint: 10.5.0(jiti@2.7.0) - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@10.2.2)': dependencies: '@eslint/object-schema': 3.0.5 debug: 4.4.3(supports-color@10.2.2) @@ -10609,7 +10848,7 @@ snapshots: dependencies: '@jsdevtools/ono': 7.1.3 '@types/json-schema': 7.0.15 - js-yaml: 4.1.1 + js-yaml: 4.2.0 '@hey-api/openapi-ts@0.98.2(magicast@0.5.2)(typescript@6.0.3)': dependencies: @@ -10669,23 +10908,23 @@ snapshots: dependencies: '@iconify/types': 2.0.0 - '@iconify/tools@4.2.0': + '@iconify/tools@4.2.0(supports-color@10.2.2)': dependencies: '@iconify/types': 2.0.0 - '@iconify/utils': 2.3.0 + '@iconify/utils': 2.3.0(supports-color@10.2.2) cheerio: 1.2.0 domhandler: 5.0.3 - extract-zip: 2.0.1 + extract-zip: 2.0.1(supports-color@10.2.2) local-pkg: 1.1.2 pathe: 2.0.3 svgo: 3.3.3 - tar: 7.5.11 + tar: 7.5.16 transitivePeerDependencies: - supports-color '@iconify/types@2.0.0': {} - '@iconify/utils@2.3.0': + '@iconify/utils@2.3.0(supports-color@10.2.2)': dependencies: '@antfu/install-pkg': 1.1.0 '@antfu/utils': 8.1.1 @@ -12116,6 +12355,12 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@storybook/addon-a11y@10.4.4(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + dependencies: + '@storybook/global': 5.0.0 + axe-core: 4.12.0 + storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + '@storybook/addon-docs@10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.7) @@ -12152,6 +12397,18 @@ snapshots: storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) ts-dedent: 2.2.0 + '@storybook/addon-vitest@10.4.4(@vitest/browser@4.1.8)(@voidzero-dev/vite-plus-test@0.1.24)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) + optionalDependencies: + '@vitest/browser': 4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-test@0.1.24) + vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + transitivePeerDependencies: + - react + - react-dom + '@storybook/builder-vite@10.4.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: '@storybook/csf-plugin': 10.4.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) @@ -12178,18 +12435,18 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@storybook/nextjs-vite@10.4.4(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)': + '@storybook/nextjs-vite@10.4.4(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@storybook/builder-vite': 10.4.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/react': 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) '@storybook/react-vite': 10.4.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) - next: 16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.7) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' - vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(typescript@6.0.3) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) @@ -12260,6 +12517,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + '@typescript-eslint/types': 8.61.0 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.4 + '@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) @@ -12361,7 +12628,7 @@ snapshots: '@tailwindcss/typography@0.5.20(tailwindcss@4.3.1)': dependencies: - postcss-selector-parser: 6.0.10 + postcss-selector-parser: 6.1.4 tailwindcss: 4.3.1 '@tailwindcss/vite@4.3.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))': @@ -12740,12 +13007,28 @@ snapshots: '@types/zen-observable@0.8.3': {} - '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.0 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + 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/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.61.0 eslint: 10.5.0(jiti@2.7.0) @@ -12756,7 +13039,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.0 + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.61.0 '@typescript-eslint/types': 8.61.0 @@ -12786,7 +13081,19 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.61.0 '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) @@ -12815,6 +13122,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) @@ -12868,13 +13186,13 @@ snapshots: dependencies: unpic: 4.2.2 - '@unpic/react@1.0.2(next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@unpic/react@1.0.2(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@unpic/core': 1.0.3 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - next: 16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@upsetjs/venn.js@2.0.0': optionalDependencies: @@ -12962,6 +13280,46 @@ snapshots: optionalDependencies: '@vitest/browser': 4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-test@0.1.24) + '@vitest/eslint-plugin@1.6.17(@types/node@25.9.3)(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + '@vitest/eslint-plugin@1.6.17(@types/node@25.9.3)(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(eslint@10.5.0(jiti@2.7.0))(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)': dependencies: '@typescript-eslint/scope-manager': 8.61.0 @@ -12969,7 +13327,7 @@ snapshots: eslint: 10.5.0(jiti@2.7.0) vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - '@arethetypeswrong/core' @@ -13489,7 +13847,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.27.2 + undici: 7.26.0 whatwg-mimetype: 4.0.0 chokidar@5.0.0: @@ -13555,9 +13913,9 @@ snapshots: - '@types/react' - '@types/react-dom' - code-inspector-plugin@1.6.0: + code-inspector-plugin@1.6.0(supports-color@10.2.2): dependencies: - '@code-inspector/core': 1.6.0 + '@code-inspector/core': 1.6.0(supports-color@10.2.2) '@code-inspector/esbuild': 1.6.0 '@code-inspector/mako': 1.6.0 '@code-inspector/turbopack': 1.6.0 @@ -14032,6 +14390,10 @@ snapshots: dependencies: embla-carousel: 8.6.0 + embla-carousel-fade@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-react@8.6.0(react@19.2.7): dependencies: embla-carousel: 8.6.0 @@ -14061,7 +14423,7 @@ snapshots: dependencies: once: 1.4.0 - engine.io-client@6.6.4: + engine.io-client@6.6.4(supports-color@10.2.2): dependencies: '@socket.io/component-emitter': 3.1.2 debug: 4.4.3(supports-color@10.2.2) @@ -14235,11 +14597,21 @@ snapshots: escape-string-regexp@5.0.0: {} + eslint-compat-utils@0.5.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + semver: 7.8.4 + eslint-compat-utils@0.5.1(eslint@10.5.0(jiti@2.7.0)): dependencies: eslint: 10.5.0(jiti@2.7.0) semver: 7.8.4 + eslint-config-flat-gitignore@2.3.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + '@eslint/compat': 2.0.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-config-flat-gitignore@2.3.0(eslint@10.5.0(jiti@2.7.0)): dependencies: '@eslint/compat': 2.0.3(eslint@10.5.0(jiti@2.7.0)) @@ -14250,12 +14622,28 @@ snapshots: '@eslint/config-helpers': 0.5.5 pathe: 2.0.3 + eslint-json-compat-utils@0.2.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(jsonc-eslint-parser@3.1.0): + dependencies: + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + esquery: 1.7.0 + jsonc-eslint-parser: 3.1.0 + eslint-json-compat-utils@0.2.3(eslint@10.5.0(jiti@2.7.0))(jsonc-eslint-parser@3.1.0): dependencies: eslint: 10.5.0(jiti@2.7.0) esquery: 1.7.0 jsonc-eslint-parser: 3.1.0 + eslint-markdown@0.11.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + '@eslint/markdown': 7.5.1 + micromark-util-normalize-identifier: 2.0.1 + parse5: 8.0.1 + optionalDependencies: + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + eslint-markdown@0.11.0(eslint@10.5.0(jiti@2.7.0)): dependencies: '@eslint/markdown': 7.5.1 @@ -14266,10 +14654,18 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-merge-processors@2.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-merge-processors@2.0.0(eslint@10.5.0(jiti@2.7.0)): dependencies: eslint: 10.5.0(jiti@2.7.0) + eslint-plugin-antfu@3.2.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-plugin-antfu@3.2.3(eslint@10.5.0(jiti@2.7.0)): dependencies: eslint: 10.5.0(jiti@2.7.0) @@ -14292,6 +14688,13 @@ snapshots: - '@eslint/css' - typescript + eslint-plugin-command@3.5.2(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + '@es-joy/jsdoccomment': 0.84.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-plugin-command@3.5.2(@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3))(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)): dependencies: '@es-joy/jsdoccomment': 0.84.0 @@ -14299,6 +14702,13 @@ snapshots: '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.5.0(jiti@2.7.0) + eslint-plugin-es-x@7.8.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/regexpp': 4.12.2 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-compat-utils: 0.5.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-es-x@7.8.0(eslint@10.5.0(jiti@2.7.0)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) @@ -14310,10 +14720,34 @@ snapshots: dependencies: eslint: 10.5.0(jiti@2.7.0) + eslint-plugin-import-lite@0.6.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-plugin-import-lite@0.6.0(eslint@10.5.0(jiti@2.7.0)): dependencies: eslint: 10.5.0(jiti@2.7.0) + eslint-plugin-jsdoc@62.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + '@es-joy/jsdoccomment': 0.86.0 + '@es-joy/resolve.exports': 1.2.0 + are-docs-informative: 0.0.2 + comment-parser: 1.4.6 + debug: 4.4.3(supports-color@10.2.2) + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.0 + parse-imports-exports: 0.2.4 + semver: 7.8.4 + spdx-expression-parse: 4.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + eslint-plugin-jsdoc@62.9.0(eslint@10.5.0(jiti@2.7.0)): dependencies: '@es-joy/jsdoccomment': 0.86.0 @@ -14334,6 +14768,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-jsonc@3.1.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + diff-sequences: 29.6.3 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-json-compat-utils: 0.2.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(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-jsonc@3.1.2(eslint@10.5.0(jiti@2.7.0)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) @@ -14349,6 +14798,26 @@ snapshots: transitivePeerDependencies: - '@eslint/json' + eslint-plugin-jsx-a11y@6.10.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.12.0 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + optional: true + eslint-plugin-jsx-a11y@6.10.2(eslint@10.5.0(jiti@2.7.0)): dependencies: aria-query: 5.3.2 @@ -14368,6 +14837,26 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 + eslint-plugin-markdown-preferences@0.41.1(@eslint/markdown@8.0.1)(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + '@eslint/markdown': 8.0.1 + diff-sequences: 29.6.3 + emoji-regex-xs: 2.0.1 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + mdast-util-from-markdown: 2.0.3 + mdast-util-frontmatter: 2.0.1 + mdast-util-gfm: 3.1.0 + mdast-util-math: 3.0.0 + micromark-extension-frontmatter: 2.0.0 + micromark-extension-gfm: 3.0.0 + micromark-extension-math: 3.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + string-width: 8.2.1 + transitivePeerDependencies: + - supports-color + eslint-plugin-markdown-preferences@0.41.1(@eslint/markdown@8.0.1)(eslint@10.5.0(jiti@2.7.0)): dependencies: '@eslint/markdown': 8.0.1 @@ -14388,6 +14877,20 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-n@18.0.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + enhanced-resolve: 5.21.6 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-plugin-es-x: 7.8.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + get-tsconfig: 4.14.0 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.8.4 + optionalDependencies: + typescript: 6.0.3 + eslint-plugin-n@18.0.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) @@ -14402,6 +14905,14 @@ snapshots: optionalDependencies: typescript: 6.0.3 + eslint-plugin-no-barrel-files@1.3.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3): + dependencies: + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + - typescript + eslint-plugin-no-barrel-files@1.3.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -14412,6 +14923,15 @@ snapshots: eslint-plugin-no-only-tests@3.4.0: {} + eslint-plugin-perfectionist@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3): + dependencies: + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + eslint-plugin-perfectionist@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -14421,6 +14941,17 @@ snapshots: - supports-color - typescript + eslint-plugin-pnpm@1.6.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + empathic: 2.0.0 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + jsonc-eslint-parser: 3.1.0 + pathe: 2.0.3 + pnpm-workspace-yaml: 1.6.0 + tinyglobby: 0.2.16 + yaml: 2.9.0 + yaml-eslint-parser: 2.0.0 + eslint-plugin-pnpm@1.6.0(eslint@10.5.0(jiti@2.7.0)): dependencies: empathic: 2.0.0 @@ -14432,6 +14963,21 @@ snapshots: yaml: 2.9.0 yaml-eslint-parser: 2.0.0 + eslint-plugin-react-dom@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3): + dependencies: + '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + compare-versions: 6.1.1 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + eslint-plugin-react-dom@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -14446,6 +14992,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-react-jsx@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3): + dependencies: + '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/core': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + eslint-plugin-react-jsx@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -14460,6 +15021,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-react-naming-convention@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3): + dependencies: + '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/core': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + ts-pattern: 5.9.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + eslint-plugin-react-naming-convention@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -14474,10 +15050,30 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + optional: true + eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)): dependencies: eslint: 10.5.0(jiti@2.7.0) + eslint-plugin-react-rsc@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3): + dependencies: + '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/core': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + eslint-plugin-react-rsc@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -14492,6 +15088,23 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-react-web-api@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3): + dependencies: + '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/core': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + birecord: 0.1.1 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + ts-pattern: 5.9.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + eslint-plugin-react-web-api@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -14508,6 +15121,29 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-react-x@5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3): + dependencies: + '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/core': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/eslint': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/jsx': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + compare-versions: 6.1.1 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + string-ts: 2.3.1 + ts-api-utils: 2.5.0(typescript@6.0.3) + ts-pattern: 5.9.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + eslint-plugin-react-x@5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@eslint-react/ast': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -14517,7 +15153,7 @@ snapshots: '@eslint-react/shared': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@eslint-react/var': 5.9.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/types': 8.61.0 '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -14530,6 +15166,17 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-regexp@3.1.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/regexpp': 4.12.2 + comment-parser: 1.4.6 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + jsdoc-type-pratt-parser: 7.2.0 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + scslre: 0.3.0 + eslint-plugin-regexp@3.1.0(eslint@10.5.0(jiti@2.7.0)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) @@ -14566,6 +15213,17 @@ snapshots: - supports-color - typescript + eslint-plugin-toml@1.3.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + toml-eslint-parser: 1.0.3 + transitivePeerDependencies: + - supports-color + eslint-plugin-toml@1.3.1(eslint@10.5.0(jiti@2.7.0)): dependencies: '@eslint/core': 1.2.1 @@ -14577,6 +15235,26 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-unicorn@64.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + change-case: 5.4.4 + ci-info: 4.4.0 + clean-regexp: 1.0.0 + core-js-compat: 3.49.0 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + find-up-simple: 1.0.1 + globals: 17.6.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.13.0 + semver: 7.8.4 + strip-indent: 4.1.1 + eslint-plugin-unicorn@64.0.0(eslint@10.5.0(jiti@2.7.0)): dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -14597,11 +15275,31 @@ snapshots: semver: 7.8.4 strip-indent: 4.1.1 + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)): dependencies: eslint: 10.5.0(jiti@2.7.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + + eslint-plugin-vue@10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.4 + semver: 7.8.4 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + xml-name-validator: 4.0.0 + optionalDependencies: + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) eslint-plugin-vue@10.9.1(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.7.0)))(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.7.0))): dependencies: @@ -14609,13 +15307,24 @@ snapshots: eslint: 10.5.0(jiti@2.7.0) natural-compare: 1.4.0 nth-check: 2.1.1 - postcss-selector-parser: 7.1.1 + postcss-selector-parser: 7.1.4 semver: 7.8.4 vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.7.0)) xml-name-validator: 4.0.0 optionalDependencies: '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.7.0)) - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(supports-color@10.2.2)(typescript@6.0.3) + + eslint-plugin-yml@3.3.2(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@ota-meshi/ast-token-store': 0.3.0 + diff-sequences: 29.6.3 + escape-string-regexp: 5.0.0 + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + natural-compare: 1.4.0 + yaml-eslint-parser: 2.0.0 eslint-plugin-yml@3.3.2(eslint@10.5.0(jiti@2.7.0)): dependencies: @@ -14628,6 +15337,10 @@ snapshots: natural-compare: 1.4.0 yaml-eslint-parser: 2.0.0 + eslint-processor-vue-blocks@2.0.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)): + dependencies: + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-processor-vue-blocks@2.0.0(eslint@10.5.0(jiti@2.7.0)): dependencies: eslint: 10.5.0(jiti@2.7.0) @@ -14649,7 +15362,44 @@ snapshots: dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@10.2.2) + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@10.2.2) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5(supports-color@10.2.2) '@eslint/config-helpers': 0.6.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 @@ -14754,7 +15504,7 @@ snapshots: extend@3.0.2: {} - extract-zip@2.0.1: + extract-zip@2.0.1(supports-color@10.2.2): dependencies: debug: 4.4.3(supports-color@10.2.2) get-stream: 5.2.0 @@ -14861,6 +15611,15 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + framer-motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 12.40.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + fs-constants@1.0.0: optional: true @@ -15199,9 +15958,9 @@ snapshots: optionalDependencies: typescript: 6.0.3 - iconify-import-svg@0.2.0: + iconify-import-svg@0.2.0(supports-color@10.2.2): dependencies: - '@iconify/tools': 4.2.0 + '@iconify/tools': 4.2.0(supports-color@10.2.2) '@iconify/types': 2.0.0 '@iconify/utils': 3.1.0 transitivePeerDependencies: @@ -15423,10 +16182,10 @@ snapshots: jiti@2.7.0: {} - jotai@2.20.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.17)(react@19.2.7): + jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7): optionalDependencies: - '@babel/core': 7.29.0 - '@babel/template': 7.28.6 + '@babel/core': 7.29.7 + '@babel/template': 7.29.7 '@types/react': 19.2.17 react: 19.2.7 @@ -15442,10 +16201,6 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -16267,6 +17022,20 @@ snapshots: dependencies: color-name: 1.1.4 + motion-dom@12.40.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + framer-motion: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + mrmime@2.0.1: {} ms@2.1.3: {} @@ -16289,7 +17058,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.9 '@swc/helpers': 0.5.15 @@ -16298,7 +17067,7 @@ snapshots: postcss: 8.5.15 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.7) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) optionalDependencies: '@next/swc-darwin-arm64': 16.2.9 '@next/swc-darwin-x64': 16.2.9 @@ -16344,12 +17113,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.8.9(next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + nuqs@2.8.9(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.7 optionalDependencies: - next: 16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) object-assign@4.1.1: {} @@ -16792,7 +17561,7 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - portfinder@1.0.38: + portfinder@1.0.38(supports-color@10.2.2): dependencies: async: 3.2.6 debug: 4.4.3(supports-color@10.2.2) @@ -16801,12 +17570,12 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-selector-parser@6.0.10: + postcss-selector-parser@6.1.4: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-selector-parser@7.1.1: + postcss-selector-parser@7.1.4: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -16900,7 +17669,7 @@ snapshots: react-docgen@8.0.3: dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 '@types/babel__core': 7.20.5 @@ -17502,18 +18271,18 @@ snapshots: smol-toml@1.6.1: {} - socket.io-client@4.8.3: + socket.io-client@4.8.3(supports-color@10.2.2): dependencies: '@socket.io/component-emitter': 3.1.2 debug: 4.4.3(supports-color@10.2.2) - engine.io-client: 6.6.4 - socket.io-parser: 4.2.6 + engine.io-client: 6.6.4(supports-color@10.2.2) + socket.io-parser: 4.2.6(supports-color@10.2.2) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - socket.io-parser@4.2.6: + socket.io-parser@4.2.6(supports-color@10.2.2): dependencies: '@socket.io/component-emitter': 3.1.2 debug: 4.4.3(supports-color@10.2.2) @@ -17701,12 +18470,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.7): + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7): dependencies: client-only: 0.0.1 react: 19.2.7 optionalDependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 stylis@4.3.6: {} @@ -17761,7 +18530,7 @@ snapshots: readable-stream: 3.6.2 optional: true - tar@7.5.11: + tar@7.5.16: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -17931,6 +18700,8 @@ snapshots: undici-types@7.24.6: {} + undici@7.26.0: {} + undici@7.27.2: {} unicode-trie@2.0.0: @@ -18094,9 +18865,9 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@0.1.2(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.2(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + vinext@0.1.2(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.2(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)(typescript@6.0.3): dependencies: - '@unpic/react': 1.0.2(next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@unpic/react': 1.0.2(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@vercel/og': 0.8.6 '@vitejs/plugin-react': 6.0.2(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) image-size: 2.0.2 @@ -18106,7 +18877,7 @@ snapshots: react-dom: 19.2.7(react@19.2.7) vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' vite-plugin-commonjs: 0.10.4 - vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3) + vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3) web-vitals: 4.2.4 optionalDependencies: '@mdx-js/rollup': 3.1.1 @@ -18149,17 +18920,17 @@ snapshots: - typescript - utf-8-validate - vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3): + vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(storybook@10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)))(supports-color@10.2.2)(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.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) storybook: 10.4.4(@testing-library/dom@10.4.1)(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vite-plus@0.1.24(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.3)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)) ts-dedent: 2.2.0 vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0)' - vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3) + vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3) transitivePeerDependencies: - supports-color - typescript @@ -18264,7 +19035,7 @@ snapshots: - vite - yaml - vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3): + vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3): dependencies: debug: 4.4.3(supports-color@10.2.2) globrex: 0.1.2 @@ -18275,7 +19046,7 @@ snapshots: - supports-color - typescript - vite-tsconfig-paths@6.1.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3): + vite-tsconfig-paths@6.1.1(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(supports-color@10.2.2)(typescript@6.0.3): dependencies: debug: 4.4.3(supports-color@10.2.2) globrex: 0.1.2 @@ -18308,6 +19079,18 @@ snapshots: vscode-uri@3.1.0: {} + vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.5.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.4 + transitivePeerDependencies: + - supports-color + vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.7.0)): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -18522,10 +19305,12 @@ time: '@remixicon/react@4.9.0': '2026-01-29T10:53:18.993Z' '@rgrove/parse-xml@4.2.0': '2024-10-25T03:58:22.145Z' '@sentry/react@10.57.0': '2026-06-09T09:44:56.173Z' + '@storybook/addon-a11y@10.4.4': '2026-06-11T11:47:24.917Z' '@storybook/addon-docs@10.4.4': '2026-06-11T11:47:25.111Z' '@storybook/addon-links@10.4.4': '2026-06-11T11:47:29.814Z' '@storybook/addon-onboarding@10.4.4': '2026-06-11T11:47:29.674Z' '@storybook/addon-themes@10.4.4': '2026-06-11T11:47:34.054Z' + '@storybook/addon-vitest@10.4.4': '2026-06-11T11:47:37.457Z' '@storybook/nextjs-vite@10.4.4': '2026-06-11T11:47:57.869Z' '@storybook/react-vite@10.4.4': '2026-06-11T11:48:05.555Z' '@storybook/react@10.4.4': '2026-06-11T11:48:50.264Z' @@ -18585,6 +19370,7 @@ time: echarts@6.1.0: '2026-05-19T17:52:11.076Z' elkjs@0.11.1: '2026-03-03T12:21:48.463Z' embla-carousel-autoplay@8.6.0: '2025-04-04T17:37:46.303Z' + embla-carousel-fade@8.6.0: '2025-04-04T17:37:50.278Z' embla-carousel-react@8.6.0: '2025-04-04T17:37:53.976Z' emoji-mart@5.6.0: '2024-04-25T14:22:21.440Z' es-toolkit@1.47.1: '2026-06-12T07:38:48.983Z' @@ -18627,6 +19413,7 @@ time: mermaid@11.15.0: '2026-05-11T11:15:09.824Z' mime@4.1.0: '2025-09-12T17:53:01.376Z' mitt@3.0.1: '2023-07-04T17:31:47.638Z' + motion@12.40.0: '2026-05-21T12:00:11.274Z' negotiator@1.0.0: '2024-08-31T15:42:18.280Z' next-themes@0.4.6: '2025-03-11T21:02:05.882Z' next@16.2.9: '2026-06-09T23:02:22.464Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2e40972bfb6..313f2102ebf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -27,19 +27,24 @@ packages: - packages/* - cli overrides: + '@babel/core@<=7.29.0': ^7.29.1 '@lexical/code': npm:lexical-code-no-prism@0.41.0 canvas: ^3.2.3 esbuild@<0.27.2: 0.27.2 esbuild@>=0.17.0 <0.28.1: ^0.28.1 esbuild@>=0.27.3 <0.28.1: ^0.28.1 is-core-module: npm:@nolyfill/is-core-module@^1.0.39 + js-yaml@<=4.1.1: ^4.1.2 picomatch@>=4.0.0 <4.0.4: 4.0.4 + postcss-selector-parser@>=6.0.0 <6.1.3: 6.1.4 + postcss-selector-parser@>=7.0.0 <7.1.3: 7.1.4 postcss@<8.5.10: ^8.5.10 rollup@>=4.0.0 <4.59.0: 4.61.1 safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 side-channel: npm:@nolyfill/side-channel@^1.0.44 solid-js: 1.9.13 string-width: ~8.2.1 + tar@<=7.5.15: ^7.5.16 vite: npm:@voidzero-dev/vite-plus-core@0.1.24 vitest: npm:@voidzero-dev/vite-plus-test@0.1.24 ws@>=8.0.0 <8.20.1: ^8.21.0 @@ -85,10 +90,12 @@ catalog: '@remixicon/react': 4.9.0 '@rgrove/parse-xml': 4.2.0 '@sentry/react': 10.57.0 + '@storybook/addon-a11y': 10.4.4 '@storybook/addon-docs': 10.4.4 '@storybook/addon-links': 10.4.4 '@storybook/addon-onboarding': 10.4.4 '@storybook/addon-themes': 10.4.4 + '@storybook/addon-vitest': 10.4.4 '@storybook/nextjs-vite': 10.4.4 '@storybook/react': 10.4.4 '@storybook/react-vite': 10.4.4 @@ -146,6 +153,7 @@ catalog: echarts-for-react: 3.0.6 elkjs: 0.11.1 embla-carousel-autoplay: 8.6.0 + embla-carousel-fade: 8.6.0 embla-carousel-react: 8.6.0 emoji-mart: 5.6.0 es-toolkit: 1.47.1 @@ -187,6 +195,7 @@ catalog: mermaid: 11.15.0 mime: 4.1.0 mitt: 3.0.1 + motion: 12.40.0 negotiator: 1.0.0 next: 16.2.9 next-themes: 0.4.6 diff --git a/web/__mocks__/provider-context.ts b/web/__mocks__/provider-context.ts index 10fac8d8b61..38afc2ff0e5 100644 --- a/web/__mocks__/provider-context.ts +++ b/web/__mocks__/provider-context.ts @@ -8,6 +8,7 @@ import { defaultPlan } from '@/app/components/billing/config' export const baseProviderContextValue: ProviderContextState = { modelProviders: [], refreshModelProviders: noop, + isLoadingModelProviders: false, textGenerationModelList: [], supportRetrievalMethods: [], isAPIKeySet: true, diff --git a/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx index 053edb82882..0e51c650b21 100644 --- a/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx +++ b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx @@ -154,18 +154,13 @@ describe('App Sidebar Shell Flow', () => { expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse') }) - it('switches to the workflow fullscreen dropdown shell and opens its navigation menu', async () => { + it('keeps the normal sidebar on workflow routes', () => { mockPathname = '/app/app-1/workflow' mockSelectedSegment = 'workflow' - localStorage.setItem('workflow-canvas-maximize', 'true') render() - expect(screen.queryByTestId('app-info')).not.toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'operation.more' })) - - expect(await screen.findByText('Demo App')).toBeInTheDocument() + expect(screen.getByTestId('app-info')).toBeInTheDocument() expect(screen.getByRole('link', { name: /Overview/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /Logs/i })).toBeInTheDocument() }) diff --git a/web/__tests__/app-star-i18n.test.ts b/web/__tests__/app-star-i18n.test.ts new file mode 100644 index 00000000000..c785d7d8c84 --- /dev/null +++ b/web/__tests__/app-star-i18n.test.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs' +import path from 'node:path' + +const I18N_DIR = path.join(__dirname, '../i18n') + +const REQUIRED_APP_STAR_KEYS = [ + 'studio.allApps', + 'studio.starApp', + 'studio.starFailed', + 'studio.starred', + 'studio.unstarApp', +] as const + +type AppTranslations = Record + +const getSupportedLocales = () => fs.readdirSync(I18N_DIR) + .filter(item => fs.statSync(path.join(I18N_DIR, item)).isDirectory()) + .sort() + +const loadAppTranslations = (locale: string): AppTranslations => { + const filePath = path.join(I18N_DIR, locale, 'app.json') + + if (!fs.existsSync(filePath)) + throw new Error(`Translation file not found: ${filePath}`) + + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as AppTranslations +} + +describe('App star i18n translations', () => { + it('should define star-related app list labels for every locale', () => { + const supportedLocales = getSupportedLocales() + + const missingKeys = supportedLocales.flatMap((locale) => { + const translations = loadAppTranslations(locale) + + return REQUIRED_APP_STAR_KEYS + .filter((key) => { + const value = translations[key] + return typeof value !== 'string' || value.trim() === '' + }) + .map(key => `${locale}:${key}`) + }) + + expect(supportedLocales.length).toBeGreaterThan(0) + expect(missingKeys).toEqual([]) + }) +}) diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 8162f12dadd..5702755a5c8 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -100,6 +100,10 @@ vi.mock('@/service/use-apps', () => ({ mutateAsync: mockDeleteAppMutation, isPending: mockDeleteMutationPending, }), + useToggleAppStarMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), })) vi.mock('@/service/apps', () => ({ @@ -277,21 +281,13 @@ describe('App Card Operations Flow', () => { it('should navigate to app config page when card is clicked', () => { renderAppCard({ id: 'app-123', mode: AppModeEnum.CHAT }) - const card = screen.getByText('Test Chat App').closest('[class*="cursor-pointer"]') - if (card) - fireEvent.click(card) - - expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/configuration') + expect(screen.getByRole('link', { name: 'Test Chat App' })).toHaveAttribute('href', '/app/app-123/configuration') }) it('should navigate to workflow page for workflow apps', () => { renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' }) - const card = screen.getByText('WF App').closest('[class*="cursor-pointer"]') - if (card) - fireEvent.click(card) - - expect(mockRouterPush).toHaveBeenCalledWith('/app/app-wf/workflow') + expect(screen.getByRole('link', { name: 'WF App' })).toHaveAttribute('href', '/app/app-wf/workflow') }) }) diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index 0c434720208..39d9219be66 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -64,6 +64,7 @@ vi.mock('@/context/app-context', () => ({ isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, + userProfile: { id: 'member-1' }, }), })) @@ -89,6 +90,17 @@ vi.mock('@/service/tag', () => ({ fetchTagList: vi.fn().mockResolvedValue([]), })) +vi.mock('@/service/use-common', () => ({ + useMembers: () => ({ + data: { + accounts: [ + { id: 'member-1', name: 'Alice', avatar_url: null, status: 'active' }, + { id: 'member-2', name: 'Bob', avatar_url: null, status: 'active' }, + ], + }, + }), +})) + vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() return { @@ -114,6 +126,10 @@ vi.mock('@/service/use-apps', () => ({ mutateAsync: vi.fn(), isPending: false, }), + useToggleAppStarMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), })) vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({ @@ -229,7 +245,7 @@ describe('App List Browsing Flow', () => { mockPages = [createPage([])] renderList() - expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument() + expect(screen.getByText('app.firstEmpty.title')).toBeInTheDocument() }) it('should transition from loading to content when data loads', () => { @@ -277,17 +293,17 @@ describe('App List Browsing Flow', () => { expect(screen.getByText('A powerful AI assistant')).toBeInTheDocument() }) - it('should show the NewAppCard for workspace editors', () => { + it('should show the create menu for workspace editors', () => { mockPages = [createPage([ createMockApp({ name: 'Test App' }), ])] renderList() - expect(screen.getByText('app.createApp')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument() }) - it('should hide NewAppCard when user is not a workspace editor', () => { + it('should hide the create menu when user is not a workspace editor', () => { mockIsCurrentWorkspaceEditor = false mockPages = [createPage([ createMockApp({ name: 'Test App' }), @@ -295,20 +311,20 @@ describe('App List Browsing Flow', () => { renderList() - expect(screen.queryByText('app.createApp')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument() }) }) - // -- Footer visibility -- - describe('Footer Visibility', () => { - it('should show footer when branding is disabled', () => { + // -- Legacy footer removal -- + describe('Legacy Footer', () => { + it('should not show the legacy footer when branding is disabled', () => { mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: false } } mockPages = [createPage([createMockApp()])] renderList() - expect(screen.getByText('app.join')).toBeInTheDocument() - expect(screen.getByText('app.communityIntro')).toBeInTheDocument() + expect(screen.queryByText('app.join')).not.toBeInTheDocument() + expect(screen.queryByText('app.communityIntro')).not.toBeInTheDocument() }) it('should hide footer when branding is enabled', () => { @@ -341,11 +357,18 @@ describe('App List Browsing Flow', () => { // -- Tab navigation -- describe('Tab Navigation', () => { - it('should render the app type dropdown trigger', () => { + it('should render all category options', async () => { mockPages = [createPage([createMockApp()])] renderList() - expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.types' })) + + expect(await screen.findByRole('menuitemradio', { name: 'app.types.all' })).toBeInTheDocument() + expect(await screen.findByRole('menuitemradio', { name: 'app.types.workflow' })).toBeInTheDocument() + expect(await screen.findByRole('menuitemradio', { name: 'app.types.advanced' })).toBeInTheDocument() + expect(await screen.findByRole('menuitemradio', { name: 'app.types.chatbot' })).toBeInTheDocument() + expect(await screen.findByRole('menuitemradio', { name: 'app.types.agent' })).toBeInTheDocument() + expect(await screen.findByRole('menuitemradio', { name: 'app.newApp.completeApp' })).toBeInTheDocument() }) }) @@ -374,21 +397,22 @@ describe('App List Browsing Flow', () => { }) }) - // -- "Created by me" filter -- - describe('Created By Me Filter', () => { - it('should not render a standalone "created by me" checkbox in the current header layout', () => { + // -- Creators filter -- + describe('Creators Filter', () => { + it('should render the creators filter', () => { mockPages = [createPage([createMockApp()])] renderList() - expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument() + expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument() }) - it('should keep the current layout stable without a "created by me" control', () => { + it('should open the creators filter menu', () => { mockPages = [createPage([createMockApp()])] renderList() - expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument() - expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.creators' })) + + expect(screen.getByRole('button', { name: /Bob/ })).toBeInTheDocument() }) }) diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index ba3ab166def..1fcc22c4d28 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -101,6 +101,10 @@ vi.mock('@/service/use-apps', () => ({ mutateAsync: vi.fn(), isPending: false, }), + useToggleAppStarMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), })) vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({ @@ -242,6 +246,15 @@ const renderList = () => { return { ...render(, { wrapper: Wrapper }), onUrlUpdate } } +const openCreateMenu = () => { + fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) +} + +const clickCreateMenuItem = (label: string) => { + openCreateMenu() + fireEvent.click(screen.getByText(label)) +} + describe('Create App Flow', () => { beforeEach(() => { vi.clearAllMocks() @@ -259,28 +272,28 @@ describe('Create App Flow', () => { }) describe('NewAppCard Rendering', () => { - it('should render the "Create App" card with all options', () => { + it('should render the create menu with all options', () => { renderList() - expect(screen.getByText('app.createApp')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument() + openCreateMenu() expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument() expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument() expect(screen.getByText('app.importDSL')).toBeInTheDocument() }) - it('should not render NewAppCard when user is not an editor', () => { + it('should not render the create menu when user is not an editor', () => { mockIsCurrentWorkspaceEditor = false renderList() - expect(screen.queryByText('app.createApp')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument() }) - it('should show loading state when workspace is loading', () => { + it('should keep the create menu available while workspace state is loading', () => { mockIsLoadingCurrentWorkspace = true renderList() - // NewAppCard renders but with loading style (pointer-events-none opacity-50) - expect(screen.getByText('app.createApp')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument() }) }) @@ -289,7 +302,7 @@ describe('Create App Flow', () => { it('should open the create app modal when "Start from Blank" is clicked', async () => { renderList() - fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + clickCreateMenuItem('app.newApp.startFromBlank') await waitFor(() => { expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() @@ -299,7 +312,7 @@ describe('Create App Flow', () => { it('should close the create app modal on cancel', async () => { renderList() - fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + clickCreateMenuItem('app.newApp.startFromBlank') await waitFor(() => { expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() }) @@ -313,7 +326,7 @@ describe('Create App Flow', () => { it('should call onPlanInfoChanged and refetch on successful creation', async () => { renderList() - fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + clickCreateMenuItem('app.newApp.startFromBlank') await waitFor(() => { expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() }) @@ -331,7 +344,7 @@ describe('Create App Flow', () => { it('should open template dialog when "Start from Template" is clicked', async () => { renderList() - fireEvent.click(screen.getByText('app.newApp.startFromTemplate')) + clickCreateMenuItem('app.newApp.startFromTemplate') await waitFor(() => { expect(screen.getByTestId('template-dialog')).toBeInTheDocument() @@ -341,7 +354,7 @@ describe('Create App Flow', () => { it('should allow switching from template to blank modal', async () => { renderList() - fireEvent.click(screen.getByText('app.newApp.startFromTemplate')) + clickCreateMenuItem('app.newApp.startFromTemplate') await waitFor(() => { expect(screen.getByTestId('template-dialog')).toBeInTheDocument() }) @@ -356,7 +369,7 @@ describe('Create App Flow', () => { it('should allow switching from blank to template dialog', async () => { renderList() - fireEvent.click(screen.getByText('app.newApp.startFromBlank')) + clickCreateMenuItem('app.newApp.startFromBlank') await waitFor(() => { expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() }) @@ -374,7 +387,7 @@ describe('Create App Flow', () => { it('should open DSL import modal when "Import DSL" is clicked', async () => { renderList() - fireEvent.click(screen.getByText('app.importDSL')) + clickCreateMenuItem('app.importDSL') await waitFor(() => { expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() @@ -384,7 +397,7 @@ describe('Create App Flow', () => { it('should close DSL import modal on cancel', async () => { renderList() - fireEvent.click(screen.getByText('app.importDSL')) + clickCreateMenuItem('app.importDSL') await waitFor(() => { expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() }) @@ -398,7 +411,7 @@ describe('Create App Flow', () => { it('should call onPlanInfoChanged and refetch on successful DSL import', async () => { renderList() - fireEvent.click(screen.getByText('app.importDSL')) + clickCreateMenuItem('app.importDSL') await waitFor(() => { expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() }) @@ -461,17 +474,18 @@ describe('Create App Flow', () => { mockPages = [createPage([])] renderList() - // NewAppCard should still be visible even with no apps - expect(screen.getByText('app.createApp')).toBeInTheDocument() + expect(screen.getByText('app.firstEmpty.title')).toBeInTheDocument() + expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument() + expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument() + expect(screen.getByText('app.importDSL')).toBeInTheDocument() }) it('should handle multiple rapid clicks on create buttons without crashing', async () => { renderList() - // Rapidly click different create options - fireEvent.click(screen.getByText('app.newApp.startFromBlank')) - fireEvent.click(screen.getByText('app.newApp.startFromTemplate')) - fireEvent.click(screen.getByText('app.importDSL')) + clickCreateMenuItem('app.newApp.startFromBlank') + clickCreateMenuItem('app.newApp.startFromTemplate') + clickCreateMenuItem('app.importDSL') // Should not crash, and some modal should be present await waitFor(() => { diff --git a/web/__tests__/base/notion-page-selector-flow.test.tsx b/web/__tests__/base/notion-page-selector-flow.test.tsx index 34f4c988e16..3943507d7e7 100644 --- a/web/__tests__/base/notion-page-selector-flow.test.tsx +++ b/web/__tests__/base/notion-page-selector-flow.test.tsx @@ -28,6 +28,9 @@ vi.mock('@/service/knowledge/use-import', () => ({ })) vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), })) diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index 0f3a0708e71..57f6faddcc7 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -21,6 +21,14 @@ let mockAppCtx: Record = {} const mockSetShowPricingModal = vi.fn() const mockSetShowAccountSettingModal = vi.fn() +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_CLOUD_EDITION: true, + } +}) + vi.mock('@/context/provider-context', () => ({ useProviderContext: () => mockProviderCtx, })) diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index 83885b9f9f9..6098d446348 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -24,6 +24,14 @@ const mockRouterPush = vi.fn() const mockMutateAsync = vi.fn() const mockSetEducationVerifying = vi.hoisted(() => vi.fn()) +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_CLOUD_EDITION: true, + } +}) + // ─── Context mocks ─────────────────────────────────────────────────────────── vi.mock('@/context/provider-context', () => ({ useProviderContext: () => mockProviderCtx, diff --git a/web/__tests__/custom/custom-page-flow.test.tsx b/web/__tests__/custom/custom-page-flow.test.tsx index 6eb5ccadb9d..66158ec5062 100644 --- a/web/__tests__/custom/custom-page-flow.test.tsx +++ b/web/__tests__/custom/custom-page-flow.test.tsx @@ -8,6 +8,14 @@ import useWebAppBrand from '@/app/components/custom/custom-web-app-brand/hooks/u const mockSetShowPricingModal = vi.fn() +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_CLOUD_EDITION: true, + } +}) + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx index dbbbbee4568..f93353443c2 100644 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -8,10 +8,10 @@ import type { Mock } from 'vitest' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { App } from '@/models/explore' import { fireEvent, screen, waitFor } from '@testing-library/react' -import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' +import { createTestQueryClient, renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import AppList from '@/app/components/explore/app-list' import { useAppContext } from '@/context/app-context' -import { fetchAppDetail } from '@/service/explore' +import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore' import { useMembers } from '@/service/use-common' import { AppModeEnum } from '@/types/app' @@ -47,9 +47,9 @@ vi.mock('ahooks', async () => { }) vi.mock('@/service/use-explore', () => ({ - useExploreAppList: () => ({ - data: mockExploreData, - isLoading: mockIsLoading, + useLearnDifyAppList: () => ({ + data: [], + isLoading: false, isError: false, }), })) @@ -57,6 +57,55 @@ vi.mock('@/service/use-explore', () => ({ vi.mock('@/service/explore', () => ({ fetchAppDetail: vi.fn(), fetchAppList: vi.fn(), + fetchBanners: vi.fn(), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: {}, + consoleQuery: { + systemFeatures: { + get: { + queryKey: () => ['console', 'systemFeatures'], + }, + }, + apps: { + list: { + queryOptions: (options: { + input?: { query?: { limit?: number } } + select?: (response: { + data: [] + has_more: boolean + limit: number + page: number + total: number + }) => unknown + }) => { + const limit = options.input?.query?.limit ?? 0 + const response = { + data: [], + has_more: false, + limit, + page: 1, + total: 0, + } + return { + queryKey: ['console', 'apps', 'list', options.input], + queryFn: () => Promise.resolve(response), + initialData: response, + select: options.select, + } + }, + }, + }, + explore: { + apps: { + queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'apps', input], + }, + banners: { + queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'banners', input], + }, + }, + }, })) vi.mock('@/context/app-context', () => ({ @@ -147,14 +196,37 @@ const mockMemberRole = (hasEditPermission: boolean) => { }) } -const renderAppList = (hasEditPermission = true, onSuccess?: () => void) => { - mockMemberRole(hasEditPermission) - return render() +const localeInput = { query: { language: 'en' } } +const exploreAppListQueryKey = ['console', 'explore', 'apps', localeInput, 'en'] +const homeContinueWorkAppsInput = { + query: { + page: 1, + limit: 8, + name: '', + }, } -const appListElement = (hasEditPermission = true, onSuccess?: () => void) => { +const createHomeQueryClient = () => { + const queryClient = createTestQueryClient() + queryClient.setQueryData(['console', 'apps', 'list', homeContinueWorkAppsInput], { + data: [], + has_more: false, + limit: 8, + page: 1, + total: 0, + }) + + if (!mockIsLoading && mockExploreData) + queryClient.setQueryData(exploreAppListQueryKey, mockExploreData) + + return queryClient +} + +const renderAppList = (hasEditPermission = true, onSuccess?: () => void) => { mockMemberRole(hasEditPermission) - return + return render(, { + queryClient: createHomeQueryClient(), + }) } describe('Explore App List Flow', () => { @@ -170,6 +242,8 @@ describe('Explore App List Flow', () => { createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, categories: ['Programming'] }), ], } + ;(fetchAppList as unknown as Mock).mockImplementation(() => new Promise(() => {})) + ;(fetchBanners as unknown as Mock).mockResolvedValue([]) }) describe('Browse and Filter Flow', () => { @@ -242,8 +316,8 @@ describe('Explore App List Flow', () => { renderAppList(true, onSuccess) - // Step 2: Click add to workspace button - opens create modal - fireEvent.click(screen.getAllByText('explore.appCard.addToWorkspace')[0]!) + // Step 2: Click the app card - opens create modal in self-hosted/non-cloud mode + fireEvent.click(screen.getByRole('button', { name: 'Writer Bot' })) // Step 3: Confirm creation in modal fireEvent.click(await screen.findByTestId('confirm-create')) @@ -256,7 +330,6 @@ describe('Explore App List Flow', () => { // Step 5: DSL import triggers pending confirmation expect(mockHandleImportDSL).toHaveBeenCalledTimes(1) - // Step 6: DSL confirm modal appears and user confirms // Step 6: DSL confirm modal appears and user confirms expect(await screen.findByTestId('dsl-confirm-modal'))!.toBeInTheDocument() fireEvent.click(screen.getByTestId('dsl-confirm')) @@ -274,7 +347,7 @@ describe('Explore App List Flow', () => { // Step 1: Loading state mockIsLoading = true mockExploreData = undefined - const { unmount } = render(appListElement()) + const { unmount } = renderAppList() expect(screen.getByRole('status'))!.toBeInTheDocument() @@ -293,16 +366,16 @@ describe('Explore App List Flow', () => { }) describe('Permission-Based Behavior', () => { - it('should hide add-to-workspace button when user has no edit permission', () => { + it('should not make app cards clickable when user has no edit permission', () => { renderAppList(false) - expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Writer Bot' })).not.toBeInTheDocument() }) - it('should show add-to-workspace button when user has edit permission', () => { + it('should make app cards clickable when user has edit permission', () => { renderAppList(true) - expect(screen.getAllByText('explore.appCard.addToWorkspace').length).toBeGreaterThan(0) + expect(screen.getByRole('button', { name: 'Writer Bot' })).toBeInTheDocument() }) }) }) diff --git a/web/__tests__/explore/installed-app-flow.test.tsx b/web/__tests__/explore/installed-app-flow.test.tsx index 34bfac5cd66..66d88cdf926 100644 --- a/web/__tests__/explore/installed-app-flow.test.tsx +++ b/web/__tests__/explore/installed-app-flow.test.tsx @@ -144,13 +144,13 @@ describe('Installed App Flow', () => { [AppModeEnum.CHAT, 'chat-with-history'], [AppModeEnum.ADVANCED_CHAT, 'chat-with-history'], [AppModeEnum.AGENT_CHAT, 'chat-with-history'], - ])('should render ChatWithHistory for %s mode', (mode, testId) => { + ])('should render ChatWithHistory for %s mode', async (mode, testId) => { const app = createInstalledApp(mode) setupDefaultMocks(app) render() - expect(screen.getByTestId(testId)).toBeInTheDocument() + expect(await screen.findByTestId(testId)).toBeInTheDocument() expect(screen.getByText(/Integration Test App/)).toBeInTheDocument() }) diff --git a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx index 35c8175a368..11c3426f484 100644 --- a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx +++ b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx @@ -24,6 +24,7 @@ let mockInstalledApps: InstalledApp[] = [] let mockIsUninstallPending = false vi.mock('@/next/navigation', () => ({ + usePathname: () => '/explore', useSelectedLayoutSegments: () => mockSegments, useRouter: () => ({ push: mockPush, diff --git a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx index bd089d325c3..421d805d072 100644 --- a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx +++ b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx @@ -38,6 +38,7 @@ vi.mock('@/context/app-context', () => ({ })) vi.mock('@/service/use-plugins', () => ({ + hasPluginPermission: () => true, useReferenceSettings: () => ({ data: { permission: { @@ -51,6 +52,24 @@ vi.mock('@/service/use-plugins', () => ({ isPending: false, }), useInvalidateReferenceSettings: () => vi.fn(), + usePluginPermissionSettings: () => ({ + data: { + install_permission: 'everyone', + debug_permission: 'noOne', + }, + }), + useMutationPluginPermissionSettings: () => ({ + mutate: vi.fn(), + isPending: false, + }), + usePluginAutoUpgradeSettings: () => ({ + data: { + auto_upgrade: false, + strategy_setting: {}, + exclude_plugins: [], + include_plugins: [], + }, + }), useInstalledPluginList: () => ({ data: { total: 2, diff --git a/web/__tests__/tools/provider-list-shell-flow.test.tsx b/web/__tests__/tools/provider-list-shell-flow.test.tsx index afa3f45e9f4..346834c642c 100644 --- a/web/__tests__/tools/provider-list-shell-flow.test.tsx +++ b/web/__tests__/tools/provider-list-shell-flow.test.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' -import ProviderList from '@/app/components/tools/provider-list' +import ProviderList from '@/app/components/integrations/tool-provider-list' import { CollectionType } from '@/app/components/tools/types' import { createNuqsTestWrapper } from '@/test/nuqs-testing' diff --git a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx index b1cc0c13122..d470326805e 100644 --- a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx +++ b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx @@ -129,30 +129,6 @@ vi.mock('@/app/components/base/tab-slider-new', () => ({ ), })) -vi.mock('@/app/components/base/input', () => ({ - default: ({ value, onChange, onClear, showLeftIcon, showClearIcon, wrapperClassName }: { - value: string - onChange: (e: { target: { value: string } }) => void - onClear: () => void - showLeftIcon?: boolean - showClearIcon?: boolean - wrapperClassName?: string - }) => ( -
- - {showClearIcon && value && ( - - )} -
- ), -})) - vi.mock('@/app/components/plugins/card', () => ({ default: ({ payload, className }: { payload: { brief: Record | string, name: string }, className?: string }) => { const briefText = typeof payload.brief === 'object' ? payload.brief?.en_US || '' : payload.brief @@ -230,7 +206,7 @@ vi.mock('@/app/components/workflow/block-selector/types', () => ({ ToolTypeEnum: { BuiltIn: 'builtin', Custom: 'api', Workflow: 'workflow', MCP: 'mcp' }, })) -const { default: ProviderList } = await import('@/app/components/tools/provider-list') +const { default: ProviderList } = await import('@/app/components/integrations/tool-provider-list') const createWrapper = () => { const { wrapper } = createSystemFeaturesWrapper({ @@ -263,7 +239,7 @@ describe('Tool Browsing & Filtering Integration', () => { it('filters tools by keyword search', async () => { render(, { wrapper: createWrapper() }) - const searchInput = screen.getByTestId('search-input') + const searchInput = screen.getByPlaceholderText('operation.search') fireEvent.change(searchInput, { target: { value: 'Google' } }) await waitFor(() => { @@ -275,7 +251,7 @@ describe('Tool Browsing & Filtering Integration', () => { it('clears search keyword and shows all tools again', async () => { render(, { wrapper: createWrapper() }) - const searchInput = screen.getByTestId('search-input') + const searchInput = screen.getByPlaceholderText('operation.search') fireEvent.change(searchInput, { target: { value: 'Google' } }) await waitFor(() => { expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument() @@ -323,7 +299,7 @@ describe('Tool Browsing & Filtering Integration', () => { expect(screen.getByTestId('card-google_search')).toBeInTheDocument() }) - const searchInput = screen.getByTestId('search-input') + const searchInput = screen.getByPlaceholderText('operation.search') fireEvent.change(searchInput, { target: { value: 'Weather' } }) await waitFor(() => { expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument() @@ -368,6 +344,6 @@ describe('Tool Browsing & Filtering Integration', () => { it('shows search input on all tabs', () => { render(, { wrapper: createWrapper() }) - expect(screen.getByTestId('search-input')).toBeInTheDocument() + expect(screen.getByPlaceholderText('operation.search')).toBeInTheDocument() }) }) diff --git a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx index 7546e95d699..45bbf24a255 100644 --- a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx +++ b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx @@ -130,6 +130,16 @@ describe('CommonLayoutHydrationBoundary', () => { expect(mocks.redirect).toHaveBeenCalledWith('/auth/refresh?redirect_url=%2Fapps%3Ftag%3Dworkflow') }) + it('should default unauthorized refresh redirects to the home path when the pathname header is missing', async () => { + mocks.headers.mockResolvedValue(new Headers()) + mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 })) + const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary') + + await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT') + + expect(mocks.redirect).toHaveBeenCalledWith('/auth/refresh?redirect_url=%2F') + }) + it('should redirect setup errors to install', async () => { mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'not_setup' }), { status: 401 })) const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary') diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx new file mode 100644 index 00000000000..f1e12b34c70 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -0,0 +1,117 @@ +import type { App } from '@/types/app' +import { render, screen, waitFor } from '@testing-library/react' +import { useStore } from '@/app/components/app/store' +import { usePathname, useRouter } from '@/next/navigation' +import { fetchAppDetailDirect } from '@/service/apps' +import { AppModeEnum } from '@/types/app' +import AppDetailLayout from '../layout-main' + +const mockReplace = vi.fn() +let mockPathname = '/app/app-1/workflow' +let mockIsCurrentWorkspaceEditor = true + +vi.mock('@/next/navigation', () => ({ + usePathname: vi.fn(), + useRouter: vi.fn(), +})) + +vi.mock('@/service/apps', () => ({ + fetchAppDetailDirect: vi.fn(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + currentWorkspace: { id: 'workspace-1' }, + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + isLoadingCurrentWorkspace: false, + }), +})) + +vi.mock('@/hooks/use-document-title', () => ({ + default: vi.fn(), +})) + +const mockUsePathname = vi.mocked(usePathname) +const mockUseRouter = vi.mocked(useRouter) +const mockFetchAppDetailDirect = vi.mocked(fetchAppDetailDirect) + +const createAppDetail = (overrides: Partial = {}) => ({ + id: 'app-1', + name: 'Demo App', + mode: AppModeEnum.WORKFLOW, + ...overrides, +}) as App + +const waitForAppContent = async () => { + await waitFor(() => { + expect(screen.getByText('App page content')).toBeInTheDocument() + }) +} + +describe('AppDetailLayout', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPathname = '/app/app-1/workflow' + mockIsCurrentWorkspaceEditor = true + mockUsePathname.mockImplementation(() => mockPathname) + mockUseRouter.mockReturnValue({ + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + push: vi.fn(), + replace: mockReplace, + prefetch: vi.fn(), + }) + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail()) + useStore.getState().setAppDetail() + }) + + it('should keep app detail data when navigating between pages in the same app', async () => { + const { rerender, unmount } = render( + +
App page content
+
, + ) + await waitForAppContent() + expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1) + + mockPathname = '/app/app-1/logs' + rerender( + +
App page content
+
, + ) + + await waitForAppContent() + expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1) + expect(useStore.getState().appDetail?.id).toBe('app-1') + + unmount() + render( + +
App page content
+
, + ) + + await waitForAppContent() + expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1) + expect(useStore.getState().appDetail?.id).toBe('app-1') + }) + + it('should redirect restricted app pages before exposing app detail content', async () => { + mockIsCurrentWorkspaceEditor = false + mockPathname = '/app/app-1/logs' + + render( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview') + }) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + expect(useStore.getState().appDetail).toBeUndefined() + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 1e001a5ca4a..8eb6a9bd0e3 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -1,41 +1,31 @@ 'use client' import type { FC } from 'react' -import type { NavIcon } from '@/app/components/app-sidebar/nav-link' import type { App } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' -import { - RiDashboard2Fill, - RiDashboard2Line, - RiFileList3Fill, - RiFileList3Line, - RiTerminalBoxFill, - RiTerminalBoxLine, - RiTerminalWindowFill, - RiTerminalWindowLine, -} from '@remixicon/react' -import { useUnmount } from 'ahooks' import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' -import AppSideBar from '@/app/components/app-sidebar' -import { AppInfoDetailLayer } from '@/app/components/app-sidebar/app-info' -import { useAppInfoActions } from '@/app/components/app-sidebar/app-info/use-app-info-actions' import { useStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' -import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { usePathname, useRouter } from '@/next/navigation' import { fetchAppDetailDirect } from '@/service/apps' import { AppModeEnum } from '@/types/app' -import s from './style.module.css' type IAppDetailLayoutProps = { children: React.ReactNode appId: string } +const isNotFoundError = (error: unknown) => ( + typeof error === 'object' + && error !== null + && 'status' in error + && error.status === 404 +) + const AppDetailLayout: FC = (props) => { const { children, @@ -44,113 +34,78 @@ const AppDetailLayout: FC = (props) => { const { t } = useTranslation() const router = useRouter() const pathname = usePathname() - const media = useBreakpoints() - const isMobile = media === MediaType.mobile const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext() - const appInfoActions = useAppInfoActions({ resetKey: appId }) - const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({ + const { appDetail, setAppDetail } = useStore(useShallow(state => ({ appDetail: state.appDetail, setAppDetail: state.setAppDetail, - setAppSidebarExpand: state.setAppSidebarExpand, }))) const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false) const [appDetailRes, setAppDetailRes] = useState(null) - const [navigation, setNavigation] = useState>([]) - - const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => { - const navConfig = [ - ...(isCurrentWorkspaceEditor - ? [{ - name: t('appMenus.promptEng', { ns: 'common' }), - href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`, - icon: RiTerminalWindowLine, - selectedIcon: RiTerminalWindowFill, - }] - : [] - ), - { - name: t('appMenus.apiAccess', { ns: 'common' }), - href: `/app/${appId}/develop`, - icon: RiTerminalBoxLine, - selectedIcon: RiTerminalBoxFill, - }, - ...(isCurrentWorkspaceEditor - ? [{ - name: mode !== AppModeEnum.WORKFLOW - ? t('appMenus.logAndAnn', { ns: 'common' }) - : t('appMenus.logs', { ns: 'common' }), - href: `/app/${appId}/logs`, - icon: RiFileList3Line, - selectedIcon: RiFileList3Fill, - }] - : [] - ), - { - name: t('appMenus.overview', { ns: 'common' }), - href: `/app/${appId}/overview`, - icon: RiDashboard2Line, - selectedIcon: RiDashboard2Fill, - }, - ] - return navConfig - }, [t]) + const routeAppDetail = appDetailRes ?? (appDetail?.id === appId ? appDetail : null) useDocumentTitle(appDetail?.name || t('menus.appDetail', { ns: 'common' })) useEffect(() => { - if (appDetail) { - const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand' - const mode = isMobile ? 'collapse' : 'expand' - setAppSidebarExpand(isMobile ? mode : localeMode) - // TODO: consider screen size and mode - // if ((appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === 'workflow') && (pathname).endsWith('workflow')) - // setAppSidebarExpand('collapse') - } - }, [appDetail, isMobile]) + let ignore = false + + const currentAppDetail = useStore.getState().appDetail + if (currentAppDetail?.id === appId) { + return () => { + ignore = true + } + } - useEffect(() => { setAppDetail() - setIsLoadingAppDetail(true) + void Promise.resolve().then(() => { + if (!ignore) + setIsLoadingAppDetail(true) + }) fetchAppDetailDirect({ url: '/apps', id: appId }).then((res: App) => { + if (ignore) + return + setAppDetailRes(res) - }).catch((e: any) => { - if (e.status === 404) + }).catch((error: unknown) => { + if (ignore) + return + + if (isNotFoundError(error)) router.replace('/apps') }).finally(() => { + if (ignore) + return + setIsLoadingAppDetail(false) }) - }, [appId, pathname]) + + return () => { + ignore = true + } + }, [appId, router, setAppDetail]) useEffect(() => { - if (!appDetailRes || !currentWorkspace.id || isLoadingCurrentWorkspace || isLoadingAppDetail) + if (!routeAppDetail || !currentWorkspace.id || isLoadingCurrentWorkspace || isLoadingAppDetail) return - const res = appDetailRes + if (routeAppDetail.id !== appId) + return + // redirection const canIEditApp = isCurrentWorkspaceEditor - if (!canIEditApp && (pathname.endsWith('configuration') || pathname.endsWith('workflow') || pathname.endsWith('logs'))) { + if (!canIEditApp && (pathname.endsWith('configuration') || pathname.endsWith('workflow') || pathname.endsWith('logs') || pathname.endsWith('annotations'))) { router.replace(`/app/${appId}/overview`) return } - if ((res.mode === AppModeEnum.WORKFLOW || res.mode === AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('configuration')) { + if ((routeAppDetail.mode === AppModeEnum.WORKFLOW || routeAppDetail.mode === AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('configuration')) { router.replace(`/app/${appId}/workflow`) } - else if ((res.mode !== AppModeEnum.WORKFLOW && res.mode !== AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('workflow')) { + else if ((routeAppDetail.mode !== AppModeEnum.WORKFLOW && routeAppDetail.mode !== AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('workflow')) { router.replace(`/app/${appId}/configuration`) + return } - else { - setAppDetail({ ...res, enable_sso: false }) - setNavigation(getNavigationConfig(appId, isCurrentWorkspaceEditor, res.mode)) - } - }, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace]) - useUnmount(() => { - setAppDetail() - }) + if (appDetailRes && appDetail?.id !== appDetailRes.id) + setAppDetail({ ...appDetailRes, enable_sso: false }) + }, [appDetail?.id, appDetailRes, appId, currentWorkspace.id, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace, pathname, routeAppDetail, router, setAppDetail]) if (!appDetail) { return ( @@ -160,18 +115,21 @@ const AppDetailLayout: FC = (props) => { ) } + const isWorkflowPage = pathname.endsWith('/workflow') + return ( -
- {appDetail && ( - +
+
+ > {children}
-
) } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx index 0fbc41eb6b8..e935fee8f37 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx @@ -10,6 +10,7 @@ import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/component import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart' import { useStore as useAppStore } from '@/app/components/app/store' import { IS_CLOUD_EDITION } from '@/config' +import { useDocLink } from '@/context/i18n' import LongTimeRangePicker from './long-time-range-picker' import TimeRangePicker from './time-range-picker' @@ -34,6 +35,7 @@ type IChartViewProps = { export default function ChartView({ appId, headerRight }: IChartViewProps) { const { t } = useTranslation() + const docLink = useDocLink() const appDetail = useAppStore(state => state.appDetail) const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow' const isWorkflow = appDetail?.mode === 'workflow' @@ -46,10 +48,26 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) { return null return ( -
-
-
{t('appMenus.overview', { ns: 'common' })}
-
+
+
+ +
{IS_CLOUD_EDITION ? (
- {!isWorkflow && ( -
- - +
+
+ {!isWorkflow && ( + <> + + + {isChatApp + ? ( + + ) + : ( + + )} + + + + {isChatApp && } + + )} + {isWorkflow && ( + <> + + + + + + )}
- )} - {!isWorkflow && ( -
- {isChatApp - ? ( - - ) - : ( - - )} - -
- )} - {!isWorkflow && ( -
- - -
- )} - {!isWorkflow && isChatApp && ( -
- -
- )} - {isWorkflow && ( -
- - -
- )} - {isWorkflow && ( -
- - -
- )} +
) } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx index 39c42d067d1..92ec0d59ec3 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx @@ -15,12 +15,14 @@ const Overview = async (props: IDevelopProps) => { } = params return ( -
+
- } - /> +
+ } + /> +
) } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx index b03fd6e30d3..a12a28a7018 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -247,7 +247,7 @@ const Panel: FC = () => { >
@@ -286,7 +286,7 @@ const Panel: FC = () => { >
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/style.module.css b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/style.module.css deleted file mode 100644 index 45c7d197b40..00000000000 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/style.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.app { - flex-grow: 1; - height: 0; - border-radius: 16px 16px 0px 0px; - box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.05), 0px 0px 2px -1px rgba(0, 0, 0, 0.03); -} diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx index 0d7f01a2104..ae1fedf8312 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx @@ -1,10 +1,9 @@ import { render, screen, waitFor } from '@testing-library/react' import { usePathname, useRouter } from '@/next/navigation' -import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset' +import { useDatasetDetail } from '@/service/knowledge/use-dataset' import DatasetDetailLayout from '../layout-main' const mockReplace = vi.fn() -const mockSetAppSidebarExpand = vi.fn() vi.mock('@/next/navigation', () => ({ usePathname: vi.fn(), @@ -13,13 +12,6 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/service/knowledge/use-dataset', () => ({ useDatasetDetail: vi.fn(), - useDatasetRelatedApps: vi.fn(), -})) - -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({ - setAppSidebarExpand: mockSetAppSidebarExpand, - }), })) vi.mock('@/context/app-context', () => ({ @@ -34,34 +26,18 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -vi.mock('@/hooks/use-breakpoints', () => ({ - default: () => 'desktop', - MediaType: { - mobile: 'mobile', - }, -})) - vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) -vi.mock('@/app/components/app-sidebar', () => ({ - default: () =>
) diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index ca78b135f99..da504977031 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -35,6 +35,9 @@ const FormContent = () => { const { isSubmitting, submit, success } = useFormSubmit(token) const removeWebappBrand = formData?.site?.custom_config?.remove_webapp_brand === true + const replaceWebappLogo = typeof formData?.site?.custom_config?.replace_webapp_logo === 'string' + ? formData.site.custom_config.replace_webapp_logo + : null const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired' const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted' @@ -54,6 +57,7 @@ const FormContent = () => { subtitle={t('humanInput.recorded', { ns: 'share' })} submissionID={token} removeWebappBrand={removeWebappBrand} + replaceWebappLogo={replaceWebappLogo} /> ) } @@ -105,6 +109,7 @@ const FormContent = () => { isSubmitting={isSubmitting} onSubmit={submit} removeWebappBrand={removeWebappBrand} + replaceWebappLogo={replaceWebappLogo} /> ) } diff --git a/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx index 304e1c625dc..9810d0a5b12 100644 --- a/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx +++ b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx @@ -6,18 +6,18 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { produce } from 'immer' import { useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item' import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time' import { getButtonStyle, getRenderedFormInputs, hasInvalidRequiredHumanInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils' -import DifyLogo from '@/app/components/base/logo/dify-logo' +import BrandingFooter from './branding-footer' type LoadedFormContentProps = { formData: FormData isSubmitting: boolean onSubmit: (inputs: Record, actionID: string, formInputs: FormData['inputs']) => void removeWebappBrand?: boolean + replaceWebappLogo?: string | null } const LoadedFormContent = ({ @@ -25,15 +25,25 @@ const LoadedFormContent = ({ isSubmitting, onSubmit, removeWebappBrand, + replaceWebappLogo, }: LoadedFormContentProps) => { - const { t } = useTranslation() const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content) const [inputs, setInputs] = useState>(() => initializeInputs(renderedFormInputs, formData.resolved_default_values), ) const contentList = useMemo(() => { - return splitByOutputVar(formData.form_content) + const contentCounts = new Map() + + return splitByOutputVar(formData.form_content).map((content) => { + const occurrence = (contentCounts.get(content) || 0) + 1 + contentCounts.set(content, occurrence) + + return { + key: `${content}-${occurrence}`, + content, + } + }) }, [formData.form_content]) const handleInputsChange = (name: string, value: HumanInputFieldValue) => { @@ -63,9 +73,9 @@ const LoadedFormContent = ({
- {contentList.map((content, index) => ( + {contentList.map(({ key, content }) => (
- {!removeWebappBrand && ( -
-
-
{t('chat.poweredBy', { ns: 'share' })}
- -
-
- )} +
) diff --git a/web/app/account/(commonLayout)/header.tsx b/web/app/account/(commonLayout)/header.tsx index daa2e0ec0c9..d1b08483908 100644 --- a/web/app/account/(commonLayout)/header.tsx +++ b/web/app/account/(commonLayout)/header.tsx @@ -1,6 +1,5 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' -import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -15,8 +14,8 @@ const Header = () => { const router = useRouter() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const goToStudio = useCallback(() => { - router.push('/apps') + const goToHome = useCallback(() => { + router.push('/') }, [router]) const logoLabel = systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify' @@ -42,10 +41,10 @@ const Header = () => {

{t('account.account', { ns: 'common' })}

-
diff --git a/web/app/auth/refresh/__tests__/route.spec.ts b/web/app/auth/refresh/__tests__/route.spec.ts index 63fe540fc3c..82e8d402884 100644 --- a/web/app/auth/refresh/__tests__/route.spec.ts +++ b/web/app/auth/refresh/__tests__/route.spec.ts @@ -103,7 +103,20 @@ describe('auth refresh route', () => { )) expect(response.status).toBe(303) - expect(response.headers.get('location')).toBe('/signin?redirect_url=%2Fapps') + expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F') + }) + + it('should default missing redirect targets to the home path', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))) + const { GET } = await import('../route') + + const response = await GET(createRequest( + 'http://localhost:3000/auth/refresh', + 'refresh_token=expired', + )) + + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F') }) it('should not leak internal request origin when redirecting to signin', async () => { diff --git a/web/app/auth/refresh/route.ts b/web/app/auth/refresh/route.ts index e3bc4ee6b42..d36d9be92ea 100644 --- a/web/app/auth/refresh/route.ts +++ b/web/app/auth/refresh/route.ts @@ -3,7 +3,7 @@ import { basePath } from '@/utils/var' const REFRESH_TOKEN_PATH = '/refresh-token' const AUTH_REFRESH_PATH = `${basePath}/auth/refresh` -const DEFAULT_REDIRECT_PATH = `${basePath}/apps` +const DEFAULT_REDIRECT_PATH = `${basePath}/` const resolveSafeRedirectPath = (request: Request) => { const requestUrl = new URL(request.url) diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx new file mode 100644 index 00000000000..19d7dd5a436 --- /dev/null +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -0,0 +1,154 @@ +import { render, screen } from '@testing-library/react' +import AppDetailSection from '../app-detail-section' +import { useAppInfoActions } from '../app-info/use-app-info-actions' + +let mockAppMode = 'chat' +let mockIsCurrentWorkspaceEditor = true +let mockPathname = '/app/app-1/logs' + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => selector({ + appDetail: { + id: 'app-1', + name: 'Test App', + mode: mockAppMode, + icon: '🤖', + icon_type: 'emoji', + icon_background: '#fff', + }, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + }), +})) + +vi.mock('@/next/navigation', () => ({ + usePathname: () => mockPathname, +})) + +vi.mock('../app-info', () => ({ + AppInfoView: ({ expand }: { expand: boolean }) =>
, +})) + +vi.mock('../app-info/use-app-info-actions', () => ({ + useAppInfoActions: vi.fn(() => ({})), +})) + +vi.mock('../../base/divider', () => ({ + default: ({ className }: { className?: string }) =>
, +})) + +vi.mock('../nav-link', () => ({ + default: ({ name, href, mode, iconMap }: { name: string, href: string, mode: string, iconMap: { normal: { displayName?: string } } }) => ( +
{name} + ), +})) + +describe('AppDetailSection', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppMode = 'chat' + mockIsCurrentWorkspaceEditor = true + mockPathname = '/app/app-1/logs' + }) + + // Rendering behavior for app detail navigation entries. + describe('Rendering', () => { + it('should split logs and annotations into separate navigation links for chat apps', () => { + // Arrange + mockAppMode = 'chat' + + // Act + render() + + // Assert + expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs') + expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('href', '/app/app-1/annotations') + expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('data-icon', 'Annotations') + }) + + it('should render dividers before logs and after annotations for chat apps', () => { + // Arrange + mockAppMode = 'chat' + + // Act + render() + + // Assert + expect(screen.getAllByRole('separator')).toHaveLength(2) + }) + + it('should only render logs navigation for workflow apps', () => { + // Arrange + mockAppMode = 'workflow' + + // Act + render() + + // Assert + expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs') + expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() + }) + + it('should render dividers before and after logs for workflow apps', () => { + // Arrange + mockAppMode = 'workflow' + + // Act + render() + + // Assert + expect(screen.getAllByRole('separator')).toHaveLength(2) + }) + + it('should only render logs navigation for completion apps', () => { + // Arrange + mockAppMode = 'completion' + + // Act + render() + + // Assert + expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs') + expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() + }) + + it('should not render log group dividers for non-editor users', () => { + // Arrange + mockIsCurrentWorkspaceEditor = false + + // Act + render() + + // Assert + expect(screen.queryAllByRole('separator')).toHaveLength(0) + expect(screen.queryByRole('link', { name: 'common.appMenus.logs' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() + }) + + it('should pass collapsed mode to app info and navigation links when collapsed', () => { + // Act + render() + + // Assert + expect(screen.getByTestId('app-info')).toHaveAttribute('data-expand', 'false') + expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('data-mode', 'collapse') + }) + + it('should scope app info state to the app instead of the current path', () => { + // Arrange + const { rerender } = render() + + // Act + mockPathname = '/app/app-1/overview' + rerender() + + // Assert + expect(useAppInfoActions).toHaveBeenCalledWith({ resetKey: 'app-1' }) + expect(useAppInfoActions).toHaveBeenLastCalledWith({ resetKey: 'app-1' }) + }) + }) +}) diff --git a/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx new file mode 100644 index 00000000000..1ae260cbb00 --- /dev/null +++ b/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { createStore, Provider as JotaiProvider } from 'jotai' +import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms' +import AppDetailTop from '../app-detail-top' + +vi.mock('../toggle-button', () => ({ + default: ({ expand, handleToggle, icon }: { expand: boolean, handleToggle: () => void, icon?: ReactNode }) => ( + + ), +})) + +function GotoAnythingOpenProbe() { + const open = useGotoAnythingOpen() + + return
{String(open)}
+} + +const renderWithGotoAnythingStore = (ui: ReactNode) => { + const store = createStore() + + return render( + + {ui} + , + ) +} + +describe('AppDetailTop', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('links the combined home control to home', () => { + renderWithGotoAnythingStore() + + expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/') + expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument() + }) + + it('links the Studio breadcrumb to the Studio page', () => { + renderWithGotoAnythingStore() + + expect(screen.getByRole('link', { name: 'common.menus.apps' })).toHaveAttribute('href', '/apps') + }) + + it('keeps the quick search action', () => { + renderWithGotoAnythingStore( + <> + + + , + ) + expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('false') + + fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' })) + + expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('true') + }) + + it('renders the sidebar toggle action in the top right', () => { + const onToggle = vi.fn() + + renderWithGotoAnythingStore() + fireEvent.click(screen.getByTestId('toggle-button')) + + expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'true') + expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-has-icon', 'true') + expect(onToggle).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx new file mode 100644 index 00000000000..ac02bb1f4e8 --- /dev/null +++ b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx @@ -0,0 +1,90 @@ +import type { DataSet, RelatedAppResponse } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import DatasetDetailSection from '../dataset-detail-section' + +let mockPathname = '/datasets/dataset-1/documents' +let mockIsDatasetOperator = false +let mockDataset: DataSet | undefined +let mockRelatedApps: RelatedAppResponse | undefined + +vi.mock('@/next/navigation', () => ({ + usePathname: () => mockPathname, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator, + }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetDetail: () => ({ data: mockDataset, refetch: vi.fn() }), + useDatasetRelatedApps: () => ({ data: mockRelatedApps }), +})) + +vi.mock('../dataset-info', () => ({ + default: ({ expand }: { expand: boolean }) =>
, +})) + +vi.mock('../nav-link', () => ({ + default: ({ name, href }: { name: string, href: string }) => {name}, +})) + +vi.mock('../../datasets/extra-info', () => ({ + default: ({ expand, documentCount }: { expand: boolean, documentCount?: number }) => ( +
+ ), +})) + +const createDataset = (overrides: Partial = {}): DataSet => ({ + id: 'dataset-1', + name: 'Camera Technical Spec', + description: '', + provider: 'internal', + icon_info: { + icon: '📙', + icon_type: 'emoji', + icon_background: '#F0F9FF', + icon_url: '', + }, + doc_form: 'hierarchical_model', + indexing_technique: 'high_quality', + document_count: 120, + runtime_mode: 'general', + retrieval_model_dict: { + search_method: 'semantic_search', + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + enable_api: true, + ...overrides, +} as DataSet) + +describe('DatasetDetailSection', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPathname = '/datasets/dataset-1/documents' + mockIsDatasetOperator = false + mockDataset = createDataset() + mockRelatedApps = { + data: [], + total: 5, + } + }) + + it('should pin dataset stats and API access to the bottom of the expanded sidebar', () => { + render() + + const extraInfo = screen.getByTestId('extra-info') + + expect(extraInfo).toHaveAttribute('data-expand', 'true') + expect(extraInfo).toHaveAttribute('data-document-count', '120') + expect(extraInfo.parentElement).toHaveClass('mt-auto', 'shrink-0') + }) +}) diff --git a/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx new file mode 100644 index 00000000000..18f1c959e3d --- /dev/null +++ b/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { createStore, Provider as JotaiProvider } from 'jotai' +import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms' +import DatasetDetailTop from '../dataset-detail-top' + +const mockBack = vi.fn() + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + back: mockBack, + }), +})) + +vi.mock('../toggle-button', () => ({ + default: ({ expand, handleToggle, icon }: { expand: boolean, handleToggle: () => void, icon?: ReactNode }) => ( + + ), +})) + +function GotoAnythingOpenProbe() { + const open = useGotoAnythingOpen() + + return
{String(open)}
+} + +const renderWithGotoAnythingStore = (ui: ReactNode) => { + const store = createStore() + + return render( + + {ui} + , + ) +} + +describe('DatasetDetailTop', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('links the home icon to home and labels the breadcrumb as datasets', () => { + renderWithGotoAnythingStore() + + expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/') + expect(screen.getByRole('link', { name: 'common.menus.datasets' })).toHaveAttribute('href', '/datasets') + }) + + it('keeps the back button and quick search actions', () => { + renderWithGotoAnythingStore( + <> + + + , + ) + expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('false') + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.back' })) + fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' })) + + expect(mockBack).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('true') + }) + + it('renders the sidebar toggle action in the top right', () => { + const onToggle = vi.fn() + + renderWithGotoAnythingStore() + fireEvent.click(screen.getByTestId('toggle-button')) + + expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'false') + expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-has-icon', 'true') + expect(onToggle).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app-sidebar/__tests__/index.spec.tsx b/web/app/components/app-sidebar/__tests__/index.spec.tsx index 7ba7b7b2594..6aefcca8c33 100644 --- a/web/app/components/app-sidebar/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/index.spec.tsx @@ -5,7 +5,6 @@ import AppDetailNav from '..' let mockAppSidebarExpand = 'expand' const mockSetAppSidebarExpand = vi.fn() -let mockPathname = '/app/123/overview' vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: Record) => unknown) => selector({ @@ -19,10 +18,6 @@ vi.mock('zustand/react/shallow', () => ({ useShallow: (fn: unknown) => fn, })) -vi.mock('@/next/navigation', () => ({ - usePathname: () => mockPathname, -})) - let mockIsHovering = true let mockKeyPressCallback: ((e: { preventDefault: () => void }) => void) | null = null @@ -41,16 +36,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({ MediaType: { mobile: 'mobile', desktop: 'desktop' }, })) -let mockSubscriptionCallback: ((v: unknown) => void) | null = null - -vi.mock('@/context/event-emitter', () => ({ - useEventEmitterContextContext: () => ({ - eventEmitter: { - useSubscription: (cb: (v: unknown) => void) => { mockSubscriptionCallback = cb }, - }, - }), -})) - vi.mock('../../base/divider', () => ({ default: ({ className }: { className?: string }) =>
, })) @@ -64,24 +49,12 @@ vi.mock('../app-info', () => ({ ), })) -vi.mock('../app-sidebar-dropdown', () => ({ - default: ({ navigation }: { navigation: unknown[] }) => ( -
- ), -})) - vi.mock('../dataset-info', () => ({ default: ({ expand }: { expand: boolean }) => (
), })) -vi.mock('../dataset-sidebar-dropdown', () => ({ - default: ({ navigation }: { navigation: unknown[] }) => ( -
- ), -})) - vi.mock('../nav-link', () => ({ default: ({ name, href, mode }: { name: string, href: string, mode?: string }) => ( {name} @@ -107,7 +80,6 @@ describe('AppDetailNav', () => { beforeEach(() => { vi.clearAllMocks() mockAppSidebarExpand = 'expand' - mockPathname = '/app/123/overview' mockIsHovering = true mockKeyPressCallback = null }) @@ -185,40 +157,6 @@ describe('AppDetailNav', () => { }) }) - describe('Workflow canvas mode', () => { - it('should render AppSidebarDropdown when in workflow canvas with hidden header', () => { - mockPathname = '/app/123/workflow' - localStorage.setItem('workflow-canvas-maximize', 'true') - - render() - - expect(screen.getByTestId('app-sidebar-dropdown')).toBeInTheDocument() - expect(screen.queryByTestId('app-info')).not.toBeInTheDocument() - }) - - it('should render normal sidebar when workflow canvas is not maximized', () => { - mockPathname = '/app/123/workflow' - localStorage.setItem('workflow-canvas-maximize', 'false') - - render() - - expect(screen.queryByTestId('app-sidebar-dropdown')).not.toBeInTheDocument() - expect(screen.getByTestId('app-info')).toBeInTheDocument() - }) - }) - - describe('Pipeline canvas mode', () => { - it('should render DatasetSidebarDropdown when in pipeline canvas with hidden header', () => { - mockPathname = '/dataset/123/pipeline' - localStorage.setItem('workflow-canvas-maximize', 'true') - - render() - - expect(screen.getByTestId('dataset-sidebar-dropdown')).toBeInTheDocument() - expect(screen.queryByTestId('app-info')).not.toBeInTheDocument() - }) - }) - describe('Navigation mode', () => { it('should pass expand mode to nav links when expanded', () => { render() @@ -271,28 +209,6 @@ describe('AppDetailNav', () => { }) }) - describe('Event emitter subscription', () => { - it('should handle workflow-canvas-maximize event', () => { - mockPathname = '/app/123/workflow' - render() - - const cb = mockSubscriptionCallback - expect(cb).not.toBeNull() - act(() => { - cb!({ type: 'workflow-canvas-maximize', payload: true }) - }) - }) - - it('should ignore non-maximize events', () => { - render() - - const cb = mockSubscriptionCallback - act(() => { - cb!({ type: 'other-event' }) - }) - }) - }) - describe('Keyboard shortcut', () => { it('should toggle sidebar on Mod+B', () => { render() diff --git a/web/app/components/app-sidebar/app-detail-section.tsx b/web/app/components/app-sidebar/app-detail-section.tsx new file mode 100644 index 00000000000..9d77e17df76 --- /dev/null +++ b/web/app/components/app-sidebar/app-detail-section.tsx @@ -0,0 +1,172 @@ +'use client' + +import type { ComponentProps } from 'react' +import type { NavIcon } from './nav-link' +import { cn } from '@langgenius/dify-ui/cn' +import { + RiDashboard2Fill, + RiDashboard2Line, + RiFileList3Fill, + RiFileList3Line, + RiTerminalBoxFill, + RiTerminalBoxLine, + RiTerminalWindowFill, + RiTerminalWindowLine, +} from '@remixicon/react' +import { Fragment, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useStore } from '@/app/components/app/store' +import Divider from '@/app/components/base/divider' +import Annotations from '@/app/components/base/icons/src/vender/Annotations' +import { useAppContext } from '@/context/app-context' +import { usePathname } from '@/next/navigation' +import { AppModeEnum } from '@/types/app' +import { AppInfoView } from './app-info' +import { useAppInfoActions } from './app-info/use-app-info-actions' +import NavLink from './nav-link' + +type AppDetailNavItem = { + name: string + href: string + icon: NavIcon + selectedIcon: NavIcon +} + +const AnnotationNavIcon = ({ className, ...props }: ComponentProps) => ( + +) + +AnnotationNavIcon.displayName = 'Annotations' + +const isLogsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/logs') +const isAnnotationsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/annotations') + +const renderNavDivider = (key: string, expand: boolean) => ( +
+ +
+) + +type AppDetailSectionProps = { + expand?: boolean +} + +const AppDetailSection = ({ + expand = true, +}: AppDetailSectionProps) => { + const { t } = useTranslation() + const pathname = usePathname() + const { isCurrentWorkspaceEditor } = useAppContext() + const appDetail = useStore(state => state.appDetail) + const appInfoActions = useAppInfoActions({ + resetKey: appDetail?.id, + }) + + const navigation = useMemo(() => { + if (!appDetail) + return [] + + const appId = appDetail.id + const isWorkflowApp = appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT + const supportsAnnotations = appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION + + return [ + ...(isCurrentWorkspaceEditor + ? [{ + name: t('appMenus.promptEng', { ns: 'common' }), + href: `/app/${appId}/${isWorkflowApp ? 'workflow' : 'configuration'}`, + icon: RiTerminalWindowLine, + selectedIcon: RiTerminalWindowFill, + }] + : [] + ), + { + name: t('appMenus.apiAccess', { ns: 'common' }), + href: `/app/${appId}/develop`, + icon: RiTerminalBoxLine, + selectedIcon: RiTerminalBoxFill, + }, + ...(isCurrentWorkspaceEditor + ? [{ + name: t('appMenus.logs', { ns: 'common' }), + href: `/app/${appId}/logs`, + icon: RiFileList3Line, + selectedIcon: RiFileList3Fill, + }, ...(supportsAnnotations + ? [{ + name: t('appMenus.annotations', { ns: 'common' }), + href: `/app/${appId}/annotations`, + icon: AnnotationNavIcon, + selectedIcon: AnnotationNavIcon, + }] + : [])] + : [] + ), + { + name: t('appMenus.overview', { ns: 'common' }), + href: `/app/${appId}/overview`, + icon: RiDashboard2Line, + selectedIcon: RiDashboard2Fill, + }, + ] + }, [appDetail, isCurrentWorkspaceEditor, t]) + + if (!appDetail) + return null + + const hasAnnotationsNavigation = navigation.some(isAnnotationsNavItem) + + return ( +
+ {!expand && ( +
+ +
+ )} +
+ +
+ +
+ ) +} + +export default AppDetailSection diff --git a/web/app/components/app-sidebar/app-detail-top.tsx b/web/app/components/app-sidebar/app-detail-top.tsx new file mode 100644 index 00000000000..bb152c78f56 --- /dev/null +++ b/web/app/components/app-sidebar/app-detail-top.tsx @@ -0,0 +1,105 @@ +'use client' + +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { formatForDisplay } from '@tanstack/react-hotkeys' +import { useTranslation } from 'react-i18next' +import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' +import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms' +import Link from '@/next/link' +import ToggleButton from './toggle-button' + +type AppDetailTopProps = { + expand?: boolean + onToggle?: () => void +} + +const SEARCH_SHORTCUT = ['Mod', 'K'] + +const AppDetailTop = ({ + expand = true, + onToggle, +}: AppDetailTopProps) => { + const { t } = useTranslation() + const setGotoAnythingOpen = useSetGotoAnythingOpen() + + if (!expand) { + return ( +
+ {onToggle && ( + } + className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" + /> + )} +
+ ) + } + + return ( +
+
+ + + + + {expand && ( + <> + + / + + + {t('menus.apps', { ns: 'common' })} + + + )} +
+ {expand && ( + + setGotoAnythingOpen(true)} + > + + + )} + /> + + {t('gotoAnything.quickAction', { ns: 'app' })} + + {SEARCH_SHORTCUT.map(key => ( + {formatForDisplay(key)} + ))} + + + + )} + {onToggle && ( + } + className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" + /> + )} +
+ ) +} + +export default AppDetailTop diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx index 65d660876c7..6546352b00d 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx @@ -40,7 +40,7 @@ describe('AppInfoTrigger', () => { it('should render app icon with small size when collapsed', () => { render() const icon = screen.getByTestId('app-icon') - expect(icon).toHaveAttribute('data-size', 'small') + expect(icon).toHaveAttribute('data-size', 'medium') }) it('should show app name when expanded', () => { @@ -77,18 +77,18 @@ describe('AppInfoTrigger', () => { const { container, rerender } = render( , ) - expect(container.querySelector('svg')).toBeInTheDocument() + expect(container.querySelector('.i-ri-equalizer-2-line')).toBeInTheDocument() rerender() - expect(container.querySelector('svg')).toBeInTheDocument() + expect(container.querySelector('.i-ri-equalizer-2-line')).not.toBeInTheDocument() }) - it('should apply ml-1 class to icon wrapper when collapsed', () => { + it('should center the icon wrapper when collapsed', () => { render( , ) const iconWrapper = screen.getByTestId('app-icon').parentElement - expect(iconWrapper).toHaveClass('ml-1') + expect(iconWrapper?.parentElement).toHaveClass('items-center') }) it('should not apply ml-1 class when expanded', () => { diff --git a/web/app/components/app-sidebar/app-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/index.spec.tsx index 2f0d748ae98..ea0f117dede 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/index.spec.tsx @@ -5,6 +5,9 @@ import * as React from 'react' import { AppModeEnum } from '@/types/app' import AppInfo from '..' +const mockDetailPanel = vi.hoisted(() => vi.fn()) +const mockModals = vi.hoisted(() => vi.fn()) + let mockIsCurrentWorkspaceEditor = true const mockSetPanelOpen = vi.fn() @@ -27,15 +30,17 @@ vi.mock('../app-info-trigger', () => ({ })) vi.mock('../app-info-detail-panel', () => ({ - default: React.memo(({ show, onClose }: { show: boolean, onClose: () => void }) => ( - show ?
: null - )), + default: React.memo((props: { show: boolean, onClose: () => void }) => { + mockDetailPanel(props) + return props.show ?
: null + }), })) vi.mock('../app-info-modals', () => ({ - default: React.memo(({ activeModal }: { activeModal: string | null }) => ( - activeModal ?
: null - )), + default: React.memo((props: { activeModal: string | null }) => { + mockModals(props) + return props.activeModal ?
: null + }), })) const mockAppDetail: App & Partial = { @@ -92,6 +97,12 @@ describe('AppInfo', () => { expect(screen.getByTestId('trigger'))!.toBeInTheDocument() }) + it('should not mount detail layer while the app info panel is closed', () => { + render() + expect(mockDetailPanel).not.toHaveBeenCalled() + expect(mockModals).not.toHaveBeenCalled() + }) + it('should not render trigger when onlyShowDetail is true', () => { render() expect(screen.queryByTestId('trigger')).not.toBeInTheDocument() @@ -133,6 +144,7 @@ describe('AppInfo', () => { mockUseAppInfoActions.panelOpen = true render() expect(screen.getByTestId('detail-panel'))!.toBeInTheDocument() + expect(mockDetailPanel).toHaveBeenCalled() }) it('should show detail panel based on openState when onlyShowDetail', () => { @@ -143,5 +155,7 @@ describe('AppInfo', () => { it('should hide detail panel when openState is false and onlyShowDetail', () => { render() expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument() + expect(mockDetailPanel).not.toHaveBeenCalled() + expect(mockModals).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx index e9217df03c6..420653a7c9e 100644 --- a/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx @@ -1,4 +1,12 @@ import type { ReactNode } from 'react' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' type AppInfoDetailDrawerProps = { open: boolean @@ -11,24 +19,28 @@ export function AppInfoDetailDrawer({ onClose, children, }: AppInfoDetailDrawerProps) { - if (!open) - return null - return ( -
-
+ { + if (!nextOpen) + onClose() + }} + > + + + + + + {children} + + + + + ) } diff --git a/web/app/components/app-sidebar/app-info/app-info-trigger.tsx b/web/app/components/app-sidebar/app-info/app-info-trigger.tsx index 32a7eba3881..9c64cfbb306 100644 --- a/web/app/components/app-sidebar/app-info/app-info-trigger.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-trigger.tsx @@ -1,6 +1,5 @@ import type { App, AppSSO } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' -import { RiEqualizer2Line } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '../../base/app-icon' @@ -23,41 +22,36 @@ const AppInfoTrigger = ({ appDetail, expand, onClick }: AppInfoTriggerProps) => className="block w-full" aria-label={!expand ? `${appDetail.name} - ${modeLabel}` : undefined} > -
-
-
+
+
+
- {expand && ( -
-
- +
+ {expand && ( + <> +
+
+
{appDetail.name}
+
+
+ {modeLabel}
- )} -
- {!expand && ( -
-
- +
+
-
- )} - {expand && ( -
-
-
{appDetail.name}
-
-
- {getAppModeLabel(appDetail.mode, t)} -
-
+ )}
diff --git a/web/app/components/app-sidebar/app-info/index.tsx b/web/app/components/app-sidebar/app-info/index.tsx index 14a2defc1e7..c2bde1ddb10 100644 --- a/web/app/components/app-sidebar/app-info/index.tsx +++ b/web/app/components/app-sidebar/app-info/index.tsx @@ -23,7 +23,7 @@ type AppInfoDetailLayerProps = { open?: boolean } -export const AppInfoDetailLayer = ({ +const AppInfoDetailLayer = ({ actions, open = actions.panelOpen, }: AppInfoDetailLayerProps) => { @@ -84,11 +84,16 @@ export const AppInfoView = ({ appDetail, panelOpen, setPanelOpen, + activeModal, + secretEnvList, } = actions if (!appDetail) return null + const detailLayerOpen = onlyShowDetail ? openState : panelOpen + const shouldRenderDetailLayer = renderDetail && (detailLayerOpen || activeModal || secretEnvList.length > 0) + return (
{!onlyShowDetail && ( @@ -101,10 +106,10 @@ export const AppInfoView = ({ }} /> )} - {renderDetail && ( + {shouldRenderDetailLayer && ( )}
diff --git a/web/app/components/app-sidebar/dataset-detail-section.tsx b/web/app/components/app-sidebar/dataset-detail-section.tsx new file mode 100644 index 00000000000..824712155a1 --- /dev/null +++ b/web/app/components/app-sidebar/dataset-detail-section.tsx @@ -0,0 +1,145 @@ +'use client' + +import type { RemixiconComponentType } from '@remixicon/react' +import { cn } from '@langgenius/dify-ui/cn' +import { + RiEqualizer2Fill, + RiEqualizer2Line, + RiFileTextFill, + RiFileTextLine, + RiFocus2Fill, + RiFocus2Line, +} from '@remixicon/react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import Divider from '@/app/components/base/divider' +import { PipelineFill, PipelineLine } from '@/app/components/base/icons/src/vender/pipeline' +import ExtraInfo from '@/app/components/datasets/extra-info' +import { useAppContext } from '@/context/app-context' +import DatasetDetailContext from '@/context/dataset-detail' +import { usePathname } from '@/next/navigation' +import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset' +import DatasetInfo from './dataset-info' +import NavLink from './nav-link' + +const getDatasetIdFromPathname = (pathname: string) => { + const [, section, datasetId] = pathname.split('/') + return section === 'datasets' ? datasetId : undefined +} + +type DatasetDetailSectionProps = { + expand?: boolean +} + +const DatasetDetailSection = ({ + expand = true, +}: DatasetDetailSectionProps) => { + const { t } = useTranslation() + const pathname = usePathname() + const datasetId = getDatasetIdFromPathname(pathname) + const { isCurrentWorkspaceDatasetOperator } = useAppContext() + const { data: datasetRes, refetch: mutateDatasetRes } = useDatasetDetail(datasetId ?? '') + const { data: relatedApps } = useDatasetRelatedApps(datasetId ?? '', { enabled: !!datasetId && !!datasetRes }) + + const isButtonDisabledWithPipeline = useMemo(() => { + if (!datasetRes) + return true + if (datasetRes.provider === 'external') + return false + if (datasetRes.runtime_mode === 'general') + return false + return !datasetRes.is_published + }, [datasetRes]) + + const navigation = useMemo(() => { + if (!datasetId) + return [] + + const baseNavigation = [ + { + name: t('datasetMenus.hitTesting', { ns: 'common' }), + href: `/datasets/${datasetId}/hitTesting`, + icon: RiFocus2Line, + selectedIcon: RiFocus2Fill, + disabled: isButtonDisabledWithPipeline, + }, + { + name: t('datasetMenus.settings', { ns: 'common' }), + href: `/datasets/${datasetId}/settings`, + icon: RiEqualizer2Line, + selectedIcon: RiEqualizer2Fill, + disabled: false, + }, + ] + + if (datasetRes?.provider !== 'external') { + baseNavigation.unshift({ + name: t('datasetMenus.pipeline', { ns: 'common' }), + href: `/datasets/${datasetId}/pipeline`, + icon: PipelineLine as RemixiconComponentType, + selectedIcon: PipelineFill as RemixiconComponentType, + disabled: false, + }) + baseNavigation.unshift({ + name: t('datasetMenus.documents', { ns: 'common' }), + href: `/datasets/${datasetId}/documents`, + icon: RiFileTextLine, + selectedIcon: RiFileTextFill, + disabled: isButtonDisabledWithPipeline, + }) + } + + return baseNavigation + }, [t, datasetId, isButtonDisabledWithPipeline, datasetRes?.provider]) + + if (!datasetRes) + return null + + return ( + +
+ {!expand && ( +
+ +
+ )} +
+ +
+ + {!isCurrentWorkspaceDatasetOperator && ( +
+ +
+ )} +
+
+ ) +} + +export default DatasetDetailSection diff --git a/web/app/components/app-sidebar/dataset-detail-top.tsx b/web/app/components/app-sidebar/dataset-detail-top.tsx new file mode 100644 index 00000000000..60e93fa80e9 --- /dev/null +++ b/web/app/components/app-sidebar/dataset-detail-top.tsx @@ -0,0 +1,113 @@ +'use client' + +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { formatForDisplay } from '@tanstack/react-hotkeys' +import { useTranslation } from 'react-i18next' +import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' +import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms' +import Link from '@/next/link' +import { useRouter } from '@/next/navigation' +import ToggleButton from './toggle-button' + +type DatasetDetailTopProps = { + expand?: boolean + onToggle?: () => void +} + +const SEARCH_SHORTCUT = ['Mod', 'K'] + +const DatasetDetailTop = ({ + expand = true, + onToggle, +}: DatasetDetailTopProps) => { + const { t } = useTranslation() + const router = useRouter() + const setGotoAnythingOpen = useSetGotoAnythingOpen() + + if (!expand) { + return ( +
+ {onToggle && ( + } + className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" + /> + )} +
+ ) + } + + return ( +
+
+
+ + + + +
+ {expand && ( + <> + + / + + + {t('menus.datasets', { ns: 'common' })} + + + )} +
+ {expand && ( + + setGotoAnythingOpen(true)} + > + + + )} + /> + + {t('gotoAnything.quickAction', { ns: 'app' })} + + {SEARCH_SHORTCUT.map(key => ( + {formatForDisplay(key)} + ))} + + + + )} + {onToggle && ( + } + className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" + /> + )} +
+ ) +} + +export default DatasetDetailTop diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index e6d3f94e2a0..e1c4aa7b2b7 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -168,14 +168,15 @@ describe('DatasetInfo', () => { describe('Rendering', () => { it('should show dataset details when expanded', () => { // Arrange - mockDataset = createDataset({ is_published: true }) + mockDataset = createDataset({ is_published: false }) render() // Assert expect(screen.getByText('Dataset Name')).toBeInTheDocument() - expect(screen.getByText('Dataset description')).toBeInTheDocument() + expect(screen.queryByText('Dataset description')).not.toBeInTheDocument() expect(screen.getByText('dataset.chunkingMode.general')).toBeInTheDocument() expect(screen.getByText('indexing-technique')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.more' })).toBeInTheDocument() }) it('should show external tag when provider is external', () => { diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index b06d92e8d97..8d37fadb656 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -32,6 +32,7 @@ import Menu from './menu' type DropDownProps = { expand: boolean + triggerClassName?: string } type JsonErrorResponse = { @@ -55,6 +56,7 @@ const getErrorMessage = async (error: unknown) => { const DropDown = ({ expand, + triggerClassName, }: DropDownProps) => { const { t } = useTranslation() const { replace } = useRouter() @@ -134,7 +136,8 @@ const DropDown = ({ render={( )} > diff --git a/web/app/components/app-sidebar/dataset-info/index.tsx b/web/app/components/app-sidebar/dataset-info/index.tsx index 02b2251eb8b..b9f09b5b999 100644 --- a/web/app/components/app-sidebar/dataset-info/index.tsx +++ b/web/app/components/app-sidebar/dataset-info/index.tsx @@ -1,24 +1,21 @@ 'use client' -import type { FC } from 'react' import type { DataSet } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' -import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useKnowledge } from '@/hooks/use-knowledge' import { DOC_FORM_TEXT } from '@/models/datasets' import AppIcon from '../../base/app-icon' -import Effect from '../../base/effect' import Dropdown from './dropdown' type DatasetInfoProps = { expand: boolean } -const DatasetInfo: FC = ({ +const DatasetInfo = ({ expand, -}) => { +}: DatasetInfoProps) => { const { t } = useTranslation() const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet const iconInfo = dataset.icon_info || { @@ -28,62 +25,53 @@ const DatasetInfo: FC = ({ icon_url: '', } const isExternalProvider = dataset.provider === 'external' - const isPipelinePublished = useMemo(() => { - return dataset.runtime_mode === 'rag_pipeline' && dataset.is_published - }, [dataset.runtime_mode, dataset.is_published]) const { formatIndexingTechniqueAndMethod } = useKnowledge() return ( -
- {expand && ( - +
-
-
- -
- {expand && ( -
+ aria-label={!expand ? dataset.name : undefined} + > +
+
+ +
+ {expand && ( + <> +
+
+
+ {dataset.name} +
+
+
+ {isExternalProvider && ( + {t('externalTag', { ns: 'dataset' })} + )} + {!!(!isExternalProvider && dataset.doc_form && dataset.indexing_technique) && ( + <> + {t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })} + {formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)} + + )} +
+
+
- )} -
- {!expand && ( -
- -
- )} - {expand && ( -
-
- {dataset.name} -
-
- {isExternalProvider && t('externalTag', { ns: 'dataset' })} - {!!(!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique) && ( -
- {t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })} - {formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)} -
- )} -
- {!!dataset.description && ( -

- {dataset.description} -

- )} -
+ )}
diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index abc9cb27024..22ae25ddb0a 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -4,17 +4,13 @@ import { cn } from '@langgenius/dify-ui/cn' import { useHotkey } from '@tanstack/react-hotkeys' import { useHover } from 'ahooks' import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect } from 'react' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' -import { useEventEmitterContextContext } from '@/context/event-emitter' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import { usePathname } from '@/next/navigation' import Divider from '../base/divider' import AppInfo, { AppInfoView } from './app-info' -import AppSidebarDropdown from './app-sidebar-dropdown' import DatasetInfo from './dataset-info' -import DatasetSidebarDropdown from './dataset-sidebar-dropdown' import NavLink from './nav-link' import ToggleButton from './toggle-button' @@ -56,19 +52,6 @@ const AppDetailNav = ({ const isHoveringSidebar = useHover(sidebarRef) - // Check if the current path is a workflow canvas & fullscreen - const pathname = usePathname() - const inWorkflowCanvas = pathname.endsWith('/workflow') - const isPipelineCanvas = pathname.endsWith('/pipeline') - const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true' - const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize) - const { eventEmitter } = useEventEmitterContextContext() - - eventEmitter?.useSubscription((v: any) => { - if (v?.type === 'workflow-canvas-maximize') - setHideHeader(v.payload) - }) - useEffect(() => { if (appSidebarExpand) { localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand) @@ -83,25 +66,6 @@ const AppDetailNav = ({ ignoreInputs: true, }) - if (inWorkflowCanvas && hideHeader) { - return ( -
- -
- ) - } - - if (isPipelineCanvas && hideHeader) { - return ( -
- -
- ) - } - return (
{ Object.defineProperty(window, 'getComputedStyle', { value: vi.fn((element) => { const isExpanded = element.getAttribute('data-mode') === 'expand' - return { + const style = { transition: 'all 0.3s ease', opacity: isExpanded ? '1' : '0', width: isExpanded ? 'auto' : '0px', @@ -47,6 +47,10 @@ describe('NavLink Animation and Layout Issues', () => { paddingLeft: isExpanded ? '12px' : '10px', // px-3 vs px-2.5 paddingRight: isExpanded ? '12px' : '10px', } + return { + ...style, + getPropertyValue: (property: keyof typeof style) => style[property] ?? '', + } }), writable: true, }) @@ -66,10 +70,10 @@ describe('NavLink Animation and Layout Issues', () => { // Icon should still be present expect(screen.getByTestId('nav-icon')).toBeInTheDocument() - // Check consistent padding in collapse mode + // Check Figma collapsed icon-only sizing. const linkElement = screen.getByTestId('nav-link') - expect(linkElement).toHaveClass('pl-3') - expect(linkElement).toHaveClass('pr-1') + expect(linkElement).toHaveClass('size-8') + expect(linkElement).toHaveClass('p-1.5') // Switch to expand mode - should have smooth text transition rerender() @@ -77,9 +81,10 @@ describe('NavLink Animation and Layout Issues', () => { // Text should now be visible with opacity animation expect(screen.getByText('Orchestrate')).toBeInTheDocument() - // Check padding remains consistent - no layout shift - expect(linkElement).toHaveClass('pl-3') - expect(linkElement).toHaveClass('pr-1') + // Expanded state returns to the full-width navigation item. + const expandedLinkElement = screen.getByTestId('nav-link') + expect(expandedLinkElement).toHaveClass('pl-3') + expect(expandedLinkElement).toHaveClass('pr-1') // Fixed: text now uses max-width animation instead of abrupt show/hide const expandedTextElement = screen.getByText('Orchestrate') @@ -99,18 +104,24 @@ describe('NavLink Animation and Layout Issues', () => { const iconElement = screen.getByTestId('nav-icon') const iconWrapper = iconElement.parentElement - // Icon wrapper should have -ml-1 micro-adjustment in collapse mode for centering - expect(iconWrapper).toHaveClass('-ml-1') + // Icon wrapper should center the icon in the collapsed button. + expect(iconWrapper).toHaveClass('flex') + expect(iconWrapper).toHaveClass('size-5') + expect(iconWrapper).toHaveClass('items-center') + expect(iconWrapper).toHaveClass('justify-center') rerender() - // In expand mode, wrapper should not have the micro-adjustment + // In expand mode, wrapper should keep the fixed icon slot. const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement - expect(expandedIconWrapper).not.toHaveClass('-ml-1') + expect(expandedIconWrapper).toHaveClass('flex') + expect(expandedIconWrapper).toHaveClass('size-5') + expect(expandedIconWrapper).toHaveClass('items-center') + expect(expandedIconWrapper).toHaveClass('justify-center') - // Icon itself maintains consistent classes - no margin changes - expect(iconElement).toHaveClass('size-4') - expect(iconElement).toHaveClass('size-4') + // Icon itself uses the fixed glyph size. + const expandedIconElement = screen.getByTestId('nav-icon') + expect(expandedIconElement).toHaveClass('size-[18px]') expect(iconElement).toHaveClass('shrink-0') // This wrapper approach eliminates the icon margin shift issue @@ -145,15 +156,16 @@ describe('NavLink Animation and Layout Issues', () => { const linkElement = screen.getByTestId('nav-link') - // Consistent padding in collapsed state - expect(linkElement).toHaveClass('pl-3') - expect(linkElement).toHaveClass('pr-1') + // Collapsed state uses a fixed icon-only button. + expect(linkElement).toHaveClass('size-8') + expect(linkElement).toHaveClass('p-1.5') rerender() - // Same padding in expanded state - no layout shift - expect(linkElement).toHaveClass('pl-3') - expect(linkElement).toHaveClass('pr-1') + // Expanded state uses text item padding. + const expandedLinkElement = screen.getByTestId('nav-link') + expect(expandedLinkElement).toHaveClass('pl-3') + expect(expandedLinkElement).toHaveClass('pr-1') // This consistency eliminates the layout shift issue }) @@ -164,25 +176,30 @@ describe('NavLink Animation and Layout Issues', () => { const iconElement = screen.getByTestId('nav-icon') const iconWrapper = iconElement.parentElement - // Collapsed: wrapper has micro-adjustment for centering - expect(iconWrapper).toHaveClass('-ml-1') + // Collapsed: wrapper centers the icon in the 32px item. + expect(iconWrapper).toHaveClass('flex') + expect(iconWrapper).toHaveClass('size-5') + expect(iconWrapper).toHaveClass('items-center') + expect(iconWrapper).toHaveClass('justify-center') - // Icon itself has consistent classes - expect(iconElement).toHaveClass('size-4') - expect(iconElement).toHaveClass('size-4') + // Icon itself uses the larger collapsed glyph size. + expect(iconElement).toHaveClass('size-[18px]') expect(iconElement).toHaveClass('shrink-0') rerender() const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement - // Expanded: no wrapper adjustment needed - expect(expandedIconWrapper).not.toHaveClass('-ml-1') + // Expanded: wrapper still centers the 18px icon in a 20px slot. + expect(expandedIconWrapper).toHaveClass('flex') + expect(expandedIconWrapper).toHaveClass('size-5') + expect(expandedIconWrapper).toHaveClass('items-center') + expect(expandedIconWrapper).toHaveClass('justify-center') - // Icon classes remain consistent - no margin shifts - expect(iconElement).toHaveClass('size-4') - expect(iconElement).toHaveClass('size-4') - expect(iconElement).toHaveClass('shrink-0') + // Icon keeps the same fixed glyph size. + const expandedIconElement = screen.getByTestId('nav-icon') + expect(expandedIconElement).toHaveClass('size-[18px]') + expect(expandedIconElement).toHaveClass('shrink-0') }) }) @@ -206,6 +223,32 @@ describe('NavLink Animation and Layout Issues', () => { expect(linkElement).toHaveClass('bg-components-menu-item-bg-active') expect(linkElement).toHaveClass('text-text-accent-light-mode-only') }) + + it('should not mark logs active on the annotations pathname', () => { + render( + , + ) + + const linkElement = screen.getByRole('link', { name: 'Orchestrate' }) + expect(linkElement).not.toHaveClass('bg-components-menu-item-bg-active') + }) + + it('should use pathname to mark annotations active when rendered outside the app detail route segment', () => { + render( + , + ) + + const linkElement = screen.getByRole('link', { name: 'Orchestrate' }) + expect(linkElement).toHaveClass('bg-components-menu-item-bg-active') + }) }) describe('Text Animation Classes', () => { @@ -216,7 +259,7 @@ describe('NavLink Animation and Layout Issues', () => { expect(textElement).toHaveClass('overflow-hidden') expect(textElement).toHaveClass('whitespace-nowrap') - expect(textElement).toHaveClass('transition-all') + expect(textElement).toHaveClass('transition-[margin-left,max-width,opacity]') expect(textElement).toHaveClass('duration-200') expect(textElement).toHaveClass('ease-in-out') expect(textElement).toHaveClass('ml-0') @@ -231,7 +274,7 @@ describe('NavLink Animation and Layout Issues', () => { expect(textElement).toHaveClass('overflow-hidden') expect(textElement).toHaveClass('whitespace-nowrap') - expect(textElement).toHaveClass('transition-all') + expect(textElement).toHaveClass('transition-[margin-left,max-width,opacity]') expect(textElement).toHaveClass('duration-200') expect(textElement).toHaveClass('ease-in-out') expect(textElement).toHaveClass('ml-2') @@ -255,11 +298,11 @@ describe('NavLink Animation and Layout Issues', () => { render() const buttonElement = screen.getByRole('button') - expect(buttonElement).toHaveClass('pl-3') - expect(buttonElement).toHaveClass('pr-1') + expect(buttonElement).toHaveClass('size-8') + expect(buttonElement).toHaveClass('p-1.5') const iconWrapper = screen.getByTestId('nav-icon').parentElement - expect(iconWrapper).toHaveClass('-ml-1') + expect(iconWrapper).toHaveClass('size-5') }) }) diff --git a/web/app/components/app-sidebar/nav-link/index.tsx b/web/app/components/app-sidebar/nav-link/index.tsx index 31c3dd7bc9d..ab7ec49acca 100644 --- a/web/app/components/app-sidebar/nav-link/index.tsx +++ b/web/app/components/app-sidebar/nav-link/index.tsx @@ -21,6 +21,7 @@ export type NavLinkProps = { } mode?: string disabled?: boolean + pathname?: string active?: boolean onClick?: () => void } @@ -31,27 +32,32 @@ const NavLink = ({ iconMap, mode = 'expand', disabled = false, + pathname, active, onClick, }: NavLinkProps) => { const segment = useSelectedLayoutSegment() - const formattedSegment = (() => { - let res = segment?.toLowerCase() - // logs and annotations use the same nav - if (res === 'annotations') - res = 'logs' + const formatSegment = (value?: string | null) => { + const res = value?.toLowerCase() - return res - })() + return !pathname && res === 'annotations' ? 'logs' : res + } + const formattedSegment = formatSegment(pathname ? pathname.split('/').filter(Boolean).pop() : segment) const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false) const NavIcon = isActive ? iconMap.selected : iconMap.normal - const linkClassName = cn(isActive - ? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only' - : 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3') + + const isCollapsed = mode !== 'expand' + const linkClassName = cn( + isActive + ? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only' + : 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', + isCollapsed ? 'flex size-8 items-center justify-center p-1.5' : 'flex h-8 items-center rounded-lg pr-1 pl-3', + 'rounded-lg', + ) const renderIcon = () => ( -
-