diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 0cae2ef552..2ce8a09a7d 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -2,6 +2,8 @@ name: autofix.ci on: pull_request: branches: ["main"] + push: + branches: ["main"] permissions: contents: read diff --git a/.gitignore b/.gitignore index 76cfd7d9bf..c6067e96cd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ __pycache__/ # C extensions *.so +# *db files +*.db + # Distribution / packaging .Python build/ @@ -235,4 +238,7 @@ scripts/stress-test/reports/ # mcp .playwright-mcp/ -.serena/ \ No newline at end of file +.serena/ + +# settings +*.local.json diff --git a/api/.env.example b/api/.env.example index 3120e1cdd6..64fe20aa27 100644 --- a/api/.env.example +++ b/api/.env.example @@ -27,6 +27,9 @@ FILES_URL=http://localhost:5001 # Example: INTERNAL_FILES_URL=http://api:5001 INTERNAL_FILES_URL=http://127.0.0.1:5001 +# TRIGGER URL +TRIGGER_URL=http://localhost:5001 + # The time in seconds after the signature is rejected FILES_ACCESS_TIMEOUT=300 @@ -466,6 +469,9 @@ HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760 HTTP_REQUEST_NODE_MAX_TEXT_SIZE=1048576 HTTP_REQUEST_NODE_SSL_VERIFY=True +# Webhook request configuration +WEBHOOK_REQUEST_BODY_MAX_SIZE=10485760 + # Respect X-* headers to redirect clients RESPECT_XFORWARD_HEADERS_ENABLED=false @@ -543,6 +549,12 @@ ENABLE_CLEAN_MESSAGES=false ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK=false ENABLE_DATASETS_QUEUE_MONITOR=false ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK=true +ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK=true +# Interval time in minutes for polling scheduled workflows(default: 1 min) +WORKFLOW_SCHEDULE_POLLER_INTERVAL=1 +WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE=100 +# Maximum number of scheduled workflows to dispatch per tick (0 for unlimited) +WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK=0 # Position configuration POSITION_TOOL_PINS= diff --git a/api/.vscode/launch.json.example b/api/.vscode/launch.json.example index e97828f9d8..092c66e798 100644 --- a/api/.vscode/launch.json.example +++ b/api/.vscode/launch.json.example @@ -54,7 +54,7 @@ "--loglevel", "DEBUG", "-Q", - "dataset,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,priority_pipeline,pipeline" + "dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor" ] } ] diff --git a/api/AGENTS.md b/api/AGENTS.md new file mode 100644 index 0000000000..17398ec4b8 --- /dev/null +++ b/api/AGENTS.md @@ -0,0 +1,62 @@ +# Agent Skill Index + +Start with the section that best matches your need. Each entry lists the problems it solves plus key files/concepts so you know what to expect before opening it. + +______________________________________________________________________ + +## Platform Foundations + +- **[Infrastructure Overview](agent_skills/infra.md)**\ + When to read this: + + - You need to understand where a feature belongs in the architecture. + - You’re wiring storage, Redis, vector stores, or OTEL. + - You’re about to add CLI commands or async jobs.\ + What it covers: configuration stack (`configs/app_config.py`, remote settings), storage entry points (`extensions/ext_storage.py`, `core/file/file_manager.py`), Redis conventions (`extensions/ext_redis.py`), plugin runtime topology, vector-store factory (`core/rag/datasource/vdb/*`), observability hooks, SSRF proxy usage, and core CLI commands. + +- **[Coding Style](agent_skills/coding_style.md)**\ + When to read this: + + - You’re writing or reviewing backend code and need the authoritative checklist. + - You’re unsure about Pydantic validators, SQLAlchemy session usage, or logging patterns. + - You want the exact lint/type/test commands used in PRs.\ + Includes: Ruff & BasedPyright commands, no-annotation policy, session examples (`with Session(db.engine, ...)`), `@field_validator` usage, logging expectations, and the rule set for file size, helpers, and package management. + +______________________________________________________________________ + +## Plugin & Extension Development + +- **[Plugin Systems](agent_skills/plugin.md)**\ + When to read this: + + - You’re building or debugging a marketplace plugin. + - You need to know how manifests, providers, daemons, and migrations fit together.\ + What it covers: plugin manifests (`core/plugin/entities/plugin.py`), installation/upgrade flows (`services/plugin/plugin_service.py`, CLI commands), runtime adapters (`core/plugin/impl/*` for tool/model/datasource/trigger/endpoint/agent), daemon coordination (`core/plugin/entities/plugin_daemon.py`), and how provider registries surface capabilities to the rest of the platform. + +- **[Plugin OAuth](agent_skills/plugin_oauth.md)**\ + When to read this: + + - You must integrate OAuth for a plugin or datasource. + - You’re handling credential encryption or refresh flows.\ + Topics: credential storage, encryption helpers (`core/helper/provider_encryption.py`), OAuth client bootstrap (`services/plugin/oauth_service.py`, `services/plugin/plugin_parameter_service.py`), and how console/API layers expose the flows. + +______________________________________________________________________ + +## Workflow Entry & Execution + +- **[Trigger Concepts](agent_skills/trigger.md)**\ + When to read this: + - You’re debugging why a workflow didn’t start. + - You’re adding a new trigger type or hook. + - You need to trace async execution, draft debugging, or webhook/schedule pipelines.\ + Details: Start-node taxonomy, webhook & schedule internals (`core/workflow/nodes/trigger_*`, `services/trigger/*`), async orchestration (`services/async_workflow_service.py`, Celery queues), debug event bus, and storage/logging interactions. + +______________________________________________________________________ + +## Additional Notes for Agents + +- All skill docs assume you follow the coding style guide—run Ruff/BasedPyright/tests listed there before submitting changes. +- When you cannot find an answer in these briefs, search the codebase using the paths referenced (e.g., `core/plugin/impl/tool.py`, `services/dataset_service.py`). +- If you run into cross-cutting concerns (tenancy, configuration, storage), check the infrastructure guide first; it links to most supporting modules. +- Keep multi-tenancy and configuration central: everything flows through `configs.dify_config` and `tenant_id`. +- When touching plugins or triggers, consult both the system overview and the specialised doc to ensure you adjust lifecycle, storage, and observability consistently. diff --git a/api/agent_skills/coding_style.md b/api/agent_skills/coding_style.md new file mode 100644 index 0000000000..a2b66f0bd5 --- /dev/null +++ b/api/agent_skills/coding_style.md @@ -0,0 +1,115 @@ +## Linter + +- Always follow `.ruff.toml`. +- Run `uv run ruff check --fix --unsafe-fixes`. +- Keep each line under 100 characters (including spaces). + +## Code Style + +- `snake_case` for variables and functions. +- `PascalCase` for classes. +- `UPPER_CASE` for constants. + +## Rules + +- Use Pydantic v2 standard. +- Use `uv` for package management. +- Do not override dunder methods like `__init__`, `__iadd__`, etc. +- Never launch services (`uv run app.py`, `flask run`, etc.); running tests under `tests/` is allowed. +- Prefer simple functions over classes for lightweight helpers. +- Keep files below 800 lines; split when necessary. +- Keep code readable—no clever hacks. +- Never use `print`; log with `logger = logging.getLogger(__name__)`. + +## Guiding Principles + +- Mirror the project’s layered architecture: controller → service → core/domain. +- Reuse existing helpers in `core/`, `services/`, and `libs/` before creating new abstractions. +- Optimise for observability: deterministic control flow, clear logging, actionable errors. + +## SQLAlchemy Patterns + +- Models inherit from `models.base.Base`; never create ad-hoc metadata or engines. + +- Open sessions with context managers: + + ```python + from sqlalchemy.orm import Session + + with Session(db.engine, expire_on_commit=False) as session: + stmt = select(Workflow).where( + Workflow.id == workflow_id, + Workflow.tenant_id == tenant_id, + ) + workflow = session.execute(stmt).scalar_one_or_none() + ``` + +- Use SQLAlchemy expressions; avoid raw SQL unless necessary. + +- Introduce repository abstractions only for very large tables (e.g., workflow executions) to support alternative storage strategies. + +- Always scope queries by `tenant_id` and protect write paths with safeguards (`FOR UPDATE`, row counts, etc.). + +## Storage & External IO + +- Access storage via `extensions.ext_storage.storage`. +- Use `core.helper.ssrf_proxy` for outbound HTTP fetches. +- Background tasks that touch storage must be idempotent and log the relevant object identifiers. + +## Pydantic Usage + +- Define DTOs with Pydantic v2 models and forbid extras by default. + +- Use `@field_validator` / `@model_validator` for domain rules. + +- Example: + + ```python + from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator + + class TriggerConfig(BaseModel): + endpoint: HttpUrl + secret: str + + model_config = ConfigDict(extra="forbid") + + @field_validator("secret") + def ensure_secret_prefix(cls, value: str) -> str: + if not value.startswith("dify_"): + raise ValueError("secret must start with dify_") + return value + ``` + +## Generics & Protocols + +- Use `typing.Protocol` to define behavioural contracts (e.g., cache interfaces). +- Apply generics (`TypeVar`, `Generic`) for reusable utilities like caches or providers. +- Validate dynamic inputs at runtime when generics cannot enforce safety alone. + +## Error Handling & Logging + +- Raise domain-specific exceptions (`services/errors`, `core/errors`) and translate to HTTP responses in controllers. +- Declare `logger = logging.getLogger(__name__)` at module top. +- Include tenant/app/workflow identifiers in log context. +- Log retryable events at `warning`, terminal failures at `error`. + +## Tooling & Checks + +- Format/lint: `uv run --project api --dev ruff format ./api` and `uv run --project api --dev ruff check --fix --unsafe-fixes ./api`. +- Type checks: `uv run --directory api --dev basedpyright`. +- Tests: `uv run --project api --dev dev/pytest/pytest_unit_tests.sh`. +- Run all of the above before submitting your work. + +## Controllers & Services + +- Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic. +- Services: coordinate repositories, providers, background tasks; keep side effects explicit. +- Avoid repositories unless necessary; direct SQLAlchemy usage is preferred for typical tables. +- Document non-obvious behaviour with concise comments. + +## Miscellaneous + +- Use `configs.dify_config` for configuration—never read environment variables directly. +- Maintain tenant awareness end-to-end; `tenant_id` must flow through every layer touching shared resources. +- Queue async work through `services/async_workflow_service`; implement tasks under `tasks/` with explicit queue selection. +- Keep experimental scripts under `dev/`; do not ship them in production builds. diff --git a/api/agent_skills/infra.md b/api/agent_skills/infra.md new file mode 100644 index 0000000000..bc36c7bf64 --- /dev/null +++ b/api/agent_skills/infra.md @@ -0,0 +1,96 @@ +## Configuration + +- Import `configs.dify_config` for every runtime toggle. Do not read environment variables directly. +- Add new settings to the proper mixin inside `configs/` (deployment, feature, middleware, etc.) so they load through `DifyConfig`. +- Remote overrides come from the optional providers in `configs/remote_settings_sources`; keep defaults in code safe when the value is missing. +- Example: logging pulls targets from `extensions/ext_logging.py`, and model provider URLs are assembled in `services/entities/model_provider_entities.py`. + +## Dependencies + +- Runtime dependencies live in `[project].dependencies` inside `pyproject.toml`. Optional clients go into the `storage`, `tools`, or `vdb` groups under `[dependency-groups]`. +- Always pin versions and keep the list alphabetised. Shared tooling (lint, typing, pytest) belongs in the `dev` group. +- When code needs a new package, explain why in the PR and run `uv lock` so the lockfile stays current. + +## Storage & Files + +- Use `extensions.ext_storage.storage` for all blob IO; it already respects the configured backend. +- Convert files for workflows with helpers in `core/file/file_manager.py`; they handle signed URLs and multimodal payloads. +- When writing controller logic, delegate upload quotas and metadata to `services/file_service.py` instead of touching storage directly. +- All outbound HTTP fetches (webhooks, remote files) must go through the SSRF-safe client in `core/helper/ssrf_proxy.py`; it wraps `httpx` with the allow/deny rules configured for the platform. + +## Redis & Shared State + +- Access Redis through `extensions.ext_redis.redis_client`. For locking, reuse `redis_client.lock`. +- Prefer higher-level helpers when available: rate limits use `libs.helper.RateLimiter`, provider metadata uses caches in `core/helper/provider_cache.py`. + +## Models + +- SQLAlchemy models sit in `models/` and inherit from the shared declarative `Base` defined in `models/base.py` (metadata configured via `models/engine.py`). +- `models/__init__.py` exposes grouped aggregates: account/tenant models, app and conversation tables, datasets, providers, workflow runs, triggers, etc. Import from there to avoid deep path churn. +- Follow the DDD boundary: persistence objects live in `models/`, repositories under `repositories/` translate them into domain entities, and services consume those repositories. +- When adding a table, create the model class, register it in `models/__init__.py`, wire a repository if needed, and generate an Alembic migration as described below. + +## Vector Stores + +- Vector client implementations live in `core/rag/datasource/vdb/`, with a common factory in `core/rag/datasource/vdb/vector_factory.py` and enums in `core/rag/datasource/vdb/vector_type.py`. +- Retrieval pipelines call these providers through `core/rag/datasource/retrieval_service.py` and dataset ingestion flows in `services/dataset_service.py`. +- The CLI helper `flask vdb-migrate` orchestrates bulk migrations using routines in `commands.py`; reuse that pattern when adding new backend transitions. +- To add another store, mirror the provider layout, register it with the factory, and include any schema changes in Alembic migrations. + +## Observability & OTEL + +- OpenTelemetry settings live under the observability mixin in `configs/observability`. Toggle exporters and sampling via `dify_config`, not ad-hoc env reads. +- HTTP, Celery, Redis, SQLAlchemy, and httpx instrumentation is initialised in `extensions/ext_app_metrics.py` and `extensions/ext_request_logging.py`; reuse these hooks when adding new workers or entrypoints. +- When creating background tasks or external calls, propagate tracing context with helpers in the existing instrumented clients (e.g. use the shared `httpx` session from `core/helper/http_client_pooling.py`). +- If you add a new external integration, ensure spans and metrics are emitted by wiring the appropriate OTEL instrumentation package in `pyproject.toml` and configuring it in `extensions/`. + +## Ops Integrations + +- Langfuse support and other tracing bridges live under `core/ops/opik_trace`. Config toggles sit in `configs/observability`, while exporters are initialised in the OTEL extensions mentioned above. +- External monitoring services should follow this pattern: keep client code in `core/ops`, expose switches via `dify_config`, and hook initialisation in `extensions/ext_app_metrics.py` or sibling modules. +- Before instrumenting new code paths, check whether existing context helpers (e.g. `extensions/ext_request_logging.py`) already capture the necessary metadata. + +## Controllers, Services, Core + +- Controllers only parse HTTP input and call a service method. Keep business rules in `services/`. +- Services enforce tenant rules, quotas, and orchestration, then call into `core/` engines (workflow execution, tools, LLMs). +- When adding a new endpoint, search for an existing service to extend before introducing a new layer. Example: workflow APIs pipe through `services/workflow_service.py` into `core/workflow`. + +## Plugins, Tools, Providers + +- In Dify a plugin is a tenant-installable bundle that declares one or more providers (tool, model, datasource, trigger, endpoint, agent strategy) plus its resource needs and version metadata. The manifest (`core/plugin/entities/plugin.py`) mirrors what you see in the marketplace documentation. +- Installation, upgrades, and migrations are orchestrated by `services/plugin/plugin_service.py` together with helpers such as `services/plugin/plugin_migration.py`. +- Runtime loading happens through the implementations under `core/plugin/impl/*` (tool/model/datasource/trigger/endpoint/agent). These modules normalise plugin providers so that downstream systems (`core/tools/tool_manager.py`, `services/model_provider_service.py`, `services/trigger/*`) can treat builtin and plugin capabilities the same way. +- For remote execution, plugin daemons (`core/plugin/entities/plugin_daemon.py`, `core/plugin/impl/plugin.py`) manage lifecycle hooks, credential forwarding, and background workers that keep plugin processes in sync with the main application. +- Acquire tool implementations through `core/tools/tool_manager.py`; it resolves builtin, plugin, and workflow-as-tool providers uniformly, injecting the right context (tenant, credentials, runtime config). +- To add a new plugin capability, extend the relevant `core/plugin/entities` schema and register the implementation in the matching `core/plugin/impl` module rather than importing the provider directly. + +## Async Workloads + +see `agent_skills/trigger.md` for more detailed documentation. + +- Enqueue background work through `services/async_workflow_service.py`. It routes jobs to the tiered Celery queues defined in `tasks/`. +- Workers boot from `celery_entrypoint.py` and execute functions in `tasks/workflow_execution_tasks.py`, `tasks/trigger_processing_tasks.py`, etc. +- Scheduled workflows poll from `schedule/workflow_schedule_tasks.py`. Follow the same pattern if you need new periodic jobs. + +## Database & Migrations + +- SQLAlchemy models live under `models/` and map directly to migration files in `migrations/versions`. +- Generate migrations with `uv run --project api flask db revision --autogenerate -m ""`, then review the diff; never hand-edit the database outside Alembic. +- Apply migrations locally using `uv run --project api flask db upgrade`; production deploys expect the same history. +- If you add tenant-scoped data, confirm the upgrade includes tenant filters or defaults consistent with the service logic touching those tables. + +## CLI Commands + +- Maintenance commands from `commands.py` are registered on the Flask CLI. Run them via `uv run --project api flask `. +- Use the built-in `db` commands from Flask-Migrate for schema operations (`flask db upgrade`, `flask db stamp`, etc.). Only fall back to custom helpers if you need their extra behaviour. +- Custom entries such as `flask reset-password`, `flask reset-email`, and `flask vdb-migrate` handle self-hosted account recovery and vector database migrations. +- Before adding a new command, check whether an existing service can be reused and ensure the command guards edition-specific behaviour (many enforce `SELF_HOSTED`). Document any additions in the PR. +- Ruff helpers are run directly with `uv`: `uv run --project api --dev ruff format ./api` for formatting and `uv run --project api --dev ruff check ./api` (add `--fix` if you want automatic fixes). + +## When You Add Features + +- Check for an existing helper or service before writing a new util. +- Uphold tenancy: every service method should receive the tenant ID from controller wrappers such as `controllers/console/wraps.py`. +- Update or create tests alongside behaviour changes (`tests/unit_tests` for fast coverage, `tests/integration_tests` when touching orchestrations). +- Run `uv run --project api --dev ruff check ./api`, `uv run --directory api --dev basedpyright`, and `uv run --project api --dev dev/pytest/pytest_unit_tests.sh` before submitting changes. diff --git a/api/agent_skills/plugin.md b/api/agent_skills/plugin.md new file mode 100644 index 0000000000..954ddd236b --- /dev/null +++ b/api/agent_skills/plugin.md @@ -0,0 +1 @@ +// TBD diff --git a/api/agent_skills/plugin_oauth.md b/api/agent_skills/plugin_oauth.md new file mode 100644 index 0000000000..954ddd236b --- /dev/null +++ b/api/agent_skills/plugin_oauth.md @@ -0,0 +1 @@ +// TBD diff --git a/api/agent_skills/trigger.md b/api/agent_skills/trigger.md new file mode 100644 index 0000000000..f4b076332c --- /dev/null +++ b/api/agent_skills/trigger.md @@ -0,0 +1,53 @@ +## Overview + +Trigger is a collection of nodes that we called `Start` nodes, also, the concept of `Start` is the same as `RootNode` in the workflow engine `core/workflow/graph_engine`, On the other hand, `Start` node is the entry point of workflows, every workflow run always starts from a `Start` node. + +## Trigger nodes + +- `UserInput` +- `Trigger Webhook` +- `Trigger Schedule` +- `Trigger Plugin` + +### UserInput + +Before `Trigger` concept is introduced, it's what we called `Start` node, but now, to avoid confusion, it was renamed to `UserInput` node, has a strong relation with `ServiceAPI` in `controllers/service_api/app` + +1. `UserInput` node introduces a list of arguments that need to be provided by the user, finally it will be converted into variables in the workflow variable pool. +1. `ServiceAPI` accept those arguments, and pass through them into `UserInput` node. +1. For its detailed implementation, please refer to `core/workflow/nodes/start` + +### Trigger Webhook + +Inside Webhook Node, Dify provided a UI panel that allows user define a HTTP manifest `core/workflow/nodes/trigger_webhook/entities.py`.`WebhookData`, also, Dify generates a random webhook id for each `Trigger Webhook` node, the implementation was implemented in `core/trigger/utils/endpoint.py`, as you can see, `webhook-debug` is a debug mode for webhook, you may find it in `controllers/trigger/webhook.py`. + +Finally, requests to `webhook` endpoint will be converted into variables in workflow variable pool during workflow execution. + +### Trigger Schedule + +`Trigger Schedule` node is a node that allows user define a schedule to trigger the workflow, detailed manifest is here `core/workflow/nodes/trigger_schedule/entities.py`, we have a poller and executor to handle millions of schedules, see `docker/entrypoint.sh` / `schedule/workflow_schedule_task.py` for help. + +To Achieve this, a `WorkflowSchedulePlan` model was introduced in `models/trigger.py`, and a `events/event_handlers/sync_workflow_schedule_when_app_published.py` was used to sync workflow schedule plans when app is published. + +### Trigger Plugin + +`Trigger Plugin` node allows user define there own distributed trigger plugin, whenever a request was received, Dify forwards it to the plugin and wait for parsed variables from it. + +1. Requests were saved in storage by `services/trigger/trigger_request_service.py`, referenced by `services/trigger/trigger_service.py`.`TriggerService`.`process_endpoint` +1. Plugins accept those requests and parse variables from it, see `core/plugin/impl/trigger.py` for details. + +A `subscription` concept was out here by Dify, it means an endpoint address from Dify was bound to thirdparty webhook service like `Github` `Slack` `Linear` `GoogleDrive` `Gmail` etc. Once a subscription was created, Dify continually receives requests from the platforms and handle them one by one. + +## Worker Pool / Async Task + +All the events that triggered a new workflow run is always in async mode, a unified entrypoint can be found here `services/async_workflow_service.py`.`AsyncWorkflowService`.`trigger_workflow_async`. + +The infrastructure we used is `celery`, we've already configured it in `docker/entrypoint.sh`, and the consumers are in `tasks/async_workflow_tasks.py`, 3 queues were used to handle different tiers of users, `PROFESSIONAL_QUEUE` `TEAM_QUEUE` `SANDBOX_QUEUE`. + +## Debug Strategy + +Dify divided users into 2 groups: builders / end users. + +Builders are the users who create workflows, in this stage, debugging a workflow becomes a critical part of the workflow development process, as the start node in workflows, trigger nodes can `listen` to the events from `WebhookDebug` `Schedule` `Plugin`, debugging process was created in `controllers/console/app/workflow.py`.`DraftWorkflowTriggerNodeApi`. + +A polling process can be considered as combine of few single `poll` operations, each `poll` operation fetches events cached in `Redis`, returns `None` if no event was found, more detailed implemented: `core/trigger/debug/event_bus.py` was used to handle the polling process, and `core/trigger/debug/event_selectors.py` was used to select the event poller based on the trigger type. diff --git a/api/commands.py b/api/commands.py index 48c8fe4c08..e15c996a34 100644 --- a/api/commands.py +++ b/api/commands.py @@ -15,12 +15,12 @@ from sqlalchemy.orm import sessionmaker from configs import dify_config from constants.languages import languages from core.helper import encrypter +from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.plugin import PluginInstaller from core.rag.datasource.vdb.vector_factory import Vector from core.rag.datasource.vdb.vector_type import VectorType from core.rag.index_processor.constant.built_in_field import BuiltInField from core.rag.models.document import Document -from core.tools.entities.tool_entities import CredentialType from core.tools.utils.system_oauth_encryption import encrypt_system_oauth_params from events.app_event import app_was_created from extensions.ext_database import db @@ -1229,6 +1229,55 @@ def setup_system_tool_oauth_client(provider, client_params): click.echo(click.style(f"OAuth client params setup successfully. id: {oauth_client.id}", fg="green")) +@click.command("setup-system-trigger-oauth-client", help="Setup system trigger oauth client.") +@click.option("--provider", prompt=True, help="Provider name") +@click.option("--client-params", prompt=True, help="Client Params") +def setup_system_trigger_oauth_client(provider, client_params): + """ + Setup system trigger oauth client + """ + from models.provider_ids import TriggerProviderID + from models.trigger import TriggerOAuthSystemClient + + provider_id = TriggerProviderID(provider) + provider_name = provider_id.provider_name + plugin_id = provider_id.plugin_id + + try: + # json validate + click.echo(click.style(f"Validating client params: {client_params}", fg="yellow")) + client_params_dict = TypeAdapter(dict[str, Any]).validate_json(client_params) + click.echo(click.style("Client params validated successfully.", fg="green")) + + click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow")) + click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow")) + oauth_client_params = encrypt_system_oauth_params(client_params_dict) + click.echo(click.style("Client params encrypted successfully.", fg="green")) + except Exception as e: + click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red")) + return + + deleted_count = ( + db.session.query(TriggerOAuthSystemClient) + .filter_by( + provider=provider_name, + plugin_id=plugin_id, + ) + .delete() + ) + if deleted_count > 0: + click.echo(click.style(f"Deleted {deleted_count} existing oauth client params.", fg="yellow")) + + oauth_client = TriggerOAuthSystemClient( + provider=provider_name, + plugin_id=plugin_id, + encrypted_oauth_params=oauth_client_params, + ) + db.session.add(oauth_client) + db.session.commit() + click.echo(click.style(f"OAuth client params setup successfully. id: {oauth_client.id}", fg="green")) + + def _find_orphaned_draft_variables(batch_size: int = 1000) -> list[str]: """ Find draft variables that reference non-existent apps. diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 86c37dca25..65f07d65c3 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -174,6 +174,33 @@ class CodeExecutionSandboxConfig(BaseSettings): ) +class TriggerConfig(BaseSettings): + """ + Configuration for trigger + """ + + WEBHOOK_REQUEST_BODY_MAX_SIZE: PositiveInt = Field( + description="Maximum allowed size for webhook request bodies in bytes", + default=10485760, + ) + + +class AsyncWorkflowConfig(BaseSettings): + """ + Configuration for async workflow + """ + + ASYNC_WORKFLOW_SCHEDULER_GRANULARITY: int = Field( + description="Granularity for async workflow scheduler, " + "sometime, few users could block the queue due to some time-consuming tasks, " + "to avoid this, workflow can be suspended if needed, to achieve" + "this, a time-based checker is required, every granularity seconds, " + "the checker will check the workflow queue and suspend the workflow", + default=120, + ge=1, + ) + + class PluginConfig(BaseSettings): """ Plugin configs @@ -263,6 +290,8 @@ class EndpointConfig(BaseSettings): description="Template url for endpoint plugin", default="http://localhost:5002/e/{hook_id}" ) + TRIGGER_URL: str = Field(description="Template url for triggers", default="http://localhost:5001") + class FileAccessConfig(BaseSettings): """ @@ -1025,6 +1054,44 @@ class CeleryScheduleTasksConfig(BaseSettings): description="Enable check upgradable plugin task", default=True, ) + ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK: bool = Field( + description="Enable workflow schedule poller task", + default=True, + ) + WORKFLOW_SCHEDULE_POLLER_INTERVAL: int = Field( + description="Workflow schedule poller interval in minutes", + default=1, + ) + WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE: int = Field( + description="Maximum number of schedules to process in each poll batch", + default=100, + ) + WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK: int = Field( + description="Maximum schedules to dispatch per tick (0=unlimited, circuit breaker)", + default=0, + ) + + # Trigger provider refresh (simple version) + ENABLE_TRIGGER_PROVIDER_REFRESH_TASK: bool = Field( + description="Enable trigger provider refresh poller", + default=True, + ) + TRIGGER_PROVIDER_REFRESH_INTERVAL: int = Field( + description="Trigger provider refresh poller interval in minutes", + default=1, + ) + TRIGGER_PROVIDER_REFRESH_BATCH_SIZE: int = Field( + description="Max trigger subscriptions to process per tick", + default=200, + ) + TRIGGER_PROVIDER_CREDENTIAL_THRESHOLD_SECONDS: int = Field( + description="Proactive credential refresh threshold in seconds", + default=180, + ) + TRIGGER_PROVIDER_SUBSCRIPTION_THRESHOLD_SECONDS: int = Field( + description="Proactive subscription refresh threshold in seconds", + default=60 * 60, + ) class PositionConfig(BaseSettings): @@ -1155,6 +1222,8 @@ class FeatureConfig( AuthConfig, # Changed from OAuthConfig to AuthConfig BillingConfig, CodeExecutionSandboxConfig, + TriggerConfig, + AsyncWorkflowConfig, PluginConfig, MarketplaceConfig, DataSetConfig, diff --git a/api/contexts/__init__.py b/api/contexts/__init__.py index 2126a06f75..7c16bc231f 100644 --- a/api/contexts/__init__.py +++ b/api/contexts/__init__.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from core.model_runtime.entities.model_entities import AIModelEntity from core.plugin.entities.plugin_daemon import PluginModelProviderEntity from core.tools.plugin_tool.provider import PluginToolProviderController + from core.trigger.provider import PluginTriggerProviderController """ @@ -41,3 +42,11 @@ datasource_plugin_providers: RecyclableContextVar[dict[str, "DatasourcePluginPro datasource_plugin_providers_lock: RecyclableContextVar[Lock] = RecyclableContextVar( ContextVar("datasource_plugin_providers_lock") ) + +plugin_trigger_providers: RecyclableContextVar[dict[str, "PluginTriggerProviderController"]] = RecyclableContextVar( + ContextVar("plugin_trigger_providers") +) + +plugin_trigger_providers_lock: RecyclableContextVar[Lock] = RecyclableContextVar( + ContextVar("plugin_trigger_providers_lock") +) diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 621f5066e4..ad878fc266 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -66,6 +66,7 @@ from .app import ( workflow_draft_variable, workflow_run, workflow_statistic, + workflow_trigger, ) # Import auth controllers @@ -126,6 +127,7 @@ from .workspace import ( models, plugin, tool_providers, + trigger_providers, workspace, ) @@ -196,6 +198,7 @@ __all__ = [ "statistic", "tags", "tool_providers", + "trigger_providers", "version", "website", "workflow", @@ -203,5 +206,6 @@ __all__ = [ "workflow_draft_variable", "workflow_run", "workflow_statistic", + "workflow_trigger", "workspace", ] diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py index b6ca97ab4f..54a101946c 100644 --- a/api/controllers/console/app/generator.py +++ b/api/controllers/console/app/generator.py @@ -11,6 +11,7 @@ from controllers.console.app.error import ( ) from controllers.console.wraps import account_initialization_required, setup_required from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError +from core.helper.code_executor.code_node_provider import CodeNodeProvider from core.helper.code_executor.javascript.javascript_code_provider import JavascriptCodeProvider from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider from core.llm_generator.llm_generator import LLMGenerator @@ -206,13 +207,11 @@ class InstructionGenerateApi(Resource): ) args = parser.parse_args() _, current_tenant_id = current_account_with_tenant() - code_template = ( - Python3CodeProvider.get_default_code() - if args["language"] == "python" - else (JavascriptCodeProvider.get_default_code()) - if args["language"] == "javascript" - else "" + providers: list[type[CodeNodeProvider]] = [Python3CodeProvider, JavascriptCodeProvider] + code_provider: type[CodeNodeProvider] | None = next( + (p for p in providers if p.is_accept_language(args["language"])), None ) + code_template = code_provider.get_default_code() if code_provider else "" try: # Generate from nothing for a workflow node if (args["current"] == code_template or args["current"] == "") and args["node_id"] != "": diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 5f41b65e88..8c451cd08c 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -16,9 +16,19 @@ from controllers.console.wraps import account_initialization_required, edit_perm from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.app_config.features.file_upload.manager import FileUploadConfigManager from core.app.apps.base_app_queue_manager import AppQueueManager +from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY from core.app.entities.app_invoke_entities import InvokeFrom from core.file.models import File from core.helper.trace_id_helper import get_external_trace_id +from core.model_runtime.utils.encoders import jsonable_encoder +from core.plugin.impl.exc import PluginInvokeError +from core.trigger.debug.event_selectors import ( + TriggerDebugEvent, + TriggerDebugEventPoller, + create_event_poller, + select_trigger_debug_events, +) +from core.workflow.enums import NodeType from core.workflow.graph_engine.manager import GraphEngineManager from extensions.ext_database import db from factories import file_factory, variable_factory @@ -37,6 +47,7 @@ from services.errors.llm import InvokeRateLimitError from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService logger = logging.getLogger(__name__) +LISTENING_RETRY_IN = 2000 # TODO(QuantumGhost): Refactor existing node run API to handle file parameter parsing @@ -926,3 +937,234 @@ class DraftWorkflowNodeLastRunApi(Resource): if node_exec is None: raise NotFound("last run not found") return node_exec + + +@console_ns.route("/apps//workflows/draft/trigger/run") +class DraftWorkflowTriggerRunApi(Resource): + """ + Full workflow debug - Polling API for trigger events + Path: /apps//workflows/draft/trigger/run + """ + + @api.doc("poll_draft_workflow_trigger_run") + @api.doc(description="Poll for trigger events and execute full workflow when event arrives") + @api.doc(params={"app_id": "Application ID"}) + @api.expect( + api.model( + "DraftWorkflowTriggerRunRequest", + { + "node_id": fields.String(required=True, description="Node ID"), + }, + ) + ) + @api.response(200, "Trigger event received and workflow executed successfully") + @api.response(403, "Permission denied") + @api.response(500, "Internal server error") + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.WORKFLOW]) + @edit_permission_required + def post(self, app_model: App): + """ + Poll for trigger events and execute full workflow when event arrives + """ + current_user, _ = current_account_with_tenant() + parser = reqparse.RequestParser() + parser.add_argument("node_id", type=str, required=True, location="json", nullable=False) + args = parser.parse_args() + node_id = args["node_id"] + workflow_service = WorkflowService() + draft_workflow = workflow_service.get_draft_workflow(app_model) + if not draft_workflow: + raise ValueError("Workflow not found") + + poller: TriggerDebugEventPoller = create_event_poller( + draft_workflow=draft_workflow, + tenant_id=app_model.tenant_id, + user_id=current_user.id, + app_id=app_model.id, + node_id=node_id, + ) + event: TriggerDebugEvent | None = None + try: + event = poller.poll() + if not event: + return jsonable_encoder({"status": "waiting", "retry_in": LISTENING_RETRY_IN}) + workflow_args = dict(event.workflow_args) + workflow_args[SKIP_PREPARE_USER_INPUTS_KEY] = True + return helper.compact_generate_response( + AppGenerateService.generate( + app_model=app_model, + user=current_user, + args=workflow_args, + invoke_from=InvokeFrom.DEBUGGER, + streaming=True, + root_node_id=node_id, + ) + ) + except InvokeRateLimitError as ex: + raise InvokeRateLimitHttpError(ex.description) + except PluginInvokeError as e: + return jsonable_encoder({"status": "error", "error": e.to_user_friendly_error()}), 400 + except Exception as e: + logger.exception("Error polling trigger debug event") + raise e + + +@console_ns.route("/apps//workflows/draft/nodes//trigger/run") +class DraftWorkflowTriggerNodeApi(Resource): + """ + Single node debug - Polling API for trigger events + Path: /apps//workflows/draft/nodes//trigger/run + """ + + @api.doc("poll_draft_workflow_trigger_node") + @api.doc(description="Poll for trigger events and execute single node when event arrives") + @api.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) + @api.response(200, "Trigger event received and node executed successfully") + @api.response(403, "Permission denied") + @api.response(500, "Internal server error") + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.WORKFLOW]) + @edit_permission_required + def post(self, app_model: App, node_id: str): + """ + Poll for trigger events and execute single node when event arrives + """ + current_user, _ = current_account_with_tenant() + + workflow_service = WorkflowService() + draft_workflow = workflow_service.get_draft_workflow(app_model) + if not draft_workflow: + raise ValueError("Workflow not found") + + node_config = draft_workflow.get_node_config_by_id(node_id=node_id) + if not node_config: + raise ValueError("Node data not found for node %s", node_id) + node_type: NodeType = draft_workflow.get_node_type_from_node_config(node_config) + event: TriggerDebugEvent | None = None + # for schedule trigger, when run single node, just execute directly + if node_type == NodeType.TRIGGER_SCHEDULE: + event = TriggerDebugEvent( + workflow_args={}, + node_id=node_id, + ) + # for other trigger types, poll for the event + else: + try: + poller: TriggerDebugEventPoller = create_event_poller( + draft_workflow=draft_workflow, + tenant_id=app_model.tenant_id, + user_id=current_user.id, + app_id=app_model.id, + node_id=node_id, + ) + event = poller.poll() + except PluginInvokeError as e: + return jsonable_encoder({"status": "error", "error": e.to_user_friendly_error()}), 400 + except Exception as e: + logger.exception("Error polling trigger debug event") + raise e + if not event: + return jsonable_encoder({"status": "waiting", "retry_in": LISTENING_RETRY_IN}) + + raw_files = event.workflow_args.get("files") + files = _parse_file(draft_workflow, raw_files if isinstance(raw_files, list) else None) + try: + node_execution = workflow_service.run_draft_workflow_node( + app_model=app_model, + draft_workflow=draft_workflow, + node_id=node_id, + user_inputs=event.workflow_args.get("inputs") or {}, + account=current_user, + query="", + files=files, + ) + return jsonable_encoder(node_execution) + except Exception as e: + logger.exception("Error running draft workflow trigger node") + return jsonable_encoder( + {"status": "error", "error": "An unexpected error occurred while running the node."} + ), 400 + + +@console_ns.route("/apps//workflows/draft/trigger/run-all") +class DraftWorkflowTriggerRunAllApi(Resource): + """ + Full workflow debug - Polling API for trigger events + Path: /apps//workflows/draft/trigger/run-all + """ + + @api.doc("draft_workflow_trigger_run_all") + @api.doc(description="Full workflow debug when the start node is a trigger") + @api.doc(params={"app_id": "Application ID"}) + @api.expect( + api.model( + "DraftWorkflowTriggerRunAllRequest", + { + "node_ids": fields.List(fields.String, required=True, description="Node IDs"), + }, + ) + ) + @api.response(200, "Workflow executed successfully") + @api.response(403, "Permission denied") + @api.response(500, "Internal server error") + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.WORKFLOW]) + @edit_permission_required + def post(self, app_model: App): + """ + Full workflow debug when the start node is a trigger + """ + current_user, _ = current_account_with_tenant() + + parser = reqparse.RequestParser() + parser.add_argument("node_ids", type=list, required=True, location="json", nullable=False) + args = parser.parse_args() + node_ids = args["node_ids"] + workflow_service = WorkflowService() + draft_workflow = workflow_service.get_draft_workflow(app_model) + if not draft_workflow: + raise ValueError("Workflow not found") + + try: + trigger_debug_event: TriggerDebugEvent | None = select_trigger_debug_events( + draft_workflow=draft_workflow, + app_model=app_model, + user_id=current_user.id, + node_ids=node_ids, + ) + except PluginInvokeError as e: + return jsonable_encoder({"status": "error", "error": e.to_user_friendly_error()}), 400 + except Exception as e: + logger.exception("Error polling trigger debug event") + raise e + if trigger_debug_event is None: + return jsonable_encoder({"status": "waiting", "retry_in": LISTENING_RETRY_IN}) + + try: + workflow_args = dict(trigger_debug_event.workflow_args) + workflow_args[SKIP_PREPARE_USER_INPUTS_KEY] = True + response = AppGenerateService.generate( + app_model=app_model, + user=current_user, + args=workflow_args, + invoke_from=InvokeFrom.DEBUGGER, + streaming=True, + root_node_id=trigger_debug_event.node_id, + ) + return helper.compact_generate_response(response) + except InvokeRateLimitError as ex: + raise InvokeRateLimitHttpError(ex.description) + except Exception: + logger.exception("Error running draft workflow trigger run-all") + return jsonable_encoder( + { + "status": "error", + } + ), 400 diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py index cbf4e84ff0..d7ecc7c91b 100644 --- a/api/controllers/console/app/workflow_app_log.py +++ b/api/controllers/console/app/workflow_app_log.py @@ -28,6 +28,7 @@ class WorkflowAppLogApi(Resource): "created_at__after": "Filter logs created after this timestamp", "created_by_end_user_session_id": "Filter by end user session ID", "created_by_account": "Filter by account", + "detail": "Whether to return detailed logs", "page": "Page number (1-99999)", "limit": "Number of items per page (1-100)", } @@ -68,6 +69,7 @@ class WorkflowAppLogApi(Resource): required=False, default=None, ) + .add_argument("detail", type=bool, location="args", required=False, default=False) .add_argument("page", type=int_range(1, 99999), default=1, location="args") .add_argument("limit", type=int_range(1, 100), default=20, location="args") ) @@ -92,6 +94,7 @@ class WorkflowAppLogApi(Resource): created_at_after=args.created_at__after, page=args.page, limit=args.limit, + detail=args.detail, created_by_end_user_session_id=args.created_by_end_user_session_id, created_by_account=args.created_by_account, ) diff --git a/api/controllers/console/app/workflow_trigger.py b/api/controllers/console/app/workflow_trigger.py new file mode 100644 index 0000000000..fd64261525 --- /dev/null +++ b/api/controllers/console/app/workflow_trigger.py @@ -0,0 +1,145 @@ +import logging + +from flask_restx import Resource, marshal_with, reqparse +from sqlalchemy import select +from sqlalchemy.orm import Session +from werkzeug.exceptions import Forbidden, NotFound + +from configs import dify_config +from controllers.console import api +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required +from extensions.ext_database import db +from fields.workflow_trigger_fields import trigger_fields, triggers_list_fields, webhook_trigger_fields +from libs.login import current_user, login_required +from models.enums import AppTriggerStatus +from models.model import Account, App, AppMode +from models.trigger import AppTrigger, WorkflowWebhookTrigger + +logger = logging.getLogger(__name__) + + +class WebhookTriggerApi(Resource): + """Webhook Trigger API""" + + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=AppMode.WORKFLOW) + @marshal_with(webhook_trigger_fields) + def get(self, app_model: App): + """Get webhook trigger for a node""" + parser = reqparse.RequestParser() + parser.add_argument("node_id", type=str, required=True, help="Node ID is required") + args = parser.parse_args() + + node_id = str(args["node_id"]) + + with Session(db.engine) as session: + # Get webhook trigger for this app and node + webhook_trigger = ( + session.query(WorkflowWebhookTrigger) + .where( + WorkflowWebhookTrigger.app_id == app_model.id, + WorkflowWebhookTrigger.node_id == node_id, + ) + .first() + ) + + if not webhook_trigger: + raise NotFound("Webhook trigger not found for this node") + + return webhook_trigger + + +class AppTriggersApi(Resource): + """App Triggers list API""" + + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=AppMode.WORKFLOW) + @marshal_with(triggers_list_fields) + def get(self, app_model: App): + """Get app triggers list""" + assert isinstance(current_user, Account) + assert current_user.current_tenant_id is not None + + with Session(db.engine) as session: + # Get all triggers for this app using select API + triggers = ( + session.execute( + select(AppTrigger) + .where( + AppTrigger.tenant_id == current_user.current_tenant_id, + AppTrigger.app_id == app_model.id, + ) + .order_by(AppTrigger.created_at.desc(), AppTrigger.id.desc()) + ) + .scalars() + .all() + ) + + # Add computed icon field for each trigger + url_prefix = dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/" + for trigger in triggers: + if trigger.trigger_type == "trigger-plugin": + trigger.icon = url_prefix + trigger.provider_name + "/icon" # type: ignore + else: + trigger.icon = "" # type: ignore + + return {"data": triggers} + + +class AppTriggerEnableApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=AppMode.WORKFLOW) + @marshal_with(trigger_fields) + def post(self, app_model: App): + """Update app trigger (enable/disable)""" + parser = reqparse.RequestParser() + parser.add_argument("trigger_id", type=str, required=True, nullable=False, location="json") + parser.add_argument("enable_trigger", type=bool, required=True, nullable=False, location="json") + args = parser.parse_args() + + assert isinstance(current_user, Account) + assert current_user.current_tenant_id is not None + if not current_user.has_edit_permission: + raise Forbidden() + + trigger_id = args["trigger_id"] + + with Session(db.engine) as session: + # Find the trigger using select + trigger = session.execute( + select(AppTrigger).where( + AppTrigger.id == trigger_id, + AppTrigger.tenant_id == current_user.current_tenant_id, + AppTrigger.app_id == app_model.id, + ) + ).scalar_one_or_none() + + if not trigger: + raise NotFound("Trigger not found") + + # Update status based on enable_trigger boolean + trigger.status = AppTriggerStatus.ENABLED if args["enable_trigger"] else AppTriggerStatus.DISABLED + + session.commit() + session.refresh(trigger) + + # Add computed icon field + url_prefix = dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/" + if trigger.trigger_type == "trigger-plugin": + trigger.icon = url_prefix + trigger.provider_name + "/icon" # type: ignore + else: + trigger.icon = "" # type: ignore + + return trigger + + +api.add_resource(WebhookTriggerApi, "/apps//workflows/triggers/webhook") +api.add_resource(AppTriggersApi, "/apps//triggers") +api.add_resource(AppTriggerEnableApi, "/apps//trigger-enable") diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index e8bc312caf..021dc96000 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -114,6 +114,25 @@ class PluginIconApi(Resource): return send_file(io.BytesIO(icon_bytes), mimetype=mimetype, max_age=icon_cache_max_age) +@console_ns.route("/workspaces/current/plugin/asset") +class PluginAssetApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + req = reqparse.RequestParser() + req.add_argument("plugin_unique_identifier", type=str, required=True, location="args") + req.add_argument("file_name", type=str, required=True, location="args") + args = req.parse_args() + + _, tenant_id = current_account_with_tenant() + try: + binary = PluginService.extract_asset(tenant_id, args["plugin_unique_identifier"], args["file_name"]) + return send_file(io.BytesIO(binary), mimetype="application/octet-stream") + except PluginDaemonClientSideError as e: + raise ValueError(e) + + @console_ns.route("/workspaces/current/plugin/upload/pkg") class PluginUploadFromPkgApi(Resource): @setup_required @@ -558,19 +577,21 @@ class PluginFetchDynamicSelectOptionsApi(Resource): .add_argument("provider", type=str, required=True, location="args") .add_argument("action", type=str, required=True, location="args") .add_argument("parameter", type=str, required=True, location="args") + .add_argument("credential_id", type=str, required=False, location="args") .add_argument("provider_type", type=str, required=True, location="args") ) args = parser.parse_args() try: options = PluginParameterService.get_dynamic_select_options( - tenant_id, - user_id, - args["plugin_id"], - args["provider"], - args["action"], - args["parameter"], - args["provider_type"], + tenant_id=tenant_id, + user_id=user_id, + plugin_id=args["plugin_id"], + provider=args["provider"], + action=args["action"], + parameter=args["parameter"], + credential_id=args["credential_id"], + provider_type=args["provider_type"], ) except PluginDaemonClientSideError as e: raise ValueError(e) @@ -686,3 +707,23 @@ class PluginAutoUpgradeExcludePluginApi(Resource): args = req.parse_args() return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args["plugin_id"])}) + + +@console_ns.route("/workspaces/current/plugin/readme") +class PluginReadmeApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + _, tenant_id = current_account_with_tenant() + parser = reqparse.RequestParser() + parser.add_argument("plugin_unique_identifier", type=str, required=True, location="args") + parser.add_argument("language", type=str, required=False, location="args") + args = parser.parse_args() + return jsonable_encoder( + { + "readme": PluginService.fetch_plugin_readme( + tenant_id, args["plugin_unique_identifier"], args.get("language", "en-US") + ) + } + ) diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 870ad87c6c..2d123106f3 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -21,12 +21,14 @@ from core.mcp.auth.auth_flow import auth, handle_callback from core.mcp.error import MCPAuthError, MCPError, MCPRefreshTokenError from core.mcp.mcp_client import MCPClient from core.model_runtime.utils.encoders import jsonable_encoder +from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.oauth import OAuthHandler -from core.tools.entities.tool_entities import CredentialType from extensions.ext_database import db from libs.helper import StrLen, alphanumeric, uuid_value from libs.login import current_account_with_tenant, login_required from models.provider_ids import ToolProviderID + +# from models.provider_ids import ToolProviderID from services.plugin.oauth_service import OAuthProxyService from services.tools.api_tools_manage_service import ApiToolManageService from services.tools.builtin_tools_manage_service import BuiltinToolManageService diff --git a/api/controllers/console/workspace/trigger_providers.py b/api/controllers/console/workspace/trigger_providers.py new file mode 100644 index 0000000000..bbbbe12fb0 --- /dev/null +++ b/api/controllers/console/workspace/trigger_providers.py @@ -0,0 +1,592 @@ +import logging + +from flask import make_response, redirect, request +from flask_restx import Resource, reqparse +from sqlalchemy.orm import Session +from werkzeug.exceptions import BadRequest, Forbidden + +from configs import dify_config +from controllers.console import api +from controllers.console.wraps import account_initialization_required, setup_required +from controllers.web.error import NotFoundError +from core.model_runtime.utils.encoders import jsonable_encoder +from core.plugin.entities.plugin_daemon import CredentialType +from core.plugin.impl.oauth import OAuthHandler +from core.trigger.entities.entities import SubscriptionBuilderUpdater +from core.trigger.trigger_manager import TriggerManager +from extensions.ext_database import db +from libs.login import current_user, login_required +from models.account import Account +from models.provider_ids import TriggerProviderID +from services.plugin.oauth_service import OAuthProxyService +from services.trigger.trigger_provider_service import TriggerProviderService +from services.trigger.trigger_subscription_builder_service import TriggerSubscriptionBuilderService +from services.trigger.trigger_subscription_operator_service import TriggerSubscriptionOperatorService + +logger = logging.getLogger(__name__) + + +class TriggerProviderIconApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider): + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + + return TriggerManager.get_trigger_plugin_icon(tenant_id=user.current_tenant_id, provider_id=provider) + + +class TriggerProviderListApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + """List all trigger providers for the current tenant""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + return jsonable_encoder(TriggerProviderService.list_trigger_providers(user.current_tenant_id)) + + +class TriggerProviderInfoApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider): + """Get info for a trigger provider""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + return jsonable_encoder( + TriggerProviderService.get_trigger_provider(user.current_tenant_id, TriggerProviderID(provider)) + ) + + +class TriggerSubscriptionListApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider): + """List all trigger subscriptions for the current tenant's provider""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + if not user.is_admin_or_owner: + raise Forbidden() + + try: + return jsonable_encoder( + TriggerProviderService.list_trigger_provider_subscriptions( + tenant_id=user.current_tenant_id, provider_id=TriggerProviderID(provider) + ) + ) + except ValueError as e: + return jsonable_encoder({"error": str(e)}), 404 + except Exception as e: + logger.exception("Error listing trigger providers", exc_info=e) + raise + + +class TriggerSubscriptionBuilderCreateApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, provider): + """Add a new subscription instance for a trigger provider""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + if not user.is_admin_or_owner: + raise Forbidden() + + parser = reqparse.RequestParser() + parser.add_argument("credential_type", type=str, required=False, nullable=True, location="json") + args = parser.parse_args() + + try: + credential_type = CredentialType.of(args.get("credential_type") or CredentialType.UNAUTHORIZED.value) + subscription_builder = TriggerSubscriptionBuilderService.create_trigger_subscription_builder( + tenant_id=user.current_tenant_id, + user_id=user.id, + provider_id=TriggerProviderID(provider), + credential_type=credential_type, + ) + return jsonable_encoder({"subscription_builder": subscription_builder}) + except Exception as e: + logger.exception("Error adding provider credential", exc_info=e) + raise + + +class TriggerSubscriptionBuilderGetApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider, subscription_builder_id): + """Get a subscription instance for a trigger provider""" + return jsonable_encoder( + TriggerSubscriptionBuilderService.get_subscription_builder_by_id(subscription_builder_id) + ) + + +class TriggerSubscriptionBuilderVerifyApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, provider, subscription_builder_id): + """Verify a subscription instance for a trigger provider""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + if not user.is_admin_or_owner: + raise Forbidden() + + parser = reqparse.RequestParser() + # The credentials of the subscription builder + parser.add_argument("credentials", type=dict, required=False, nullable=True, location="json") + args = parser.parse_args() + + try: + # Use atomic update_and_verify to prevent race conditions + return TriggerSubscriptionBuilderService.update_and_verify_builder( + tenant_id=user.current_tenant_id, + user_id=user.id, + provider_id=TriggerProviderID(provider), + subscription_builder_id=subscription_builder_id, + subscription_builder_updater=SubscriptionBuilderUpdater( + credentials=args.get("credentials", None), + ), + ) + except Exception as e: + logger.exception("Error verifying provider credential", exc_info=e) + raise ValueError(str(e)) from e + + +class TriggerSubscriptionBuilderUpdateApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, provider, subscription_builder_id): + """Update a subscription instance for a trigger provider""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + + parser = reqparse.RequestParser() + # The name of the subscription builder + parser.add_argument("name", type=str, required=False, nullable=True, location="json") + # The parameters of the subscription builder + parser.add_argument("parameters", type=dict, required=False, nullable=True, location="json") + # The properties of the subscription builder + parser.add_argument("properties", type=dict, required=False, nullable=True, location="json") + # The credentials of the subscription builder + parser.add_argument("credentials", type=dict, required=False, nullable=True, location="json") + args = parser.parse_args() + try: + return jsonable_encoder( + TriggerSubscriptionBuilderService.update_trigger_subscription_builder( + tenant_id=user.current_tenant_id, + provider_id=TriggerProviderID(provider), + subscription_builder_id=subscription_builder_id, + subscription_builder_updater=SubscriptionBuilderUpdater( + name=args.get("name", None), + parameters=args.get("parameters", None), + properties=args.get("properties", None), + credentials=args.get("credentials", None), + ), + ) + ) + except Exception as e: + logger.exception("Error updating provider credential", exc_info=e) + raise + + +class TriggerSubscriptionBuilderLogsApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider, subscription_builder_id): + """Get the request logs for a subscription instance for a trigger provider""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + + try: + logs = TriggerSubscriptionBuilderService.list_logs(subscription_builder_id) + return jsonable_encoder({"logs": [log.model_dump(mode="json") for log in logs]}) + except Exception as e: + logger.exception("Error getting request logs for subscription builder", exc_info=e) + raise + + +class TriggerSubscriptionBuilderBuildApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, provider, subscription_builder_id): + """Build a subscription instance for a trigger provider""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + if not user.is_admin_or_owner: + raise Forbidden() + + parser = reqparse.RequestParser() + # The name of the subscription builder + parser.add_argument("name", type=str, required=False, nullable=True, location="json") + # The parameters of the subscription builder + parser.add_argument("parameters", type=dict, required=False, nullable=True, location="json") + # The properties of the subscription builder + parser.add_argument("properties", type=dict, required=False, nullable=True, location="json") + # The credentials of the subscription builder + parser.add_argument("credentials", type=dict, required=False, nullable=True, location="json") + args = parser.parse_args() + try: + # Use atomic update_and_build to prevent race conditions + TriggerSubscriptionBuilderService.update_and_build_builder( + tenant_id=user.current_tenant_id, + user_id=user.id, + provider_id=TriggerProviderID(provider), + subscription_builder_id=subscription_builder_id, + subscription_builder_updater=SubscriptionBuilderUpdater( + name=args.get("name", None), + parameters=args.get("parameters", None), + properties=args.get("properties", None), + ), + ) + return 200 + except Exception as e: + logger.exception("Error building provider credential", exc_info=e) + raise ValueError(str(e)) from e + + +class TriggerSubscriptionDeleteApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, subscription_id: str): + """Delete a subscription instance""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + if not user.is_admin_or_owner: + raise Forbidden() + + try: + with Session(db.engine) as session: + # Delete trigger provider subscription + TriggerProviderService.delete_trigger_provider( + session=session, + tenant_id=user.current_tenant_id, + subscription_id=subscription_id, + ) + # Delete plugin triggers + TriggerSubscriptionOperatorService.delete_plugin_trigger_by_subscription( + session=session, + tenant_id=user.current_tenant_id, + subscription_id=subscription_id, + ) + session.commit() + return {"result": "success"} + except ValueError as e: + raise BadRequest(str(e)) + except Exception as e: + logger.exception("Error deleting provider credential", exc_info=e) + raise + + +class TriggerOAuthAuthorizeApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider): + """Initiate OAuth authorization flow for a trigger provider""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + + try: + provider_id = TriggerProviderID(provider) + plugin_id = provider_id.plugin_id + provider_name = provider_id.provider_name + tenant_id = user.current_tenant_id + + # Get OAuth client configuration + oauth_client_params = TriggerProviderService.get_oauth_client( + tenant_id=tenant_id, + provider_id=provider_id, + ) + + if oauth_client_params is None: + raise NotFoundError("No OAuth client configuration found for this trigger provider") + + # Create subscription builder + subscription_builder = TriggerSubscriptionBuilderService.create_trigger_subscription_builder( + tenant_id=tenant_id, + user_id=user.id, + provider_id=provider_id, + credential_type=CredentialType.OAUTH2, + ) + + # Create OAuth handler and proxy context + oauth_handler = OAuthHandler() + context_id = OAuthProxyService.create_proxy_context( + user_id=user.id, + tenant_id=tenant_id, + plugin_id=plugin_id, + provider=provider_name, + extra_data={ + "subscription_builder_id": subscription_builder.id, + }, + ) + + # Build redirect URI for callback + redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/trigger/callback" + + # Get authorization URL + authorization_url_response = oauth_handler.get_authorization_url( + tenant_id=tenant_id, + user_id=user.id, + plugin_id=plugin_id, + provider=provider_name, + redirect_uri=redirect_uri, + system_credentials=oauth_client_params, + ) + + # Create response with cookie + response = make_response( + jsonable_encoder( + { + "authorization_url": authorization_url_response.authorization_url, + "subscription_builder_id": subscription_builder.id, + "subscription_builder": subscription_builder, + } + ) + ) + response.set_cookie( + "context_id", + context_id, + httponly=True, + samesite="Lax", + max_age=OAuthProxyService.__MAX_AGE__, + ) + + return response + + except Exception as e: + logger.exception("Error initiating OAuth flow", exc_info=e) + raise + + +class TriggerOAuthCallbackApi(Resource): + @setup_required + def get(self, provider): + """Handle OAuth callback for trigger provider""" + context_id = request.cookies.get("context_id") + if not context_id: + raise Forbidden("context_id not found") + + # Use and validate proxy context + context = OAuthProxyService.use_proxy_context(context_id) + if context is None: + raise Forbidden("Invalid context_id") + + # Parse provider ID + provider_id = TriggerProviderID(provider) + plugin_id = provider_id.plugin_id + provider_name = provider_id.provider_name + user_id = context.get("user_id") + tenant_id = context.get("tenant_id") + subscription_builder_id = context.get("subscription_builder_id") + + # Get OAuth client configuration + oauth_client_params = TriggerProviderService.get_oauth_client( + tenant_id=tenant_id, + provider_id=provider_id, + ) + + if oauth_client_params is None: + raise Forbidden("No OAuth client configuration found for this trigger provider") + + # Get OAuth credentials from callback + oauth_handler = OAuthHandler() + redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/trigger/callback" + + credentials_response = oauth_handler.get_credentials( + tenant_id=tenant_id, + user_id=user_id, + plugin_id=plugin_id, + provider=provider_name, + redirect_uri=redirect_uri, + system_credentials=oauth_client_params, + request=request, + ) + + credentials = credentials_response.credentials + expires_at = credentials_response.expires_at + + if not credentials: + raise ValueError("Failed to get OAuth credentials from the provider.") + + # Update subscription builder + TriggerSubscriptionBuilderService.update_trigger_subscription_builder( + tenant_id=tenant_id, + provider_id=provider_id, + subscription_builder_id=subscription_builder_id, + subscription_builder_updater=SubscriptionBuilderUpdater( + credentials=credentials, + credential_expires_at=expires_at, + ), + ) + # Redirect to OAuth callback page + return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback") + + +class TriggerOAuthClientManageApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider): + """Get OAuth client configuration for a provider""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + if not user.is_admin_or_owner: + raise Forbidden() + + try: + provider_id = TriggerProviderID(provider) + + # Get custom OAuth client params if exists + custom_params = TriggerProviderService.get_custom_oauth_client_params( + tenant_id=user.current_tenant_id, + provider_id=provider_id, + ) + + # Check if custom client is enabled + is_custom_enabled = TriggerProviderService.is_oauth_custom_client_enabled( + tenant_id=user.current_tenant_id, + provider_id=provider_id, + ) + system_client_exists = TriggerProviderService.is_oauth_system_client_exists( + tenant_id=user.current_tenant_id, + provider_id=provider_id, + ) + provider_controller = TriggerManager.get_trigger_provider(user.current_tenant_id, provider_id) + redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/trigger/callback" + return jsonable_encoder( + { + "configured": bool(custom_params or system_client_exists), + "system_configured": system_client_exists, + "custom_configured": bool(custom_params), + "oauth_client_schema": provider_controller.get_oauth_client_schema(), + "custom_enabled": is_custom_enabled, + "redirect_uri": redirect_uri, + "params": custom_params or {}, + } + ) + + except Exception as e: + logger.exception("Error getting OAuth client", exc_info=e) + raise + + @setup_required + @login_required + @account_initialization_required + def post(self, provider): + """Configure custom OAuth client for a provider""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + if not user.is_admin_or_owner: + raise Forbidden() + + parser = reqparse.RequestParser() + parser.add_argument("client_params", type=dict, required=False, nullable=True, location="json") + parser.add_argument("enabled", type=bool, required=False, nullable=True, location="json") + args = parser.parse_args() + + try: + provider_id = TriggerProviderID(provider) + return TriggerProviderService.save_custom_oauth_client_params( + tenant_id=user.current_tenant_id, + provider_id=provider_id, + client_params=args.get("client_params"), + enabled=args.get("enabled"), + ) + + except ValueError as e: + raise BadRequest(str(e)) + except Exception as e: + logger.exception("Error configuring OAuth client", exc_info=e) + raise + + @setup_required + @login_required + @account_initialization_required + def delete(self, provider): + """Remove custom OAuth client configuration""" + user = current_user + assert isinstance(user, Account) + assert user.current_tenant_id is not None + if not user.is_admin_or_owner: + raise Forbidden() + + try: + provider_id = TriggerProviderID(provider) + + return TriggerProviderService.delete_custom_oauth_client_params( + tenant_id=user.current_tenant_id, + provider_id=provider_id, + ) + except ValueError as e: + raise BadRequest(str(e)) + except Exception as e: + logger.exception("Error removing OAuth client", exc_info=e) + raise + + +# Trigger Subscription +api.add_resource(TriggerProviderIconApi, "/workspaces/current/trigger-provider//icon") +api.add_resource(TriggerProviderListApi, "/workspaces/current/triggers") +api.add_resource(TriggerProviderInfoApi, "/workspaces/current/trigger-provider//info") +api.add_resource(TriggerSubscriptionListApi, "/workspaces/current/trigger-provider//subscriptions/list") +api.add_resource( + TriggerSubscriptionDeleteApi, + "/workspaces/current/trigger-provider//subscriptions/delete", +) + +# Trigger Subscription Builder +api.add_resource( + TriggerSubscriptionBuilderCreateApi, + "/workspaces/current/trigger-provider//subscriptions/builder/create", +) +api.add_resource( + TriggerSubscriptionBuilderGetApi, + "/workspaces/current/trigger-provider//subscriptions/builder/", +) +api.add_resource( + TriggerSubscriptionBuilderUpdateApi, + "/workspaces/current/trigger-provider//subscriptions/builder/update/", +) +api.add_resource( + TriggerSubscriptionBuilderVerifyApi, + "/workspaces/current/trigger-provider//subscriptions/builder/verify/", +) +api.add_resource( + TriggerSubscriptionBuilderBuildApi, + "/workspaces/current/trigger-provider//subscriptions/builder/build/", +) +api.add_resource( + TriggerSubscriptionBuilderLogsApi, + "/workspaces/current/trigger-provider//subscriptions/builder/logs/", +) + + +# OAuth +api.add_resource( + TriggerOAuthAuthorizeApi, "/workspaces/current/trigger-provider//subscriptions/oauth/authorize" +) +api.add_resource(TriggerOAuthCallbackApi, "/oauth/plugin//trigger/callback") +api.add_resource(TriggerOAuthClientManageApi, "/workspaces/current/trigger-provider//oauth/client") diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py index 319b7bd780..c07e18c686 100644 --- a/api/controllers/service_api/wraps.py +++ b/api/controllers/service_api/wraps.py @@ -20,7 +20,8 @@ from libs.datetime_utils import naive_utc_now from libs.login import current_user from models import Account, Tenant, TenantAccountJoin, TenantStatus from models.dataset import Dataset, RateLimitLog -from models.model import ApiToken, App, DefaultEndUserSessionID, EndUser +from models.model import ApiToken, App +from services.end_user_service import EndUserService from services.feature_service import FeatureService P = ParamSpec("P") @@ -84,7 +85,7 @@ def validate_app_token(view: Callable[P, R] | None = None, *, fetch_user_arg: Fe if user_id: user_id = str(user_id) - end_user = create_or_update_end_user_for_user_id(app_model, user_id) + end_user = EndUserService.get_or_create_end_user(app_model, user_id) kwargs["end_user"] = end_user # Set EndUser as current logged-in user for flask_login.current_user @@ -331,39 +332,6 @@ def validate_and_get_api_token(scope: str | None = None): return api_token -def create_or_update_end_user_for_user_id(app_model: App, user_id: str | None = None) -> EndUser: - """ - Create or update session terminal based on user ID. - """ - if not user_id: - user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID - - with Session(db.engine, expire_on_commit=False) as session: - end_user = ( - session.query(EndUser) - .where( - EndUser.tenant_id == app_model.tenant_id, - EndUser.app_id == app_model.id, - EndUser.session_id == user_id, - EndUser.type == "service_api", - ) - .first() - ) - - if end_user is None: - end_user = EndUser( - tenant_id=app_model.tenant_id, - app_id=app_model.id, - type="service_api", - is_anonymous=user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID, - session_id=user_id, - ) - session.add(end_user) - session.commit() - - return end_user - - class DatasetApiResource(Resource): method_decorators = [validate_dataset_token] diff --git a/api/controllers/trigger/__init__.py b/api/controllers/trigger/__init__.py new file mode 100644 index 0000000000..4f584dc4f6 --- /dev/null +++ b/api/controllers/trigger/__init__.py @@ -0,0 +1,12 @@ +from flask import Blueprint + +# Create trigger blueprint +bp = Blueprint("trigger", __name__, url_prefix="/triggers") + +# Import routes after blueprint creation to avoid circular imports +from . import trigger, webhook + +__all__ = [ + "trigger", + "webhook", +] diff --git a/api/controllers/trigger/trigger.py b/api/controllers/trigger/trigger.py new file mode 100644 index 0000000000..e69b22d880 --- /dev/null +++ b/api/controllers/trigger/trigger.py @@ -0,0 +1,43 @@ +import logging +import re + +from flask import jsonify, request +from werkzeug.exceptions import NotFound + +from controllers.trigger import bp +from services.trigger.trigger_service import TriggerService +from services.trigger.trigger_subscription_builder_service import TriggerSubscriptionBuilderService + +logger = logging.getLogger(__name__) + +UUID_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +UUID_MATCHER = re.compile(UUID_PATTERN) + + +@bp.route("/plugin/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) +def trigger_endpoint(endpoint_id: str): + """ + Handle endpoint trigger calls. + """ + # endpoint_id must be UUID + if not UUID_MATCHER.match(endpoint_id): + raise NotFound("Invalid endpoint ID") + handling_chain = [ + TriggerService.process_endpoint, + TriggerSubscriptionBuilderService.process_builder_validation_endpoint, + ] + response = None + try: + for handler in handling_chain: + response = handler(endpoint_id, request) + if response: + break + if not response: + logger.error("Endpoint not found for {endpoint_id}") + return jsonify({"error": "Endpoint not found"}), 404 + return response + except ValueError as e: + return jsonify({"error": "Endpoint processing failed", "message": str(e)}), 400 + except Exception: + logger.exception("Webhook processing failed for {endpoint_id}") + return jsonify({"error": "Internal server error"}), 500 diff --git a/api/controllers/trigger/webhook.py b/api/controllers/trigger/webhook.py new file mode 100644 index 0000000000..cec5c3d8ae --- /dev/null +++ b/api/controllers/trigger/webhook.py @@ -0,0 +1,105 @@ +import logging +import time + +from flask import jsonify +from werkzeug.exceptions import NotFound, RequestEntityTooLarge + +from controllers.trigger import bp +from core.trigger.debug.event_bus import TriggerDebugEventBus +from core.trigger.debug.events import WebhookDebugEvent, build_webhook_pool_key +from services.trigger.webhook_service import WebhookService + +logger = logging.getLogger(__name__) + + +def _prepare_webhook_execution(webhook_id: str, is_debug: bool = False): + """Fetch trigger context, extract request data, and validate payload using unified processing. + + Args: + webhook_id: The webhook ID to process + is_debug: If True, skip status validation for debug mode + """ + webhook_trigger, workflow, node_config = WebhookService.get_webhook_trigger_and_workflow( + webhook_id, is_debug=is_debug + ) + + try: + # Use new unified extraction and validation + webhook_data = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) + return webhook_trigger, workflow, node_config, webhook_data, None + except ValueError as e: + # Fall back to raw extraction for error reporting + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + return webhook_trigger, workflow, node_config, webhook_data, str(e) + + +@bp.route("/webhook/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) +def handle_webhook(webhook_id: str): + """ + Handle webhook trigger calls. + + This endpoint receives webhook calls and processes them according to the + configured webhook trigger settings. + """ + try: + webhook_trigger, workflow, node_config, webhook_data, error = _prepare_webhook_execution(webhook_id) + if error: + return jsonify({"error": "Bad Request", "message": error}), 400 + + # Process webhook call (send to Celery) + WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow) + + # Return configured response + response_data, status_code = WebhookService.generate_webhook_response(node_config) + return jsonify(response_data), status_code + + except ValueError as e: + raise NotFound(str(e)) + except RequestEntityTooLarge: + raise + except Exception as e: + logger.exception("Webhook processing failed for %s", webhook_id) + return jsonify({"error": "Internal server error", "message": str(e)}), 500 + + +@bp.route("/webhook-debug/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) +def handle_webhook_debug(webhook_id: str): + """Handle webhook debug calls without triggering production workflow execution.""" + try: + webhook_trigger, _, node_config, webhook_data, error = _prepare_webhook_execution(webhook_id, is_debug=True) + if error: + return jsonify({"error": "Bad Request", "message": error}), 400 + + workflow_inputs = WebhookService.build_workflow_inputs(webhook_data) + + # Generate pool key and dispatch debug event + pool_key: str = build_webhook_pool_key( + tenant_id=webhook_trigger.tenant_id, + app_id=webhook_trigger.app_id, + node_id=webhook_trigger.node_id, + ) + event = WebhookDebugEvent( + request_id=f"webhook_debug_{webhook_trigger.webhook_id}_{int(time.time() * 1000)}", + timestamp=int(time.time()), + node_id=webhook_trigger.node_id, + payload={ + "inputs": workflow_inputs, + "webhook_data": webhook_data, + "method": webhook_data.get("method"), + }, + ) + TriggerDebugEventBus.dispatch( + tenant_id=webhook_trigger.tenant_id, + event=event, + pool_key=pool_key, + ) + response_data, status_code = WebhookService.generate_webhook_response(node_config) + return jsonify(response_data), status_code + + except ValueError as e: + raise NotFound(str(e)) + except RequestEntityTooLarge: + raise + except Exception as e: + logger.exception("Webhook debug processing failed for %s", webhook_id) + return jsonify({"error": "Internal server error", "message": "An internal error has occurred."}), 500 diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index eebaaaff80..14795a430c 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -37,6 +37,7 @@ from core.file import FILE_MODEL_IDENTITY, File from core.plugin.impl.datasource import PluginDatasourceManager from core.tools.entities.tool_entities import ToolProviderType from core.tools.tool_manager import ToolManager +from core.trigger.trigger_manager import TriggerManager from core.variables.segments import ArrayFileSegment, FileSegment, Segment from core.workflow.enums import ( NodeType, @@ -303,6 +304,11 @@ class WorkflowResponseConverter: response.data.extras["icon"] = provider_entity.declaration.identity.generate_datasource_icon_url( self._application_generate_entity.app_config.tenant_id ) + elif event.node_type == NodeType.TRIGGER_PLUGIN: + response.data.extras["icon"] = TriggerManager.get_trigger_plugin_icon( + self._application_generate_entity.app_config.tenant_id, + event.provider_id, + ) return response diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index f22ef5431e..be331b92a8 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -27,6 +27,7 @@ from core.helper.trace_id_helper import extract_external_trace_id_from_args from core.model_runtime.errors.invoke import InvokeAuthorizationError from core.ops.ops_trace_manager import TraceQueueManager from core.repositories import DifyCoreRepositoryFactory +from core.workflow.graph_engine.layers.base import GraphEngineLayer from core.workflow.repositories.draft_variable_repository import DraftVariableSaverFactory from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository @@ -38,10 +39,16 @@ from models import Account, App, EndUser, Workflow, WorkflowNodeExecutionTrigger from models.enums import WorkflowRunTriggeredFrom from services.workflow_draft_variable_service import DraftVarLoader, WorkflowDraftVariableService +SKIP_PREPARE_USER_INPUTS_KEY = "_skip_prepare_user_inputs" + logger = logging.getLogger(__name__) class WorkflowAppGenerator(BaseAppGenerator): + @staticmethod + def _should_prepare_user_inputs(args: Mapping[str, Any]) -> bool: + return not bool(args.get(SKIP_PREPARE_USER_INPUTS_KEY)) + @overload def generate( self, @@ -53,7 +60,10 @@ class WorkflowAppGenerator(BaseAppGenerator): invoke_from: InvokeFrom, streaming: Literal[True], call_depth: int, - ) -> Generator[Mapping | str, None, None]: ... + triggered_from: WorkflowRunTriggeredFrom | None = None, + root_node_id: str | None = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), + ) -> Generator[Mapping[str, Any] | str, None, None]: ... @overload def generate( @@ -66,6 +76,9 @@ class WorkflowAppGenerator(BaseAppGenerator): invoke_from: InvokeFrom, streaming: Literal[False], call_depth: int, + triggered_from: WorkflowRunTriggeredFrom | None = None, + root_node_id: str | None = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ) -> Mapping[str, Any]: ... @overload @@ -79,7 +92,10 @@ class WorkflowAppGenerator(BaseAppGenerator): invoke_from: InvokeFrom, streaming: bool, call_depth: int, - ) -> Union[Mapping[str, Any], Generator[Mapping | str, None, None]]: ... + triggered_from: WorkflowRunTriggeredFrom | None = None, + root_node_id: str | None = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), + ) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]: ... def generate( self, @@ -91,7 +107,10 @@ class WorkflowAppGenerator(BaseAppGenerator): invoke_from: InvokeFrom, streaming: bool = True, call_depth: int = 0, - ) -> Union[Mapping[str, Any], Generator[Mapping | str, None, None]]: + triggered_from: WorkflowRunTriggeredFrom | None = None, + root_node_id: str | None = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), + ) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]: files: Sequence[Mapping[str, Any]] = args.get("files") or [] # parse files @@ -126,17 +145,20 @@ class WorkflowAppGenerator(BaseAppGenerator): **extract_external_trace_id_from_args(args), } workflow_run_id = str(uuid.uuid4()) + # for trigger debug run, not prepare user inputs + if self._should_prepare_user_inputs(args): + inputs = self._prepare_user_inputs( + user_inputs=inputs, + variables=app_config.variables, + tenant_id=app_model.tenant_id, + strict_type_validation=True if invoke_from == InvokeFrom.SERVICE_API else False, + ) # init application generate entity application_generate_entity = WorkflowAppGenerateEntity( task_id=str(uuid.uuid4()), app_config=app_config, file_upload_config=file_extra_config, - inputs=self._prepare_user_inputs( - user_inputs=inputs, - variables=app_config.variables, - tenant_id=app_model.tenant_id, - strict_type_validation=True if invoke_from == InvokeFrom.SERVICE_API else False, - ), + inputs=inputs, files=list(system_files), user_id=user.id, stream=streaming, @@ -155,7 +177,10 @@ class WorkflowAppGenerator(BaseAppGenerator): # Create session factory session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) # Create workflow execution(aka workflow run) repository - if invoke_from == InvokeFrom.DEBUGGER: + if triggered_from is not None: + # Use explicitly provided triggered_from (for async triggers) + workflow_triggered_from = triggered_from + elif invoke_from == InvokeFrom.DEBUGGER: workflow_triggered_from = WorkflowRunTriggeredFrom.DEBUGGING else: workflow_triggered_from = WorkflowRunTriggeredFrom.APP_RUN @@ -182,8 +207,16 @@ class WorkflowAppGenerator(BaseAppGenerator): workflow_execution_repository=workflow_execution_repository, workflow_node_execution_repository=workflow_node_execution_repository, streaming=streaming, + root_node_id=root_node_id, + graph_engine_layers=graph_engine_layers, ) + def resume(self, *, workflow_run_id: str) -> None: + """ + @TBD + """ + pass + def _generate( self, *, @@ -196,6 +229,8 @@ class WorkflowAppGenerator(BaseAppGenerator): workflow_node_execution_repository: WorkflowNodeExecutionRepository, streaming: bool = True, variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER, + root_node_id: str | None = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ) -> Union[Mapping[str, Any], Generator[str | Mapping[str, Any], None, None]]: """ Generate App response. @@ -231,8 +266,10 @@ class WorkflowAppGenerator(BaseAppGenerator): "queue_manager": queue_manager, "context": context, "variable_loader": variable_loader, + "root_node_id": root_node_id, "workflow_execution_repository": workflow_execution_repository, "workflow_node_execution_repository": workflow_node_execution_repository, + "graph_engine_layers": graph_engine_layers, }, ) @@ -426,6 +463,8 @@ class WorkflowAppGenerator(BaseAppGenerator): variable_loader: VariableLoader, workflow_execution_repository: WorkflowExecutionRepository, workflow_node_execution_repository: WorkflowNodeExecutionRepository, + root_node_id: str | None = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ) -> None: """ Generate worker in a new thread. @@ -469,6 +508,8 @@ class WorkflowAppGenerator(BaseAppGenerator): system_user_id=system_user_id, workflow_execution_repository=workflow_execution_repository, workflow_node_execution_repository=workflow_node_execution_repository, + root_node_id=root_node_id, + graph_engine_layers=graph_engine_layers, ) try: diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py index eab2256426..d8460df390 100644 --- a/api/core/app/apps/workflow/app_runner.py +++ b/api/core/app/apps/workflow/app_runner.py @@ -1,5 +1,6 @@ import logging import time +from collections.abc import Sequence from typing import cast from core.app.apps.base_app_queue_manager import AppQueueManager @@ -8,6 +9,7 @@ from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.workflow.enums import WorkflowType from core.workflow.graph_engine.command_channels.redis_channel import RedisChannel +from core.workflow.graph_engine.layers.base import GraphEngineLayer from core.workflow.graph_engine.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository @@ -16,6 +18,7 @@ from core.workflow.system_variable import SystemVariable from core.workflow.variable_loader import VariableLoader from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_redis import redis_client +from libs.datetime_utils import naive_utc_now from models.enums import UserFrom from models.workflow import Workflow @@ -35,17 +38,21 @@ class WorkflowAppRunner(WorkflowBasedAppRunner): variable_loader: VariableLoader, workflow: Workflow, system_user_id: str, + root_node_id: str | None = None, workflow_execution_repository: WorkflowExecutionRepository, workflow_node_execution_repository: WorkflowNodeExecutionRepository, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ): super().__init__( queue_manager=queue_manager, variable_loader=variable_loader, app_id=application_generate_entity.app_config.app_id, + graph_engine_layers=graph_engine_layers, ) self.application_generate_entity = application_generate_entity self._workflow = workflow self._sys_user_id = system_user_id + self._root_node_id = root_node_id self._workflow_execution_repository = workflow_execution_repository self._workflow_node_execution_repository = workflow_node_execution_repository @@ -60,6 +67,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner): files=self.application_generate_entity.files, user_id=self._sys_user_id, app_id=app_config.app_id, + timestamp=int(naive_utc_now().timestamp()), workflow_id=app_config.workflow_id, workflow_execution_id=self.application_generate_entity.workflow_execution_id, ) @@ -92,6 +100,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner): workflow_id=self._workflow.id, tenant_id=self._workflow.tenant_id, user_id=self.application_generate_entity.user_id, + root_node_id=self._root_node_id, ) # RUN WORKFLOW diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 73725e75b5..0e125b3538 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -84,6 +84,7 @@ class WorkflowBasedAppRunner: workflow_id: str = "", tenant_id: str = "", user_id: str = "", + root_node_id: str | None = None, ) -> Graph: """ Init graph @@ -117,7 +118,7 @@ class WorkflowBasedAppRunner: ) # init graph - graph = Graph.init(graph_config=graph_config, node_factory=node_factory) + graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id=root_node_id) if not graph: raise ValueError("graph not found in workflow") diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index b49d4d6511..5143dbf1e8 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/api/core/app/entities/app_invoke_entities.py @@ -32,6 +32,10 @@ class InvokeFrom(StrEnum): # https://docs.dify.ai/en/guides/application-publishing/launch-your-webapp-quickly/README WEB_APP = "web-app" + # TRIGGER indicates that this invocation is from a trigger. + # this is used for plugin trigger and webhook trigger. + TRIGGER = "trigger" + # EXPLORE indicates that this invocation is from # the workflow (or chatflow) explore page. EXPLORE = "explore" @@ -40,6 +44,9 @@ class InvokeFrom(StrEnum): DEBUGGER = "debugger" PUBLISHED = "published" + # VALIDATION indicates that this invocation is from validation. + VALIDATION = "validation" + @classmethod def value_of(cls, value: str): """ @@ -65,6 +72,8 @@ class InvokeFrom(StrEnum): return "dev" elif self == InvokeFrom.EXPLORE: return "explore_app" + elif self == InvokeFrom.TRIGGER: + return "trigger" elif self == InvokeFrom.SERVICE_API: return "api" diff --git a/api/core/app/layers/pause_state_persist_layer.py b/api/core/app/layers/pause_state_persist_layer.py index 7e79c22c4d..412eb98dd4 100644 --- a/api/core/app/layers/pause_state_persist_layer.py +++ b/api/core/app/layers/pause_state_persist_layer.py @@ -2,7 +2,7 @@ from typing import Annotated, Literal, Self, TypeAlias from pydantic import BaseModel, Field from sqlalchemy import Engine -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity from core.workflow.graph_engine.layers.base import GraphEngineLayer @@ -55,7 +55,7 @@ class WorkflowResumptionContext(BaseModel): class PauseStatePersistenceLayer(GraphEngineLayer): def __init__( self, - session_factory: Engine | sessionmaker, + session_factory: Engine | sessionmaker[Session], generate_entity: WorkflowAppGenerateEntity | AdvancedChatAppGenerateEntity, state_owner_user_id: str, ): @@ -103,10 +103,8 @@ class PauseStatePersistenceLayer(GraphEngineLayer): entity_wrapper: _GenerateEntityUnion if isinstance(self._generate_entity, WorkflowAppGenerateEntity): entity_wrapper = _WorkflowGenerateEntityWrapper(entity=self._generate_entity) - elif isinstance(self._generate_entity, AdvancedChatAppGenerateEntity): - entity_wrapper = _AdvancedChatAppGenerateEntityWrapper(entity=self._generate_entity) else: - raise AssertionError(f"unknown entity type: type={type(self._generate_entity)}") + entity_wrapper = _AdvancedChatAppGenerateEntityWrapper(entity=self._generate_entity) state = WorkflowResumptionContext( serialized_graph_runtime_state=self.graph_runtime_state.dumps(), diff --git a/api/core/app/layers/suspend_layer.py b/api/core/app/layers/suspend_layer.py new file mode 100644 index 0000000000..0a107de012 --- /dev/null +++ b/api/core/app/layers/suspend_layer.py @@ -0,0 +1,21 @@ +from core.workflow.graph_engine.layers.base import GraphEngineLayer +from core.workflow.graph_events.base import GraphEngineEvent +from core.workflow.graph_events.graph import GraphRunPausedEvent + + +class SuspendLayer(GraphEngineLayer): + """ """ + + def on_graph_start(self): + pass + + def on_event(self, event: GraphEngineEvent): + """ + Handle the paused event, stash runtime state into storage and wait for resume. + """ + if isinstance(event, GraphRunPausedEvent): + pass + + def on_graph_end(self, error: Exception | None): + """ """ + pass diff --git a/api/core/app/layers/timeslice_layer.py b/api/core/app/layers/timeslice_layer.py new file mode 100644 index 0000000000..f82397deca --- /dev/null +++ b/api/core/app/layers/timeslice_layer.py @@ -0,0 +1,88 @@ +import logging +import uuid +from typing import ClassVar + +from apscheduler.schedulers.background import BackgroundScheduler # type: ignore + +from core.workflow.graph_engine.entities.commands import CommandType, GraphEngineCommand +from core.workflow.graph_engine.layers.base import GraphEngineLayer +from core.workflow.graph_events.base import GraphEngineEvent +from services.workflow.entities import WorkflowScheduleCFSPlanEntity +from services.workflow.scheduler import CFSPlanScheduler, SchedulerCommand + +logger = logging.getLogger(__name__) + + +class TimeSliceLayer(GraphEngineLayer): + """ + CFS plan scheduler to control the timeslice of the workflow. + """ + + scheduler: ClassVar[BackgroundScheduler] = BackgroundScheduler() + + def __init__(self, cfs_plan_scheduler: CFSPlanScheduler) -> None: + """ + CFS plan scheduler allows to control the timeslice of the workflow. + """ + + if not TimeSliceLayer.scheduler.running: + TimeSliceLayer.scheduler.start() + + super().__init__() + self.cfs_plan_scheduler = cfs_plan_scheduler + self.stopped = False + self.schedule_id = "" + + def _checker_job(self, schedule_id: str): + """ + Check if the workflow need to be suspended. + """ + try: + if self.stopped: + self.scheduler.remove_job(schedule_id) + return + + if self.cfs_plan_scheduler.can_schedule() == SchedulerCommand.RESOURCE_LIMIT_REACHED: + # remove the job + self.scheduler.remove_job(schedule_id) + + if not self.command_channel: + logger.exception("No command channel to stop the workflow") + return + + # send command to pause the workflow + self.command_channel.send_command( + GraphEngineCommand( + command_type=CommandType.PAUSE, + payload={ + "reason": SchedulerCommand.RESOURCE_LIMIT_REACHED, + }, + ) + ) + + except Exception: + logger.exception("scheduler error during check if the workflow need to be suspended") + + def on_graph_start(self): + """ + Start timer to check if the workflow need to be suspended. + """ + + if self.cfs_plan_scheduler.plan.schedule_strategy == WorkflowScheduleCFSPlanEntity.Strategy.TimeSlice: + self.schedule_id = uuid.uuid4().hex + + self.scheduler.add_job( + lambda: self._checker_job(self.schedule_id), + "interval", + seconds=self.cfs_plan_scheduler.plan.granularity, + id=self.schedule_id, + ) + + def on_event(self, event: GraphEngineEvent): + pass + + def on_graph_end(self, error: Exception | None) -> None: + self.stopped = True + # remove the scheduler + if self.schedule_id: + self.scheduler.remove_job(self.schedule_id) diff --git a/api/core/app/layers/trigger_post_layer.py b/api/core/app/layers/trigger_post_layer.py new file mode 100644 index 0000000000..fe1a46a945 --- /dev/null +++ b/api/core/app/layers/trigger_post_layer.py @@ -0,0 +1,88 @@ +import logging +from datetime import UTC, datetime +from typing import Any, ClassVar + +from pydantic import TypeAdapter +from sqlalchemy.orm import Session, sessionmaker + +from core.workflow.graph_engine.layers.base import GraphEngineLayer +from core.workflow.graph_events.base import GraphEngineEvent +from core.workflow.graph_events.graph import GraphRunFailedEvent, GraphRunPausedEvent, GraphRunSucceededEvent +from models.enums import WorkflowTriggerStatus +from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository +from tasks.workflow_cfs_scheduler.cfs_scheduler import AsyncWorkflowCFSPlanEntity + +logger = logging.getLogger(__name__) + + +class TriggerPostLayer(GraphEngineLayer): + """ + Trigger post layer. + """ + + _STATUS_MAP: ClassVar[dict[type[GraphEngineEvent], WorkflowTriggerStatus]] = { + GraphRunSucceededEvent: WorkflowTriggerStatus.SUCCEEDED, + GraphRunFailedEvent: WorkflowTriggerStatus.FAILED, + GraphRunPausedEvent: WorkflowTriggerStatus.PAUSED, + } + + def __init__( + self, + cfs_plan_scheduler_entity: AsyncWorkflowCFSPlanEntity, + start_time: datetime, + trigger_log_id: str, + session_maker: sessionmaker[Session], + ): + self.trigger_log_id = trigger_log_id + self.start_time = start_time + self.cfs_plan_scheduler_entity = cfs_plan_scheduler_entity + self.session_maker = session_maker + + def on_graph_start(self): + pass + + def on_event(self, event: GraphEngineEvent): + """ + Update trigger log with success or failure. + """ + if isinstance(event, tuple(self._STATUS_MAP.keys())): + with self.session_maker() as session: + repo = SQLAlchemyWorkflowTriggerLogRepository(session) + trigger_log = repo.get_by_id(self.trigger_log_id) + if not trigger_log: + logger.exception("Trigger log not found: %s", self.trigger_log_id) + return + + # Calculate elapsed time + elapsed_time = (datetime.now(UTC) - self.start_time).total_seconds() + + # Extract relevant data from result + if not self.graph_runtime_state: + logger.exception("Graph runtime state is not set") + return + + outputs = self.graph_runtime_state.outputs + + # BASICLY, workflow_execution_id is the same as workflow_run_id + workflow_run_id = self.graph_runtime_state.system_variable.workflow_execution_id + assert workflow_run_id, "Workflow run id is not set" + + total_tokens = self.graph_runtime_state.total_tokens + + # Update trigger log with success + trigger_log.status = self._STATUS_MAP[type(event)] + trigger_log.workflow_run_id = workflow_run_id + trigger_log.outputs = TypeAdapter(dict[str, Any]).dump_json(outputs).decode() + + if trigger_log.elapsed_time is None: + trigger_log.elapsed_time = elapsed_time + else: + trigger_log.elapsed_time += elapsed_time + + trigger_log.total_tokens = total_tokens + trigger_log.finished_at = datetime.now(UTC) + repo.update(trigger_log) + session.commit() + + def on_graph_end(self, error: Exception | None) -> None: + pass diff --git a/api/core/entities/parameter_entities.py b/api/core/entities/parameter_entities.py index 0afb51edce..b61c4ad4bb 100644 --- a/api/core/entities/parameter_entities.py +++ b/api/core/entities/parameter_entities.py @@ -14,6 +14,7 @@ class CommonParameterType(StrEnum): APP_SELECTOR = "app-selector" MODEL_SELECTOR = "model-selector" TOOLS_SELECTOR = "array[tools]" + CHECKBOX = "checkbox" ANY = auto() # Dynamic select parameter diff --git a/api/core/entities/provider_entities.py b/api/core/entities/provider_entities.py index 0496959ce2..8a8067332d 100644 --- a/api/core/entities/provider_entities.py +++ b/api/core/entities/provider_entities.py @@ -107,7 +107,7 @@ class CustomModelConfiguration(BaseModel): model: str model_type: ModelType - credentials: dict | None = None + credentials: dict | None current_credential_id: str | None = None current_credential_name: str | None = None available_model_credentials: list[CredentialConfiguration] = [] @@ -207,6 +207,7 @@ class ProviderConfig(BasicProviderConfig): required: bool = False default: Union[int, str, float, bool] | None = None options: list[Option] | None = None + multiple: bool | None = False label: I18nObject | None = None help: I18nObject | None = None url: str | None = None diff --git a/api/core/helper/name_generator.py b/api/core/helper/name_generator.py index 4e19e3946f..b5f9299d9f 100644 --- a/api/core/helper/name_generator.py +++ b/api/core/helper/name_generator.py @@ -3,7 +3,7 @@ import re from collections.abc import Sequence from typing import Any -from core.tools.entities.tool_entities import CredentialType +from core.plugin.entities.plugin_daemon import CredentialType logger = logging.getLogger(__name__) diff --git a/api/core/helper/provider_encryption.py b/api/core/helper/provider_encryption.py new file mode 100644 index 0000000000..8484a28c05 --- /dev/null +++ b/api/core/helper/provider_encryption.py @@ -0,0 +1,129 @@ +import contextlib +from collections.abc import Mapping +from copy import deepcopy +from typing import Any, Protocol + +from core.entities.provider_entities import BasicProviderConfig +from core.helper import encrypter + + +class ProviderConfigCache(Protocol): + """ + Interface for provider configuration cache operations + """ + + def get(self) -> dict[str, Any] | None: + """Get cached provider configuration""" + ... + + def set(self, config: dict[str, Any]) -> None: + """Cache provider configuration""" + ... + + def delete(self) -> None: + """Delete cached provider configuration""" + ... + + +class ProviderConfigEncrypter: + tenant_id: str + config: list[BasicProviderConfig] + provider_config_cache: ProviderConfigCache + + def __init__( + self, + tenant_id: str, + config: list[BasicProviderConfig], + provider_config_cache: ProviderConfigCache, + ): + self.tenant_id = tenant_id + self.config = config + self.provider_config_cache = provider_config_cache + + def _deep_copy(self, data: Mapping[str, Any]) -> Mapping[str, Any]: + """ + deep copy data + """ + return deepcopy(data) + + def encrypt(self, data: Mapping[str, Any]) -> Mapping[str, Any]: + """ + encrypt tool credentials with tenant id + + return a deep copy of credentials with encrypted values + """ + data = dict(self._deep_copy(data)) + + # get fields need to be decrypted + fields = dict[str, BasicProviderConfig]() + for credential in self.config: + fields[credential.name] = credential + + for field_name, field in fields.items(): + if field.type == BasicProviderConfig.Type.SECRET_INPUT: + if field_name in data: + encrypted = encrypter.encrypt_token(self.tenant_id, data[field_name] or "") + data[field_name] = encrypted + + return data + + def mask_credentials(self, data: Mapping[str, Any]) -> Mapping[str, Any]: + """ + mask credentials + + return a deep copy of credentials with masked values + """ + data = dict(self._deep_copy(data)) + + # get fields need to be decrypted + fields = dict[str, BasicProviderConfig]() + for credential in self.config: + fields[credential.name] = credential + + for field_name, field in fields.items(): + if field.type == BasicProviderConfig.Type.SECRET_INPUT: + if field_name in data: + if len(data[field_name]) > 6: + data[field_name] = ( + data[field_name][:2] + "*" * (len(data[field_name]) - 4) + data[field_name][-2:] + ) + else: + data[field_name] = "*" * len(data[field_name]) + + return data + + def mask_plugin_credentials(self, data: Mapping[str, Any]) -> Mapping[str, Any]: + return self.mask_credentials(data) + + def decrypt(self, data: Mapping[str, Any]) -> Mapping[str, Any]: + """ + decrypt tool credentials with tenant id + + return a deep copy of credentials with decrypted values + """ + cached_credentials = self.provider_config_cache.get() + if cached_credentials: + return cached_credentials + + data = dict(self._deep_copy(data)) + # get fields need to be decrypted + fields = dict[str, BasicProviderConfig]() + for credential in self.config: + fields[credential.name] = credential + + for field_name, field in fields.items(): + if field.type == BasicProviderConfig.Type.SECRET_INPUT: + if field_name in data: + with contextlib.suppress(Exception): + # if the value is None or empty string, skip decrypt + if not data[field_name]: + continue + + data[field_name] = encrypter.decrypt_token(self.tenant_id, data[field_name]) + + self.provider_config_cache.set(dict(data)) + return data + + +def create_provider_encrypter(tenant_id: str, config: list[BasicProviderConfig], cache: ProviderConfigCache): + return ProviderConfigEncrypter(tenant_id=tenant_id, config=config, provider_config_cache=cache), cache diff --git a/api/core/plugin/backwards_invocation/app.py b/api/core/plugin/backwards_invocation/app.py index 32ac132e1e..32e8ef385c 100644 --- a/api/core/plugin/backwards_invocation/app.py +++ b/api/core/plugin/backwards_invocation/app.py @@ -4,7 +4,6 @@ from typing import Union from sqlalchemy import select from sqlalchemy.orm import Session -from controllers.service_api.wraps import create_or_update_end_user_for_user_id from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator from core.app.apps.agent_chat.app_generator import AgentChatAppGenerator @@ -16,6 +15,7 @@ from core.plugin.backwards_invocation.base import BaseBackwardsInvocation from extensions.ext_database import db from models import Account from models.model import App, AppMode, EndUser +from services.end_user_service import EndUserService class PluginAppBackwardsInvocation(BaseBackwardsInvocation): @@ -64,7 +64,7 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): """ app = cls._get_app(app_id, tenant_id) if not user_id: - user = create_or_update_end_user_for_user_id(app) + user = EndUserService.get_or_create_end_user(app) else: user = cls._get_user(user_id) diff --git a/api/core/plugin/entities/parameters.py b/api/core/plugin/entities/parameters.py index 1e7f8e4c86..88a3a7bd43 100644 --- a/api/core/plugin/entities/parameters.py +++ b/api/core/plugin/entities/parameters.py @@ -39,7 +39,7 @@ class PluginParameterType(StrEnum): TOOLS_SELECTOR = CommonParameterType.TOOLS_SELECTOR ANY = CommonParameterType.ANY DYNAMIC_SELECT = CommonParameterType.DYNAMIC_SELECT - + CHECKBOX = CommonParameterType.CHECKBOX # deprecated, should not use. SYSTEM_FILES = CommonParameterType.SYSTEM_FILES @@ -94,6 +94,7 @@ def as_normal_type(typ: StrEnum): if typ.value in { PluginParameterType.SECRET_INPUT, PluginParameterType.SELECT, + PluginParameterType.CHECKBOX, }: return "string" return typ.value @@ -102,7 +103,13 @@ def as_normal_type(typ: StrEnum): def cast_parameter_value(typ: StrEnum, value: Any, /): try: match typ.value: - case PluginParameterType.STRING | PluginParameterType.SECRET_INPUT | PluginParameterType.SELECT: + case ( + PluginParameterType.STRING + | PluginParameterType.SECRET_INPUT + | PluginParameterType.SELECT + | PluginParameterType.CHECKBOX + | PluginParameterType.DYNAMIC_SELECT + ): if value is None: return "" else: diff --git a/api/core/plugin/entities/plugin.py b/api/core/plugin/entities/plugin.py index f32b356937..9e1a9edf82 100644 --- a/api/core/plugin/entities/plugin.py +++ b/api/core/plugin/entities/plugin.py @@ -13,6 +13,7 @@ from core.plugin.entities.base import BasePluginEntity from core.plugin.entities.endpoint import EndpointProviderDeclaration from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ToolProviderEntity +from core.trigger.entities.entities import TriggerProviderEntity class PluginInstallationSource(StrEnum): @@ -63,6 +64,7 @@ class PluginCategory(StrEnum): Extension = auto() AgentStrategy = "agent-strategy" Datasource = "datasource" + Trigger = "trigger" class PluginDeclaration(BaseModel): @@ -71,6 +73,7 @@ class PluginDeclaration(BaseModel): models: list[str] | None = Field(default_factory=list[str]) endpoints: list[str] | None = Field(default_factory=list[str]) datasources: list[str] | None = Field(default_factory=list[str]) + triggers: list[str] | None = Field(default_factory=list[str]) class Meta(BaseModel): minimum_dify_version: str | None = Field(default=None) @@ -106,6 +109,7 @@ class PluginDeclaration(BaseModel): endpoint: EndpointProviderDeclaration | None = None agent_strategy: AgentStrategyProviderEntity | None = None datasource: DatasourceProviderEntity | None = None + trigger: TriggerProviderEntity | None = None meta: Meta @field_validator("version") @@ -129,6 +133,8 @@ class PluginDeclaration(BaseModel): values["category"] = PluginCategory.Datasource elif values.get("agent_strategy"): values["category"] = PluginCategory.AgentStrategy + elif values.get("trigger"): + values["category"] = PluginCategory.Trigger else: values["category"] = PluginCategory.Extension return values diff --git a/api/core/plugin/entities/plugin_daemon.py b/api/core/plugin/entities/plugin_daemon.py index f15acc16f9..3b83121357 100644 --- a/api/core/plugin/entities/plugin_daemon.py +++ b/api/core/plugin/entities/plugin_daemon.py @@ -1,3 +1,4 @@ +import enum from collections.abc import Mapping, Sequence from datetime import datetime from enum import StrEnum @@ -14,6 +15,7 @@ from core.plugin.entities.parameters import PluginParameterOption from core.plugin.entities.plugin import PluginDeclaration, PluginEntity from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin +from core.trigger.entities.entities import TriggerProviderEntity T = TypeVar("T", bound=(BaseModel | dict | list | bool | str)) @@ -205,3 +207,53 @@ class PluginListResponse(BaseModel): class PluginDynamicSelectOptionsResponse(BaseModel): options: Sequence[PluginParameterOption] = Field(description="The options of the dynamic select.") + + +class PluginTriggerProviderEntity(BaseModel): + provider: str + plugin_unique_identifier: str + plugin_id: str + declaration: TriggerProviderEntity + + +class CredentialType(enum.StrEnum): + API_KEY = "api-key" + OAUTH2 = "oauth2" + UNAUTHORIZED = "unauthorized" + + def get_name(self): + if self == CredentialType.API_KEY: + return "API KEY" + elif self == CredentialType.OAUTH2: + return "AUTH" + elif self == CredentialType.UNAUTHORIZED: + return "UNAUTHORIZED" + else: + return self.value.replace("-", " ").upper() + + def is_editable(self): + return self == CredentialType.API_KEY + + def is_validate_allowed(self): + return self == CredentialType.API_KEY + + @classmethod + def values(cls): + return [item.value for item in cls] + + @classmethod + def of(cls, credential_type: str) -> "CredentialType": + type_name = credential_type.lower() + if type_name in {"api-key", "api_key"}: + return cls.API_KEY + elif type_name in {"oauth2", "oauth"}: + return cls.OAUTH2 + elif type_name == "unauthorized": + return cls.UNAUTHORIZED + else: + raise ValueError(f"Invalid credential type: {credential_type}") + + +class PluginReadmeResponse(BaseModel): + content: str = Field(description="The readme of the plugin.") + language: str = Field(description="The language of the readme.") diff --git a/api/core/plugin/entities/request.py b/api/core/plugin/entities/request.py index d5df85730b..73d3b8c89c 100644 --- a/api/core/plugin/entities/request.py +++ b/api/core/plugin/entities/request.py @@ -1,5 +1,9 @@ +import binascii +import json +from collections.abc import Mapping from typing import Any, Literal +from flask import Response from pydantic import BaseModel, ConfigDict, Field, field_validator from core.entities.provider_entities import BasicProviderConfig @@ -13,6 +17,7 @@ from core.model_runtime.entities.message_entities import ( UserPromptMessage, ) from core.model_runtime.entities.model_entities import ModelType +from core.plugin.utils.http_parser import deserialize_response from core.workflow.nodes.parameter_extractor.entities import ( ModelConfig as ParameterExtractorModelConfig, ) @@ -237,3 +242,43 @@ class RequestFetchAppInfo(BaseModel): """ app_id: str + + +class TriggerInvokeEventResponse(BaseModel): + variables: Mapping[str, Any] = Field(default_factory=dict) + cancelled: bool = Field(default=False) + + model_config = ConfigDict(protected_namespaces=(), arbitrary_types_allowed=True) + + @field_validator("variables", mode="before") + @classmethod + def convert_variables(cls, v): + if isinstance(v, str): + return json.loads(v) + else: + return v + + +class TriggerSubscriptionResponse(BaseModel): + subscription: dict[str, Any] + + +class TriggerValidateProviderCredentialsResponse(BaseModel): + result: bool + + +class TriggerDispatchResponse(BaseModel): + user_id: str + events: list[str] + response: Response + payload: Mapping[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(protected_namespaces=(), arbitrary_types_allowed=True) + + @field_validator("response", mode="before") + @classmethod + def convert_response(cls, v: str): + try: + return deserialize_response(binascii.unhexlify(v.encode())) + except Exception as e: + raise ValueError("Failed to deserialize response from hex string") from e diff --git a/api/core/plugin/impl/asset.py b/api/core/plugin/impl/asset.py index b9bfe2d2cf..2798e736a9 100644 --- a/api/core/plugin/impl/asset.py +++ b/api/core/plugin/impl/asset.py @@ -10,3 +10,13 @@ class PluginAssetManager(BasePluginClient): if response.status_code != 200: raise ValueError(f"can not found asset {id}") return response.content + + def extract_asset(self, tenant_id: str, plugin_unique_identifier: str, filename: str) -> bytes: + response = self._request( + method="GET", + path=f"plugin/{tenant_id}/extract-asset/", + params={"plugin_unique_identifier": plugin_unique_identifier, "file_path": filename}, + ) + if response.status_code != 200: + raise ValueError(f"can not found asset {plugin_unique_identifier}, {str(response.status_code)}") + return response.content diff --git a/api/core/plugin/impl/base.py b/api/core/plugin/impl/base.py index e9dc58eec8..a1c84bd5d9 100644 --- a/api/core/plugin/impl/base.py +++ b/api/core/plugin/impl/base.py @@ -29,6 +29,12 @@ from core.plugin.impl.exc import ( PluginPermissionDeniedError, PluginUniqueIdentifierError, ) +from core.trigger.errors import ( + EventIgnoreError, + TriggerInvokeError, + TriggerPluginInvokeError, + TriggerProviderCredentialValidationError, +) plugin_daemon_inner_api_baseurl = URL(str(dify_config.PLUGIN_DAEMON_URL)) _plugin_daemon_timeout_config = cast( @@ -43,7 +49,7 @@ elif isinstance(_plugin_daemon_timeout_config, httpx.Timeout): else: plugin_daemon_request_timeout = httpx.Timeout(_plugin_daemon_timeout_config) -T = TypeVar("T", bound=(BaseModel | dict | list | bool | str)) +T = TypeVar("T", bound=(BaseModel | dict[str, Any] | list[Any] | bool | str)) logger = logging.getLogger(__name__) @@ -53,10 +59,10 @@ class BasePluginClient: self, method: str, path: str, - headers: dict | None = None, - data: bytes | dict | str | None = None, - params: dict | None = None, - files: dict | None = None, + headers: dict[str, str] | None = None, + data: bytes | dict[str, Any] | str | None = None, + params: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, ) -> httpx.Response: """ Make a request to the plugin daemon inner API. @@ -87,17 +93,17 @@ class BasePluginClient: def _prepare_request( self, path: str, - headers: dict | None, - data: bytes | dict | str | None, - params: dict | None, - files: dict | None, - ) -> tuple[str, dict, bytes | dict | str | None, dict | None, dict | None]: + headers: dict[str, str] | None, + data: bytes | dict[str, Any] | str | None, + params: dict[str, Any] | None, + files: dict[str, Any] | None, + ) -> tuple[str, dict[str, str], bytes | dict[str, Any] | str | None, dict[str, Any] | None, dict[str, Any] | None]: url = plugin_daemon_inner_api_baseurl / path prepared_headers = dict(headers or {}) prepared_headers["X-Api-Key"] = dify_config.PLUGIN_DAEMON_KEY prepared_headers.setdefault("Accept-Encoding", "gzip, deflate, br") - prepared_data: bytes | dict | str | None = ( + prepared_data: bytes | dict[str, Any] | str | None = ( data if isinstance(data, (bytes, str, dict)) or data is None else None ) if isinstance(data, dict): @@ -112,10 +118,10 @@ class BasePluginClient: self, method: str, path: str, - params: dict | None = None, - headers: dict | None = None, - data: bytes | dict | None = None, - files: dict | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + data: bytes | dict[str, Any] | None = None, + files: dict[str, Any] | None = None, ) -> Generator[str, None, None]: """ Make a stream request to the plugin daemon inner API @@ -138,7 +144,7 @@ class BasePluginClient: try: with httpx.stream(**stream_kwargs) as response: for raw_line in response.iter_lines(): - if raw_line is None: + if not raw_line: continue line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line line = line.strip() @@ -155,10 +161,10 @@ class BasePluginClient: method: str, path: str, type_: type[T], - headers: dict | None = None, - data: bytes | dict | None = None, - params: dict | None = None, - files: dict | None = None, + headers: dict[str, str] | None = None, + data: bytes | dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, ) -> Generator[T, None, None]: """ Make a stream request to the plugin daemon inner API and yield the response as a model. @@ -171,10 +177,10 @@ class BasePluginClient: method: str, path: str, type_: type[T], - headers: dict | None = None, + headers: dict[str, str] | None = None, data: bytes | None = None, - params: dict | None = None, - files: dict | None = None, + params: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, ) -> T: """ Make a request to the plugin daemon inner API and return the response as a model. @@ -187,11 +193,11 @@ class BasePluginClient: method: str, path: str, type_: type[T], - headers: dict | None = None, - data: bytes | dict | None = None, - params: dict | None = None, - files: dict | None = None, - transformer: Callable[[dict], dict] | None = None, + headers: dict[str, str] | None = None, + data: bytes | dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, + transformer: Callable[[dict[str, Any]], dict[str, Any]] | None = None, ) -> T: """ Make a request to the plugin daemon inner API and return the response as a model. @@ -239,10 +245,10 @@ class BasePluginClient: method: str, path: str, type_: type[T], - headers: dict | None = None, - data: bytes | dict | None = None, - params: dict | None = None, - files: dict | None = None, + headers: dict[str, str] | None = None, + data: bytes | dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, ) -> Generator[T, None, None]: """ Make a stream request to the plugin daemon inner API and yield the response as a model. @@ -302,6 +308,14 @@ class BasePluginClient: raise CredentialsValidateFailedError(error_object.get("message")) case EndpointSetupFailedError.__name__: raise EndpointSetupFailedError(error_object.get("message")) + case TriggerProviderCredentialValidationError.__name__: + raise TriggerProviderCredentialValidationError(error_object.get("message")) + case TriggerPluginInvokeError.__name__: + raise TriggerPluginInvokeError(description=error_object.get("description")) + case TriggerInvokeError.__name__: + raise TriggerInvokeError(error_object.get("message")) + case EventIgnoreError.__name__: + raise EventIgnoreError(description=error_object.get("description")) case _: raise PluginInvokeError(description=message) case PluginDaemonInternalServerError.__name__: diff --git a/api/core/plugin/impl/dynamic_select.py b/api/core/plugin/impl/dynamic_select.py index 24839849b9..0a580a2978 100644 --- a/api/core/plugin/impl/dynamic_select.py +++ b/api/core/plugin/impl/dynamic_select.py @@ -15,6 +15,7 @@ class DynamicSelectClient(BasePluginClient): provider: str, action: str, credentials: Mapping[str, Any], + credential_type: str, parameter: str, ) -> PluginDynamicSelectOptionsResponse: """ @@ -29,6 +30,7 @@ class DynamicSelectClient(BasePluginClient): "data": { "provider": GenericProviderID(provider).provider_name, "credentials": credentials, + "credential_type": credential_type, "provider_action": action, "parameter": parameter, }, diff --git a/api/core/plugin/impl/exc.py b/api/core/plugin/impl/exc.py index e28a324217..4cabdc1732 100644 --- a/api/core/plugin/impl/exc.py +++ b/api/core/plugin/impl/exc.py @@ -58,6 +58,20 @@ class PluginInvokeError(PluginDaemonClientSideError, ValueError): except Exception: return self.description + def to_user_friendly_error(self, plugin_name: str = "currently running plugin") -> str: + """ + Convert the error to a user-friendly error message. + + :param plugin_name: The name of the plugin that caused the error. + :return: A user-friendly error message. + """ + return ( + f"An error occurred in the {plugin_name}, " + f"please contact the author of {plugin_name} for help, " + f"error type: {self.get_error_type()}, " + f"error details: {self.get_error_message()}" + ) + class PluginUniqueIdentifierError(PluginDaemonClientSideError): description: str = "Unique Identifier Error" diff --git a/api/core/plugin/impl/plugin.py b/api/core/plugin/impl/plugin.py index 18b5fa8af6..0bbb62af93 100644 --- a/api/core/plugin/impl/plugin.py +++ b/api/core/plugin/impl/plugin.py @@ -1,5 +1,7 @@ from collections.abc import Sequence +from requests import HTTPError + from core.plugin.entities.bundle import PluginBundleDependency from core.plugin.entities.plugin import ( MissingPluginDependency, @@ -13,12 +15,35 @@ from core.plugin.entities.plugin_daemon import ( PluginInstallTask, PluginInstallTaskStartResponse, PluginListResponse, + PluginReadmeResponse, ) from core.plugin.impl.base import BasePluginClient from models.provider_ids import GenericProviderID class PluginInstaller(BasePluginClient): + def fetch_plugin_readme(self, tenant_id: str, plugin_unique_identifier: str, language: str) -> str: + """ + Fetch plugin readme + """ + try: + response = self._request_with_plugin_daemon_response( + "GET", + f"plugin/{tenant_id}/management/fetch/readme", + PluginReadmeResponse, + params={ + "tenant_id": tenant_id, + "plugin_unique_identifier": plugin_unique_identifier, + "language": language, + }, + ) + return response.content + except HTTPError as e: + message = e.args[0] + if "404" in message: + return "" + raise e + def fetch_plugin_by_identifier( self, tenant_id: str, diff --git a/api/core/plugin/impl/tool.py b/api/core/plugin/impl/tool.py index bc4de38099..6fa5136b42 100644 --- a/api/core/plugin/impl/tool.py +++ b/api/core/plugin/impl/tool.py @@ -3,14 +3,12 @@ from typing import Any from pydantic import BaseModel -from core.plugin.entities.plugin_daemon import ( - PluginBasicBooleanResponse, - PluginToolProviderEntity, -) +# from core.plugin.entities.plugin import GenericProviderID, ToolProviderID +from core.plugin.entities.plugin_daemon import CredentialType, PluginBasicBooleanResponse, PluginToolProviderEntity from core.plugin.impl.base import BasePluginClient from core.plugin.utils.chunk_merger import merge_blob_chunks from core.schemas.resolver import resolve_dify_schema_refs -from core.tools.entities.tool_entities import CredentialType, ToolInvokeMessage, ToolParameter +from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter from models.provider_ids import GenericProviderID, ToolProviderID diff --git a/api/core/plugin/impl/trigger.py b/api/core/plugin/impl/trigger.py new file mode 100644 index 0000000000..611ce74907 --- /dev/null +++ b/api/core/plugin/impl/trigger.py @@ -0,0 +1,305 @@ +import binascii +from collections.abc import Generator, Mapping +from typing import Any + +from flask import Request + +from core.plugin.entities.plugin_daemon import CredentialType, PluginTriggerProviderEntity +from core.plugin.entities.request import ( + TriggerDispatchResponse, + TriggerInvokeEventResponse, + TriggerSubscriptionResponse, + TriggerValidateProviderCredentialsResponse, +) +from core.plugin.impl.base import BasePluginClient +from core.plugin.utils.http_parser import serialize_request +from core.trigger.entities.entities import Subscription +from models.provider_ids import TriggerProviderID + + +class PluginTriggerClient(BasePluginClient): + def fetch_trigger_providers(self, tenant_id: str) -> list[PluginTriggerProviderEntity]: + """ + Fetch trigger providers for the given tenant. + """ + + def transformer(json_response: dict[str, Any]) -> dict[str, Any]: + for provider in json_response.get("data", []): + declaration = provider.get("declaration", {}) or {} + provider_id = provider.get("plugin_id") + "/" + provider.get("provider") + for event in declaration.get("events", []): + event["identity"]["provider"] = provider_id + + return json_response + + response: list[PluginTriggerProviderEntity] = self._request_with_plugin_daemon_response( + method="GET", + path=f"plugin/{tenant_id}/management/triggers", + type_=list[PluginTriggerProviderEntity], + params={"page": 1, "page_size": 256}, + transformer=transformer, + ) + + for provider in response: + provider.declaration.identity.name = f"{provider.plugin_id}/{provider.declaration.identity.name}" + + # override the provider name for each trigger to plugin_id/provider_name + for event in provider.declaration.events: + event.identity.provider = provider.declaration.identity.name + + return response + + def fetch_trigger_provider(self, tenant_id: str, provider_id: TriggerProviderID) -> PluginTriggerProviderEntity: + """ + Fetch trigger provider for the given tenant and plugin. + """ + + def transformer(json_response: dict[str, Any]) -> dict[str, Any]: + data = json_response.get("data") + if data: + for event in data.get("declaration", {}).get("events", []): + event["identity"]["provider"] = str(provider_id) + + return json_response + + response: PluginTriggerProviderEntity = self._request_with_plugin_daemon_response( + method="GET", + path=f"plugin/{tenant_id}/management/trigger", + type_=PluginTriggerProviderEntity, + params={"provider": provider_id.provider_name, "plugin_id": provider_id.plugin_id}, + transformer=transformer, + ) + + response.declaration.identity.name = str(provider_id) + + # override the provider name for each trigger to plugin_id/provider_name + for event in response.declaration.events: + event.identity.provider = str(provider_id) + + return response + + def invoke_trigger_event( + self, + tenant_id: str, + user_id: str, + provider: str, + event_name: str, + credentials: Mapping[str, str], + credential_type: CredentialType, + request: Request, + parameters: Mapping[str, Any], + subscription: Subscription, + payload: Mapping[str, Any], + ) -> TriggerInvokeEventResponse: + """ + Invoke a trigger with the given parameters. + """ + provider_id = TriggerProviderID(provider) + response: Generator[TriggerInvokeEventResponse, None, None] = self._request_with_plugin_daemon_response_stream( + method="POST", + path=f"plugin/{tenant_id}/dispatch/trigger/invoke_event", + type_=TriggerInvokeEventResponse, + data={ + "user_id": user_id, + "data": { + "provider": provider_id.provider_name, + "event": event_name, + "credentials": credentials, + "credential_type": credential_type, + "subscription": subscription.model_dump(), + "raw_http_request": binascii.hexlify(serialize_request(request)).decode(), + "parameters": parameters, + "payload": payload, + }, + }, + headers={ + "X-Plugin-ID": provider_id.plugin_id, + "Content-Type": "application/json", + }, + ) + + for resp in response: + return resp + + raise ValueError("No response received from plugin daemon for invoke trigger") + + def validate_provider_credentials( + self, tenant_id: str, user_id: str, provider: str, credentials: Mapping[str, str] + ) -> bool: + """ + Validate the credentials of the trigger provider. + """ + provider_id = TriggerProviderID(provider) + response: Generator[TriggerValidateProviderCredentialsResponse, None, None] = ( + self._request_with_plugin_daemon_response_stream( + method="POST", + path=f"plugin/{tenant_id}/dispatch/trigger/validate_credentials", + type_=TriggerValidateProviderCredentialsResponse, + data={ + "user_id": user_id, + "data": { + "provider": provider_id.provider_name, + "credentials": credentials, + }, + }, + headers={ + "X-Plugin-ID": provider_id.plugin_id, + "Content-Type": "application/json", + }, + ) + ) + + for resp in response: + return resp.result + + raise ValueError("No response received from plugin daemon for validate provider credentials") + + def dispatch_event( + self, + tenant_id: str, + provider: str, + subscription: Mapping[str, Any], + request: Request, + credentials: Mapping[str, str], + credential_type: CredentialType, + ) -> TriggerDispatchResponse: + """ + Dispatch an event to triggers. + """ + provider_id = TriggerProviderID(provider) + response = self._request_with_plugin_daemon_response_stream( + method="POST", + path=f"plugin/{tenant_id}/dispatch/trigger/dispatch_event", + type_=TriggerDispatchResponse, + data={ + "data": { + "provider": provider_id.provider_name, + "subscription": subscription, + "credentials": credentials, + "credential_type": credential_type, + "raw_http_request": binascii.hexlify(serialize_request(request)).decode(), + }, + }, + headers={ + "X-Plugin-ID": provider_id.plugin_id, + "Content-Type": "application/json", + }, + ) + + for resp in response: + return resp + + raise ValueError("No response received from plugin daemon for dispatch event") + + def subscribe( + self, + tenant_id: str, + user_id: str, + provider: str, + credentials: Mapping[str, str], + credential_type: CredentialType, + endpoint: str, + parameters: Mapping[str, Any], + ) -> TriggerSubscriptionResponse: + """ + Subscribe to a trigger. + """ + provider_id = TriggerProviderID(provider) + response: Generator[TriggerSubscriptionResponse, None, None] = self._request_with_plugin_daemon_response_stream( + method="POST", + path=f"plugin/{tenant_id}/dispatch/trigger/subscribe", + type_=TriggerSubscriptionResponse, + data={ + "user_id": user_id, + "data": { + "provider": provider_id.provider_name, + "credentials": credentials, + "credential_type": credential_type, + "endpoint": endpoint, + "parameters": parameters, + }, + }, + headers={ + "X-Plugin-ID": provider_id.plugin_id, + "Content-Type": "application/json", + }, + ) + + for resp in response: + return resp + + raise ValueError("No response received from plugin daemon for subscribe") + + def unsubscribe( + self, + tenant_id: str, + user_id: str, + provider: str, + subscription: Subscription, + credentials: Mapping[str, str], + credential_type: CredentialType, + ) -> TriggerSubscriptionResponse: + """ + Unsubscribe from a trigger. + """ + provider_id = TriggerProviderID(provider) + response: Generator[TriggerSubscriptionResponse, None, None] = self._request_with_plugin_daemon_response_stream( + method="POST", + path=f"plugin/{tenant_id}/dispatch/trigger/unsubscribe", + type_=TriggerSubscriptionResponse, + data={ + "user_id": user_id, + "data": { + "provider": provider_id.provider_name, + "subscription": subscription.model_dump(), + "credentials": credentials, + "credential_type": credential_type, + }, + }, + headers={ + "X-Plugin-ID": provider_id.plugin_id, + "Content-Type": "application/json", + }, + ) + + for resp in response: + return resp + + raise ValueError("No response received from plugin daemon for unsubscribe") + + def refresh( + self, + tenant_id: str, + user_id: str, + provider: str, + subscription: Subscription, + credentials: Mapping[str, str], + credential_type: CredentialType, + ) -> TriggerSubscriptionResponse: + """ + Refresh a trigger subscription. + """ + provider_id = TriggerProviderID(provider) + response: Generator[TriggerSubscriptionResponse, None, None] = self._request_with_plugin_daemon_response_stream( + method="POST", + path=f"plugin/{tenant_id}/dispatch/trigger/refresh", + type_=TriggerSubscriptionResponse, + data={ + "user_id": user_id, + "data": { + "provider": provider_id.provider_name, + "subscription": subscription.model_dump(), + "credentials": credentials, + "credential_type": credential_type, + }, + }, + headers={ + "X-Plugin-ID": provider_id.plugin_id, + "Content-Type": "application/json", + }, + ) + + for resp in response: + return resp + + raise ValueError("No response received from plugin daemon for refresh") diff --git a/api/core/plugin/utils/http_parser.py b/api/core/plugin/utils/http_parser.py new file mode 100644 index 0000000000..ce943929be --- /dev/null +++ b/api/core/plugin/utils/http_parser.py @@ -0,0 +1,163 @@ +from io import BytesIO + +from flask import Request, Response +from werkzeug.datastructures import Headers + + +def serialize_request(request: Request) -> bytes: + method = request.method + path = request.full_path.rstrip("?") + raw = f"{method} {path} HTTP/1.1\r\n".encode() + + for name, value in request.headers.items(): + raw += f"{name}: {value}\r\n".encode() + + raw += b"\r\n" + + body = request.get_data(as_text=False) + if body: + raw += body + + return raw + + +def deserialize_request(raw_data: bytes) -> Request: + header_end = raw_data.find(b"\r\n\r\n") + if header_end == -1: + header_end = raw_data.find(b"\n\n") + if header_end == -1: + header_data = raw_data + body = b"" + else: + header_data = raw_data[:header_end] + body = raw_data[header_end + 2 :] + else: + header_data = raw_data[:header_end] + body = raw_data[header_end + 4 :] + + lines = header_data.split(b"\r\n") + if len(lines) == 1 and b"\n" in lines[0]: + lines = header_data.split(b"\n") + + if not lines or not lines[0]: + raise ValueError("Empty HTTP request") + + request_line = lines[0].decode("utf-8", errors="ignore") + parts = request_line.split(" ", 2) + if len(parts) < 2: + raise ValueError(f"Invalid request line: {request_line}") + + method = parts[0] + full_path = parts[1] + protocol = parts[2] if len(parts) > 2 else "HTTP/1.1" + + if "?" in full_path: + path, query_string = full_path.split("?", 1) + else: + path = full_path + query_string = "" + + headers = Headers() + for line in lines[1:]: + if not line: + continue + line_str = line.decode("utf-8", errors="ignore") + if ":" not in line_str: + continue + name, value = line_str.split(":", 1) + headers.add(name, value.strip()) + + host = headers.get("Host", "localhost") + if ":" in host: + server_name, server_port = host.rsplit(":", 1) + else: + server_name = host + server_port = "80" + + environ = { + "REQUEST_METHOD": method, + "PATH_INFO": path, + "QUERY_STRING": query_string, + "SERVER_NAME": server_name, + "SERVER_PORT": server_port, + "SERVER_PROTOCOL": protocol, + "wsgi.input": BytesIO(body), + "wsgi.url_scheme": "http", + } + + if "Content-Type" in headers: + content_type = headers.get("Content-Type") + if content_type is not None: + environ["CONTENT_TYPE"] = content_type + + if "Content-Length" in headers: + content_length = headers.get("Content-Length") + if content_length is not None: + environ["CONTENT_LENGTH"] = content_length + elif body: + environ["CONTENT_LENGTH"] = str(len(body)) + + for name, value in headers.items(): + if name.upper() in ("CONTENT-TYPE", "CONTENT-LENGTH"): + continue + env_name = f"HTTP_{name.upper().replace('-', '_')}" + environ[env_name] = value + + return Request(environ) + + +def serialize_response(response: Response) -> bytes: + raw = f"HTTP/1.1 {response.status}\r\n".encode() + + for name, value in response.headers.items(): + raw += f"{name}: {value}\r\n".encode() + + raw += b"\r\n" + + body = response.get_data(as_text=False) + if body: + raw += body + + return raw + + +def deserialize_response(raw_data: bytes) -> Response: + header_end = raw_data.find(b"\r\n\r\n") + if header_end == -1: + header_end = raw_data.find(b"\n\n") + if header_end == -1: + header_data = raw_data + body = b"" + else: + header_data = raw_data[:header_end] + body = raw_data[header_end + 2 :] + else: + header_data = raw_data[:header_end] + body = raw_data[header_end + 4 :] + + lines = header_data.split(b"\r\n") + if len(lines) == 1 and b"\n" in lines[0]: + lines = header_data.split(b"\n") + + if not lines or not lines[0]: + raise ValueError("Empty HTTP response") + + status_line = lines[0].decode("utf-8", errors="ignore") + parts = status_line.split(" ", 2) + if len(parts) < 2: + raise ValueError(f"Invalid status line: {status_line}") + + status_code = int(parts[1]) + + response = Response(response=body, status=status_code) + + for line in lines[1:]: + if not line: + continue + line_str = line.decode("utf-8", errors="ignore") + if ":" not in line_str: + continue + name, value = line_str.split(":", 1) + response.headers[name] = value.strip() + + return response diff --git a/api/core/tools/__base/tool_runtime.py b/api/core/tools/__base/tool_runtime.py index 09bc817c01..961d13f90a 100644 --- a/api/core/tools/__base/tool_runtime.py +++ b/api/core/tools/__base/tool_runtime.py @@ -3,7 +3,8 @@ from typing import Any from pydantic import BaseModel, Field from core.app.entities.app_invoke_entities import InvokeFrom -from core.tools.entities.tool_entities import CredentialType, ToolInvokeFrom +from core.plugin.entities.plugin_daemon import CredentialType +from core.tools.entities.tool_entities import ToolInvokeFrom class ToolRuntime(BaseModel): diff --git a/api/core/tools/builtin_tool/provider.py b/api/core/tools/builtin_tool/provider.py index a391136a5c..50105bd707 100644 --- a/api/core/tools/builtin_tool/provider.py +++ b/api/core/tools/builtin_tool/provider.py @@ -4,11 +4,11 @@ from typing import Any from core.entities.provider_entities import ProviderConfig from core.helper.module_import_helper import load_single_subclass_from_source +from core.plugin.entities.plugin_daemon import CredentialType from core.tools.__base.tool_provider import ToolProviderController from core.tools.__base.tool_runtime import ToolRuntime from core.tools.builtin_tool.tool import BuiltinTool from core.tools.entities.tool_entities import ( - CredentialType, OAuthSchema, ToolEntity, ToolProviderEntity, diff --git a/api/core/tools/entities/api_entities.py b/api/core/tools/entities/api_entities.py index 8f7d1101cb..807d0245d1 100644 --- a/api/core/tools/entities/api_entities.py +++ b/api/core/tools/entities/api_entities.py @@ -6,9 +6,10 @@ from pydantic import BaseModel, Field, field_validator from core.entities.mcp_provider import MCPAuthentication, MCPConfiguration from core.model_runtime.utils.encoders import jsonable_encoder +from core.plugin.entities.plugin_daemon import CredentialType from core.tools.__base.tool import ToolParameter from core.tools.entities.common_entities import I18nObject -from core.tools.entities.tool_entities import CredentialType, ToolProviderType +from core.tools.entities.tool_entities import ToolProviderType class ToolApiEntity(BaseModel): diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py index 5b385f1bb2..353f3a646a 100644 --- a/api/core/tools/entities/tool_entities.py +++ b/api/core/tools/entities/tool_entities.py @@ -268,6 +268,7 @@ class ToolParameter(PluginParameter): SECRET_INPUT = PluginParameterType.SECRET_INPUT FILE = PluginParameterType.FILE FILES = PluginParameterType.FILES + CHECKBOX = PluginParameterType.CHECKBOX APP_SELECTOR = PluginParameterType.APP_SELECTOR MODEL_SELECTOR = PluginParameterType.MODEL_SELECTOR ANY = PluginParameterType.ANY @@ -489,36 +490,3 @@ class ToolSelector(BaseModel): def to_plugin_parameter(self) -> dict[str, Any]: return self.model_dump() - - -class CredentialType(StrEnum): - API_KEY = "api-key" - OAUTH2 = auto() - - def get_name(self): - if self == CredentialType.API_KEY: - return "API KEY" - elif self == CredentialType.OAUTH2: - return "AUTH" - else: - return self.value.replace("-", " ").upper() - - def is_editable(self): - return self == CredentialType.API_KEY - - def is_validate_allowed(self): - return self == CredentialType.API_KEY - - @classmethod - def values(cls): - return [item.value for item in cls] - - @classmethod - def of(cls, credential_type: str) -> "CredentialType": - type_name = credential_type.lower() - if type_name in {"api-key", "api_key"}: - return cls.API_KEY - elif type_name in {"oauth2", "oauth"}: - return cls.OAUTH2 - else: - raise ValueError(f"Invalid credential type: {credential_type}") diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index ff7dcc0e55..daf3772d30 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -8,7 +8,6 @@ from threading import Lock from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast import sqlalchemy as sa -from pydantic import TypeAdapter from sqlalchemy import select from sqlalchemy.orm import Session from yarl import URL @@ -39,6 +38,7 @@ from core.app.entities.app_invoke_entities import InvokeFrom from core.helper.module_import_helper import load_single_subclass_from_source from core.helper.position_helper import is_filtered from core.model_runtime.utils.encoders import jsonable_encoder +from core.plugin.entities.plugin_daemon import CredentialType from core.tools.__base.tool import Tool from core.tools.builtin_tool.provider import BuiltinToolProviderController from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort @@ -49,7 +49,6 @@ from core.tools.entities.api_entities import ToolProviderApiEntity, ToolProvider from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ( ApiProviderAuthType, - CredentialType, ToolInvokeFrom, ToolParameter, ToolProviderType, @@ -289,10 +288,8 @@ class ToolManager: credentials=decrypted_credentials, ) # update the credentials - builtin_provider.encrypted_credentials = ( - TypeAdapter(dict[str, Any]) - .dump_json(encrypter.encrypt(dict(refreshed_credentials.credentials))) - .decode("utf-8") + builtin_provider.encrypted_credentials = json.dumps( + encrypter.encrypt(refreshed_credentials.credentials) ) builtin_provider.expires_at = refreshed_credentials.expires_at db.session.commit() @@ -322,7 +319,7 @@ class ToolManager: return api_provider.get_tool(tool_name).fork_tool_runtime( runtime=ToolRuntime( tenant_id=tenant_id, - credentials=encrypter.decrypt(credentials), + credentials=dict(encrypter.decrypt(credentials)), invoke_from=invoke_from, tool_invoke_from=tool_invoke_from, ) @@ -833,7 +830,7 @@ class ToolManager: controller=controller, ) - masked_credentials = encrypter.mask_tool_credentials(encrypter.decrypt(credentials)) + masked_credentials = encrypter.mask_plugin_credentials(encrypter.decrypt(credentials)) try: icon = json.loads(provider_obj.icon) diff --git a/api/core/tools/utils/encryption.py b/api/core/tools/utils/encryption.py index 6ea033b2b6..3b6af302db 100644 --- a/api/core/tools/utils/encryption.py +++ b/api/core/tools/utils/encryption.py @@ -1,137 +1,24 @@ -import contextlib -from copy import deepcopy -from typing import Any, Protocol +# Import generic components from provider_encryption module +from core.helper.provider_encryption import ( + ProviderConfigCache, + ProviderConfigEncrypter, + create_provider_encrypter, +) -from core.entities.provider_entities import BasicProviderConfig -from core.helper import encrypter +# Re-export for backward compatibility +__all__ = [ + "ProviderConfigCache", + "ProviderConfigEncrypter", + "create_provider_encrypter", + "create_tool_provider_encrypter", +] + +# Tool-specific imports from core.helper.provider_cache import SingletonProviderCredentialsCache from core.tools.__base.tool_provider import ToolProviderController -class ProviderConfigCache(Protocol): - """ - Interface for provider configuration cache operations - """ - - def get(self) -> dict | None: - """Get cached provider configuration""" - ... - - def set(self, config: dict[str, Any]): - """Cache provider configuration""" - ... - - def delete(self): - """Delete cached provider configuration""" - ... - - -class ProviderConfigEncrypter: - tenant_id: str - config: list[BasicProviderConfig] - provider_config_cache: ProviderConfigCache - - def __init__( - self, - tenant_id: str, - config: list[BasicProviderConfig], - provider_config_cache: ProviderConfigCache, - ): - self.tenant_id = tenant_id - self.config = config - self.provider_config_cache = provider_config_cache - - def _deep_copy(self, data: dict[str, str]) -> dict[str, str]: - """ - deep copy data - """ - return deepcopy(data) - - def encrypt(self, data: dict[str, str]) -> dict[str, str]: - """ - encrypt tool credentials with tenant id - - return a deep copy of credentials with encrypted values - """ - data = self._deep_copy(data) - - # get fields need to be decrypted - fields = dict[str, BasicProviderConfig]() - for credential in self.config: - fields[credential.name] = credential - - for field_name, field in fields.items(): - if field.type == BasicProviderConfig.Type.SECRET_INPUT: - if field_name in data: - encrypted = encrypter.encrypt_token(self.tenant_id, data[field_name] or "") - data[field_name] = encrypted - - return data - - def mask_tool_credentials(self, data: dict[str, Any]) -> dict[str, Any]: - """ - mask tool credentials - - return a deep copy of credentials with masked values - """ - data = self._deep_copy(data) - - # get fields need to be decrypted - fields = dict[str, BasicProviderConfig]() - for credential in self.config: - fields[credential.name] = credential - - for field_name, field in fields.items(): - if field.type == BasicProviderConfig.Type.SECRET_INPUT: - if field_name in data: - if len(data[field_name]) > 6: - data[field_name] = ( - data[field_name][:2] + "*" * (len(data[field_name]) - 4) + data[field_name][-2:] - ) - else: - data[field_name] = "*" * len(data[field_name]) - - return data - - def decrypt(self, data: dict[str, str]) -> dict[str, Any]: - """ - decrypt tool credentials with tenant id - - return a deep copy of credentials with decrypted values - """ - cached_credentials = self.provider_config_cache.get() - if cached_credentials: - return cached_credentials - - data = self._deep_copy(data) - # get fields need to be decrypted - fields = dict[str, BasicProviderConfig]() - for credential in self.config: - fields[credential.name] = credential - - for field_name, field in fields.items(): - if field.type == BasicProviderConfig.Type.SECRET_INPUT: - if field_name in data: - with contextlib.suppress(Exception): - # if the value is None or empty string, skip decrypt - if not data[field_name]: - continue - - data[field_name] = encrypter.decrypt_token(self.tenant_id, data[field_name]) - - self.provider_config_cache.set(data) - return data - - -def create_provider_encrypter( - tenant_id: str, config: list[BasicProviderConfig], cache: ProviderConfigCache -) -> tuple[ProviderConfigEncrypter, ProviderConfigCache]: - return ProviderConfigEncrypter(tenant_id=tenant_id, config=config, provider_config_cache=cache), cache - - -def create_tool_provider_encrypter( - tenant_id: str, controller: ToolProviderController -) -> tuple[ProviderConfigEncrypter, ProviderConfigCache]: +def create_tool_provider_encrypter(tenant_id: str, controller: ToolProviderController): cache = SingletonProviderCredentialsCache( tenant_id=tenant_id, provider_type=controller.provider_type.value, diff --git a/api/core/trigger/__init__.py b/api/core/trigger/__init__.py new file mode 100644 index 0000000000..1e5b8bb445 --- /dev/null +++ b/api/core/trigger/__init__.py @@ -0,0 +1 @@ +# Core trigger module initialization diff --git a/api/core/trigger/debug/event_bus.py b/api/core/trigger/debug/event_bus.py new file mode 100644 index 0000000000..9d10e1a0e0 --- /dev/null +++ b/api/core/trigger/debug/event_bus.py @@ -0,0 +1,124 @@ +import hashlib +import logging +from typing import TypeVar + +from redis import RedisError + +from core.trigger.debug.events import BaseDebugEvent +from extensions.ext_redis import redis_client + +logger = logging.getLogger(__name__) + +TRIGGER_DEBUG_EVENT_TTL = 300 + +TTriggerDebugEvent = TypeVar("TTriggerDebugEvent", bound="BaseDebugEvent") + + +class TriggerDebugEventBus: + """ + Unified Redis-based trigger debug service with polling support. + + Uses {tenant_id} hash tags for Redis Cluster compatibility. + Supports multiple event types through a generic dispatch/poll interface. + """ + + # LUA_SELECT: Atomic poll or register for event + # KEYS[1] = trigger_debug_inbox:{tenant_id}:{address_id} + # KEYS[2] = trigger_debug_waiting_pool:{tenant_id}:... + # ARGV[1] = address_id + LUA_SELECT = ( + "local v=redis.call('GET',KEYS[1]);" + "if v then redis.call('DEL',KEYS[1]);return v end;" + "redis.call('SADD',KEYS[2],ARGV[1]);" + f"redis.call('EXPIRE',KEYS[2],{TRIGGER_DEBUG_EVENT_TTL});" + "return false" + ) + + # LUA_DISPATCH: Dispatch event to all waiting addresses + # KEYS[1] = trigger_debug_waiting_pool:{tenant_id}:... + # ARGV[1] = tenant_id + # ARGV[2] = event_json + LUA_DISPATCH = ( + "local a=redis.call('SMEMBERS',KEYS[1]);" + "if #a==0 then return 0 end;" + "redis.call('DEL',KEYS[1]);" + "for i=1,#a do " + f"redis.call('SET','trigger_debug_inbox:'..ARGV[1]..':'..a[i],ARGV[2],'EX',{TRIGGER_DEBUG_EVENT_TTL});" + "end;" + "return #a" + ) + + @classmethod + def dispatch( + cls, + tenant_id: str, + event: BaseDebugEvent, + pool_key: str, + ) -> int: + """ + Dispatch event to all waiting addresses in the pool. + + Args: + tenant_id: Tenant ID for hash tag + event: Event object to dispatch + pool_key: Pool key (generate using build_{?}_pool_key(...)) + + Returns: + Number of addresses the event was dispatched to + """ + event_data = event.model_dump_json() + try: + result = redis_client.eval( + cls.LUA_DISPATCH, + 1, + pool_key, + tenant_id, + event_data, + ) + return int(result) + except RedisError: + logger.exception("Failed to dispatch event to pool: %s", pool_key) + return 0 + + @classmethod + def poll( + cls, + event_type: type[TTriggerDebugEvent], + pool_key: str, + tenant_id: str, + user_id: str, + app_id: str, + node_id: str, + ) -> TTriggerDebugEvent | None: + """ + Poll for an event or register to the waiting pool. + + If an event is available in the inbox, return it immediately. + Otherwise, register the address to the waiting pool for future dispatch. + + Args: + event_class: Event class for deserialization and type safety + pool_key: Pool key (generate using build_{?}_pool_key(...)) + tenant_id: Tenant ID + user_id: User ID for address calculation + app_id: App ID for address calculation + node_id: Node ID for address calculation + + Returns: + Event object if available, None otherwise + """ + address_id: str = hashlib.sha256(f"{user_id}|{app_id}|{node_id}".encode()).hexdigest() + address: str = f"trigger_debug_inbox:{tenant_id}:{address_id}" + + try: + event_data = redis_client.eval( + cls.LUA_SELECT, + 2, + address, + pool_key, + address_id, + ) + return event_type.model_validate_json(json_data=event_data) if event_data else None + except RedisError: + logger.exception("Failed to poll event from pool: %s", pool_key) + return None diff --git a/api/core/trigger/debug/event_selectors.py b/api/core/trigger/debug/event_selectors.py new file mode 100644 index 0000000000..bd1ff4ebfe --- /dev/null +++ b/api/core/trigger/debug/event_selectors.py @@ -0,0 +1,243 @@ +"""Trigger debug service supporting plugin and webhook debugging in draft workflows.""" + +import hashlib +import logging +import time +from abc import ABC, abstractmethod +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + +from core.plugin.entities.request import TriggerInvokeEventResponse +from core.trigger.debug.event_bus import TriggerDebugEventBus +from core.trigger.debug.events import ( + PluginTriggerDebugEvent, + ScheduleDebugEvent, + WebhookDebugEvent, + build_plugin_pool_key, + build_webhook_pool_key, +) +from core.workflow.enums import NodeType +from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData +from core.workflow.nodes.trigger_schedule.entities import ScheduleConfig +from extensions.ext_redis import redis_client +from libs.datetime_utils import ensure_naive_utc, naive_utc_now +from libs.schedule_utils import calculate_next_run_at +from models.model import App +from models.provider_ids import TriggerProviderID +from models.workflow import Workflow + +logger = logging.getLogger(__name__) + + +class TriggerDebugEvent(BaseModel): + workflow_args: Mapping[str, Any] + node_id: str + + +class TriggerDebugEventPoller(ABC): + app_id: str + user_id: str + tenant_id: str + node_config: Mapping[str, Any] + node_id: str + + def __init__(self, tenant_id: str, user_id: str, app_id: str, node_config: Mapping[str, Any], node_id: str): + self.tenant_id = tenant_id + self.user_id = user_id + self.app_id = app_id + self.node_config = node_config + self.node_id = node_id + + @abstractmethod + def poll(self) -> TriggerDebugEvent | None: + raise NotImplementedError + + +class PluginTriggerDebugEventPoller(TriggerDebugEventPoller): + def poll(self) -> TriggerDebugEvent | None: + from services.trigger.trigger_service import TriggerService + + plugin_trigger_data = TriggerEventNodeData.model_validate(self.node_config.get("data", {})) + provider_id = TriggerProviderID(plugin_trigger_data.provider_id) + pool_key: str = build_plugin_pool_key( + name=plugin_trigger_data.event_name, + provider_id=str(provider_id), + tenant_id=self.tenant_id, + subscription_id=plugin_trigger_data.subscription_id, + ) + plugin_trigger_event: PluginTriggerDebugEvent | None = TriggerDebugEventBus.poll( + event_type=PluginTriggerDebugEvent, + pool_key=pool_key, + tenant_id=self.tenant_id, + user_id=self.user_id, + app_id=self.app_id, + node_id=self.node_id, + ) + if not plugin_trigger_event: + return None + trigger_event_response: TriggerInvokeEventResponse = TriggerService.invoke_trigger_event( + event=plugin_trigger_event, + user_id=plugin_trigger_event.user_id, + tenant_id=self.tenant_id, + node_config=self.node_config, + ) + + if trigger_event_response.cancelled: + return None + + return TriggerDebugEvent( + workflow_args={ + "inputs": trigger_event_response.variables, + "files": [], + }, + node_id=self.node_id, + ) + + +class WebhookTriggerDebugEventPoller(TriggerDebugEventPoller): + def poll(self) -> TriggerDebugEvent | None: + pool_key = build_webhook_pool_key( + tenant_id=self.tenant_id, + app_id=self.app_id, + node_id=self.node_id, + ) + webhook_event: WebhookDebugEvent | None = TriggerDebugEventBus.poll( + event_type=WebhookDebugEvent, + pool_key=pool_key, + tenant_id=self.tenant_id, + user_id=self.user_id, + app_id=self.app_id, + node_id=self.node_id, + ) + if not webhook_event: + return None + + from services.trigger.webhook_service import WebhookService + + payload = webhook_event.payload or {} + workflow_inputs = payload.get("inputs") + if workflow_inputs is None: + webhook_data = payload.get("webhook_data", {}) + workflow_inputs = WebhookService.build_workflow_inputs(webhook_data) + + workflow_args: Mapping[str, Any] = { + "inputs": workflow_inputs or {}, + "files": [], + } + return TriggerDebugEvent(workflow_args=workflow_args, node_id=self.node_id) + + +class ScheduleTriggerDebugEventPoller(TriggerDebugEventPoller): + """ + Poller for schedule trigger debug events. + + This poller will simulate the schedule trigger event by creating a schedule debug runtime cache + and calculating the next run at. + """ + + RUNTIME_CACHE_TTL = 60 * 5 + + class ScheduleDebugRuntime(BaseModel): + cache_key: str + timezone: str + cron_expression: str + next_run_at: datetime + + def schedule_debug_runtime_key(self, cron_hash: str) -> str: + return f"schedule_debug_runtime:{self.tenant_id}:{self.user_id}:{self.app_id}:{self.node_id}:{cron_hash}" + + def get_or_create_schedule_debug_runtime(self): + from services.trigger.schedule_service import ScheduleService + + schedule_config: ScheduleConfig = ScheduleService.to_schedule_config(self.node_config) + cron_hash = hashlib.sha256(schedule_config.cron_expression.encode()).hexdigest() + cache_key = self.schedule_debug_runtime_key(cron_hash) + runtime_cache = redis_client.get(cache_key) + if runtime_cache is None: + schedule_debug_runtime = self.ScheduleDebugRuntime( + cron_expression=schedule_config.cron_expression, + timezone=schedule_config.timezone, + cache_key=cache_key, + next_run_at=ensure_naive_utc( + calculate_next_run_at(schedule_config.cron_expression, schedule_config.timezone) + ), + ) + redis_client.setex( + name=self.schedule_debug_runtime_key(cron_hash), + time=self.RUNTIME_CACHE_TTL, + value=schedule_debug_runtime.model_dump_json(), + ) + return schedule_debug_runtime + else: + redis_client.expire(cache_key, self.RUNTIME_CACHE_TTL) + runtime = self.ScheduleDebugRuntime.model_validate_json(runtime_cache) + runtime.next_run_at = ensure_naive_utc(runtime.next_run_at) + return runtime + + def create_schedule_event(self, schedule_debug_runtime: ScheduleDebugRuntime) -> ScheduleDebugEvent: + redis_client.delete(schedule_debug_runtime.cache_key) + return ScheduleDebugEvent( + timestamp=int(time.time()), + node_id=self.node_id, + inputs={}, + ) + + def poll(self) -> TriggerDebugEvent | None: + schedule_debug_runtime = self.get_or_create_schedule_debug_runtime() + if schedule_debug_runtime.next_run_at > naive_utc_now(): + return None + + schedule_event: ScheduleDebugEvent = self.create_schedule_event(schedule_debug_runtime) + workflow_args: Mapping[str, Any] = { + "inputs": schedule_event.inputs or {}, + "files": [], + } + return TriggerDebugEvent(workflow_args=workflow_args, node_id=self.node_id) + + +def create_event_poller( + draft_workflow: Workflow, tenant_id: str, user_id: str, app_id: str, node_id: str +) -> TriggerDebugEventPoller: + node_config = draft_workflow.get_node_config_by_id(node_id=node_id) + if not node_config: + raise ValueError("Node data not found for node %s", node_id) + node_type = draft_workflow.get_node_type_from_node_config(node_config) + match node_type: + case NodeType.TRIGGER_PLUGIN: + return PluginTriggerDebugEventPoller( + tenant_id=tenant_id, user_id=user_id, app_id=app_id, node_config=node_config, node_id=node_id + ) + case NodeType.TRIGGER_WEBHOOK: + return WebhookTriggerDebugEventPoller( + tenant_id=tenant_id, user_id=user_id, app_id=app_id, node_config=node_config, node_id=node_id + ) + case NodeType.TRIGGER_SCHEDULE: + return ScheduleTriggerDebugEventPoller( + tenant_id=tenant_id, user_id=user_id, app_id=app_id, node_config=node_config, node_id=node_id + ) + case _: + raise ValueError("unable to create event poller for node type %s", node_type) + + +def select_trigger_debug_events( + draft_workflow: Workflow, app_model: App, user_id: str, node_ids: list[str] +) -> TriggerDebugEvent | None: + event: TriggerDebugEvent | None = None + for node_id in node_ids: + node_config = draft_workflow.get_node_config_by_id(node_id=node_id) + if not node_config: + raise ValueError("Node data not found for node %s", node_id) + poller: TriggerDebugEventPoller = create_event_poller( + draft_workflow=draft_workflow, + tenant_id=app_model.tenant_id, + user_id=user_id, + app_id=app_model.id, + node_id=node_id, + ) + event = poller.poll() + if event is not None: + return event + return None diff --git a/api/core/trigger/debug/events.py b/api/core/trigger/debug/events.py new file mode 100644 index 0000000000..9f7bab5e49 --- /dev/null +++ b/api/core/trigger/debug/events.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field + + +class TriggerDebugPoolKey(StrEnum): + """Trigger debug pool key.""" + + SCHEDULE = "schedule_trigger_debug_waiting_pool" + WEBHOOK = "webhook_trigger_debug_waiting_pool" + PLUGIN = "plugin_trigger_debug_waiting_pool" + + +class BaseDebugEvent(BaseModel): + """Base class for all debug events.""" + + timestamp: int + + +class ScheduleDebugEvent(BaseDebugEvent): + """Debug event for schedule triggers.""" + + node_id: str + inputs: Mapping[str, Any] + + +class WebhookDebugEvent(BaseDebugEvent): + """Debug event for webhook triggers.""" + + request_id: str + node_id: str + payload: dict[str, Any] = Field(default_factory=dict) + + +def build_webhook_pool_key(tenant_id: str, app_id: str, node_id: str) -> str: + """Generate pool key for webhook events. + + Args: + tenant_id: Tenant ID + app_id: App ID + node_id: Node ID + """ + return f"{TriggerDebugPoolKey.WEBHOOK}:{tenant_id}:{app_id}:{node_id}" + + +class PluginTriggerDebugEvent(BaseDebugEvent): + """Debug event for plugin triggers.""" + + name: str + user_id: str = Field(description="This is end user id, only for trigger the event. no related with account user id") + request_id: str + subscription_id: str + provider_id: str + + +def build_plugin_pool_key(tenant_id: str, provider_id: str, subscription_id: str, name: str) -> str: + """Generate pool key for plugin trigger events. + + Args: + name: Event name + tenant_id: Tenant ID + provider_id: Provider ID + subscription_id: Subscription ID + """ + return f"{TriggerDebugPoolKey.PLUGIN}:{tenant_id}:{str(provider_id)}:{subscription_id}:{name}" diff --git a/api/core/trigger/entities/api_entities.py b/api/core/trigger/entities/api_entities.py new file mode 100644 index 0000000000..ad7c816144 --- /dev/null +++ b/api/core/trigger/entities/api_entities.py @@ -0,0 +1,76 @@ +from collections.abc import Mapping +from typing import Any + +from pydantic import BaseModel, Field + +from core.entities.provider_entities import ProviderConfig +from core.plugin.entities.plugin_daemon import CredentialType +from core.tools.entities.common_entities import I18nObject +from core.trigger.entities.entities import ( + EventIdentity, + EventParameter, + SubscriptionConstructor, + TriggerCreationMethod, +) + + +class TriggerProviderSubscriptionApiEntity(BaseModel): + id: str = Field(description="The unique id of the subscription") + name: str = Field(description="The name of the subscription") + provider: str = Field(description="The provider id of the subscription") + credential_type: CredentialType = Field(description="The type of the credential") + credentials: dict[str, Any] = Field(description="The credentials of the subscription") + endpoint: str = Field(description="The endpoint of the subscription") + parameters: dict[str, Any] = Field(description="The parameters of the subscription") + properties: dict[str, Any] = Field(description="The properties of the subscription") + workflows_in_use: int = Field(description="The number of workflows using this subscription") + + +class EventApiEntity(BaseModel): + name: str = Field(description="The name of the trigger") + identity: EventIdentity = Field(description="The identity of the trigger") + description: I18nObject = Field(description="The description of the trigger") + parameters: list[EventParameter] = Field(description="The parameters of the trigger") + output_schema: Mapping[str, Any] | None = Field(description="The output schema of the trigger") + + +class TriggerProviderApiEntity(BaseModel): + author: str = Field(..., description="The author of the trigger provider") + name: str = Field(..., description="The name of the trigger provider") + label: I18nObject = Field(..., description="The label of the trigger provider") + description: I18nObject = Field(..., description="The description of the trigger provider") + icon: str | None = Field(default=None, description="The icon of the trigger provider") + icon_dark: str | None = Field(default=None, description="The dark icon of the trigger provider") + tags: list[str] = Field(default_factory=list, description="The tags of the trigger provider") + + plugin_id: str | None = Field(default="", description="The plugin id of the tool") + plugin_unique_identifier: str | None = Field(default="", description="The unique identifier of the tool") + + supported_creation_methods: list[TriggerCreationMethod] = Field( + default_factory=list, + description="Supported creation methods for the trigger provider. like 'OAUTH', 'APIKEY', 'MANUAL'.", + ) + + subscription_constructor: SubscriptionConstructor | None = Field( + default=None, description="The subscription constructor of the trigger provider" + ) + + subscription_schema: list[ProviderConfig] = Field( + default_factory=list, + description="The subscription schema of the trigger provider", + ) + events: list[EventApiEntity] = Field(description="The events of the trigger provider") + + +class SubscriptionBuilderApiEntity(BaseModel): + id: str = Field(description="The id of the subscription builder") + name: str = Field(description="The name of the subscription builder") + provider: str = Field(description="The provider id of the subscription builder") + endpoint: str = Field(description="The endpoint id of the subscription builder") + parameters: Mapping[str, Any] = Field(description="The parameters of the subscription builder") + properties: Mapping[str, Any] = Field(description="The properties of the subscription builder") + credentials: Mapping[str, str] = Field(description="The credentials of the subscription builder") + credential_type: CredentialType = Field(description="The credential type of the subscription builder") + + +__all__ = ["EventApiEntity", "TriggerProviderApiEntity", "TriggerProviderSubscriptionApiEntity"] diff --git a/api/core/trigger/entities/entities.py b/api/core/trigger/entities/entities.py new file mode 100644 index 0000000000..49e24fe8b8 --- /dev/null +++ b/api/core/trigger/entities/entities.py @@ -0,0 +1,288 @@ +from collections.abc import Mapping +from datetime import datetime +from enum import StrEnum +from typing import Any, Union + +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator + +from core.entities.provider_entities import ProviderConfig +from core.plugin.entities.parameters import ( + PluginParameterAutoGenerate, + PluginParameterOption, + PluginParameterTemplate, + PluginParameterType, +) +from core.tools.entities.common_entities import I18nObject + + +class EventParameterType(StrEnum): + """The type of the parameter""" + + STRING = PluginParameterType.STRING + NUMBER = PluginParameterType.NUMBER + BOOLEAN = PluginParameterType.BOOLEAN + SELECT = PluginParameterType.SELECT + FILE = PluginParameterType.FILE + FILES = PluginParameterType.FILES + MODEL_SELECTOR = PluginParameterType.MODEL_SELECTOR + APP_SELECTOR = PluginParameterType.APP_SELECTOR + OBJECT = PluginParameterType.OBJECT + ARRAY = PluginParameterType.ARRAY + DYNAMIC_SELECT = PluginParameterType.DYNAMIC_SELECT + CHECKBOX = PluginParameterType.CHECKBOX + + +class EventParameter(BaseModel): + """ + The parameter of the event + """ + + name: str = Field(..., description="The name of the parameter") + label: I18nObject = Field(..., description="The label presented to the user") + type: EventParameterType = Field(..., description="The type of the parameter") + auto_generate: PluginParameterAutoGenerate | None = Field( + default=None, description="The auto generate of the parameter" + ) + template: PluginParameterTemplate | None = Field(default=None, description="The template of the parameter") + scope: str | None = None + required: bool | None = False + multiple: bool | None = Field( + default=False, + description="Whether the parameter is multiple select, only valid for select or dynamic-select type", + ) + default: Union[int, float, str, list[Any], None] = None + min: Union[float, int, None] = None + max: Union[float, int, None] = None + precision: int | None = None + options: list[PluginParameterOption] | None = None + description: I18nObject | None = None + + +class TriggerProviderIdentity(BaseModel): + """ + The identity of the trigger provider + """ + + author: str = Field(..., description="The author of the trigger provider") + name: str = Field(..., description="The name of the trigger provider") + label: I18nObject = Field(..., description="The label of the trigger provider") + description: I18nObject = Field(..., description="The description of the trigger provider") + icon: str | None = Field(default=None, description="The icon of the trigger provider") + icon_dark: str | None = Field(default=None, description="The dark icon of the trigger provider") + tags: list[str] = Field(default_factory=list, description="The tags of the trigger provider") + + +class EventIdentity(BaseModel): + """ + The identity of the event + """ + + author: str = Field(..., description="The author of the event") + name: str = Field(..., description="The name of the event") + label: I18nObject = Field(..., description="The label of the event") + provider: str | None = Field(default=None, description="The provider of the event") + + +class EventEntity(BaseModel): + """ + The configuration of an event + """ + + identity: EventIdentity = Field(..., description="The identity of the event") + parameters: list[EventParameter] = Field( + default_factory=list[EventParameter], description="The parameters of the event" + ) + description: I18nObject = Field(..., description="The description of the event") + output_schema: Mapping[str, Any] | None = Field( + default=None, description="The output schema that this event produces" + ) + + @field_validator("parameters", mode="before") + @classmethod + def set_parameters(cls, v, validation_info: ValidationInfo) -> list[EventParameter]: + return v or [] + + +class OAuthSchema(BaseModel): + client_schema: list[ProviderConfig] = Field(default_factory=list, description="The schema of the OAuth client") + credentials_schema: list[ProviderConfig] = Field( + default_factory=list, description="The schema of the OAuth credentials" + ) + + +class SubscriptionConstructor(BaseModel): + """ + The subscription constructor of the trigger provider + """ + + parameters: list[EventParameter] = Field( + default_factory=list, description="The parameters schema of the subscription constructor" + ) + + credentials_schema: list[ProviderConfig] = Field( + default_factory=list, + description="The credentials schema of the subscription constructor", + ) + + oauth_schema: OAuthSchema | None = Field( + default=None, + description="The OAuth schema of the subscription constructor if OAuth is supported", + ) + + def get_default_parameters(self) -> Mapping[str, Any]: + """Get the default parameters from the parameters schema""" + if not self.parameters: + return {} + return {param.name: param.default for param in self.parameters if param.default} + + +class TriggerProviderEntity(BaseModel): + """ + The configuration of a trigger provider + """ + + identity: TriggerProviderIdentity = Field(..., description="The identity of the trigger provider") + subscription_schema: list[ProviderConfig] = Field( + default_factory=list, + description="The configuration schema stored in the subscription entity", + ) + subscription_constructor: SubscriptionConstructor | None = Field( + default=None, + description="The subscription constructor of the trigger provider", + ) + events: list[EventEntity] = Field(default_factory=list, description="The events of the trigger provider") + + +class Subscription(BaseModel): + """ + Result of a successful trigger subscription operation. + + Contains all information needed to manage the subscription lifecycle. + """ + + expires_at: int = Field( + ..., description="The timestamp when the subscription will expire, this for refresh the subscription" + ) + + endpoint: str = Field(..., description="The webhook endpoint URL allocated by Dify for receiving events") + parameters: Mapping[str, Any] = Field( + default_factory=dict, description="The parameters of the subscription constructor" + ) + properties: Mapping[str, Any] = Field( + ..., description="Subscription data containing all properties and provider-specific information" + ) + + +class UnsubscribeResult(BaseModel): + """ + Result of a trigger unsubscription operation. + + Provides detailed information about the unsubscription attempt, + including success status and error details if failed. + """ + + success: bool = Field(..., description="Whether the unsubscription was successful") + + message: str | None = Field( + None, + description="Human-readable message about the operation result. " + "Success message for successful operations, " + "detailed error information for failures.", + ) + + +class RequestLog(BaseModel): + id: str = Field(..., description="The id of the request log") + endpoint: str = Field(..., description="The endpoint of the request log") + request: dict[str, Any] = Field(..., description="The request of the request log") + response: dict[str, Any] = Field(..., description="The response of the request log") + created_at: datetime = Field(..., description="The created at of the request log") + + +class SubscriptionBuilder(BaseModel): + id: str = Field(..., description="The id of the subscription builder") + name: str | None = Field(default=None, description="The name of the subscription builder") + tenant_id: str = Field(..., description="The tenant id of the subscription builder") + user_id: str = Field(..., description="The user id of the subscription builder") + provider_id: str = Field(..., description="The provider id of the subscription builder") + endpoint_id: str = Field(..., description="The endpoint id of the subscription builder") + parameters: Mapping[str, Any] = Field(..., description="The parameters of the subscription builder") + properties: Mapping[str, Any] = Field(..., description="The properties of the subscription builder") + credentials: Mapping[str, Any] = Field(..., description="The credentials of the subscription builder") + credential_type: str | None = Field(default=None, description="The credential type of the subscription builder") + credential_expires_at: int | None = Field( + default=None, description="The credential expires at of the subscription builder" + ) + expires_at: int = Field(..., description="The expires at of the subscription builder") + + def to_subscription(self) -> Subscription: + return Subscription( + expires_at=self.expires_at, + endpoint=self.endpoint_id, + properties=self.properties, + ) + + +class SubscriptionBuilderUpdater(BaseModel): + name: str | None = Field(default=None, description="The name of the subscription builder") + parameters: Mapping[str, Any] | None = Field(default=None, description="The parameters of the subscription builder") + properties: Mapping[str, Any] | None = Field(default=None, description="The properties of the subscription builder") + credentials: Mapping[str, Any] | None = Field( + default=None, description="The credentials of the subscription builder" + ) + credential_type: str | None = Field(default=None, description="The credential type of the subscription builder") + credential_expires_at: int | None = Field( + default=None, description="The credential expires at of the subscription builder" + ) + expires_at: int | None = Field(default=None, description="The expires at of the subscription builder") + + def update(self, subscription_builder: SubscriptionBuilder) -> None: + if self.name is not None: + subscription_builder.name = self.name + if self.parameters is not None: + subscription_builder.parameters = self.parameters + if self.properties is not None: + subscription_builder.properties = self.properties + if self.credentials is not None: + subscription_builder.credentials = self.credentials + if self.credential_type is not None: + subscription_builder.credential_type = self.credential_type + if self.credential_expires_at is not None: + subscription_builder.credential_expires_at = self.credential_expires_at + if self.expires_at is not None: + subscription_builder.expires_at = self.expires_at + + +class TriggerEventData(BaseModel): + """Event data dispatched to trigger sessions.""" + + subscription_id: str + events: list[str] + request_id: str + timestamp: float + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class TriggerCreationMethod(StrEnum): + OAUTH = "OAUTH" + APIKEY = "APIKEY" + MANUAL = "MANUAL" + + +# Export all entities +__all__: list[str] = [ + "EventEntity", + "EventIdentity", + "EventParameter", + "EventParameterType", + "OAuthSchema", + "RequestLog", + "Subscription", + "SubscriptionBuilder", + "TriggerCreationMethod", + "TriggerEventData", + "TriggerProviderEntity", + "TriggerProviderIdentity", + "UnsubscribeResult", +] diff --git a/api/core/trigger/errors.py b/api/core/trigger/errors.py new file mode 100644 index 0000000000..4edb1def22 --- /dev/null +++ b/api/core/trigger/errors.py @@ -0,0 +1,19 @@ +from core.plugin.impl.exc import PluginInvokeError + + +class TriggerProviderCredentialValidationError(ValueError): + pass + + +class TriggerPluginInvokeError(PluginInvokeError): + pass + + +class TriggerInvokeError(PluginInvokeError): + pass + + +class EventIgnoreError(TriggerInvokeError): + """ + Trigger event ignore error + """ diff --git a/api/core/trigger/provider.py b/api/core/trigger/provider.py new file mode 100644 index 0000000000..10fa31fdfa --- /dev/null +++ b/api/core/trigger/provider.py @@ -0,0 +1,421 @@ +""" +Trigger Provider Controller for managing trigger providers +""" + +import logging +from collections.abc import Mapping +from typing import Any + +from flask import Request + +from core.entities.provider_entities import BasicProviderConfig +from core.plugin.entities.plugin_daemon import CredentialType +from core.plugin.entities.request import ( + TriggerDispatchResponse, + TriggerInvokeEventResponse, + TriggerSubscriptionResponse, +) +from core.plugin.impl.trigger import PluginTriggerClient +from core.trigger.entities.api_entities import EventApiEntity, TriggerProviderApiEntity +from core.trigger.entities.entities import ( + EventEntity, + EventParameter, + ProviderConfig, + Subscription, + SubscriptionConstructor, + TriggerCreationMethod, + TriggerProviderEntity, + TriggerProviderIdentity, + UnsubscribeResult, +) +from core.trigger.errors import TriggerProviderCredentialValidationError +from models.provider_ids import TriggerProviderID +from services.plugin.plugin_service import PluginService + +logger = logging.getLogger(__name__) + + +class PluginTriggerProviderController: + """ + Controller for plugin trigger providers + """ + + def __init__( + self, + entity: TriggerProviderEntity, + plugin_id: str, + plugin_unique_identifier: str, + provider_id: TriggerProviderID, + tenant_id: str, + ): + """ + Initialize plugin trigger provider controller + + :param entity: Trigger provider entity + :param plugin_id: Plugin ID + :param plugin_unique_identifier: Plugin unique identifier + :param provider_id: Provider ID + :param tenant_id: Tenant ID + """ + self.entity = entity + self.tenant_id = tenant_id + self.plugin_id = plugin_id + self.provider_id = provider_id + self.plugin_unique_identifier = plugin_unique_identifier + + def get_provider_id(self) -> TriggerProviderID: + """ + Get provider ID + """ + return self.provider_id + + def to_api_entity(self) -> TriggerProviderApiEntity: + """ + Convert to API entity + """ + icon = ( + PluginService.get_plugin_icon_url(self.tenant_id, self.entity.identity.icon) + if self.entity.identity.icon + else None + ) + icon_dark = ( + PluginService.get_plugin_icon_url(self.tenant_id, self.entity.identity.icon_dark) + if self.entity.identity.icon_dark + else None + ) + subscription_constructor = self.entity.subscription_constructor + supported_creation_methods = [TriggerCreationMethod.MANUAL] + if subscription_constructor and subscription_constructor.oauth_schema: + supported_creation_methods.append(TriggerCreationMethod.OAUTH) + if subscription_constructor and subscription_constructor.credentials_schema: + supported_creation_methods.append(TriggerCreationMethod.APIKEY) + return TriggerProviderApiEntity( + author=self.entity.identity.author, + name=self.entity.identity.name, + label=self.entity.identity.label, + description=self.entity.identity.description, + icon=icon, + icon_dark=icon_dark, + tags=self.entity.identity.tags, + plugin_id=self.plugin_id, + plugin_unique_identifier=self.plugin_unique_identifier, + subscription_constructor=subscription_constructor, + subscription_schema=self.entity.subscription_schema, + supported_creation_methods=supported_creation_methods, + events=[ + EventApiEntity( + name=event.identity.name, + identity=event.identity, + description=event.description, + parameters=event.parameters, + output_schema=event.output_schema, + ) + for event in self.entity.events + ], + ) + + @property + def identity(self) -> TriggerProviderIdentity: + """Get provider identity""" + return self.entity.identity + + def get_events(self) -> list[EventEntity]: + """ + Get all events for this provider + + :return: List of event entities + """ + return self.entity.events + + def get_event(self, event_name: str) -> EventEntity | None: + """ + Get a specific event by name + + :param event_name: Event name + :return: Event entity or None + """ + for event in self.entity.events: + if event.identity.name == event_name: + return event + return None + + def get_subscription_default_properties(self) -> Mapping[str, Any]: + """ + Get default properties for this provider + + :return: Default properties + """ + return {prop.name: prop.default for prop in self.entity.subscription_schema if prop.default} + + def get_subscription_constructor(self) -> SubscriptionConstructor | None: + """ + Get subscription constructor for this provider + + :return: Subscription constructor + """ + return self.entity.subscription_constructor + + def validate_credentials(self, user_id: str, credentials: Mapping[str, str]) -> None: + """ + Validate credentials against schema + + :param credentials: Credentials to validate + :return: Validation response + """ + # First validate against schema + subscription_constructor: SubscriptionConstructor | None = self.entity.subscription_constructor + if not subscription_constructor: + raise ValueError("Subscription constructor not found") + for config in subscription_constructor.credentials_schema or []: + if config.required and config.name not in credentials: + raise TriggerProviderCredentialValidationError(f"Missing required credential field: {config.name}") + + # Then validate with the plugin daemon + manager = PluginTriggerClient() + provider_id = self.get_provider_id() + response = manager.validate_provider_credentials( + tenant_id=self.tenant_id, + user_id=user_id, + provider=str(provider_id), + credentials=credentials, + ) + if not response: + raise TriggerProviderCredentialValidationError( + "Invalid credentials", + ) + + def get_supported_credential_types(self) -> list[CredentialType]: + """ + Get supported credential types for this provider. + + :return: List of supported credential types + """ + types: list[CredentialType] = [] + subscription_constructor = self.entity.subscription_constructor + if subscription_constructor and subscription_constructor.oauth_schema: + types.append(CredentialType.OAUTH2) + if subscription_constructor and subscription_constructor.credentials_schema: + types.append(CredentialType.API_KEY) + return types + + def get_credentials_schema(self, credential_type: CredentialType | str) -> list[ProviderConfig]: + """ + Get credentials schema by credential type + + :param credential_type: The type of credential (oauth or api_key) + :return: List of provider config schemas + """ + subscription_constructor = self.entity.subscription_constructor + if not subscription_constructor: + return [] + credential_type = CredentialType.of(credential_type) + if credential_type == CredentialType.OAUTH2: + return ( + subscription_constructor.oauth_schema.credentials_schema.copy() + if subscription_constructor and subscription_constructor.oauth_schema + else [] + ) + if credential_type == CredentialType.API_KEY: + return ( + subscription_constructor.credentials_schema.copy() or [] + if subscription_constructor and subscription_constructor.credentials_schema + else [] + ) + if credential_type == CredentialType.UNAUTHORIZED: + return [] + raise ValueError(f"Invalid credential type: {credential_type}") + + def get_credential_schema_config(self, credential_type: CredentialType | str) -> list[BasicProviderConfig]: + """ + Get credential schema config by credential type + """ + return [x.to_basic_provider_config() for x in self.get_credentials_schema(credential_type)] + + def get_oauth_client_schema(self) -> list[ProviderConfig]: + """ + Get OAuth client schema for this provider + + :return: List of OAuth client config schemas + """ + subscription_constructor = self.entity.subscription_constructor + return ( + subscription_constructor.oauth_schema.client_schema.copy() + if subscription_constructor and subscription_constructor.oauth_schema + else [] + ) + + def get_properties_schema(self) -> list[BasicProviderConfig]: + """ + Get properties schema for this provider + + :return: List of properties config schemas + """ + return ( + [x.to_basic_provider_config() for x in self.entity.subscription_schema.copy()] + if self.entity.subscription_schema + else [] + ) + + def get_event_parameters(self, event_name: str) -> Mapping[str, EventParameter]: + """ + Get event parameters for this provider + """ + event = self.get_event(event_name) + if not event: + return {} + return {parameter.name: parameter for parameter in event.parameters} + + def dispatch( + self, + request: Request, + subscription: Subscription, + credentials: Mapping[str, str], + credential_type: CredentialType, + ) -> TriggerDispatchResponse: + """ + Dispatch a trigger through plugin runtime + + :param user_id: User ID + :param request: Flask request object + :param subscription: Subscription + :param credentials: Provider credentials + :param credential_type: Credential type + :return: Dispatch response with triggers and raw HTTP response + """ + manager = PluginTriggerClient() + provider_id: TriggerProviderID = self.get_provider_id() + + response: TriggerDispatchResponse = manager.dispatch_event( + tenant_id=self.tenant_id, + provider=str(provider_id), + subscription=subscription.model_dump(), + request=request, + credentials=credentials, + credential_type=credential_type, + ) + return response + + def invoke_trigger_event( + self, + user_id: str, + event_name: str, + parameters: Mapping[str, Any], + credentials: Mapping[str, str], + credential_type: CredentialType, + subscription: Subscription, + request: Request, + payload: Mapping[str, Any], + ) -> TriggerInvokeEventResponse: + """ + Execute a trigger through plugin runtime + + :param user_id: User ID + :param event_name: Event name + :param parameters: Trigger parameters + :param credentials: Provider credentials + :param credential_type: Credential type + :param request: Request + :param payload: Payload + :return: Trigger execution result + """ + manager = PluginTriggerClient() + provider_id: TriggerProviderID = self.get_provider_id() + + return manager.invoke_trigger_event( + tenant_id=self.tenant_id, + user_id=user_id, + provider=str(provider_id), + event_name=event_name, + credentials=credentials, + credential_type=credential_type, + request=request, + parameters=parameters, + subscription=subscription, + payload=payload, + ) + + def subscribe_trigger( + self, + user_id: str, + endpoint: str, + parameters: Mapping[str, Any], + credentials: Mapping[str, str], + credential_type: CredentialType, + ) -> Subscription: + """ + Subscribe to a trigger through plugin runtime + + :param user_id: User ID + :param endpoint: Subscription endpoint + :param subscription_params: Subscription parameters + :param credentials: Provider credentials + :param credential_type: Credential type + :return: Subscription result + """ + manager = PluginTriggerClient() + provider_id: TriggerProviderID = self.get_provider_id() + + response: TriggerSubscriptionResponse = manager.subscribe( + tenant_id=self.tenant_id, + user_id=user_id, + provider=str(provider_id), + endpoint=endpoint, + parameters=parameters, + credentials=credentials, + credential_type=credential_type, + ) + + return Subscription.model_validate(response.subscription) + + def unsubscribe_trigger( + self, user_id: str, subscription: Subscription, credentials: Mapping[str, str], credential_type: CredentialType + ) -> UnsubscribeResult: + """ + Unsubscribe from a trigger through plugin runtime + + :param user_id: User ID + :param subscription: Subscription metadata + :param credentials: Provider credentials + :param credential_type: Credential type + :return: Unsubscribe result + """ + manager = PluginTriggerClient() + provider_id: TriggerProviderID = self.get_provider_id() + + response: TriggerSubscriptionResponse = manager.unsubscribe( + tenant_id=self.tenant_id, + user_id=user_id, + provider=str(provider_id), + subscription=subscription, + credentials=credentials, + credential_type=credential_type, + ) + + return UnsubscribeResult.model_validate(response.subscription) + + def refresh_trigger( + self, subscription: Subscription, credentials: Mapping[str, str], credential_type: CredentialType + ) -> Subscription: + """ + Refresh a trigger subscription through plugin runtime + + :param subscription: Subscription metadata + :param credentials: Provider credentials + :return: Refreshed subscription result + """ + manager = PluginTriggerClient() + provider_id: TriggerProviderID = self.get_provider_id() + + response: TriggerSubscriptionResponse = manager.refresh( + tenant_id=self.tenant_id, + user_id="system", # System refresh + provider=str(provider_id), + subscription=subscription, + credentials=credentials, + credential_type=credential_type, + ) + + return Subscription.model_validate(response.subscription) + + +__all__ = ["PluginTriggerProviderController"] diff --git a/api/core/trigger/trigger_manager.py b/api/core/trigger/trigger_manager.py new file mode 100644 index 0000000000..0ef968b265 --- /dev/null +++ b/api/core/trigger/trigger_manager.py @@ -0,0 +1,285 @@ +""" +Trigger Manager for loading and managing trigger providers and triggers +""" + +import logging +from collections.abc import Mapping +from threading import Lock +from typing import Any + +from flask import Request + +import contexts +from configs import dify_config +from core.plugin.entities.plugin_daemon import CredentialType, PluginTriggerProviderEntity +from core.plugin.entities.request import TriggerInvokeEventResponse +from core.plugin.impl.exc import PluginDaemonError, PluginNotFoundError +from core.plugin.impl.trigger import PluginTriggerClient +from core.trigger.entities.entities import ( + EventEntity, + Subscription, + UnsubscribeResult, +) +from core.trigger.errors import EventIgnoreError +from core.trigger.provider import PluginTriggerProviderController +from models.provider_ids import TriggerProviderID + +logger = logging.getLogger(__name__) + + +class TriggerManager: + """ + Manager for trigger providers and triggers + """ + + @classmethod + def get_trigger_plugin_icon(cls, tenant_id: str, provider_id: str) -> str: + """ + Get the icon of a trigger plugin + """ + manager = PluginTriggerClient() + provider: PluginTriggerProviderEntity = manager.fetch_trigger_provider( + tenant_id=tenant_id, provider_id=TriggerProviderID(provider_id) + ) + filename = provider.declaration.identity.icon + base_url = f"{dify_config.CONSOLE_API_URL}/console/api/workspaces/current/plugin/icon" + return f"{base_url}?tenant_id={tenant_id}&filename={filename}" + + @classmethod + def list_plugin_trigger_providers(cls, tenant_id: str) -> list[PluginTriggerProviderController]: + """ + List all plugin trigger providers for a tenant + + :param tenant_id: Tenant ID + :return: List of trigger provider controllers + """ + manager = PluginTriggerClient() + provider_entities = manager.fetch_trigger_providers(tenant_id) + + controllers: list[PluginTriggerProviderController] = [] + for provider in provider_entities: + try: + controller = PluginTriggerProviderController( + entity=provider.declaration, + plugin_id=provider.plugin_id, + plugin_unique_identifier=provider.plugin_unique_identifier, + provider_id=TriggerProviderID(provider.provider), + tenant_id=tenant_id, + ) + controllers.append(controller) + except Exception: + logger.exception("Failed to load trigger provider %s", provider.plugin_id) + continue + + return controllers + + @classmethod + def get_trigger_provider(cls, tenant_id: str, provider_id: TriggerProviderID) -> PluginTriggerProviderController: + """ + Get a specific plugin trigger provider + + :param tenant_id: Tenant ID + :param provider_id: Provider ID + :return: Trigger provider controller or None + """ + # check if context is set + try: + contexts.plugin_trigger_providers.get() + except LookupError: + contexts.plugin_trigger_providers.set({}) + contexts.plugin_trigger_providers_lock.set(Lock()) + + plugin_trigger_providers = contexts.plugin_trigger_providers.get() + provider_id_str = str(provider_id) + if provider_id_str in plugin_trigger_providers: + return plugin_trigger_providers[provider_id_str] + + with contexts.plugin_trigger_providers_lock.get(): + # double check + plugin_trigger_providers = contexts.plugin_trigger_providers.get() + if provider_id_str in plugin_trigger_providers: + return plugin_trigger_providers[provider_id_str] + + try: + manager = PluginTriggerClient() + provider = manager.fetch_trigger_provider(tenant_id, provider_id) + + if not provider: + raise ValueError(f"Trigger provider {provider_id} not found") + + controller = PluginTriggerProviderController( + entity=provider.declaration, + plugin_id=provider.plugin_id, + plugin_unique_identifier=provider.plugin_unique_identifier, + provider_id=provider_id, + tenant_id=tenant_id, + ) + plugin_trigger_providers[provider_id_str] = controller + return controller + except PluginNotFoundError as e: + raise ValueError(f"Trigger provider {provider_id} not found") from e + except PluginDaemonError as e: + raise e + except Exception as e: + logger.exception("Failed to load trigger provider") + raise e + + @classmethod + def list_all_trigger_providers(cls, tenant_id: str) -> list[PluginTriggerProviderController]: + """ + List all trigger providers (plugin) + + :param tenant_id: Tenant ID + :return: List of all trigger provider controllers + """ + return cls.list_plugin_trigger_providers(tenant_id) + + @classmethod + def list_triggers_by_provider(cls, tenant_id: str, provider_id: TriggerProviderID) -> list[EventEntity]: + """ + List all triggers for a specific provider + + :param tenant_id: Tenant ID + :param provider_id: Provider ID + :return: List of trigger entities + """ + provider = cls.get_trigger_provider(tenant_id, provider_id) + return provider.get_events() + + @classmethod + def invoke_trigger_event( + cls, + tenant_id: str, + user_id: str, + provider_id: TriggerProviderID, + event_name: str, + parameters: Mapping[str, Any], + credentials: Mapping[str, str], + credential_type: CredentialType, + subscription: Subscription, + request: Request, + payload: Mapping[str, Any], + ) -> TriggerInvokeEventResponse: + """ + Execute a trigger + + :param tenant_id: Tenant ID + :param user_id: User ID + :param provider_id: Provider ID + :param event_name: Event name + :param parameters: Trigger parameters + :param credentials: Provider credentials + :param credential_type: Credential type + :param subscription: Subscription + :param request: Request + :param payload: Payload + :return: Trigger execution result + """ + provider: PluginTriggerProviderController = cls.get_trigger_provider( + tenant_id=tenant_id, provider_id=provider_id + ) + try: + return provider.invoke_trigger_event( + user_id=user_id, + event_name=event_name, + parameters=parameters, + credentials=credentials, + credential_type=credential_type, + subscription=subscription, + request=request, + payload=payload, + ) + except EventIgnoreError: + return TriggerInvokeEventResponse(variables={}, cancelled=True) + except Exception as e: + raise e + + @classmethod + def subscribe_trigger( + cls, + tenant_id: str, + user_id: str, + provider_id: TriggerProviderID, + endpoint: str, + parameters: Mapping[str, Any], + credentials: Mapping[str, str], + credential_type: CredentialType, + ) -> Subscription: + """ + Subscribe to a trigger (e.g., register webhook) + + :param tenant_id: Tenant ID + :param user_id: User ID + :param provider_id: Provider ID + :param endpoint: Subscription endpoint + :param parameters: Subscription parameters + :param credentials: Provider credentials + :param credential_type: Credential type + :return: Subscription result + """ + provider: PluginTriggerProviderController = cls.get_trigger_provider( + tenant_id=tenant_id, provider_id=provider_id + ) + return provider.subscribe_trigger( + user_id=user_id, + endpoint=endpoint, + parameters=parameters, + credentials=credentials, + credential_type=credential_type, + ) + + @classmethod + def unsubscribe_trigger( + cls, + tenant_id: str, + user_id: str, + provider_id: TriggerProviderID, + subscription: Subscription, + credentials: Mapping[str, str], + credential_type: CredentialType, + ) -> UnsubscribeResult: + """ + Unsubscribe from a trigger + + :param tenant_id: Tenant ID + :param user_id: User ID + :param provider_id: Provider ID + :param subscription: Subscription metadata from subscribe operation + :param credentials: Provider credentials + :param credential_type: Credential type + :return: Unsubscription result + """ + provider: PluginTriggerProviderController = cls.get_trigger_provider( + tenant_id=tenant_id, provider_id=provider_id + ) + return provider.unsubscribe_trigger( + user_id=user_id, + subscription=subscription, + credentials=credentials, + credential_type=credential_type, + ) + + @classmethod + def refresh_trigger( + cls, + tenant_id: str, + provider_id: TriggerProviderID, + subscription: Subscription, + credentials: Mapping[str, str], + credential_type: CredentialType, + ) -> Subscription: + """ + Refresh a trigger subscription + + :param tenant_id: Tenant ID + :param provider_id: Provider ID + :param subscription: Subscription metadata from subscribe operation + :param credentials: Provider credentials + :param credential_type: Credential type + :return: Refreshed subscription result + """ + + # TODO you should update the subscription using the return value of the refresh_trigger + return cls.get_trigger_provider(tenant_id=tenant_id, provider_id=provider_id).refresh_trigger( + subscription=subscription, credentials=credentials, credential_type=credential_type + ) diff --git a/api/core/trigger/utils/encryption.py b/api/core/trigger/utils/encryption.py new file mode 100644 index 0000000000..026a65aa23 --- /dev/null +++ b/api/core/trigger/utils/encryption.py @@ -0,0 +1,145 @@ +from collections.abc import Mapping +from typing import Union + +from core.entities.provider_entities import BasicProviderConfig, ProviderConfig +from core.helper.provider_cache import ProviderCredentialsCache +from core.helper.provider_encryption import ProviderConfigCache, ProviderConfigEncrypter, create_provider_encrypter +from core.plugin.entities.plugin_daemon import CredentialType +from core.trigger.entities.api_entities import TriggerProviderSubscriptionApiEntity +from core.trigger.provider import PluginTriggerProviderController +from models.trigger import TriggerSubscription + + +class TriggerProviderCredentialsCache(ProviderCredentialsCache): + """Cache for trigger provider credentials""" + + def __init__(self, tenant_id: str, provider_id: str, credential_id: str): + super().__init__(tenant_id=tenant_id, provider_id=provider_id, credential_id=credential_id) + + def _generate_cache_key(self, **kwargs) -> str: + tenant_id = kwargs["tenant_id"] + provider_id = kwargs["provider_id"] + credential_id = kwargs["credential_id"] + return f"trigger_credentials:tenant_id:{tenant_id}:provider_id:{provider_id}:credential_id:{credential_id}" + + +class TriggerProviderOAuthClientParamsCache(ProviderCredentialsCache): + """Cache for trigger provider OAuth client""" + + def __init__(self, tenant_id: str, provider_id: str): + super().__init__(tenant_id=tenant_id, provider_id=provider_id) + + def _generate_cache_key(self, **kwargs) -> str: + tenant_id = kwargs["tenant_id"] + provider_id = kwargs["provider_id"] + return f"trigger_oauth_client:tenant_id:{tenant_id}:provider_id:{provider_id}" + + +class TriggerProviderPropertiesCache(ProviderCredentialsCache): + """Cache for trigger provider properties""" + + def __init__(self, tenant_id: str, provider_id: str, subscription_id: str): + super().__init__(tenant_id=tenant_id, provider_id=provider_id, subscription_id=subscription_id) + + def _generate_cache_key(self, **kwargs) -> str: + tenant_id = kwargs["tenant_id"] + provider_id = kwargs["provider_id"] + subscription_id = kwargs["subscription_id"] + return f"trigger_properties:tenant_id:{tenant_id}:provider_id:{provider_id}:subscription_id:{subscription_id}" + + +def create_trigger_provider_encrypter_for_subscription( + tenant_id: str, + controller: PluginTriggerProviderController, + subscription: Union[TriggerSubscription, TriggerProviderSubscriptionApiEntity], +) -> tuple[ProviderConfigEncrypter, ProviderConfigCache]: + cache = TriggerProviderCredentialsCache( + tenant_id=tenant_id, + provider_id=str(controller.get_provider_id()), + credential_id=subscription.id, + ) + encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=controller.get_credential_schema_config(subscription.credential_type), + cache=cache, + ) + return encrypter, cache + + +def delete_cache_for_subscription(tenant_id: str, provider_id: str, subscription_id: str): + cache = TriggerProviderCredentialsCache( + tenant_id=tenant_id, + provider_id=provider_id, + credential_id=subscription_id, + ) + cache.delete() + + +def create_trigger_provider_encrypter_for_properties( + tenant_id: str, + controller: PluginTriggerProviderController, + subscription: Union[TriggerSubscription, TriggerProviderSubscriptionApiEntity], +) -> tuple[ProviderConfigEncrypter, ProviderConfigCache]: + cache = TriggerProviderPropertiesCache( + tenant_id=tenant_id, + provider_id=str(controller.get_provider_id()), + subscription_id=subscription.id, + ) + encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=controller.get_properties_schema(), + cache=cache, + ) + return encrypter, cache + + +def create_trigger_provider_encrypter( + tenant_id: str, controller: PluginTriggerProviderController, credential_id: str, credential_type: CredentialType +) -> tuple[ProviderConfigEncrypter, ProviderConfigCache]: + cache = TriggerProviderCredentialsCache( + tenant_id=tenant_id, + provider_id=str(controller.get_provider_id()), + credential_id=credential_id, + ) + encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=controller.get_credential_schema_config(credential_type), + cache=cache, + ) + return encrypter, cache + + +def create_trigger_provider_oauth_encrypter( + tenant_id: str, controller: PluginTriggerProviderController +) -> tuple[ProviderConfigEncrypter, ProviderConfigCache]: + cache = TriggerProviderOAuthClientParamsCache( + tenant_id=tenant_id, + provider_id=str(controller.get_provider_id()), + ) + encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=[x.to_basic_provider_config() for x in controller.get_oauth_client_schema()], + cache=cache, + ) + return encrypter, cache + + +def masked_credentials( + schemas: list[ProviderConfig], + credentials: Mapping[str, str], +) -> Mapping[str, str]: + masked_credentials = {} + configs = {x.name: x.to_basic_provider_config() for x in schemas} + for key, value in credentials.items(): + config = configs.get(key) + if not config: + masked_credentials[key] = value + continue + if config.type == BasicProviderConfig.Type.SECRET_INPUT: + if len(value) <= 4: + masked_credentials[key] = "*" * len(value) + else: + masked_credentials[key] = value[:2] + "*" * (len(value) - 4) + value[-2:] + else: + masked_credentials[key] = value + return masked_credentials diff --git a/api/core/trigger/utils/endpoint.py b/api/core/trigger/utils/endpoint.py new file mode 100644 index 0000000000..b282d62d58 --- /dev/null +++ b/api/core/trigger/utils/endpoint.py @@ -0,0 +1,24 @@ +from yarl import URL + +from configs import dify_config + +""" +Basic URL for thirdparty trigger services +""" +base_url = URL(dify_config.TRIGGER_URL) + + +def generate_plugin_trigger_endpoint_url(endpoint_id: str) -> str: + """ + Generate url for plugin trigger endpoint url + """ + + return str(base_url / "triggers" / "plugin" / endpoint_id) + + +def generate_webhook_trigger_endpoint(webhook_id: str, debug: bool = False) -> str: + """ + Generate url for webhook trigger endpoint url + """ + + return str(base_url / "triggers" / ("webhook-debug" if debug else "webhook") / webhook_id) diff --git a/api/core/trigger/utils/locks.py b/api/core/trigger/utils/locks.py new file mode 100644 index 0000000000..46833396e0 --- /dev/null +++ b/api/core/trigger/utils/locks.py @@ -0,0 +1,12 @@ +from collections.abc import Sequence +from itertools import starmap + + +def build_trigger_refresh_lock_key(tenant_id: str, subscription_id: str) -> str: + """Build the Redis lock key for trigger subscription refresh in-flight protection.""" + return f"trigger_provider_refresh_lock:{tenant_id}_{subscription_id}" + + +def build_trigger_refresh_lock_keys(pairs: Sequence[tuple[str, str]]) -> list[str]: + """Build Redis lock keys for a sequence of (tenant_id, subscription_id) pairs.""" + return list(starmap(build_trigger_refresh_lock_key, pairs)) diff --git a/api/core/workflow/enums.py b/api/core/workflow/enums.py index 6f95ecc76f..cf12d5ec1f 100644 --- a/api/core/workflow/enums.py +++ b/api/core/workflow/enums.py @@ -22,6 +22,7 @@ class SystemVariableKey(StrEnum): APP_ID = "app_id" WORKFLOW_ID = "workflow_id" WORKFLOW_EXECUTION_ID = "workflow_run_id" + TIMESTAMP = "timestamp" # RAG Pipeline DOCUMENT_ID = "document_id" ORIGINAL_DOCUMENT_ID = "original_document_id" @@ -58,8 +59,31 @@ class NodeType(StrEnum): DOCUMENT_EXTRACTOR = "document-extractor" LIST_OPERATOR = "list-operator" AGENT = "agent" + TRIGGER_WEBHOOK = "trigger-webhook" + TRIGGER_SCHEDULE = "trigger-schedule" + TRIGGER_PLUGIN = "trigger-plugin" HUMAN_INPUT = "human-input" + @property + def is_trigger_node(self) -> bool: + """Check if this node type is a trigger node.""" + return self in [ + NodeType.TRIGGER_WEBHOOK, + NodeType.TRIGGER_SCHEDULE, + NodeType.TRIGGER_PLUGIN, + ] + + @property + def is_start_node(self) -> bool: + """Check if this node type can serve as a workflow entry point.""" + return self in [ + NodeType.START, + NodeType.DATASOURCE, + NodeType.TRIGGER_WEBHOOK, + NodeType.TRIGGER_SCHEDULE, + NodeType.TRIGGER_PLUGIN, + ] + class NodeExecutionType(StrEnum): """Node execution type classification.""" @@ -208,6 +232,7 @@ class WorkflowNodeExecutionMetadataKey(StrEnum): CURRENCY = "currency" TOOL_INFO = "tool_info" AGENT_LOG = "agent_log" + TRIGGER_INFO = "trigger_info" ITERATION_ID = "iteration_id" ITERATION_INDEX = "iteration_index" LOOP_ID = "loop_id" diff --git a/api/core/workflow/graph/graph.py b/api/core/workflow/graph/graph.py index d04724425c..ba5a01fc94 100644 --- a/api/core/workflow/graph/graph.py +++ b/api/core/workflow/graph/graph.py @@ -117,7 +117,7 @@ class Graph: node_type = node_data.get("type") if not isinstance(node_type, str): continue - if node_type in [NodeType.START, NodeType.DATASOURCE]: + if NodeType(node_type).is_start_node: start_node_id = nid break diff --git a/api/core/workflow/graph/validation.py b/api/core/workflow/graph/validation.py index 87aa7db2e4..41b4fdfa60 100644 --- a/api/core/workflow/graph/validation.py +++ b/api/core/workflow/graph/validation.py @@ -114,9 +114,45 @@ class GraphValidator: raise GraphValidationError(issues) +@dataclass(frozen=True, slots=True) +class _TriggerStartExclusivityValidator: + """Ensures trigger nodes do not coexist with UserInput (start) nodes.""" + + conflict_code: str = "TRIGGER_START_NODE_CONFLICT" + + def validate(self, graph: Graph) -> Sequence[GraphValidationIssue]: + start_node_id: str | None = None + trigger_node_ids: list[str] = [] + + for node in graph.nodes.values(): + node_type = getattr(node, "node_type", None) + if not isinstance(node_type, NodeType): + continue + + if node_type == NodeType.START: + start_node_id = node.id + elif node_type.is_trigger_node: + trigger_node_ids.append(node.id) + + if start_node_id and trigger_node_ids: + trigger_list = ", ".join(trigger_node_ids) + return [ + GraphValidationIssue( + code=self.conflict_code, + message=( + f"UserInput (start) node '{start_node_id}' cannot coexist with trigger nodes: {trigger_list}." + ), + node_id=start_node_id, + ) + ] + + return [] + + _DEFAULT_RULES: tuple[GraphValidationRule, ...] = ( _EdgeEndpointValidator(), _RootNodeValidator(), + _TriggerStartExclusivityValidator(), ) diff --git a/api/core/workflow/nodes/base/node.py b/api/core/workflow/nodes/base/node.py index 7f8c1eddff..eda030699a 100644 --- a/api/core/workflow/nodes/base/node.py +++ b/api/core/workflow/nodes/base/node.py @@ -126,6 +126,12 @@ class Node: start_event.provider_id = f"{plugin_id}/{provider_name}" start_event.provider_type = getattr(self.get_base_node_data(), "provider_type", "") + from core.workflow.nodes.trigger_plugin.trigger_event_node import TriggerEventNode + + if isinstance(self, TriggerEventNode): + start_event.provider_id = getattr(self.get_base_node_data(), "provider_id", "") + start_event.provider_type = getattr(self.get_base_node_data(), "provider_type", "") + from typing import cast from core.workflow.nodes.agent.agent_node import AgentNode diff --git a/api/core/workflow/nodes/node_mapping.py b/api/core/workflow/nodes/node_mapping.py index 3ee28802f1..b926645f18 100644 --- a/api/core/workflow/nodes/node_mapping.py +++ b/api/core/workflow/nodes/node_mapping.py @@ -22,6 +22,9 @@ from core.workflow.nodes.question_classifier import QuestionClassifierNode from core.workflow.nodes.start import StartNode from core.workflow.nodes.template_transform import TemplateTransformNode from core.workflow.nodes.tool import ToolNode +from core.workflow.nodes.trigger_plugin import TriggerEventNode +from core.workflow.nodes.trigger_schedule import TriggerScheduleNode +from core.workflow.nodes.trigger_webhook import TriggerWebhookNode from core.workflow.nodes.variable_aggregator import VariableAggregatorNode from core.workflow.nodes.variable_assigner.v1 import VariableAssignerNode as VariableAssignerNodeV1 from core.workflow.nodes.variable_assigner.v2 import VariableAssignerNode as VariableAssignerNodeV2 @@ -147,4 +150,16 @@ NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[Node]]] = { LATEST_VERSION: KnowledgeIndexNode, "1": KnowledgeIndexNode, }, + NodeType.TRIGGER_WEBHOOK: { + LATEST_VERSION: TriggerWebhookNode, + "1": TriggerWebhookNode, + }, + NodeType.TRIGGER_PLUGIN: { + LATEST_VERSION: TriggerEventNode, + "1": TriggerEventNode, + }, + NodeType.TRIGGER_SCHEDULE: { + LATEST_VERSION: TriggerScheduleNode, + "1": TriggerScheduleNode, + }, } diff --git a/api/core/workflow/nodes/tool/tool_node.py b/api/core/workflow/nodes/tool/tool_node.py index 69ab6f0718..799ad9b92f 100644 --- a/api/core/workflow/nodes/tool/tool_node.py +++ b/api/core/workflow/nodes/tool/tool_node.py @@ -164,10 +164,7 @@ class ToolNode(Node): status=WorkflowNodeExecutionStatus.FAILED, inputs=parameters_for_log, metadata={WorkflowNodeExecutionMetadataKey.TOOL_INFO: tool_info}, - error="An error occurred in the plugin, " - f"please contact the author of {node_data.provider_name} for help, " - f"error type: {e.get_error_type()}, " - f"error details: {e.get_error_message()}", + error=e.to_user_friendly_error(plugin_name=node_data.provider_name), error_type=type(e).__name__, ) ) diff --git a/api/core/workflow/nodes/trigger_plugin/__init__.py b/api/core/workflow/nodes/trigger_plugin/__init__.py new file mode 100644 index 0000000000..0f700fbcf9 --- /dev/null +++ b/api/core/workflow/nodes/trigger_plugin/__init__.py @@ -0,0 +1,3 @@ +from .trigger_event_node import TriggerEventNode + +__all__ = ["TriggerEventNode"] diff --git a/api/core/workflow/nodes/trigger_plugin/entities.py b/api/core/workflow/nodes/trigger_plugin/entities.py new file mode 100644 index 0000000000..6c53acee4f --- /dev/null +++ b/api/core/workflow/nodes/trigger_plugin/entities.py @@ -0,0 +1,77 @@ +from collections.abc import Mapping +from typing import Any, Literal, Union + +from pydantic import BaseModel, Field, ValidationInfo, field_validator + +from core.trigger.entities.entities import EventParameter +from core.workflow.nodes.base.entities import BaseNodeData +from core.workflow.nodes.trigger_plugin.exc import TriggerEventParameterError + + +class TriggerEventNodeData(BaseNodeData): + """Plugin trigger node data""" + + class TriggerEventInput(BaseModel): + value: Union[Any, list[str]] + type: Literal["mixed", "variable", "constant"] + + @field_validator("type", mode="before") + @classmethod + def check_type(cls, value, validation_info: ValidationInfo): + type = value + value = validation_info.data.get("value") + + if value is None: + return type + + if type == "mixed" and not isinstance(value, str): + raise ValueError("value must be a string") + + if type == "variable": + if not isinstance(value, list): + raise ValueError("value must be a list") + for val in value: + if not isinstance(val, str): + raise ValueError("value must be a list of strings") + + if type == "constant" and not isinstance(value, str | int | float | bool | dict | list): + raise ValueError("value must be a string, int, float, bool or dict") + return type + + title: str + desc: str | None = None + plugin_id: str = Field(..., description="Plugin ID") + provider_id: str = Field(..., description="Provider ID") + event_name: str = Field(..., description="Event name") + subscription_id: str = Field(..., description="Subscription ID") + plugin_unique_identifier: str = Field(..., description="Plugin unique identifier") + event_parameters: Mapping[str, TriggerEventInput] = Field(default_factory=dict, description="Trigger parameters") + + def resolve_parameters( + self, + *, + parameter_schemas: Mapping[str, EventParameter], + ) -> Mapping[str, Any]: + """ + Generate parameters based on the given plugin trigger parameters. + + Args: + parameter_schemas (Mapping[str, EventParameter]): The mapping of parameter schemas. + + Returns: + Mapping[str, Any]: A dictionary containing the generated parameters. + + """ + result: dict[str, Any] = {} + for parameter_name in self.event_parameters: + parameter: EventParameter | None = parameter_schemas.get(parameter_name) + if not parameter: + result[parameter_name] = None + continue + event_input = self.event_parameters[parameter_name] + + # trigger node only supports constant input + if event_input.type != "constant": + raise TriggerEventParameterError(f"Unknown plugin trigger input type '{event_input.type}'") + result[parameter_name] = event_input.value + return result diff --git a/api/core/workflow/nodes/trigger_plugin/exc.py b/api/core/workflow/nodes/trigger_plugin/exc.py new file mode 100644 index 0000000000..ba884b325c --- /dev/null +++ b/api/core/workflow/nodes/trigger_plugin/exc.py @@ -0,0 +1,10 @@ +class TriggerEventNodeError(ValueError): + """Base exception for plugin trigger node errors.""" + + pass + + +class TriggerEventParameterError(TriggerEventNodeError): + """Exception raised for errors in plugin trigger parameters.""" + + pass diff --git a/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py b/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py new file mode 100644 index 0000000000..c4c2ff87db --- /dev/null +++ b/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py @@ -0,0 +1,89 @@ +from collections.abc import Mapping +from typing import Any + +from core.workflow.constants import SYSTEM_VARIABLE_NODE_ID +from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus +from core.workflow.enums import ErrorStrategy, NodeExecutionType, NodeType +from core.workflow.node_events import NodeRunResult +from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig +from core.workflow.nodes.base.node import Node + +from .entities import TriggerEventNodeData + + +class TriggerEventNode(Node): + node_type = NodeType.TRIGGER_PLUGIN + execution_type = NodeExecutionType.ROOT + + _node_data: TriggerEventNodeData + + def init_node_data(self, data: Mapping[str, Any]) -> None: + self._node_data = TriggerEventNodeData.model_validate(data) + + def _get_error_strategy(self) -> ErrorStrategy | None: + return self._node_data.error_strategy + + def _get_retry_config(self) -> RetryConfig: + return self._node_data.retry_config + + def _get_title(self) -> str: + return self._node_data.title + + def _get_description(self) -> str | None: + return self._node_data.desc + + def _get_default_value_dict(self) -> dict[str, Any]: + return self._node_data.default_value_dict + + def get_base_node_data(self) -> BaseNodeData: + return self._node_data + + @classmethod + def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]: + return { + "type": "plugin", + "config": { + "title": "", + "plugin_id": "", + "provider_id": "", + "event_name": "", + "subscription_id": "", + "plugin_unique_identifier": "", + "event_parameters": {}, + }, + } + + @classmethod + def version(cls) -> str: + return "1" + + def _run(self) -> NodeRunResult: + """ + Run the plugin trigger node. + + This node invokes the trigger to convert request data into events + and makes them available to downstream nodes. + """ + + # Get trigger data passed when workflow was triggered + metadata = { + WorkflowNodeExecutionMetadataKey.TRIGGER_INFO: { + "provider_id": self._node_data.provider_id, + "event_name": self._node_data.event_name, + "plugin_unique_identifier": self._node_data.plugin_unique_identifier, + }, + } + node_inputs = dict(self.graph_runtime_state.variable_pool.user_inputs) + system_inputs = self.graph_runtime_state.variable_pool.system_variables.to_dict() + + # TODO: System variables should be directly accessible, no need for special handling + # Set system variables as node outputs. + for var in system_inputs: + node_inputs[SYSTEM_VARIABLE_NODE_ID + "." + var] = system_inputs[var] + outputs = dict(node_inputs) + return NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + inputs=node_inputs, + outputs=outputs, + metadata=metadata, + ) diff --git a/api/core/workflow/nodes/trigger_schedule/__init__.py b/api/core/workflow/nodes/trigger_schedule/__init__.py new file mode 100644 index 0000000000..6773bae502 --- /dev/null +++ b/api/core/workflow/nodes/trigger_schedule/__init__.py @@ -0,0 +1,3 @@ +from core.workflow.nodes.trigger_schedule.trigger_schedule_node import TriggerScheduleNode + +__all__ = ["TriggerScheduleNode"] diff --git a/api/core/workflow/nodes/trigger_schedule/entities.py b/api/core/workflow/nodes/trigger_schedule/entities.py new file mode 100644 index 0000000000..a515d02d55 --- /dev/null +++ b/api/core/workflow/nodes/trigger_schedule/entities.py @@ -0,0 +1,49 @@ +from typing import Literal, Union + +from pydantic import BaseModel, Field + +from core.workflow.nodes.base import BaseNodeData + + +class TriggerScheduleNodeData(BaseNodeData): + """ + Trigger Schedule Node Data + """ + + mode: str = Field(default="visual", description="Schedule mode: visual or cron") + frequency: str | None = Field(default=None, description="Frequency for visual mode: hourly, daily, weekly, monthly") + cron_expression: str | None = Field(default=None, description="Cron expression for cron mode") + visual_config: dict | None = Field(default=None, description="Visual configuration details") + timezone: str = Field(default="UTC", description="Timezone for schedule execution") + + +class ScheduleConfig(BaseModel): + node_id: str + cron_expression: str + timezone: str = "UTC" + + +class SchedulePlanUpdate(BaseModel): + node_id: str | None = None + cron_expression: str | None = None + timezone: str | None = None + + +class VisualConfig(BaseModel): + """Visual configuration for schedule trigger""" + + # For hourly frequency + on_minute: int | None = Field(default=0, ge=0, le=59, description="Minute of the hour (0-59)") + + # For daily, weekly, monthly frequencies + time: str | None = Field(default="12:00 AM", description="Time in 12-hour format (e.g., '2:30 PM')") + + # For weekly frequency + weekdays: list[Literal["sun", "mon", "tue", "wed", "thu", "fri", "sat"]] | None = Field( + default=None, description="List of weekdays to run on" + ) + + # For monthly frequency + monthly_days: list[Union[int, Literal["last"]]] | None = Field( + default=None, description="Days of month to run on (1-31 or 'last')" + ) diff --git a/api/core/workflow/nodes/trigger_schedule/exc.py b/api/core/workflow/nodes/trigger_schedule/exc.py new file mode 100644 index 0000000000..2f99880ff1 --- /dev/null +++ b/api/core/workflow/nodes/trigger_schedule/exc.py @@ -0,0 +1,31 @@ +from core.workflow.nodes.base.exc import BaseNodeError + + +class ScheduleNodeError(BaseNodeError): + """Base schedule node error.""" + + pass + + +class ScheduleNotFoundError(ScheduleNodeError): + """Schedule not found error.""" + + pass + + +class ScheduleConfigError(ScheduleNodeError): + """Schedule configuration error.""" + + pass + + +class ScheduleExecutionError(ScheduleNodeError): + """Schedule execution error.""" + + pass + + +class TenantOwnerNotFoundError(ScheduleExecutionError): + """Tenant owner not found error for schedule execution.""" + + pass diff --git a/api/core/workflow/nodes/trigger_schedule/trigger_schedule_node.py b/api/core/workflow/nodes/trigger_schedule/trigger_schedule_node.py new file mode 100644 index 0000000000..98a841d1be --- /dev/null +++ b/api/core/workflow/nodes/trigger_schedule/trigger_schedule_node.py @@ -0,0 +1,69 @@ +from collections.abc import Mapping +from typing import Any + +from core.workflow.constants import SYSTEM_VARIABLE_NODE_ID +from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus +from core.workflow.enums import ErrorStrategy, NodeExecutionType, NodeType +from core.workflow.node_events import NodeRunResult +from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig +from core.workflow.nodes.base.node import Node +from core.workflow.nodes.trigger_schedule.entities import TriggerScheduleNodeData + + +class TriggerScheduleNode(Node): + node_type = NodeType.TRIGGER_SCHEDULE + execution_type = NodeExecutionType.ROOT + + _node_data: TriggerScheduleNodeData + + def init_node_data(self, data: Mapping[str, Any]) -> None: + self._node_data = TriggerScheduleNodeData(**data) + + def _get_error_strategy(self) -> ErrorStrategy | None: + return self._node_data.error_strategy + + def _get_retry_config(self) -> RetryConfig: + return self._node_data.retry_config + + def _get_title(self) -> str: + return self._node_data.title + + def _get_description(self) -> str | None: + return self._node_data.desc + + def _get_default_value_dict(self) -> dict[str, Any]: + return self._node_data.default_value_dict + + def get_base_node_data(self) -> BaseNodeData: + return self._node_data + + @classmethod + def version(cls) -> str: + return "1" + + @classmethod + def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]: + return { + "type": "trigger-schedule", + "config": { + "mode": "visual", + "frequency": "daily", + "visual_config": {"time": "12:00 AM", "on_minute": 0, "weekdays": ["sun"], "monthly_days": [1]}, + "timezone": "UTC", + }, + } + + def _run(self) -> NodeRunResult: + node_inputs = dict(self.graph_runtime_state.variable_pool.user_inputs) + system_inputs = self.graph_runtime_state.variable_pool.system_variables.to_dict() + + # TODO: System variables should be directly accessible, no need for special handling + # Set system variables as node outputs. + for var in system_inputs: + node_inputs[SYSTEM_VARIABLE_NODE_ID + "." + var] = system_inputs[var] + outputs = dict(node_inputs) + return NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + inputs=node_inputs, + outputs=outputs, + ) diff --git a/api/core/workflow/nodes/trigger_webhook/__init__.py b/api/core/workflow/nodes/trigger_webhook/__init__.py new file mode 100644 index 0000000000..e41d290f6d --- /dev/null +++ b/api/core/workflow/nodes/trigger_webhook/__init__.py @@ -0,0 +1,3 @@ +from .node import TriggerWebhookNode + +__all__ = ["TriggerWebhookNode"] diff --git a/api/core/workflow/nodes/trigger_webhook/entities.py b/api/core/workflow/nodes/trigger_webhook/entities.py new file mode 100644 index 0000000000..1011e60b43 --- /dev/null +++ b/api/core/workflow/nodes/trigger_webhook/entities.py @@ -0,0 +1,79 @@ +from collections.abc import Sequence +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + +from core.workflow.nodes.base import BaseNodeData + + +class Method(StrEnum): + GET = "get" + POST = "post" + HEAD = "head" + PATCH = "patch" + PUT = "put" + DELETE = "delete" + + +class ContentType(StrEnum): + JSON = "application/json" + FORM_DATA = "multipart/form-data" + FORM_URLENCODED = "application/x-www-form-urlencoded" + TEXT = "text/plain" + BINARY = "application/octet-stream" + + +class WebhookParameter(BaseModel): + """Parameter definition for headers, query params, or body.""" + + name: str + required: bool = False + + +class WebhookBodyParameter(BaseModel): + """Body parameter with type information.""" + + name: str + type: Literal[ + "string", + "number", + "boolean", + "object", + "array[string]", + "array[number]", + "array[boolean]", + "array[object]", + "file", + ] = "string" + required: bool = False + + +class WebhookData(BaseNodeData): + """ + Webhook Node Data. + """ + + class SyncMode(StrEnum): + SYNC = "async" # only support + + method: Method = Method.GET + content_type: ContentType = Field(default=ContentType.JSON) + headers: Sequence[WebhookParameter] = Field(default_factory=list) + params: Sequence[WebhookParameter] = Field(default_factory=list) # query parameters + body: Sequence[WebhookBodyParameter] = Field(default_factory=list) + + @field_validator("method", mode="before") + @classmethod + def normalize_method(cls, v) -> str: + """Normalize HTTP method to lowercase to support both uppercase and lowercase input.""" + if isinstance(v, str): + return v.lower() + return v + + status_code: int = 200 # Expected status code for response + response_body: str = "" # Template for response body + + # Webhook specific fields (not from client data, set internally) + webhook_id: str | None = None # Set when webhook trigger is created + timeout: int = 30 # Timeout in seconds to wait for webhook response diff --git a/api/core/workflow/nodes/trigger_webhook/exc.py b/api/core/workflow/nodes/trigger_webhook/exc.py new file mode 100644 index 0000000000..dc2239c287 --- /dev/null +++ b/api/core/workflow/nodes/trigger_webhook/exc.py @@ -0,0 +1,25 @@ +from core.workflow.nodes.base.exc import BaseNodeError + + +class WebhookNodeError(BaseNodeError): + """Base webhook node error.""" + + pass + + +class WebhookTimeoutError(WebhookNodeError): + """Webhook timeout error.""" + + pass + + +class WebhookNotFoundError(WebhookNodeError): + """Webhook not found error.""" + + pass + + +class WebhookConfigError(WebhookNodeError): + """Webhook configuration error.""" + + pass diff --git a/api/core/workflow/nodes/trigger_webhook/node.py b/api/core/workflow/nodes/trigger_webhook/node.py new file mode 100644 index 0000000000..15009f90d0 --- /dev/null +++ b/api/core/workflow/nodes/trigger_webhook/node.py @@ -0,0 +1,148 @@ +from collections.abc import Mapping +from typing import Any + +from core.workflow.constants import SYSTEM_VARIABLE_NODE_ID +from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus +from core.workflow.enums import ErrorStrategy, NodeExecutionType, NodeType +from core.workflow.node_events import NodeRunResult +from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig +from core.workflow.nodes.base.node import Node + +from .entities import ContentType, WebhookData + + +class TriggerWebhookNode(Node): + node_type = NodeType.TRIGGER_WEBHOOK + execution_type = NodeExecutionType.ROOT + + _node_data: WebhookData + + def init_node_data(self, data: Mapping[str, Any]) -> None: + self._node_data = WebhookData.model_validate(data) + + def _get_error_strategy(self) -> ErrorStrategy | None: + return self._node_data.error_strategy + + def _get_retry_config(self) -> RetryConfig: + return self._node_data.retry_config + + def _get_title(self) -> str: + return self._node_data.title + + def _get_description(self) -> str | None: + return self._node_data.desc + + def _get_default_value_dict(self) -> dict[str, Any]: + return self._node_data.default_value_dict + + def get_base_node_data(self) -> BaseNodeData: + return self._node_data + + @classmethod + def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]: + return { + "type": "webhook", + "config": { + "method": "get", + "content_type": "application/json", + "headers": [], + "params": [], + "body": [], + "async_mode": True, + "status_code": 200, + "response_body": "", + "timeout": 30, + }, + } + + @classmethod + def version(cls) -> str: + return "1" + + def _run(self) -> NodeRunResult: + """ + Run the webhook node. + + Like the start node, this simply takes the webhook data from the variable pool + and makes it available to downstream nodes. The actual webhook handling + happens in the trigger controller. + """ + # Get webhook data from variable pool (injected by Celery task) + webhook_inputs = dict(self.graph_runtime_state.variable_pool.user_inputs) + + # Extract webhook-specific outputs based on node configuration + outputs = self._extract_configured_outputs(webhook_inputs) + system_inputs = self.graph_runtime_state.variable_pool.system_variables.to_dict() + + # TODO: System variables should be directly accessible, no need for special handling + # Set system variables as node outputs. + for var in system_inputs: + outputs[SYSTEM_VARIABLE_NODE_ID + "." + var] = system_inputs[var] + return NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + inputs=webhook_inputs, + outputs=outputs, + ) + + def _extract_configured_outputs(self, webhook_inputs: dict[str, Any]) -> dict[str, Any]: + """Extract outputs based on node configuration from webhook inputs.""" + outputs = {} + + # Get the raw webhook data (should be injected by Celery task) + webhook_data = webhook_inputs.get("webhook_data", {}) + + def _to_sanitized(name: str) -> str: + return name.replace("-", "_") + + def _get_normalized(mapping: dict[str, Any], key: str) -> Any: + if not isinstance(mapping, dict): + return None + if key in mapping: + return mapping[key] + alternate = key.replace("-", "_") if "-" in key else key.replace("_", "-") + if alternate in mapping: + return mapping[alternate] + return None + + # Extract configured headers (case-insensitive) + webhook_headers = webhook_data.get("headers", {}) + webhook_headers_lower = {k.lower(): v for k, v in webhook_headers.items()} + + for header in self._node_data.headers: + header_name = header.name + value = _get_normalized(webhook_headers, header_name) + if value is None: + value = _get_normalized(webhook_headers_lower, header_name.lower()) + sanitized_name = _to_sanitized(header_name) + outputs[sanitized_name] = value + + # Extract configured query parameters + for param in self._node_data.params: + param_name = param.name + outputs[param_name] = webhook_data.get("query_params", {}).get(param_name) + + # Extract configured body parameters + for body_param in self._node_data.body: + param_name = body_param.name + param_type = body_param.type + + if self._node_data.content_type == ContentType.TEXT: + # For text/plain, the entire body is a single string parameter + outputs[param_name] = str(webhook_data.get("body", {}).get("raw", "")) + continue + elif self._node_data.content_type == ContentType.BINARY: + outputs[param_name] = webhook_data.get("body", {}).get("raw", b"") + continue + + if param_type == "file": + # Get File object (already processed by webhook controller) + file_obj = webhook_data.get("files", {}).get(param_name) + outputs[param_name] = file_obj + else: + # Get regular body parameter + outputs[param_name] = webhook_data.get("body", {}).get(param_name) + + # Include raw webhook data for debugging/advanced use + outputs["_webhook_raw"] = webhook_data + + return outputs diff --git a/api/core/workflow/system_variable.py b/api/core/workflow/system_variable.py index 29bf19716c..ad925912a4 100644 --- a/api/core/workflow/system_variable.py +++ b/api/core/workflow/system_variable.py @@ -29,6 +29,8 @@ class SystemVariable(BaseModel): app_id: str | None = None workflow_id: str | None = None + timestamp: int | None = None + files: Sequence[File] = Field(default_factory=list) # NOTE: The `workflow_execution_id` field was previously named `workflow_run_id`. @@ -108,6 +110,8 @@ class SystemVariable(BaseModel): d[SystemVariableKey.DATASOURCE_INFO] = self.datasource_info if self.invoke_from is not None: d[SystemVariableKey.INVOKE_FROM] = self.invoke_from + if self.timestamp is not None: + d[SystemVariableKey.TIMESTAMP] = self.timestamp return d def as_view(self) -> "SystemVariableReadOnlyView": diff --git a/api/docker/entrypoint.sh b/api/docker/entrypoint.sh index 41b5eb20b5..6313085e64 100755 --- a/api/docker/entrypoint.sh +++ b/api/docker/entrypoint.sh @@ -30,10 +30,42 @@ if [[ "${MODE}" == "worker" ]]; then CONCURRENCY_OPTION="-c ${CELERY_WORKER_AMOUNT:-1}" fi - exec celery -A celery_entrypoint.celery worker -P ${CELERY_WORKER_CLASS:-gevent} $CONCURRENCY_OPTION \ + # Configure queues based on edition if not explicitly set + if [[ -z "${CELERY_QUEUES}" ]]; then + if [[ "${EDITION}" == "CLOUD" ]]; then + # Cloud edition: separate queues for dataset and trigger tasks + DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor" + else + # Community edition (SELF_HOSTED): dataset, pipeline and workflow have separate queues + DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor" + fi + else + DEFAULT_QUEUES="${CELERY_QUEUES}" + fi + + # Support for Kubernetes deployment with specific queue workers + # Environment variables that can be set: + # - CELERY_WORKER_QUEUES: Comma-separated list of queues (overrides CELERY_QUEUES) + # - CELERY_WORKER_CONCURRENCY: Number of worker processes (overrides CELERY_WORKER_AMOUNT) + # - CELERY_WORKER_POOL: Pool implementation (overrides CELERY_WORKER_CLASS) + + if [[ -n "${CELERY_WORKER_QUEUES}" ]]; then + DEFAULT_QUEUES="${CELERY_WORKER_QUEUES}" + echo "Using CELERY_WORKER_QUEUES: ${DEFAULT_QUEUES}" + fi + + if [[ -n "${CELERY_WORKER_CONCURRENCY}" ]]; then + CONCURRENCY_OPTION="-c ${CELERY_WORKER_CONCURRENCY}" + echo "Using CELERY_WORKER_CONCURRENCY: ${CELERY_WORKER_CONCURRENCY}" + fi + + WORKER_POOL="${CELERY_WORKER_POOL:-${CELERY_WORKER_CLASS:-gevent}}" + echo "Starting Celery worker with queues: ${DEFAULT_QUEUES}" + + exec celery -A celery_entrypoint.celery worker -P ${WORKER_POOL} $CONCURRENCY_OPTION \ --max-tasks-per-child ${MAX_TASKS_PER_CHILD:-50} --loglevel ${LOG_LEVEL:-INFO} \ - -Q ${CELERY_QUEUES:-dataset,priority_dataset,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,priority_pipeline,pipeline} \ - --prefetch-multiplier=1 + -Q ${DEFAULT_QUEUES} \ + --prefetch-multiplier=${CELERY_PREFETCH_MULTIPLIER:-1} elif [[ "${MODE}" == "beat" ]]; then exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO} diff --git a/api/events/event_handlers/__init__.py b/api/events/event_handlers/__init__.py index d714747e59..c79764983b 100644 --- a/api/events/event_handlers/__init__.py +++ b/api/events/event_handlers/__init__.py @@ -6,12 +6,18 @@ from .create_site_record_when_app_created import handle as handle_create_site_re from .delete_tool_parameters_cache_when_sync_draft_workflow import ( handle as handle_delete_tool_parameters_cache_when_sync_draft_workflow, ) +from .sync_plugin_trigger_when_app_created import handle as handle_sync_plugin_trigger_when_app_created +from .sync_webhook_when_app_created import handle as handle_sync_webhook_when_app_created +from .sync_workflow_schedule_when_app_published import handle as handle_sync_workflow_schedule_when_app_published from .update_app_dataset_join_when_app_model_config_updated import ( handle as handle_update_app_dataset_join_when_app_model_config_updated, ) from .update_app_dataset_join_when_app_published_workflow_updated import ( handle as handle_update_app_dataset_join_when_app_published_workflow_updated, ) +from .update_app_triggers_when_app_published_workflow_updated import ( + handle as handle_update_app_triggers_when_app_published_workflow_updated, +) # Consolidated handler replaces both deduct_quota_when_message_created and # update_provider_last_used_at_when_message_created @@ -24,7 +30,11 @@ __all__ = [ "handle_create_installed_app_when_app_created", "handle_create_site_record_when_app_created", "handle_delete_tool_parameters_cache_when_sync_draft_workflow", + "handle_sync_plugin_trigger_when_app_created", + "handle_sync_webhook_when_app_created", + "handle_sync_workflow_schedule_when_app_published", "handle_update_app_dataset_join_when_app_model_config_updated", "handle_update_app_dataset_join_when_app_published_workflow_updated", + "handle_update_app_triggers_when_app_published_workflow_updated", "handle_update_provider_when_message_created", ] diff --git a/api/events/event_handlers/sync_plugin_trigger_when_app_created.py b/api/events/event_handlers/sync_plugin_trigger_when_app_created.py new file mode 100644 index 0000000000..68be37dfdb --- /dev/null +++ b/api/events/event_handlers/sync_plugin_trigger_when_app_created.py @@ -0,0 +1,22 @@ +import logging + +from events.app_event import app_draft_workflow_was_synced +from models.model import App, AppMode +from models.workflow import Workflow +from services.trigger.trigger_service import TriggerService + +logger = logging.getLogger(__name__) + + +@app_draft_workflow_was_synced.connect +def handle(sender, synced_draft_workflow: Workflow, **kwargs): + """ + While creating a workflow or updating a workflow, we may need to sync + its plugin trigger relationships in DB. + """ + app: App = sender + if app.mode != AppMode.WORKFLOW.value: + # only handle workflow app, chatflow is not supported yet + return + + TriggerService.sync_plugin_trigger_relationships(app, synced_draft_workflow) diff --git a/api/events/event_handlers/sync_webhook_when_app_created.py b/api/events/event_handlers/sync_webhook_when_app_created.py new file mode 100644 index 0000000000..481561faa2 --- /dev/null +++ b/api/events/event_handlers/sync_webhook_when_app_created.py @@ -0,0 +1,22 @@ +import logging + +from events.app_event import app_draft_workflow_was_synced +from models.model import App, AppMode +from models.workflow import Workflow +from services.trigger.webhook_service import WebhookService + +logger = logging.getLogger(__name__) + + +@app_draft_workflow_was_synced.connect +def handle(sender, synced_draft_workflow: Workflow, **kwargs): + """ + While creating a workflow or updating a workflow, we may need to sync + its webhook relationships in DB. + """ + app: App = sender + if app.mode != AppMode.WORKFLOW.value: + # only handle workflow app, chatflow is not supported yet + return + + WebhookService.sync_webhook_relationships(app, synced_draft_workflow) diff --git a/api/events/event_handlers/sync_workflow_schedule_when_app_published.py b/api/events/event_handlers/sync_workflow_schedule_when_app_published.py new file mode 100644 index 0000000000..168513fc04 --- /dev/null +++ b/api/events/event_handlers/sync_workflow_schedule_when_app_published.py @@ -0,0 +1,86 @@ +import logging +from typing import cast + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.workflow.nodes.trigger_schedule.entities import SchedulePlanUpdate +from events.app_event import app_published_workflow_was_updated +from extensions.ext_database import db +from models import AppMode, Workflow, WorkflowSchedulePlan +from services.trigger.schedule_service import ScheduleService + +logger = logging.getLogger(__name__) + + +@app_published_workflow_was_updated.connect +def handle(sender, **kwargs): + """ + Handle app published workflow update event to sync workflow_schedule_plans table. + + When a workflow is published, this handler will: + 1. Extract schedule trigger nodes from the workflow graph + 2. Compare with existing workflow_schedule_plans records + 3. Create/update/delete schedule plans as needed + """ + app = sender + if app.mode != AppMode.WORKFLOW.value: + return + + published_workflow = kwargs.get("published_workflow") + published_workflow = cast(Workflow, published_workflow) + + sync_schedule_from_workflow(tenant_id=app.tenant_id, app_id=app.id, workflow=published_workflow) + + +def sync_schedule_from_workflow(tenant_id: str, app_id: str, workflow: Workflow) -> WorkflowSchedulePlan | None: + """ + Sync schedule plan from workflow graph configuration. + + Args: + tenant_id: Tenant ID + app_id: App ID + workflow: Published workflow instance + + Returns: + Updated or created WorkflowSchedulePlan, or None if no schedule node + """ + with Session(db.engine) as session: + schedule_config = ScheduleService.extract_schedule_config(workflow) + + existing_plan = session.scalar( + select(WorkflowSchedulePlan).where( + WorkflowSchedulePlan.tenant_id == tenant_id, + WorkflowSchedulePlan.app_id == app_id, + ) + ) + + if not schedule_config: + if existing_plan: + logger.info("No schedule node in workflow for app %s, removing schedule plan", app_id) + ScheduleService.delete_schedule(session=session, schedule_id=existing_plan.id) + session.commit() + return None + + if existing_plan: + updates = SchedulePlanUpdate( + node_id=schedule_config.node_id, + cron_expression=schedule_config.cron_expression, + timezone=schedule_config.timezone, + ) + updated_plan = ScheduleService.update_schedule( + session=session, + schedule_id=existing_plan.id, + updates=updates, + ) + session.commit() + return updated_plan + else: + new_plan = ScheduleService.create_schedule( + session=session, + tenant_id=tenant_id, + app_id=app_id, + config=schedule_config, + ) + session.commit() + return new_plan diff --git a/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py b/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py new file mode 100644 index 0000000000..430514ada2 --- /dev/null +++ b/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @@ -0,0 +1,114 @@ +from typing import cast + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.workflow.nodes import NodeType +from events.app_event import app_published_workflow_was_updated +from extensions.ext_database import db +from models import AppMode +from models.enums import AppTriggerStatus +from models.trigger import AppTrigger +from models.workflow import Workflow + + +@app_published_workflow_was_updated.connect +def handle(sender, **kwargs): + """ + Handle app published workflow update event to sync app_triggers table. + + When a workflow is published, this handler will: + 1. Extract trigger nodes from the workflow graph + 2. Compare with existing app_triggers records + 3. Add new triggers and remove obsolete ones + """ + app = sender + if app.mode != AppMode.WORKFLOW.value: + return + + published_workflow = kwargs.get("published_workflow") + published_workflow = cast(Workflow, published_workflow) + # Extract trigger info from workflow + trigger_infos = get_trigger_infos_from_workflow(published_workflow) + + with Session(db.engine) as session: + # Get existing app triggers + existing_triggers = ( + session.execute( + select(AppTrigger).where(AppTrigger.tenant_id == app.tenant_id, AppTrigger.app_id == app.id) + ) + .scalars() + .all() + ) + + # Convert existing triggers to dict for easy lookup + existing_triggers_map = {trigger.node_id: trigger for trigger in existing_triggers} + + # Get current and new node IDs + existing_node_ids = set(existing_triggers_map.keys()) + new_node_ids = {info["node_id"] for info in trigger_infos} + + # Calculate changes + added_node_ids = new_node_ids - existing_node_ids + removed_node_ids = existing_node_ids - new_node_ids + + # Remove obsolete triggers + for node_id in removed_node_ids: + session.delete(existing_triggers_map[node_id]) + + for trigger_info in trigger_infos: + node_id = trigger_info["node_id"] + + if node_id in added_node_ids: + # Create new trigger + app_trigger = AppTrigger( + tenant_id=app.tenant_id, + app_id=app.id, + trigger_type=trigger_info["node_type"], + title=trigger_info["node_title"], + node_id=node_id, + provider_name=trigger_info.get("node_provider_name", ""), + status=AppTriggerStatus.ENABLED, + ) + session.add(app_trigger) + elif node_id in existing_node_ids: + # Update existing trigger if needed + existing_trigger = existing_triggers_map[node_id] + new_title = trigger_info["node_title"] + if new_title and existing_trigger.title != new_title: + existing_trigger.title = new_title + session.add(existing_trigger) + + session.commit() + + +def get_trigger_infos_from_workflow(published_workflow: Workflow) -> list[dict]: + """ + Extract trigger node information from the workflow graph. + + Returns: + List of trigger info dictionaries containing: + - node_type: The type of the trigger node ('trigger-webhook', 'trigger-schedule', 'trigger-plugin') + - node_id: The node ID in the workflow + - node_title: The title of the node + - node_provider_name: The name of the node's provider, only for plugin + """ + graph = published_workflow.graph_dict + if not graph: + return [] + + nodes = graph.get("nodes", []) + trigger_types = {NodeType.TRIGGER_WEBHOOK.value, NodeType.TRIGGER_SCHEDULE.value, NodeType.TRIGGER_PLUGIN.value} + + trigger_infos = [ + { + "node_type": node.get("data", {}).get("type"), + "node_id": node.get("id"), + "node_title": node.get("data", {}).get("title"), + "node_provider_name": node.get("data", {}).get("provider_name"), + } + for node in nodes + if node.get("data", {}).get("type") in trigger_types + ] + + return trigger_infos diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index 82f0542b35..44b50e42ee 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -18,6 +18,7 @@ def init_app(app: DifyApp): from controllers.inner_api import bp as inner_api_bp from controllers.mcp import bp as mcp_bp from controllers.service_api import bp as service_api_bp + from controllers.trigger import bp as trigger_bp from controllers.web import bp as web_bp CORS( @@ -56,3 +57,11 @@ def init_app(app: DifyApp): app.register_blueprint(inner_api_bp) app.register_blueprint(mcp_bp) + + # Register trigger blueprint with CORS for webhook calls + CORS( + trigger_bp, + allow_headers=["Content-Type", "Authorization", "X-App-Code"], + methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH", "HEAD"], + ) + app.register_blueprint(trigger_bp) diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index 6d7d81ed87..5cf4984709 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -96,7 +96,10 @@ def init_app(app: DifyApp) -> Celery: celery_app.set_default() app.extensions["celery"] = celery_app - imports = [] + imports = [ + "tasks.async_workflow_tasks", # trigger workers + "tasks.trigger_processing_tasks", # async trigger processing + ] day = dify_config.CELERY_BEAT_SCHEDULER_TIME # if you add a new task, please add the switch to CeleryScheduleTasksConfig @@ -157,6 +160,18 @@ def init_app(app: DifyApp) -> Celery: "task": "schedule.clean_workflow_runlogs_precise.clean_workflow_runlogs_precise", "schedule": crontab(minute="0", hour="2"), } + if dify_config.ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK: + imports.append("schedule.workflow_schedule_task") + beat_schedule["workflow_schedule_task"] = { + "task": "schedule.workflow_schedule_task.poll_workflow_schedules", + "schedule": timedelta(minutes=dify_config.WORKFLOW_SCHEDULE_POLLER_INTERVAL), + } + if dify_config.ENABLE_TRIGGER_PROVIDER_REFRESH_TASK: + imports.append("schedule.trigger_provider_refresh_task") + beat_schedule["trigger_provider_refresh"] = { + "task": "schedule.trigger_provider_refresh_task.trigger_provider_refresh", + "schedule": timedelta(minutes=dify_config.TRIGGER_PROVIDER_REFRESH_INTERVAL), + } celery_app.conf.update(beat_schedule=beat_schedule, imports=imports) return celery_app diff --git a/api/extensions/ext_commands.py b/api/extensions/ext_commands.py index 79dcdda6e3..71a63168a5 100644 --- a/api/extensions/ext_commands.py +++ b/api/extensions/ext_commands.py @@ -23,6 +23,7 @@ def init_app(app: DifyApp): reset_password, setup_datasource_oauth_client, setup_system_tool_oauth_client, + setup_system_trigger_oauth_client, transform_datasource_credentials, upgrade_db, vdb_migrate, @@ -47,6 +48,7 @@ def init_app(app: DifyApp): clear_orphaned_file_records, remove_orphaned_files_on_storage, setup_system_tool_oauth_client, + setup_system_trigger_oauth_client, cleanup_orphaned_draft_variables, migrate_oss, setup_datasource_oauth_client, diff --git a/api/fields/workflow_app_log_fields.py b/api/fields/workflow_app_log_fields.py index 243efd817c..4cbdf6f0ca 100644 --- a/api/fields/workflow_app_log_fields.py +++ b/api/fields/workflow_app_log_fields.py @@ -8,6 +8,7 @@ from libs.helper import TimestampField workflow_app_log_partial_fields = { "id": fields.String, "workflow_run": fields.Nested(workflow_run_for_log_fields, attribute="workflow_run", allow_null=True), + "details": fields.Raw(attribute="details"), "created_from": fields.String, "created_by_role": fields.String, "created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), diff --git a/api/fields/workflow_run_fields.py b/api/fields/workflow_run_fields.py index 79594beeed..821ce62ecc 100644 --- a/api/fields/workflow_run_fields.py +++ b/api/fields/workflow_run_fields.py @@ -8,6 +8,7 @@ workflow_run_for_log_fields = { "id": fields.String, "version": fields.String, "status": fields.String, + "triggered_from": fields.String, "error": fields.String, "elapsed_time": fields.Float, "total_tokens": fields.Integer, diff --git a/api/fields/workflow_trigger_fields.py b/api/fields/workflow_trigger_fields.py new file mode 100644 index 0000000000..ce51d1833a --- /dev/null +++ b/api/fields/workflow_trigger_fields.py @@ -0,0 +1,25 @@ +from flask_restx import fields + +trigger_fields = { + "id": fields.String, + "trigger_type": fields.String, + "title": fields.String, + "node_id": fields.String, + "provider_name": fields.String, + "icon": fields.String, + "status": fields.String, + "created_at": fields.DateTime(dt_format="iso8601"), + "updated_at": fields.DateTime(dt_format="iso8601"), +} + +triggers_list_fields = {"data": fields.List(fields.Nested(trigger_fields))} + + +webhook_trigger_fields = { + "id": fields.String, + "webhook_id": fields.String, + "webhook_url": fields.String, + "webhook_debug_url": fields.String, + "node_id": fields.String, + "created_at": fields.DateTime(dt_format="iso8601"), +} diff --git a/api/libs/datetime_utils.py b/api/libs/datetime_utils.py index 88f45bd4de..c08578981b 100644 --- a/api/libs/datetime_utils.py +++ b/api/libs/datetime_utils.py @@ -24,6 +24,17 @@ def naive_utc_now() -> datetime.datetime: return _now_func(datetime.UTC).replace(tzinfo=None) +def ensure_naive_utc(dt: datetime.datetime) -> datetime.datetime: + """Return the datetime as naive UTC (tzinfo=None). + + If the input is timezone-aware, convert to UTC and drop the tzinfo. + Assumes naive datetimes are already expressed in UTC. + """ + if dt.tzinfo is None: + return dt + return dt.astimezone(datetime.UTC).replace(tzinfo=None) + + def parse_time_range( start: str | None, end: str | None, tzname: str ) -> tuple[datetime.datetime | None, datetime.datetime | None]: diff --git a/api/libs/schedule_utils.py b/api/libs/schedule_utils.py new file mode 100644 index 0000000000..1ab5f499e9 --- /dev/null +++ b/api/libs/schedule_utils.py @@ -0,0 +1,108 @@ +from datetime import UTC, datetime + +import pytz +from croniter import croniter + + +def calculate_next_run_at( + cron_expression: str, + timezone: str, + base_time: datetime | None = None, +) -> datetime: + """ + Calculate the next run time for a cron expression in a specific timezone. + + Args: + cron_expression: Standard 5-field cron expression or predefined expression + timezone: Timezone string (e.g., 'UTC', 'America/New_York') + base_time: Base time to calculate from (defaults to current UTC time) + + Returns: + Next run time in UTC + + Note: + Supports enhanced cron syntax including: + - Month abbreviations: JAN, FEB, MAR-JUN, JAN,JUN,DEC + - Day abbreviations: MON, TUE, MON-FRI, SUN,WED,FRI + - Predefined expressions: @daily, @weekly, @monthly, @yearly, @hourly + - Special characters: ? wildcard, L (last day), Sunday as 7 + - Standard 5-field format only (minute hour day month dayOfWeek) + """ + # Validate cron expression format to match frontend behavior + parts = cron_expression.strip().split() + + # Support both 5-field format and predefined expressions (matching frontend) + if len(parts) != 5 and not cron_expression.startswith("@"): + raise ValueError( + f"Cron expression must have exactly 5 fields or be a predefined expression " + f"(@daily, @weekly, etc.). Got {len(parts)} fields: '{cron_expression}'" + ) + + tz = pytz.timezone(timezone) + + if base_time is None: + base_time = datetime.now(UTC) + + base_time_tz = base_time.astimezone(tz) + cron = croniter(cron_expression, base_time_tz) + next_run_tz = cron.get_next(datetime) + next_run_utc = next_run_tz.astimezone(UTC) + + return next_run_utc + + +def convert_12h_to_24h(time_str: str) -> tuple[int, int]: + """ + Parse 12-hour time format to 24-hour format for cron compatibility. + + Args: + time_str: Time string in format "HH:MM AM/PM" (e.g., "12:30 PM") + + Returns: + Tuple of (hour, minute) in 24-hour format + + Raises: + ValueError: If time string format is invalid or values are out of range + + Examples: + - "12:00 AM" -> (0, 0) # Midnight + - "12:00 PM" -> (12, 0) # Noon + - "1:30 PM" -> (13, 30) + - "11:59 PM" -> (23, 59) + """ + if not time_str or not time_str.strip(): + raise ValueError("Time string cannot be empty") + + parts = time_str.strip().split() + if len(parts) != 2: + raise ValueError(f"Invalid time format: '{time_str}'. Expected 'HH:MM AM/PM'") + + time_part, period = parts + period = period.upper() + + if period not in ["AM", "PM"]: + raise ValueError(f"Invalid period: '{period}'. Must be 'AM' or 'PM'") + + time_parts = time_part.split(":") + if len(time_parts) != 2: + raise ValueError(f"Invalid time format: '{time_part}'. Expected 'HH:MM'") + + try: + hour = int(time_parts[0]) + minute = int(time_parts[1]) + except ValueError as e: + raise ValueError(f"Invalid time values: {e}") + + if hour < 1 or hour > 12: + raise ValueError(f"Invalid hour: {hour}. Must be between 1 and 12") + + if minute < 0 or minute > 59: + raise ValueError(f"Invalid minute: {minute}. Must be between 0 and 59") + + # Handle 12-hour to 24-hour edge cases + if period == "PM" and hour != 12: + hour += 12 + elif period == "AM" and hour == 12: + hour = 0 + + return hour, minute diff --git a/api/migrations/versions/2025_10_30_1518-669ffd70119c_introduce_trigger.py b/api/migrations/versions/2025_10_30_1518-669ffd70119c_introduce_trigger.py new file mode 100644 index 0000000000..c03d64b234 --- /dev/null +++ b/api/migrations/versions/2025_10_30_1518-669ffd70119c_introduce_trigger.py @@ -0,0 +1,235 @@ +"""introduce_trigger + +Revision ID: 669ffd70119c +Revises: 03f8dcbc611e +Create Date: 2025-10-30 15:18:49.549156 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + +from models.enums import AppTriggerStatus, AppTriggerType + + +# revision identifiers, used by Alembic. +revision = '669ffd70119c' +down_revision = '03f8dcbc611e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('app_triggers', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('node_id', sa.String(length=64), nullable=False), + sa.Column('trigger_type', models.types.EnumText(AppTriggerType, length=50), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('provider_name', sa.String(length=255), server_default='', nullable=True), + sa.Column('status', models.types.EnumText(AppTriggerStatus, length=50), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name='app_trigger_pkey') + ) + with op.batch_alter_table('app_triggers', schema=None) as batch_op: + batch_op.create_index('app_trigger_tenant_app_idx', ['tenant_id', 'app_id'], unique=False) + + op.create_table('trigger_oauth_system_clients', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('plugin_id', sa.String(length=512), nullable=False), + sa.Column('provider', sa.String(length=255), nullable=False), + sa.Column('encrypted_oauth_params', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='trigger_oauth_system_client_pkey'), + sa.UniqueConstraint('plugin_id', 'provider', name='trigger_oauth_system_client_plugin_id_provider_idx') + ) + op.create_table('trigger_oauth_tenant_clients', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('plugin_id', sa.String(length=512), nullable=False), + sa.Column('provider', sa.String(length=255), nullable=False), + sa.Column('enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False), + sa.Column('encrypted_oauth_params', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='trigger_oauth_tenant_client_pkey'), + sa.UniqueConstraint('tenant_id', 'plugin_id', 'provider', name='unique_trigger_oauth_tenant_client') + ) + op.create_table('trigger_subscriptions', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False, comment='Subscription instance name'), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('user_id', models.types.StringUUID(), nullable=False), + sa.Column('provider_id', sa.String(length=255), nullable=False, comment='Provider identifier (e.g., plugin_id/provider_name)'), + sa.Column('endpoint_id', sa.String(length=255), nullable=False, comment='Subscription endpoint'), + sa.Column('parameters', sa.JSON(), nullable=False, comment='Subscription parameters JSON'), + sa.Column('properties', sa.JSON(), nullable=False, comment='Subscription properties JSON'), + sa.Column('credentials', sa.JSON(), nullable=False, comment='Subscription credentials JSON'), + sa.Column('credential_type', sa.String(length=50), nullable=False, comment='oauth or api_key'), + sa.Column('credential_expires_at', sa.Integer(), nullable=False, comment='OAuth token expiration timestamp, -1 for never'), + sa.Column('expires_at', sa.Integer(), nullable=False, comment='Subscription instance expiration timestamp, -1 for never'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='trigger_provider_pkey'), + sa.UniqueConstraint('tenant_id', 'provider_id', 'name', name='unique_trigger_provider') + ) + with op.batch_alter_table('trigger_subscriptions', schema=None) as batch_op: + batch_op.create_index('idx_trigger_providers_endpoint', ['endpoint_id'], unique=True) + batch_op.create_index('idx_trigger_providers_tenant_endpoint', ['tenant_id', 'endpoint_id'], unique=False) + batch_op.create_index('idx_trigger_providers_tenant_provider', ['tenant_id', 'provider_id'], unique=False) + + op.create_table('workflow_plugin_triggers', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('node_id', sa.String(length=64), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('provider_id', sa.String(length=512), nullable=False), + sa.Column('event_name', sa.String(length=255), nullable=False), + sa.Column('subscription_id', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='workflow_plugin_trigger_pkey'), + sa.UniqueConstraint('app_id', 'node_id', name='uniq_app_node_subscription') + ) + with op.batch_alter_table('workflow_plugin_triggers', schema=None) as batch_op: + batch_op.create_index('workflow_plugin_trigger_tenant_subscription_idx', ['tenant_id', 'subscription_id', 'event_name'], unique=False) + + op.create_table('workflow_schedule_plans', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('node_id', sa.String(length=64), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('cron_expression', sa.String(length=255), nullable=False), + sa.Column('timezone', sa.String(length=64), nullable=False), + sa.Column('next_run_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='workflow_schedule_plan_pkey'), + sa.UniqueConstraint('app_id', 'node_id', name='uniq_app_node') + ) + with op.batch_alter_table('workflow_schedule_plans', schema=None) as batch_op: + batch_op.create_index('workflow_schedule_plan_next_idx', ['next_run_at'], unique=False) + + op.create_table('workflow_trigger_logs', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('workflow_id', models.types.StringUUID(), nullable=False), + sa.Column('workflow_run_id', models.types.StringUUID(), nullable=True), + sa.Column('root_node_id', sa.String(length=255), nullable=True), + sa.Column('trigger_metadata', sa.Text(), nullable=False), + sa.Column('trigger_type', models.types.EnumText(AppTriggerType, length=50), nullable=False), + sa.Column('trigger_data', sa.Text(), nullable=False), + sa.Column('inputs', sa.Text(), nullable=False), + sa.Column('outputs', sa.Text(), nullable=True), + sa.Column('status', models.types.EnumText(AppTriggerStatus, length=50), nullable=False), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('queue_name', sa.String(length=100), nullable=False), + sa.Column('celery_task_id', sa.String(length=255), nullable=True), + sa.Column('retry_count', sa.Integer(), nullable=False), + sa.Column('elapsed_time', sa.Float(), nullable=True), + sa.Column('total_tokens', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('created_by_role', sa.String(length=255), nullable=False), + sa.Column('created_by', sa.String(length=255), nullable=False), + sa.Column('triggered_at', sa.DateTime(), nullable=True), + sa.Column('finished_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id', name='workflow_trigger_log_pkey') + ) + with op.batch_alter_table('workflow_trigger_logs', schema=None) as batch_op: + batch_op.create_index('workflow_trigger_log_created_at_idx', ['created_at'], unique=False) + batch_op.create_index('workflow_trigger_log_status_idx', ['status'], unique=False) + batch_op.create_index('workflow_trigger_log_tenant_app_idx', ['tenant_id', 'app_id'], unique=False) + batch_op.create_index('workflow_trigger_log_workflow_id_idx', ['workflow_id'], unique=False) + batch_op.create_index('workflow_trigger_log_workflow_run_idx', ['workflow_run_id'], unique=False) + + op.create_table('workflow_webhook_triggers', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('node_id', sa.String(length=64), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('webhook_id', sa.String(length=24), nullable=False), + sa.Column('created_by', models.types.StringUUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='workflow_webhook_trigger_pkey'), + sa.UniqueConstraint('app_id', 'node_id', name='uniq_node'), + sa.UniqueConstraint('webhook_id', name='uniq_webhook_id') + ) + with op.batch_alter_table('workflow_webhook_triggers', schema=None) as batch_op: + batch_op.create_index('workflow_webhook_trigger_tenant_idx', ['tenant_id'], unique=False) + + with op.batch_alter_table('celery_taskmeta', schema=None) as batch_op: + batch_op.alter_column('task_id', + existing_type=sa.VARCHAR(length=155), + nullable=False) + batch_op.alter_column('status', + existing_type=sa.VARCHAR(length=50), + nullable=False) + + with op.batch_alter_table('celery_tasksetmeta', schema=None) as batch_op: + batch_op.alter_column('taskset_id', + existing_type=sa.VARCHAR(length=155), + nullable=False) + + with op.batch_alter_table('providers', schema=None) as batch_op: + batch_op.drop_column('credential_status') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('providers', schema=None) as batch_op: + batch_op.add_column(sa.Column('credential_status', sa.VARCHAR(length=20), server_default=sa.text("'active'::character varying"), autoincrement=False, nullable=True)) + + with op.batch_alter_table('celery_tasksetmeta', schema=None) as batch_op: + batch_op.alter_column('taskset_id', + existing_type=sa.VARCHAR(length=155), + nullable=True) + + with op.batch_alter_table('celery_taskmeta', schema=None) as batch_op: + batch_op.alter_column('status', + existing_type=sa.VARCHAR(length=50), + nullable=True) + batch_op.alter_column('task_id', + existing_type=sa.VARCHAR(length=155), + nullable=True) + + with op.batch_alter_table('workflow_webhook_triggers', schema=None) as batch_op: + batch_op.drop_index('workflow_webhook_trigger_tenant_idx') + + op.drop_table('workflow_webhook_triggers') + with op.batch_alter_table('workflow_trigger_logs', schema=None) as batch_op: + batch_op.drop_index('workflow_trigger_log_workflow_run_idx') + batch_op.drop_index('workflow_trigger_log_workflow_id_idx') + batch_op.drop_index('workflow_trigger_log_tenant_app_idx') + batch_op.drop_index('workflow_trigger_log_status_idx') + batch_op.drop_index('workflow_trigger_log_created_at_idx') + + op.drop_table('workflow_trigger_logs') + with op.batch_alter_table('workflow_schedule_plans', schema=None) as batch_op: + batch_op.drop_index('workflow_schedule_plan_next_idx') + + op.drop_table('workflow_schedule_plans') + with op.batch_alter_table('workflow_plugin_triggers', schema=None) as batch_op: + batch_op.drop_index('workflow_plugin_trigger_tenant_subscription_idx') + + op.drop_table('workflow_plugin_triggers') + with op.batch_alter_table('trigger_subscriptions', schema=None) as batch_op: + batch_op.drop_index('idx_trigger_providers_tenant_provider') + batch_op.drop_index('idx_trigger_providers_tenant_endpoint') + batch_op.drop_index('idx_trigger_providers_endpoint') + + op.drop_table('trigger_subscriptions') + op.drop_table('trigger_oauth_tenant_clients') + op.drop_table('trigger_oauth_system_clients') + with op.batch_alter_table('app_triggers', schema=None) as batch_op: + batch_op.drop_index('app_trigger_tenant_app_idx') + + op.drop_table('app_triggers') + # ### end Alembic commands ### diff --git a/api/models/__init__.py b/api/models/__init__.py index 1c09b4610d..906bc3198e 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -26,7 +26,14 @@ from .dataset import ( TidbAuthBinding, Whitelist, ) -from .enums import CreatorUserRole, UserFrom, WorkflowRunTriggeredFrom +from .enums import ( + AppTriggerStatus, + AppTriggerType, + CreatorUserRole, + UserFrom, + WorkflowRunTriggeredFrom, + WorkflowTriggerStatus, +) from .model import ( ApiRequest, ApiToken, @@ -79,6 +86,13 @@ from .tools import ( ToolModelInvoke, WorkflowToolProvider, ) +from .trigger import ( + AppTrigger, + TriggerOAuthSystemClient, + TriggerOAuthTenantClient, + TriggerSubscription, + WorkflowSchedulePlan, +) from .web import PinnedConversation, SavedMessage from .workflow import ( ConversationVariable, @@ -106,9 +120,12 @@ __all__ = [ "AppAnnotationHitHistory", "AppAnnotationSetting", "AppDatasetJoin", - "AppMCPServer", # Added + "AppMCPServer", "AppMode", "AppModelConfig", + "AppTrigger", + "AppTriggerStatus", + "AppTriggerType", "BuiltinToolProvider", "CeleryTask", "CeleryTaskSet", @@ -169,6 +186,9 @@ __all__ = [ "ToolLabelBinding", "ToolModelInvoke", "TraceAppConfig", + "TriggerOAuthSystemClient", + "TriggerOAuthTenantClient", + "TriggerSubscription", "UploadFile", "UserFrom", "Whitelist", @@ -181,6 +201,8 @@ __all__ = [ "WorkflowPause", "WorkflowRun", "WorkflowRunTriggeredFrom", + "WorkflowSchedulePlan", "WorkflowToolProvider", + "WorkflowTriggerStatus", "WorkflowType", ] diff --git a/api/models/enums.py b/api/models/enums.py index 0be7567c80..d06d0d5ebc 100644 --- a/api/models/enums.py +++ b/api/models/enums.py @@ -1,5 +1,7 @@ from enum import StrEnum +from core.workflow.enums import NodeType + class CreatorUserRole(StrEnum): ACCOUNT = "account" @@ -13,9 +15,12 @@ class UserFrom(StrEnum): class WorkflowRunTriggeredFrom(StrEnum): DEBUGGING = "debugging" - APP_RUN = "app-run" + APP_RUN = "app-run" # webapp / service api RAG_PIPELINE_RUN = "rag-pipeline-run" RAG_PIPELINE_DEBUGGING = "rag-pipeline-debugging" + WEBHOOK = "webhook" + SCHEDULE = "schedule" + PLUGIN = "plugin" class DraftVariableType(StrEnum): @@ -38,3 +43,35 @@ class ExecutionOffLoadType(StrEnum): INPUTS = "inputs" PROCESS_DATA = "process_data" OUTPUTS = "outputs" + + +class WorkflowTriggerStatus(StrEnum): + """Workflow Trigger Execution Status""" + + PENDING = "pending" + QUEUED = "queued" + RUNNING = "running" + SUCCEEDED = "succeeded" + PAUSED = "paused" + FAILED = "failed" + RATE_LIMITED = "rate_limited" + RETRYING = "retrying" + + +class AppTriggerStatus(StrEnum): + """App Trigger Status Enum""" + + ENABLED = "enabled" + DISABLED = "disabled" + UNAUTHORIZED = "unauthorized" + + +class AppTriggerType(StrEnum): + """App Trigger Type Enum""" + + TRIGGER_WEBHOOK = NodeType.TRIGGER_WEBHOOK.value + TRIGGER_SCHEDULE = NodeType.TRIGGER_SCHEDULE.value + TRIGGER_PLUGIN = NodeType.TRIGGER_PLUGIN.value + + # for backward compatibility + UNKNOWN = "unknown" diff --git a/api/models/provider_ids.py b/api/models/provider_ids.py index 98dc67f2f3..0be6a3dc98 100644 --- a/api/models/provider_ids.py +++ b/api/models/provider_ids.py @@ -57,3 +57,8 @@ class ToolProviderID(GenericProviderID): class DatasourceProviderID(GenericProviderID): def __init__(self, value: str, is_hardcoded: bool = False) -> None: super().__init__(value, is_hardcoded) + + +class TriggerProviderID(GenericProviderID): + def __init__(self, value: str, is_hardcoded: bool = False) -> None: + super().__init__(value, is_hardcoded) diff --git a/api/models/trigger.py b/api/models/trigger.py new file mode 100644 index 0000000000..c2b66ace46 --- /dev/null +++ b/api/models/trigger.py @@ -0,0 +1,456 @@ +import json +import time +from collections.abc import Mapping +from datetime import datetime +from functools import cached_property +from typing import Any, cast + +import sqlalchemy as sa +from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from core.plugin.entities.plugin_daemon import CredentialType +from core.trigger.entities.api_entities import TriggerProviderSubscriptionApiEntity +from core.trigger.entities.entities import Subscription +from core.trigger.utils.endpoint import generate_plugin_trigger_endpoint_url, generate_webhook_trigger_endpoint +from libs.datetime_utils import naive_utc_now +from models.base import Base +from models.engine import db +from models.enums import AppTriggerStatus, AppTriggerType, CreatorUserRole, WorkflowTriggerStatus +from models.model import Account +from models.types import EnumText, StringUUID + + +class TriggerSubscription(Base): + """ + Trigger provider model for managing credentials + Supports multiple credential instances per provider + """ + + __tablename__ = "trigger_subscriptions" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="trigger_provider_pkey"), + Index("idx_trigger_providers_tenant_provider", "tenant_id", "provider_id"), + # Primary index for O(1) lookup by endpoint + Index("idx_trigger_providers_endpoint", "endpoint_id", unique=True), + # Composite index for tenant-specific queries (optional, kept for compatibility) + Index("idx_trigger_providers_tenant_endpoint", "tenant_id", "endpoint_id"), + UniqueConstraint("tenant_id", "provider_id", "name", name="unique_trigger_provider"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()")) + name: Mapped[str] = mapped_column(String(255), nullable=False, comment="Subscription instance name") + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + user_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + provider_id: Mapped[str] = mapped_column( + String(255), nullable=False, comment="Provider identifier (e.g., plugin_id/provider_name)" + ) + endpoint_id: Mapped[str] = mapped_column(String(255), nullable=False, comment="Subscription endpoint") + parameters: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False, comment="Subscription parameters JSON") + properties: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False, comment="Subscription properties JSON") + + credentials: Mapped[dict[str, Any]] = mapped_column( + sa.JSON, nullable=False, comment="Subscription credentials JSON" + ) + credential_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="oauth or api_key") + credential_expires_at: Mapped[int] = mapped_column( + Integer, default=-1, comment="OAuth token expiration timestamp, -1 for never" + ) + expires_at: Mapped[int] = mapped_column( + Integer, default=-1, comment="Subscription instance expiration timestamp, -1 for never" + ) + + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.current_timestamp(), + server_onupdate=func.current_timestamp(), + ) + + def is_credential_expired(self) -> bool: + """Check if credential is expired""" + if self.credential_expires_at == -1: + return False + # Check if token expires in next 3 minutes + return (self.credential_expires_at - 180) < int(time.time()) + + def to_entity(self) -> Subscription: + return Subscription( + expires_at=self.expires_at, + endpoint=generate_plugin_trigger_endpoint_url(self.endpoint_id), + parameters=self.parameters, + properties=self.properties, + ) + + def to_api_entity(self) -> TriggerProviderSubscriptionApiEntity: + return TriggerProviderSubscriptionApiEntity( + id=self.id, + name=self.name, + provider=self.provider_id, + endpoint=generate_plugin_trigger_endpoint_url(self.endpoint_id), + parameters=self.parameters, + properties=self.properties, + credential_type=CredentialType(self.credential_type), + credentials=self.credentials, + workflows_in_use=-1, + ) + + +# system level trigger oauth client params +class TriggerOAuthSystemClient(Base): + __tablename__ = "trigger_oauth_system_clients" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="trigger_oauth_system_client_pkey"), + sa.UniqueConstraint("plugin_id", "provider", name="trigger_oauth_system_client_plugin_id_provider_idx"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()")) + plugin_id: Mapped[str] = mapped_column(String(512), nullable=False) + provider: Mapped[str] = mapped_column(String(255), nullable=False) + # oauth params of the trigger provider + encrypted_oauth_params: Mapped[str] = mapped_column(sa.Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.current_timestamp(), + server_onupdate=func.current_timestamp(), + ) + + +# tenant level trigger oauth client params (client_id, client_secret, etc.) +class TriggerOAuthTenantClient(Base): + __tablename__ = "trigger_oauth_tenant_clients" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="trigger_oauth_tenant_client_pkey"), + sa.UniqueConstraint("tenant_id", "plugin_id", "provider", name="unique_trigger_oauth_tenant_client"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()")) + # tenant id + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + plugin_id: Mapped[str] = mapped_column(String(512), nullable=False) + provider: Mapped[str] = mapped_column(String(255), nullable=False) + enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true")) + # oauth params of the trigger provider + encrypted_oauth_params: Mapped[str] = mapped_column(sa.Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.current_timestamp(), + server_onupdate=func.current_timestamp(), + ) + + @property + def oauth_params(self) -> Mapping[str, Any]: + return cast(Mapping[str, Any], json.loads(self.encrypted_oauth_params or "{}")) + + +class WorkflowTriggerLog(Base): + """ + Workflow Trigger Log + + Track async trigger workflow runs with re-invocation capability + + Attributes: + - id (uuid) Trigger Log ID (used as workflow_trigger_log_id) + - tenant_id (uuid) Workspace ID + - app_id (uuid) App ID + - workflow_id (uuid) Workflow ID + - workflow_run_id (uuid) Optional - Associated workflow run ID when execution starts + - root_node_id (string) Optional - Custom starting node ID for workflow execution + - trigger_metadata (text) Optional - Trigger metadata (JSON) + - trigger_type (string) Type of trigger: webhook, schedule, plugin + - trigger_data (text) Full trigger data including inputs (JSON) + - inputs (text) Input parameters (JSON) + - outputs (text) Optional - Output content (JSON) + - status (string) Execution status + - error (text) Optional - Error message if failed + - queue_name (string) Celery queue used + - celery_task_id (string) Optional - Celery task ID for tracking + - retry_count (int) Number of retry attempts + - elapsed_time (float) Optional - Time consumption in seconds + - total_tokens (int) Optional - Total tokens used + - created_by_role (string) Creator role: account, end_user + - created_by (string) Creator ID + - created_at (timestamp) Creation time + - triggered_at (timestamp) Optional - When actually triggered + - finished_at (timestamp) Optional - Completion time + """ + + __tablename__ = "workflow_trigger_logs" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="workflow_trigger_log_pkey"), + sa.Index("workflow_trigger_log_tenant_app_idx", "tenant_id", "app_id"), + sa.Index("workflow_trigger_log_status_idx", "status"), + sa.Index("workflow_trigger_log_created_at_idx", "created_at"), + sa.Index("workflow_trigger_log_workflow_run_idx", "workflow_run_id"), + sa.Index("workflow_trigger_log_workflow_id_idx", "workflow_id"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()")) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + root_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + trigger_metadata: Mapped[str] = mapped_column(sa.Text, nullable=False) + trigger_type: Mapped[str] = mapped_column(EnumText(AppTriggerType, length=50), nullable=False) + trigger_data: Mapped[str] = mapped_column(sa.Text, nullable=False) # Full TriggerData as JSON + inputs: Mapped[str] = mapped_column(sa.Text, nullable=False) # Just inputs for easy viewing + outputs: Mapped[str | None] = mapped_column(sa.Text, nullable=True) + + status: Mapped[str] = mapped_column( + EnumText(WorkflowTriggerStatus, length=50), nullable=False, default=WorkflowTriggerStatus.PENDING + ) + error: Mapped[str | None] = mapped_column(sa.Text, nullable=True) + + queue_name: Mapped[str] = mapped_column(String(100), nullable=False) + celery_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + retry_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) + + elapsed_time: Mapped[float | None] = mapped_column(sa.Float, nullable=True) + total_tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + created_by_role: Mapped[str] = mapped_column(String(255), nullable=False) + created_by: Mapped[str] = mapped_column(String(255), nullable=False) + + triggered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + @property + def created_by_account(self): + created_by_role = CreatorUserRole(self.created_by_role) + return db.session.get(Account, self.created_by) if created_by_role == CreatorUserRole.ACCOUNT else None + + @property + def created_by_end_user(self): + from models.model import EndUser + + created_by_role = CreatorUserRole(self.created_by_role) + return db.session.get(EndUser, self.created_by) if created_by_role == CreatorUserRole.END_USER else None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for API responses""" + return { + "id": self.id, + "tenant_id": self.tenant_id, + "app_id": self.app_id, + "workflow_id": self.workflow_id, + "workflow_run_id": self.workflow_run_id, + "root_node_id": self.root_node_id, + "trigger_metadata": json.loads(self.trigger_metadata) if self.trigger_metadata else None, + "trigger_type": self.trigger_type, + "trigger_data": json.loads(self.trigger_data), + "inputs": json.loads(self.inputs), + "outputs": json.loads(self.outputs) if self.outputs else None, + "status": self.status, + "error": self.error, + "queue_name": self.queue_name, + "celery_task_id": self.celery_task_id, + "retry_count": self.retry_count, + "elapsed_time": self.elapsed_time, + "total_tokens": self.total_tokens, + "created_by_role": self.created_by_role, + "created_by": self.created_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "triggered_at": self.triggered_at.isoformat() if self.triggered_at else None, + "finished_at": self.finished_at.isoformat() if self.finished_at else None, + } + + +class WorkflowWebhookTrigger(Base): + """ + Workflow Webhook Trigger + + Attributes: + - id (uuid) Primary key + - app_id (uuid) App ID to bind to a specific app + - node_id (varchar) Node ID which node in the workflow + - tenant_id (uuid) Workspace ID + - webhook_id (varchar) Webhook ID for URL: https://api.dify.ai/triggers/webhook/:webhook_id + - created_by (varchar) User ID of the creator + - created_at (timestamp) Creation time + - updated_at (timestamp) Last update time + """ + + __tablename__ = "workflow_webhook_triggers" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="workflow_webhook_trigger_pkey"), + sa.Index("workflow_webhook_trigger_tenant_idx", "tenant_id"), + sa.UniqueConstraint("app_id", "node_id", name="uniq_node"), + sa.UniqueConstraint("webhook_id", name="uniq_webhook_id"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()")) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + node_id: Mapped[str] = mapped_column(String(64), nullable=False) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + webhook_id: Mapped[str] = mapped_column(String(24), nullable=False) + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.current_timestamp(), + server_onupdate=func.current_timestamp(), + ) + + @cached_property + def webhook_url(self): + """ + Generated webhook url + """ + return generate_webhook_trigger_endpoint(self.webhook_id) + + @cached_property + def webhook_debug_url(self): + """ + Generated debug webhook url + """ + return generate_webhook_trigger_endpoint(self.webhook_id, True) + + +class WorkflowPluginTrigger(Base): + """ + Workflow Plugin Trigger + + Maps plugin triggers to workflow nodes, similar to WorkflowWebhookTrigger + + Attributes: + - id (uuid) Primary key + - app_id (uuid) App ID to bind to a specific app + - node_id (varchar) Node ID which node in the workflow + - tenant_id (uuid) Workspace ID + - provider_id (varchar) Plugin provider ID + - event_name (varchar) trigger name + - subscription_id (varchar) Subscription ID + - created_at (timestamp) Creation time + - updated_at (timestamp) Last update time + """ + + __tablename__ = "workflow_plugin_triggers" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="workflow_plugin_trigger_pkey"), + sa.Index("workflow_plugin_trigger_tenant_subscription_idx", "tenant_id", "subscription_id", "event_name"), + sa.UniqueConstraint("app_id", "node_id", name="uniq_app_node_subscription"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()")) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + node_id: Mapped[str] = mapped_column(String(64), nullable=False) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + provider_id: Mapped[str] = mapped_column(String(512), nullable=False) + event_name: Mapped[str] = mapped_column(String(255), nullable=False) + subscription_id: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.current_timestamp(), + server_onupdate=func.current_timestamp(), + ) + + +class AppTrigger(Base): + """ + App Trigger + + Manages multiple triggers for an app with enable/disable and authorization states. + + Attributes: + - id (uuid) Primary key + - tenant_id (uuid) Workspace ID + - app_id (uuid) App ID + - trigger_type (string) Type: webhook, schedule, plugin + - title (string) Trigger title + + - status (string) Status: enabled, disabled, unauthorized, error + - node_id (string) Optional workflow node ID + - created_at (timestamp) Creation time + - updated_at (timestamp) Last update time + """ + + __tablename__ = "app_triggers" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="app_trigger_pkey"), + sa.Index("app_trigger_tenant_app_idx", "tenant_id", "app_id"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()")) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + node_id: Mapped[str | None] = mapped_column(String(64), nullable=False) + trigger_type: Mapped[str] = mapped_column(EnumText(AppTriggerType, length=50), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + provider_name: Mapped[str] = mapped_column(String(255), server_default="", nullable=True) + status: Mapped[str] = mapped_column( + EnumText(AppTriggerStatus, length=50), nullable=False, default=AppTriggerStatus.ENABLED + ) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=naive_utc_now(), + server_onupdate=func.current_timestamp(), + ) + + +class WorkflowSchedulePlan(Base): + """ + Workflow Schedule Configuration + + Store schedule configurations for time-based workflow triggers. + Uses cron expressions with timezone support for flexible scheduling. + + Attributes: + - id (uuid) Primary key + - app_id (uuid) App ID to bind to a specific app + - node_id (varchar) Starting node ID for workflow execution + - tenant_id (uuid) Workspace ID for multi-tenancy + - cron_expression (varchar) Cron expression defining schedule pattern + - timezone (varchar) Timezone for cron evaluation (e.g., 'Asia/Shanghai') + - next_run_at (timestamp) Next scheduled execution time + - created_at (timestamp) Creation timestamp + - updated_at (timestamp) Last update timestamp + """ + + __tablename__ = "workflow_schedule_plans" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="workflow_schedule_plan_pkey"), + sa.UniqueConstraint("app_id", "node_id", name="uniq_app_node"), + sa.Index("workflow_schedule_plan_next_idx", "next_run_at"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuidv7()")) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + node_id: Mapped[str] = mapped_column(String(64), nullable=False) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + + # Schedule configuration + cron_expression: Mapped[str] = mapped_column(String(255), nullable=False) + timezone: Mapped[str] = mapped_column(String(64), nullable=False) + + # Schedule control + next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + ) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary representation""" + return { + "id": self.id, + "app_id": self.app_id, + "node_id": self.node_id, + "tenant_id": self.tenant_id, + "cron_expression": self.cron_expression, + "timezone": self.timezone, + "next_run_at": self.next_run_at.isoformat() if self.next_run_at else None, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } diff --git a/api/models/workflow.py b/api/models/workflow.py index ed30821bc0..4eff16dda2 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -1,6 +1,6 @@ import json import logging -from collections.abc import Mapping, Sequence +from collections.abc import Generator, Mapping, Sequence from datetime import datetime from enum import StrEnum from typing import TYPE_CHECKING, Any, Optional, Union, cast @@ -303,6 +303,54 @@ class Workflow(Base): def features_dict(self) -> dict[str, Any]: return json.loads(self.features) if self.features else {} + def walk_nodes( + self, specific_node_type: NodeType | None = None + ) -> Generator[tuple[str, Mapping[str, Any]], None, None]: + """ + Walk through the workflow nodes, yield each node configuration. + + Each node configuration is a tuple containing the node's id and the node's properties. + + Node properties example: + { + "type": "llm", + "title": "LLM", + "desc": "", + "variables": [], + "model": + { + "provider": "langgenius/openai/openai", + "name": "gpt-4", + "mode": "chat", + "completion_params": { "temperature": 0.7 }, + }, + "prompt_template": [{ "role": "system", "text": "" }], + "context": { "enabled": false, "variable_selector": [] }, + "vision": { "enabled": false }, + "memory": + { + "window": { "enabled": false, "size": 10 }, + "query_prompt_template": "{{#sys.query#}}\n\n{{#sys.files#}}", + "role_prefix": { "user": "", "assistant": "" }, + }, + "selected": false, + } + + For specific node type, refer to `core.workflow.nodes` + """ + graph_dict = self.graph_dict + if "nodes" not in graph_dict: + raise WorkflowDataError("nodes not found in workflow graph") + + if specific_node_type: + yield from ( + (node["id"], node["data"]) + for node in graph_dict["nodes"] + if node["data"]["type"] == specific_node_type.value + ) + else: + yield from ((node["id"], node["data"]) for node in graph_dict["nodes"]) + def user_input_form(self, to_old_structure: bool = False) -> list[Any]: # get start node from graph if not self.graph: diff --git a/api/pyproject.toml b/api/pyproject.toml index 3c6930d50d..5d72b18204 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -87,7 +87,9 @@ dependencies = [ "sendgrid~=6.12.3", "flask-restx~=1.3.0", "packaging~=23.2", + "croniter>=6.0.0", "weaviate-client==4.17.0", + "apscheduler>=3.11.0", ] # Before adding new dependency, consider place it in # alphabet order (a-z) and suitable group. diff --git a/api/repositories/factory.py b/api/repositories/factory.py index 96f9f886a4..8e098a7059 100644 --- a/api/repositories/factory.py +++ b/api/repositories/factory.py @@ -5,7 +5,7 @@ This factory is specifically designed for DifyAPI repositories that handle service-layer operations with dependency injection patterns. """ -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from core.repositories import DifyCoreRepositoryFactory, RepositoryImportError @@ -25,7 +25,7 @@ class DifyAPIRepositoryFactory(DifyCoreRepositoryFactory): @classmethod def create_api_workflow_node_execution_repository( - cls, session_maker: sessionmaker + cls, session_maker: sessionmaker[Session] ) -> DifyAPIWorkflowNodeExecutionRepository: """ Create a DifyAPIWorkflowNodeExecutionRepository instance based on configuration. @@ -55,7 +55,7 @@ class DifyAPIRepositoryFactory(DifyCoreRepositoryFactory): ) from e @classmethod - def create_api_workflow_run_repository(cls, session_maker: sessionmaker) -> APIWorkflowRunRepository: + def create_api_workflow_run_repository(cls, session_maker: sessionmaker[Session]) -> APIWorkflowRunRepository: """ Create an APIWorkflowRunRepository instance based on configuration. diff --git a/api/repositories/sqlalchemy_workflow_trigger_log_repository.py b/api/repositories/sqlalchemy_workflow_trigger_log_repository.py new file mode 100644 index 0000000000..0d67e286b0 --- /dev/null +++ b/api/repositories/sqlalchemy_workflow_trigger_log_repository.py @@ -0,0 +1,86 @@ +""" +SQLAlchemy implementation of WorkflowTriggerLogRepository. +""" + +from collections.abc import Sequence +from datetime import UTC, datetime, timedelta + +from sqlalchemy import and_, select +from sqlalchemy.orm import Session + +from models.enums import WorkflowTriggerStatus +from models.trigger import WorkflowTriggerLog +from repositories.workflow_trigger_log_repository import WorkflowTriggerLogRepository + + +class SQLAlchemyWorkflowTriggerLogRepository(WorkflowTriggerLogRepository): + """ + SQLAlchemy implementation of WorkflowTriggerLogRepository. + + Optimized for large table operations with proper indexing and batch processing. + """ + + def __init__(self, session: Session): + self.session = session + + def create(self, trigger_log: WorkflowTriggerLog) -> WorkflowTriggerLog: + """Create a new trigger log entry.""" + self.session.add(trigger_log) + self.session.flush() + return trigger_log + + def update(self, trigger_log: WorkflowTriggerLog) -> WorkflowTriggerLog: + """Update an existing trigger log entry.""" + self.session.merge(trigger_log) + self.session.flush() + return trigger_log + + def get_by_id(self, trigger_log_id: str, tenant_id: str | None = None) -> WorkflowTriggerLog | None: + """Get a trigger log by its ID.""" + query = select(WorkflowTriggerLog).where(WorkflowTriggerLog.id == trigger_log_id) + + if tenant_id: + query = query.where(WorkflowTriggerLog.tenant_id == tenant_id) + + return self.session.scalar(query) + + def get_failed_for_retry( + self, tenant_id: str, max_retry_count: int = 3, limit: int = 100 + ) -> Sequence[WorkflowTriggerLog]: + """Get failed trigger logs eligible for retry.""" + query = ( + select(WorkflowTriggerLog) + .where( + and_( + WorkflowTriggerLog.tenant_id == tenant_id, + WorkflowTriggerLog.status.in_([WorkflowTriggerStatus.FAILED, WorkflowTriggerStatus.RATE_LIMITED]), + WorkflowTriggerLog.retry_count < max_retry_count, + ) + ) + .order_by(WorkflowTriggerLog.created_at.asc()) + .limit(limit) + ) + + return list(self.session.scalars(query).all()) + + def get_recent_logs( + self, tenant_id: str, app_id: str, hours: int = 24, limit: int = 100, offset: int = 0 + ) -> Sequence[WorkflowTriggerLog]: + """Get recent trigger logs within specified hours.""" + since = datetime.now(UTC) - timedelta(hours=hours) + + query = ( + select(WorkflowTriggerLog) + .where( + and_( + WorkflowTriggerLog.tenant_id == tenant_id, + WorkflowTriggerLog.app_id == app_id, + WorkflowTriggerLog.created_at >= since, + ) + ) + .order_by(WorkflowTriggerLog.created_at.desc()) + .limit(limit) + .offset(offset) + ) + + return list(self.session.scalars(query).all()) diff --git a/api/repositories/workflow_trigger_log_repository.py b/api/repositories/workflow_trigger_log_repository.py new file mode 100644 index 0000000000..138b8779ac --- /dev/null +++ b/api/repositories/workflow_trigger_log_repository.py @@ -0,0 +1,111 @@ +""" +Repository protocol for WorkflowTriggerLog operations. + +This module provides a protocol interface for operations on WorkflowTriggerLog, +designed to efficiently handle a potentially large volume of trigger logs with +proper indexing and batch operations. +""" + +from collections.abc import Sequence +from enum import StrEnum +from typing import Protocol + +from models.trigger import WorkflowTriggerLog + + +class TriggerLogOrderBy(StrEnum): + """Fields available for ordering trigger logs""" + + CREATED_AT = "created_at" + TRIGGERED_AT = "triggered_at" + FINISHED_AT = "finished_at" + STATUS = "status" + + +class WorkflowTriggerLogRepository(Protocol): + """ + Protocol for operations on WorkflowTriggerLog. + + This repository provides efficient access patterns for the trigger log table, + which is expected to grow large over time. It includes: + - Batch operations for cleanup + - Efficient queries with proper indexing + - Pagination support + - Status-based filtering + + Implementation notes: + - Leverage database indexes on (tenant_id, app_id), status, and created_at + - Use batch operations for deletions to avoid locking + - Support pagination for large result sets + """ + + def create(self, trigger_log: WorkflowTriggerLog) -> WorkflowTriggerLog: + """ + Create a new trigger log entry. + + Args: + trigger_log: The WorkflowTriggerLog instance to create + + Returns: + The created WorkflowTriggerLog with generated ID + """ + ... + + def update(self, trigger_log: WorkflowTriggerLog) -> WorkflowTriggerLog: + """ + Update an existing trigger log entry. + + Args: + trigger_log: The WorkflowTriggerLog instance to update + + Returns: + The updated WorkflowTriggerLog + """ + ... + + def get_by_id(self, trigger_log_id: str, tenant_id: str | None = None) -> WorkflowTriggerLog | None: + """ + Get a trigger log by its ID. + + Args: + trigger_log_id: The trigger log identifier + tenant_id: Optional tenant identifier for additional security + + Returns: + The WorkflowTriggerLog if found, None otherwise + """ + ... + + def get_failed_for_retry( + self, tenant_id: str, max_retry_count: int = 3, limit: int = 100 + ) -> Sequence[WorkflowTriggerLog]: + """ + Get failed trigger logs that are eligible for retry. + + Args: + tenant_id: The tenant identifier + max_retry_count: Maximum retry count to consider + limit: Maximum number of results + + Returns: + A sequence of WorkflowTriggerLog instances eligible for retry + """ + ... + + def get_recent_logs( + self, tenant_id: str, app_id: str, hours: int = 24, limit: int = 100, offset: int = 0 + ) -> Sequence[WorkflowTriggerLog]: + """ + Get recent trigger logs within specified hours. + + Args: + tenant_id: The tenant identifier + app_id: The application identifier + hours: Number of hours to look back + limit: Maximum number of results + offset: Number of results to skip + + Returns: + A sequence of recent WorkflowTriggerLog instances + """ + ... diff --git a/api/schedule/trigger_provider_refresh_task.py b/api/schedule/trigger_provider_refresh_task.py new file mode 100644 index 0000000000..3b3e478793 --- /dev/null +++ b/api/schedule/trigger_provider_refresh_task.py @@ -0,0 +1,104 @@ +import logging +import math +import time +from collections.abc import Iterable, Sequence + +from sqlalchemy import ColumnElement, and_, func, or_, select +from sqlalchemy.engine.row import Row +from sqlalchemy.orm import Session + +import app +from configs import dify_config +from core.trigger.utils.locks import build_trigger_refresh_lock_keys +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.trigger import TriggerSubscription +from tasks.trigger_subscription_refresh_tasks import trigger_subscription_refresh + +logger = logging.getLogger(__name__) + + +def _now_ts() -> int: + return int(time.time()) + + +def _build_due_filter(now_ts: int): + """Build SQLAlchemy filter for due credential or subscription refresh.""" + credential_due: ColumnElement[bool] = and_( + TriggerSubscription.credential_expires_at != -1, + TriggerSubscription.credential_expires_at + <= now_ts + int(dify_config.TRIGGER_PROVIDER_CREDENTIAL_THRESHOLD_SECONDS), + ) + subscription_due: ColumnElement[bool] = and_( + TriggerSubscription.expires_at != -1, + TriggerSubscription.expires_at <= now_ts + int(dify_config.TRIGGER_PROVIDER_SUBSCRIPTION_THRESHOLD_SECONDS), + ) + return or_(credential_due, subscription_due) + + +def _acquire_locks(keys: Iterable[str], ttl_seconds: int) -> list[bool]: + """Attempt to acquire locks in a single pipelined round-trip. + + Returns a list of booleans indicating which locks were acquired. + """ + pipe = redis_client.pipeline(transaction=False) + for key in keys: + pipe.set(key, b"1", ex=ttl_seconds, nx=True) + results = pipe.execute() + return [bool(r) for r in results] + + +@app.celery.task(queue="trigger_refresh_publisher") +def trigger_provider_refresh() -> None: + """ + Scan due trigger subscriptions and enqueue refresh tasks with in-flight locks. + """ + now: int = _now_ts() + + batch_size: int = int(dify_config.TRIGGER_PROVIDER_REFRESH_BATCH_SIZE) + lock_ttl: int = max(300, int(dify_config.TRIGGER_PROVIDER_SUBSCRIPTION_THRESHOLD_SECONDS)) + + with Session(db.engine, expire_on_commit=False) as session: + filter: ColumnElement[bool] = _build_due_filter(now_ts=now) + total_due: int = int(session.scalar(statement=select(func.count()).where(filter)) or 0) + logger.info("Trigger refresh scan start: due=%d", total_due) + if total_due == 0: + return + + pages: int = math.ceil(total_due / batch_size) + for page in range(pages): + offset: int = page * batch_size + subscription_rows: Sequence[Row[tuple[str, str]]] = session.execute( + select(TriggerSubscription.tenant_id, TriggerSubscription.id) + .where(filter) + .order_by(TriggerSubscription.updated_at.asc()) + .offset(offset) + .limit(batch_size) + ).all() + if not subscription_rows: + logger.debug("Trigger refresh page %d/%d empty", page + 1, pages) + continue + + subscriptions: list[tuple[str, str]] = [ + (str(tenant_id), str(subscription_id)) for tenant_id, subscription_id in subscription_rows + ] + lock_keys: list[str] = build_trigger_refresh_lock_keys(subscriptions) + acquired: list[bool] = _acquire_locks(keys=lock_keys, ttl_seconds=lock_ttl) + + enqueued: int = 0 + for (tenant_id, subscription_id), is_locked in zip(subscriptions, acquired): + if not is_locked: + continue + trigger_subscription_refresh.delay(tenant_id=tenant_id, subscription_id=subscription_id) + enqueued += 1 + + logger.info( + "Trigger refresh page %d/%d: scanned=%d locks_acquired=%d enqueued=%d", + page + 1, + pages, + len(subscriptions), + sum(1 for x in acquired if x), + enqueued, + ) + + logger.info("Trigger refresh scan done: due=%d", total_due) diff --git a/api/schedule/workflow_schedule_task.py b/api/schedule/workflow_schedule_task.py new file mode 100644 index 0000000000..41e2232353 --- /dev/null +++ b/api/schedule/workflow_schedule_task.py @@ -0,0 +1,127 @@ +import logging + +from celery import group, shared_task +from sqlalchemy import and_, select +from sqlalchemy.orm import Session, sessionmaker + +from configs import dify_config +from extensions.ext_database import db +from libs.datetime_utils import naive_utc_now +from libs.schedule_utils import calculate_next_run_at +from models.trigger import AppTrigger, AppTriggerStatus, AppTriggerType, WorkflowSchedulePlan +from services.workflow.queue_dispatcher import QueueDispatcherManager +from tasks.workflow_schedule_tasks import run_schedule_trigger + +logger = logging.getLogger(__name__) + + +@shared_task(queue="schedule_poller") +def poll_workflow_schedules() -> None: + """ + Poll and process due workflow schedules. + + Streaming flow: + 1. Fetch due schedules in batches + 2. Process each batch until all due schedules are handled + 3. Optional: Limit total dispatches per tick as a circuit breaker + """ + session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) + + with session_factory() as session: + total_dispatched = 0 + total_rate_limited = 0 + + # Process in batches until we've handled all due schedules or hit the limit + while True: + due_schedules = _fetch_due_schedules(session) + + if not due_schedules: + break + + dispatched_count, rate_limited_count = _process_schedules(session, due_schedules) + total_dispatched += dispatched_count + total_rate_limited += rate_limited_count + + logger.debug("Batch processed: %d dispatched, %d rate limited", dispatched_count, rate_limited_count) + + # Circuit breaker: check if we've hit the per-tick limit (if enabled) + if ( + dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK > 0 + and total_dispatched >= dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK + ): + logger.warning( + "Circuit breaker activated: reached dispatch limit (%d), will continue next tick", + dify_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK, + ) + break + + if total_dispatched > 0 or total_rate_limited > 0: + logger.info("Total processed: %d dispatched, %d rate limited", total_dispatched, total_rate_limited) + + +def _fetch_due_schedules(session: Session) -> list[WorkflowSchedulePlan]: + """ + Fetch a batch of due schedules, sorted by most overdue first. + + Returns up to WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE schedules per call. + Used in a loop to progressively process all due schedules. + """ + now = naive_utc_now() + + due_schedules = session.scalars( + ( + select(WorkflowSchedulePlan) + .join( + AppTrigger, + and_( + AppTrigger.app_id == WorkflowSchedulePlan.app_id, + AppTrigger.node_id == WorkflowSchedulePlan.node_id, + AppTrigger.trigger_type == AppTriggerType.TRIGGER_SCHEDULE, + ), + ) + .where( + WorkflowSchedulePlan.next_run_at <= now, + WorkflowSchedulePlan.next_run_at.isnot(None), + AppTrigger.status == AppTriggerStatus.ENABLED, + ) + ) + .order_by(WorkflowSchedulePlan.next_run_at.asc()) + .with_for_update(skip_locked=True) + .limit(dify_config.WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE) + ) + + return list(due_schedules) + + +def _process_schedules(session: Session, schedules: list[WorkflowSchedulePlan]) -> tuple[int, int]: + """Process schedules: check quota, update next run time and dispatch to Celery in parallel.""" + if not schedules: + return 0, 0 + + dispatcher_manager = QueueDispatcherManager() + tasks_to_dispatch: list[str] = [] + rate_limited_count = 0 + + for schedule in schedules: + next_run_at = calculate_next_run_at( + schedule.cron_expression, + schedule.timezone, + ) + schedule.next_run_at = next_run_at + + dispatcher = dispatcher_manager.get_dispatcher(schedule.tenant_id) + if not dispatcher.check_daily_quota(schedule.tenant_id): + logger.info("Tenant %s rate limited, skipping schedule_plan %s", schedule.tenant_id, schedule.id) + rate_limited_count += 1 + else: + tasks_to_dispatch.append(schedule.id) + + if tasks_to_dispatch: + job = group(run_schedule_trigger.s(schedule_id) for schedule_id in tasks_to_dispatch) + job.apply_async() + + logger.debug("Dispatched %d tasks in parallel", len(tasks_to_dispatch)) + + session.commit() + + return len(tasks_to_dispatch), rate_limited_count diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index edb18a845a..15fefd6116 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -26,6 +26,7 @@ from core.workflow.nodes.llm.entities import LLMNodeData from core.workflow.nodes.parameter_extractor.entities import ParameterExtractorNodeData from core.workflow.nodes.question_classifier.entities import QuestionClassifierNodeData from core.workflow.nodes.tool.entities import ToolNodeData +from core.workflow.nodes.trigger_schedule.trigger_schedule_node import TriggerScheduleNode from events.app_event import app_model_config_was_updated, app_was_created from extensions.ext_redis import redis_client from factories import variable_factory @@ -43,7 +44,7 @@ IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:" CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:" IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB -CURRENT_DSL_VERSION = "0.4.0" +CURRENT_DSL_VERSION = "0.5.0" class ImportMode(StrEnum): @@ -599,6 +600,16 @@ class AppDslService: if not include_secret and data_type == NodeType.AGENT: for tool in node_data.get("agent_parameters", {}).get("tools", {}).get("value", []): tool.pop("credential_id", None) + if data_type == NodeType.TRIGGER_SCHEDULE.value: + # override the config with the default config + node_data["config"] = TriggerScheduleNode.get_default_config()["config"] + if data_type == NodeType.TRIGGER_WEBHOOK.value: + # clear the webhook_url + node_data["webhook_url"] = "" + node_data["webhook_debug_url"] = "" + if data_type == NodeType.TRIGGER_PLUGIN.value: + # clear the subscription_id + node_data["subscription_id"] = "" export_data["workflow"] = workflow_dict dependencies = cls._extract_dependencies_from_workflow(workflow) diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 25ee8223c2..5b09bd9593 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -31,6 +31,7 @@ class AppGenerateService: args: Mapping[str, Any], invoke_from: InvokeFrom, streaming: bool = True, + root_node_id: str | None = None, ): """ App Content Generate @@ -114,6 +115,7 @@ class AppGenerateService: args=args, invoke_from=invoke_from, streaming=streaming, + root_node_id=root_node_id, call_depth=0, ), ), diff --git a/api/services/async_workflow_service.py b/api/services/async_workflow_service.py new file mode 100644 index 0000000000..034d7ffedb --- /dev/null +++ b/api/services/async_workflow_service.py @@ -0,0 +1,323 @@ +""" +Universal async workflow execution service. + +This service provides a centralized entry point for triggering workflows asynchronously +with support for different subscription tiers, rate limiting, and execution tracking. +""" + +import json +from datetime import UTC, datetime +from typing import Any, Union + +from celery.result import AsyncResult +from sqlalchemy import select +from sqlalchemy.orm import Session + +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.account import Account +from models.enums import CreatorUserRole, WorkflowTriggerStatus +from models.model import App, EndUser +from models.trigger import WorkflowTriggerLog +from models.workflow import Workflow +from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository +from services.errors.app import InvokeDailyRateLimitError, WorkflowNotFoundError +from services.workflow.entities import AsyncTriggerResponse, TriggerData, WorkflowTaskData +from services.workflow.queue_dispatcher import QueueDispatcherManager, QueuePriority +from services.workflow.rate_limiter import TenantDailyRateLimiter +from services.workflow_service import WorkflowService +from tasks.async_workflow_tasks import ( + execute_workflow_professional, + execute_workflow_sandbox, + execute_workflow_team, +) + + +class AsyncWorkflowService: + """ + Universal entry point for async workflow execution - ALL METHODS ARE NON-BLOCKING + + This service handles: + - Trigger data validation and processing + - Queue routing based on subscription tier + - Daily rate limiting with timezone support + - Execution tracking and logging + - Retry mechanisms for failed executions + + Important: All trigger methods return immediately after queuing tasks. + Actual workflow execution happens asynchronously in background Celery workers. + Use trigger log IDs to monitor execution status and results. + """ + + @classmethod + def trigger_workflow_async( + cls, session: Session, user: Union[Account, EndUser], trigger_data: TriggerData + ) -> AsyncTriggerResponse: + """ + Universal entry point for async workflow execution - THIS METHOD WILL NOT BLOCK + + Creates a trigger log and dispatches to appropriate queue based on subscription tier. + The workflow execution happens asynchronously in the background via Celery workers. + This method returns immediately after queuing the task, not after execution completion. + + Args: + session: Database session to use for operations + user: User (Account or EndUser) who initiated the workflow trigger + trigger_data: Validated Pydantic model containing trigger information + + Returns: + AsyncTriggerResponse with workflow_trigger_log_id, task_id, status="queued", and queue + Note: The actual workflow execution status must be checked separately via workflow_trigger_log_id + + Raises: + WorkflowNotFoundError: If app or workflow not found + InvokeDailyRateLimitError: If daily rate limit exceeded + + Behavior: + - Non-blocking: Returns immediately after queuing + - Asynchronous: Actual execution happens in background Celery workers + - Status tracking: Use workflow_trigger_log_id to monitor progress + - Queue-based: Routes to different queues based on subscription tier + """ + trigger_log_repo = SQLAlchemyWorkflowTriggerLogRepository(session) + dispatcher_manager = QueueDispatcherManager() + workflow_service = WorkflowService() + rate_limiter = TenantDailyRateLimiter(redis_client) + + # 1. Validate app exists + app_model = session.scalar(select(App).where(App.id == trigger_data.app_id)) + if not app_model: + raise WorkflowNotFoundError(f"App not found: {trigger_data.app_id}") + + # 2. Get workflow + workflow = cls._get_workflow(workflow_service, app_model, trigger_data.workflow_id) + + # 3. Get dispatcher based on tenant subscription + dispatcher = dispatcher_manager.get_dispatcher(trigger_data.tenant_id) + + # 4. Rate limiting check will be done without timezone first + + # 5. Determine user role and ID + if isinstance(user, Account): + created_by_role = CreatorUserRole.ACCOUNT + created_by = user.id + else: # EndUser + created_by_role = CreatorUserRole.END_USER + created_by = user.id + + # 6. Create trigger log entry first (for tracking) + trigger_log = WorkflowTriggerLog( + tenant_id=trigger_data.tenant_id, + app_id=trigger_data.app_id, + workflow_id=workflow.id, + root_node_id=trigger_data.root_node_id, + trigger_metadata=( + trigger_data.trigger_metadata.model_dump_json() if trigger_data.trigger_metadata else "{}" + ), + trigger_type=trigger_data.trigger_type, + trigger_data=trigger_data.model_dump_json(), + inputs=json.dumps(dict(trigger_data.inputs)), + status=WorkflowTriggerStatus.PENDING, + queue_name=dispatcher.get_queue_name(), + retry_count=0, + created_by_role=created_by_role, + created_by=created_by, + ) + + trigger_log = trigger_log_repo.create(trigger_log) + session.commit() + + # 7. Check and consume daily quota + if not dispatcher.consume_quota(trigger_data.tenant_id): + # Update trigger log status + trigger_log.status = WorkflowTriggerStatus.RATE_LIMITED + trigger_log.error = f"Daily limit reached for {dispatcher.get_queue_name()}" + trigger_log_repo.update(trigger_log) + session.commit() + + tenant_owner_tz = rate_limiter.get_tenant_owner_timezone(trigger_data.tenant_id) + + remaining = rate_limiter.get_remaining_quota(trigger_data.tenant_id, dispatcher.get_daily_limit()) + + reset_time = rate_limiter.get_quota_reset_time(trigger_data.tenant_id, tenant_owner_tz) + + raise InvokeDailyRateLimitError( + f"Daily workflow execution limit reached. " + f"Limit resets at {reset_time.strftime('%Y-%m-%d %H:%M:%S %Z')}. " + f"Remaining quota: {remaining}" + ) + + # 8. Create task data + queue_name = dispatcher.get_queue_name() + + task_data = WorkflowTaskData(workflow_trigger_log_id=trigger_log.id) + + # 9. Dispatch to appropriate queue + task_data_dict = task_data.model_dump(mode="json") + + task: AsyncResult[Any] | None = None + if queue_name == QueuePriority.PROFESSIONAL: + task = execute_workflow_professional.delay(task_data_dict) # type: ignore + elif queue_name == QueuePriority.TEAM: + task = execute_workflow_team.delay(task_data_dict) # type: ignore + else: # SANDBOX + task = execute_workflow_sandbox.delay(task_data_dict) # type: ignore + + # 10. Update trigger log with task info + trigger_log.status = WorkflowTriggerStatus.QUEUED + trigger_log.celery_task_id = task.id + trigger_log.triggered_at = datetime.now(UTC) + trigger_log_repo.update(trigger_log) + session.commit() + + return AsyncTriggerResponse( + workflow_trigger_log_id=trigger_log.id, + task_id=task.id, # type: ignore + status="queued", + queue=queue_name, + ) + + @classmethod + def reinvoke_trigger( + cls, session: Session, user: Union[Account, EndUser], workflow_trigger_log_id: str + ) -> AsyncTriggerResponse: + """ + Re-invoke a previously failed or rate-limited trigger - THIS METHOD WILL NOT BLOCK + + Updates the existing trigger log to retry status and creates a new async execution. + Returns immediately after queuing the retry, not after execution completion. + + Args: + session: Database session to use for operations + user: User (Account or EndUser) who initiated the retry + workflow_trigger_log_id: ID of the trigger log to re-invoke + + Returns: + AsyncTriggerResponse with new execution information (status="queued") + Note: This creates a new trigger log entry for the retry attempt + + Raises: + ValueError: If trigger log not found + + Behavior: + - Non-blocking: Returns immediately after queuing retry + - Creates new trigger log: Original log marked as retrying, new log for execution + - Preserves original trigger data: Uses same inputs and configuration + """ + trigger_log_repo = SQLAlchemyWorkflowTriggerLogRepository(session) + + trigger_log = trigger_log_repo.get_by_id(workflow_trigger_log_id) + + if not trigger_log: + raise ValueError(f"Trigger log not found: {workflow_trigger_log_id}") + + # Reconstruct trigger data from log + trigger_data = TriggerData.model_validate_json(trigger_log.trigger_data) + + # Reset log for retry + trigger_log.status = WorkflowTriggerStatus.RETRYING + trigger_log.retry_count += 1 + trigger_log.error = None + trigger_log.triggered_at = datetime.now(UTC) + trigger_log_repo.update(trigger_log) + session.commit() + + # Re-trigger workflow (this will create a new trigger log) + return cls.trigger_workflow_async(session, user, trigger_data) + + @classmethod + def get_trigger_log(cls, workflow_trigger_log_id: str, tenant_id: str | None = None) -> dict[str, Any] | None: + """ + Get trigger log by ID + + Args: + workflow_trigger_log_id: ID of the trigger log + tenant_id: Optional tenant ID for security check + + Returns: + Trigger log as dictionary or None if not found + """ + with Session(db.engine) as session: + trigger_log_repo = SQLAlchemyWorkflowTriggerLogRepository(session) + trigger_log = trigger_log_repo.get_by_id(workflow_trigger_log_id, tenant_id) + + if not trigger_log: + return None + + return trigger_log.to_dict() + + @classmethod + def get_recent_logs( + cls, tenant_id: str, app_id: str, hours: int = 24, limit: int = 100, offset: int = 0 + ) -> list[dict[str, Any]]: + """ + Get recent trigger logs + + Args: + tenant_id: Tenant ID + app_id: Application ID + hours: Number of hours to look back + limit: Maximum number of results + offset: Number of results to skip + + Returns: + List of trigger logs as dictionaries + """ + with Session(db.engine) as session: + trigger_log_repo = SQLAlchemyWorkflowTriggerLogRepository(session) + logs = trigger_log_repo.get_recent_logs( + tenant_id=tenant_id, app_id=app_id, hours=hours, limit=limit, offset=offset + ) + + return [log.to_dict() for log in logs] + + @classmethod + def get_failed_logs_for_retry( + cls, tenant_id: str, max_retry_count: int = 3, limit: int = 100 + ) -> list[dict[str, Any]]: + """ + Get failed logs eligible for retry + + Args: + tenant_id: Tenant ID + max_retry_count: Maximum retry count + limit: Maximum number of results + + Returns: + List of failed trigger logs as dictionaries + """ + with Session(db.engine) as session: + trigger_log_repo = SQLAlchemyWorkflowTriggerLogRepository(session) + logs = trigger_log_repo.get_failed_for_retry( + tenant_id=tenant_id, max_retry_count=max_retry_count, limit=limit + ) + + return [log.to_dict() for log in logs] + + @staticmethod + def _get_workflow(workflow_service: WorkflowService, app_model: App, workflow_id: str | None = None) -> Workflow: + """ + Get workflow for the app + + Args: + app_model: App model instance + workflow_id: Optional specific workflow ID + + Returns: + Workflow instance + + Raises: + WorkflowNotFoundError: If workflow not found + """ + if workflow_id: + # Get specific published workflow + workflow = workflow_service.get_published_workflow_by_id(app_model, workflow_id) + if not workflow: + raise WorkflowNotFoundError(f"Published workflow not found: {workflow_id}") + else: + # Get default published workflow + workflow = workflow_service.get_published_workflow(app_model) + if not workflow: + raise WorkflowNotFoundError(f"No published workflow found for app: {app_model.id}") + + return workflow diff --git a/api/services/datasource_provider_service.py b/api/services/datasource_provider_service.py index 1b690e2266..81e0c0ecd4 100644 --- a/api/services/datasource_provider_service.py +++ b/api/services/datasource_provider_service.py @@ -11,9 +11,9 @@ from core.helper import encrypter from core.helper.name_generator import generate_incremental_name from core.helper.provider_cache import NoOpProviderCredentialCache from core.model_runtime.entities.provider_entities import FormType +from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.datasource import PluginDatasourceManager from core.plugin.impl.oauth import OAuthHandler -from core.tools.entities.tool_entities import CredentialType from core.tools.utils.encryption import ProviderConfigCache, ProviderConfigEncrypter, create_provider_encrypter from extensions.ext_database import db from extensions.ext_redis import redis_client @@ -338,7 +338,7 @@ class DatasourceProviderService: key: value if value != HIDDEN_VALUE else original_params.get(key, UNKNOWN_VALUE) for key, value in client_params.items() } - tenant_oauth_client_params.client_params = encrypter.encrypt(new_params) + tenant_oauth_client_params.client_params = dict(encrypter.encrypt(new_params)) if enabled is not None: tenant_oauth_client_params.enabled = enabled @@ -374,7 +374,7 @@ class DatasourceProviderService: def get_tenant_oauth_client( self, tenant_id: str, datasource_provider_id: DatasourceProviderID, mask: bool = False - ) -> dict[str, Any] | None: + ) -> Mapping[str, Any] | None: """ get tenant oauth client """ @@ -390,7 +390,7 @@ class DatasourceProviderService: if tenant_oauth_client_params: encrypter, _ = self.get_oauth_encrypter(tenant_id, datasource_provider_id) if mask: - return encrypter.mask_tool_credentials(encrypter.decrypt(tenant_oauth_client_params.client_params)) + return encrypter.mask_plugin_credentials(encrypter.decrypt(tenant_oauth_client_params.client_params)) else: return encrypter.decrypt(tenant_oauth_client_params.client_params) return None @@ -434,7 +434,7 @@ class DatasourceProviderService: ) if tenant_oauth_client_params: encrypter, _ = self.get_oauth_encrypter(tenant_id, datasource_provider_id) - return encrypter.decrypt(tenant_oauth_client_params.client_params) + return dict(encrypter.decrypt(tenant_oauth_client_params.client_params)) provider_controller = self.provider_manager.fetch_datasource_provider( tenant_id=tenant_id, provider_id=str(datasource_provider_id) diff --git a/api/services/end_user_service.py b/api/services/end_user_service.py new file mode 100644 index 0000000000..aa4a2e46ec --- /dev/null +++ b/api/services/end_user_service.py @@ -0,0 +1,141 @@ +from collections.abc import Mapping + +from sqlalchemy.orm import Session + +from core.app.entities.app_invoke_entities import InvokeFrom +from extensions.ext_database import db +from models.model import App, DefaultEndUserSessionID, EndUser + + +class EndUserService: + """ + Service for managing end users. + """ + + @classmethod + def get_or_create_end_user(cls, app_model: App, user_id: str | None = None) -> EndUser: + """ + Get or create an end user for a given app. + """ + + return cls.get_or_create_end_user_by_type(InvokeFrom.SERVICE_API, app_model.tenant_id, app_model.id, user_id) + + @classmethod + def get_or_create_end_user_by_type( + cls, type: InvokeFrom, tenant_id: str, app_id: str, user_id: str | None = None + ) -> EndUser: + """ + Get or create an end user for a given app and type. + """ + + if not user_id: + user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID + + with Session(db.engine, expire_on_commit=False) as session: + end_user = ( + session.query(EndUser) + .where( + EndUser.tenant_id == tenant_id, + EndUser.app_id == app_id, + EndUser.session_id == user_id, + EndUser.type == type, + ) + .first() + ) + + if end_user is None: + end_user = EndUser( + tenant_id=tenant_id, + app_id=app_id, + type=type, + is_anonymous=user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID, + session_id=user_id, + external_user_id=user_id, + ) + session.add(end_user) + session.commit() + + return end_user + + @classmethod + def create_end_user_batch( + cls, type: InvokeFrom, tenant_id: str, app_ids: list[str], user_id: str + ) -> Mapping[str, EndUser]: + """Create end users in batch. + + Creates end users in batch for the specified tenant and application IDs in O(1) time. + + This batch creation is necessary because trigger subscriptions can span multiple applications, + and trigger events may be dispatched to multiple applications simultaneously. + + For each app_id in app_ids, check if an `EndUser` with the given + `user_id` (as session_id/external_user_id) already exists for the + tenant/app and type `type`. If it exists, return it; otherwise, + create it. Operates with minimal DB I/O by querying and inserting in + batches. + + Returns a mapping of `app_id -> EndUser`. + """ + + # Normalize user_id to default if empty + if not user_id: + user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID + + # Deduplicate app_ids while preserving input order + seen: set[str] = set() + unique_app_ids: list[str] = [] + for app_id in app_ids: + if app_id not in seen: + seen.add(app_id) + unique_app_ids.append(app_id) + + # Result is a simple app_id -> EndUser mapping + result: dict[str, EndUser] = {} + if not unique_app_ids: + return result + + with Session(db.engine, expire_on_commit=False) as session: + # Fetch existing end users for all target apps in a single query + existing_end_users: list[EndUser] = ( + session.query(EndUser) + .where( + EndUser.tenant_id == tenant_id, + EndUser.app_id.in_(unique_app_ids), + EndUser.session_id == user_id, + EndUser.type == type, + ) + .all() + ) + + found_app_ids: set[str] = set() + for eu in existing_end_users: + # If duplicates exist due to weak DB constraints, prefer the first + if eu.app_id not in result: + result[eu.app_id] = eu + found_app_ids.add(eu.app_id) + + # Determine which apps still need an EndUser created + missing_app_ids = [app_id for app_id in unique_app_ids if app_id not in found_app_ids] + + if missing_app_ids: + new_end_users: list[EndUser] = [] + is_anonymous = user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID + for app_id in missing_app_ids: + new_end_users.append( + EndUser( + tenant_id=tenant_id, + app_id=app_id, + type=type, + is_anonymous=is_anonymous, + session_id=user_id, + external_user_id=user_id, + ) + ) + + session.add_all(new_end_users) + session.commit() + + for eu in new_end_users: + result[eu.app_id] = eu + + return result diff --git a/api/services/errors/app.py b/api/services/errors/app.py index 390716a47f..338636d9b6 100644 --- a/api/services/errors/app.py +++ b/api/services/errors/app.py @@ -16,3 +16,9 @@ class WorkflowNotFoundError(Exception): class WorkflowIdFormatError(Exception): pass + + +class InvokeDailyRateLimitError(Exception): + """Raised when daily rate limit is exceeded for workflow invocations.""" + + pass diff --git a/api/services/plugin/oauth_service.py b/api/services/plugin/oauth_service.py index 057b20428f..88dec062a0 100644 --- a/api/services/plugin/oauth_service.py +++ b/api/services/plugin/oauth_service.py @@ -16,6 +16,7 @@ class OAuthProxyService(BasePluginClient): tenant_id: str, plugin_id: str, provider: str, + extra_data: dict = {}, credential_id: str | None = None, ): """ @@ -32,6 +33,7 @@ class OAuthProxyService(BasePluginClient): """ context_id = str(uuid.uuid4()) data = { + **extra_data, "user_id": user_id, "plugin_id": plugin_id, "tenant_id": tenant_id, diff --git a/api/services/plugin/plugin_parameter_service.py b/api/services/plugin/plugin_parameter_service.py index 00b59dacb3..c517d9f966 100644 --- a/api/services/plugin/plugin_parameter_service.py +++ b/api/services/plugin/plugin_parameter_service.py @@ -4,11 +4,16 @@ from typing import Any, Literal from sqlalchemy.orm import Session from core.plugin.entities.parameters import PluginParameterOption +from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.dynamic_select import DynamicSelectClient from core.tools.tool_manager import ToolManager from core.tools.utils.encryption import create_tool_provider_encrypter +from core.trigger.entities.api_entities import TriggerProviderSubscriptionApiEntity +from core.trigger.entities.entities import SubscriptionBuilder from extensions.ext_database import db from models.tools import BuiltinToolProvider +from services.trigger.trigger_provider_service import TriggerProviderService +from services.trigger.trigger_subscription_builder_service import TriggerSubscriptionBuilderService class PluginParameterService: @@ -20,7 +25,8 @@ class PluginParameterService: provider: str, action: str, parameter: str, - provider_type: Literal["tool"], + credential_id: str | None, + provider_type: Literal["tool", "trigger"], ) -> Sequence[PluginParameterOption]: """ Get dynamic select options for a plugin parameter. @@ -33,7 +39,7 @@ class PluginParameterService: parameter: The parameter name. """ credentials: Mapping[str, Any] = {} - + credential_type: str = CredentialType.UNAUTHORIZED.value match provider_type: case "tool": provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) @@ -49,24 +55,53 @@ class PluginParameterService: else: # fetch credentials from db with Session(db.engine) as session: - db_record = ( - session.query(BuiltinToolProvider) - .where( - BuiltinToolProvider.tenant_id == tenant_id, - BuiltinToolProvider.provider == provider, + if credential_id: + db_record = ( + session.query(BuiltinToolProvider) + .where( + BuiltinToolProvider.tenant_id == tenant_id, + BuiltinToolProvider.provider == provider, + BuiltinToolProvider.id == credential_id, + ) + .first() + ) + else: + db_record = ( + session.query(BuiltinToolProvider) + .where( + BuiltinToolProvider.tenant_id == tenant_id, + BuiltinToolProvider.provider == provider, + ) + .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) + .first() ) - .first() - ) if db_record is None: raise ValueError(f"Builtin provider {provider} not found when fetching credentials") credentials = encrypter.decrypt(db_record.credentials) - case _: - raise ValueError(f"Invalid provider type: {provider_type}") + credential_type = db_record.credential_type + case "trigger": + subscription: TriggerProviderSubscriptionApiEntity | SubscriptionBuilder | None + if credential_id: + subscription = TriggerSubscriptionBuilderService.get_subscription_builder(credential_id) + if not subscription: + trigger_subscription = TriggerProviderService.get_subscription_by_id(tenant_id, credential_id) + subscription = trigger_subscription.to_api_entity() if trigger_subscription else None + else: + trigger_subscription = TriggerProviderService.get_subscription_by_id(tenant_id) + subscription = trigger_subscription.to_api_entity() if trigger_subscription else None + + if subscription is None: + raise ValueError(f"Subscription {credential_id} not found") + + credentials = subscription.credentials + credential_type = subscription.credential_type or CredentialType.UNAUTHORIZED return ( DynamicSelectClient() - .fetch_dynamic_select_options(tenant_id, user_id, plugin_id, provider, action, credentials, parameter) + .fetch_dynamic_select_options( + tenant_id, user_id, plugin_id, provider, action, credentials, credential_type, parameter + ) .options ) diff --git a/api/services/plugin/plugin_service.py b/api/services/plugin/plugin_service.py index 525ccc9417..b8303eb724 100644 --- a/api/services/plugin/plugin_service.py +++ b/api/services/plugin/plugin_service.py @@ -3,6 +3,7 @@ from collections.abc import Mapping, Sequence from mimetypes import guess_type from pydantic import BaseModel +from yarl import URL from configs import dify_config from core.helper import marketplace @@ -175,6 +176,13 @@ class PluginService: manager = PluginInstaller() return manager.fetch_plugin_installation_by_ids(tenant_id, ids) + @classmethod + def get_plugin_icon_url(cls, tenant_id: str, filename: str) -> str: + url_prefix = ( + URL(dify_config.CONSOLE_API_URL or "/") / "console" / "api" / "workspaces" / "current" / "plugin" / "icon" + ) + return str(url_prefix % {"tenant_id": tenant_id, "filename": filename}) + @staticmethod def get_asset(tenant_id: str, asset_file: str) -> tuple[bytes, str]: """ @@ -185,6 +193,11 @@ class PluginService: mime_type, _ = guess_type(asset_file) return manager.fetch_asset(tenant_id, asset_file), mime_type or "application/octet-stream" + @staticmethod + def extract_asset(tenant_id: str, plugin_unique_identifier: str, file_name: str) -> bytes: + manager = PluginAssetManager() + return manager.extract_asset(tenant_id, plugin_unique_identifier, file_name) + @staticmethod def check_plugin_unique_identifier(tenant_id: str, plugin_unique_identifier: str) -> bool: """ @@ -502,3 +515,11 @@ class PluginService: """ manager = PluginInstaller() return manager.check_tools_existence(tenant_id, provider_ids) + + @staticmethod + def fetch_plugin_readme(tenant_id: str, plugin_unique_identifier: str, language: str) -> str: + """ + Fetch plugin readme + """ + manager = PluginInstaller() + return manager.fetch_plugin_readme(tenant_id, plugin_unique_identifier, language) diff --git a/api/services/tools/api_tools_manage_service.py b/api/services/tools/api_tools_manage_service.py index bb024cc846..250d29f335 100644 --- a/api/services/tools/api_tools_manage_service.py +++ b/api/services/tools/api_tools_manage_service.py @@ -300,13 +300,13 @@ class ApiToolManageService: ) original_credentials = encrypter.decrypt(provider.credentials) - masked_credentials = encrypter.mask_tool_credentials(original_credentials) + masked_credentials = encrypter.mask_plugin_credentials(original_credentials) # check if the credential has changed, save the original credential for name, value in credentials.items(): if name in masked_credentials and value == masked_credentials[name]: credentials[name] = original_credentials[name] - credentials = encrypter.encrypt(credentials) + credentials = dict(encrypter.encrypt(credentials)) provider.credentials_str = json.dumps(credentials) db.session.add(provider) @@ -417,7 +417,7 @@ class ApiToolManageService: ) decrypted_credentials = encrypter.decrypt(credentials) # check if the credential has changed, save the original credential - masked_credentials = encrypter.mask_tool_credentials(decrypted_credentials) + masked_credentials = encrypter.mask_plugin_credentials(decrypted_credentials) for name, value in credentials.items(): if name in masked_credentials and value == masked_credentials[name]: credentials[name] = decrypted_credentials[name] diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index 0628c8f22e..783f2f0d21 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -12,6 +12,7 @@ from constants import HIDDEN_VALUE, UNKNOWN_VALUE from core.helper.name_generator import generate_incremental_name from core.helper.position_helper import is_filtered from core.helper.provider_cache import NoOpProviderCredentialCache, ToolProviderCredentialsCache +from core.plugin.entities.plugin_daemon import CredentialType from core.tools.builtin_tool.provider import BuiltinToolProviderController from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort from core.tools.entities.api_entities import ( @@ -20,7 +21,6 @@ from core.tools.entities.api_entities import ( ToolProviderCredentialApiEntity, ToolProviderCredentialInfoApiEntity, ) -from core.tools.entities.tool_entities import CredentialType from core.tools.errors import ToolProviderNotFoundError from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.tool_label_manager import ToolLabelManager @@ -39,7 +39,6 @@ logger = logging.getLogger(__name__) class BuiltinToolManageService: __MAX_BUILTIN_TOOL_PROVIDER_COUNT__ = 100 - __DEFAULT_EXPIRES_AT__ = 2147483647 @staticmethod def delete_custom_oauth_client_params(tenant_id: str, provider: str): @@ -278,9 +277,7 @@ class BuiltinToolManageService: encrypted_credentials=json.dumps(encrypter.encrypt(credentials)), credential_type=api_type.value, name=name, - expires_at=expires_at - if expires_at is not None - else BuiltinToolManageService.__DEFAULT_EXPIRES_AT__, + expires_at=expires_at if expires_at is not None else -1, ) session.add(db_provider) @@ -353,10 +350,10 @@ class BuiltinToolManageService: encrypter, _ = BuiltinToolManageService.create_tool_encrypter( tenant_id, provider, provider.provider, provider_controller ) - decrypt_credential = encrypter.mask_tool_credentials(encrypter.decrypt(provider.credentials)) + decrypt_credential = encrypter.mask_plugin_credentials(encrypter.decrypt(provider.credentials)) credential_entity = ToolTransformService.convert_builtin_provider_to_credential_entity( provider=provider, - credentials=decrypt_credential, + credentials=dict(decrypt_credential), ) credentials.append(credential_entity) return credentials @@ -727,4 +724,4 @@ class BuiltinToolManageService: cache=NoOpProviderCredentialCache(), ) - return encrypter.mask_tool_credentials(encrypter.decrypt(custom_oauth_client_params.oauth_params)) + return encrypter.mask_plugin_credentials(encrypter.decrypt(custom_oauth_client_params.oauth_params)) diff --git a/api/services/tools/mcp_tools_manage_service.py b/api/services/tools/mcp_tools_manage_service.py index e219bd4ce9..d798e11ff1 100644 --- a/api/services/tools/mcp_tools_manage_service.py +++ b/api/services/tools/mcp_tools_manage_service.py @@ -1,6 +1,7 @@ import hashlib import json import logging +from collections.abc import Mapping from datetime import datetime from enum import StrEnum from typing import Any @@ -420,7 +421,7 @@ class MCPToolManageService: return json.dumps({"content": icon, "background": icon_background}) return icon - def _encrypt_dict_fields(self, data: dict[str, Any], secret_fields: list[str], tenant_id: str) -> dict[str, str]: + def _encrypt_dict_fields(self, data: dict[str, Any], secret_fields: list[str], tenant_id: str) -> Mapping[str, str]: """Encrypt specified fields in a dictionary. Args: diff --git a/api/services/tools/tools_transform_service.py b/api/services/tools/tools_transform_service.py index ab80af7a8d..3e976234ba 100644 --- a/api/services/tools/tools_transform_service.py +++ b/api/services/tools/tools_transform_service.py @@ -9,7 +9,7 @@ from yarl import URL from configs import dify_config from core.helper.provider_cache import ToolProviderCredentialsCache from core.mcp.types import Tool as MCPTool -from core.plugin.entities.plugin_daemon import PluginDatasourceProviderEntity +from core.plugin.entities.plugin_daemon import CredentialType, PluginDatasourceProviderEntity from core.tools.__base.tool import Tool from core.tools.__base.tool_runtime import ToolRuntime from core.tools.builtin_tool.provider import BuiltinToolProviderController @@ -19,7 +19,6 @@ from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_bundle import ApiToolBundle from core.tools.entities.tool_entities import ( ApiProviderAuthType, - CredentialType, ToolParameter, ToolProviderType, ) @@ -28,18 +27,12 @@ from core.tools.utils.encryption import create_provider_encrypter, create_tool_p from core.tools.workflow_as_tool.provider import WorkflowToolProviderController from core.tools.workflow_as_tool.tool import WorkflowTool from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider +from services.plugin.plugin_service import PluginService logger = logging.getLogger(__name__) class ToolTransformService: - @classmethod - def get_plugin_icon_url(cls, tenant_id: str, filename: str) -> str: - url_prefix = ( - URL(dify_config.CONSOLE_API_URL or "/") / "console" / "api" / "workspaces" / "current" / "plugin" / "icon" - ) - return str(url_prefix % {"tenant_id": tenant_id, "filename": filename}) - @classmethod def get_tool_provider_icon_url( cls, provider_type: str, provider_name: str, icon: str | Mapping[str, str] @@ -79,11 +72,9 @@ class ToolTransformService: elif isinstance(provider, ToolProviderApiEntity): if provider.plugin_id: if isinstance(provider.icon, str): - provider.icon = ToolTransformService.get_plugin_icon_url( - tenant_id=tenant_id, filename=provider.icon - ) + provider.icon = PluginService.get_plugin_icon_url(tenant_id=tenant_id, filename=provider.icon) if isinstance(provider.icon_dark, str) and provider.icon_dark: - provider.icon_dark = ToolTransformService.get_plugin_icon_url( + provider.icon_dark = PluginService.get_plugin_icon_url( tenant_id=tenant_id, filename=provider.icon_dark ) else: @@ -97,7 +88,7 @@ class ToolTransformService: elif isinstance(provider, PluginDatasourceProviderEntity): if provider.plugin_id: if isinstance(provider.declaration.identity.icon, str): - provider.declaration.identity.icon = ToolTransformService.get_plugin_icon_url( + provider.declaration.identity.icon = PluginService.get_plugin_icon_url( tenant_id=tenant_id, filename=provider.declaration.identity.icon ) @@ -172,7 +163,7 @@ class ToolTransformService: ) # decrypt the credentials and mask the credentials decrypted_credentials = encrypter.decrypt(data=credentials) - masked_credentials = encrypter.mask_tool_credentials(data=decrypted_credentials) + masked_credentials = encrypter.mask_plugin_credentials(data=decrypted_credentials) result.masked_credentials = masked_credentials result.original_credentials = decrypted_credentials @@ -345,7 +336,7 @@ class ToolTransformService: # decrypt the credentials and mask the credentials decrypted_credentials = encrypter.decrypt(data=credentials) - masked_credentials = encrypter.mask_tool_credentials(data=decrypted_credentials) + masked_credentials = encrypter.mask_plugin_credentials(data=decrypted_credentials) result.masked_credentials = masked_credentials diff --git a/api/services/trigger/schedule_service.py b/api/services/trigger/schedule_service.py new file mode 100644 index 0000000000..b49d14f860 --- /dev/null +++ b/api/services/trigger/schedule_service.py @@ -0,0 +1,312 @@ +import json +import logging +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.workflow.nodes import NodeType +from core.workflow.nodes.trigger_schedule.entities import ScheduleConfig, SchedulePlanUpdate, VisualConfig +from core.workflow.nodes.trigger_schedule.exc import ScheduleConfigError, ScheduleNotFoundError +from libs.schedule_utils import calculate_next_run_at, convert_12h_to_24h +from models.account import Account, TenantAccountJoin +from models.trigger import WorkflowSchedulePlan +from models.workflow import Workflow +from services.errors.account import AccountNotFoundError + +logger = logging.getLogger(__name__) + + +class ScheduleService: + @staticmethod + def create_schedule( + session: Session, + tenant_id: str, + app_id: str, + config: ScheduleConfig, + ) -> WorkflowSchedulePlan: + """ + Create a new schedule with validated configuration. + + Args: + session: Database session + tenant_id: Tenant ID + app_id: Application ID + config: Validated schedule configuration + + Returns: + Created WorkflowSchedulePlan instance + """ + next_run_at = calculate_next_run_at( + config.cron_expression, + config.timezone, + ) + + schedule = WorkflowSchedulePlan( + tenant_id=tenant_id, + app_id=app_id, + node_id=config.node_id, + cron_expression=config.cron_expression, + timezone=config.timezone, + next_run_at=next_run_at, + ) + + session.add(schedule) + session.flush() + + return schedule + + @staticmethod + def update_schedule( + session: Session, + schedule_id: str, + updates: SchedulePlanUpdate, + ) -> WorkflowSchedulePlan: + """ + Update an existing schedule with validated configuration. + + Args: + session: Database session + schedule_id: Schedule ID to update + updates: Validated update configuration + + Raises: + ScheduleNotFoundError: If schedule not found + + Returns: + Updated WorkflowSchedulePlan instance + """ + schedule = session.get(WorkflowSchedulePlan, schedule_id) + if not schedule: + raise ScheduleNotFoundError(f"Schedule not found: {schedule_id}") + + # If time-related fields are updated, synchronously update the next_run_at. + time_fields_updated = False + + if updates.node_id is not None: + schedule.node_id = updates.node_id + + if updates.cron_expression is not None: + schedule.cron_expression = updates.cron_expression + time_fields_updated = True + + if updates.timezone is not None: + schedule.timezone = updates.timezone + time_fields_updated = True + + if time_fields_updated: + schedule.next_run_at = calculate_next_run_at( + schedule.cron_expression, + schedule.timezone, + ) + + session.flush() + return schedule + + @staticmethod + def delete_schedule( + session: Session, + schedule_id: str, + ) -> None: + """ + Delete a schedule plan. + + Args: + session: Database session + schedule_id: Schedule ID to delete + """ + schedule = session.get(WorkflowSchedulePlan, schedule_id) + if not schedule: + raise ScheduleNotFoundError(f"Schedule not found: {schedule_id}") + + session.delete(schedule) + session.flush() + + @staticmethod + def get_tenant_owner(session: Session, tenant_id: str) -> Account: + """ + Returns an account to execute scheduled workflows on behalf of the tenant. + Prioritizes owner over admin to ensure proper authorization hierarchy. + """ + result = session.execute( + select(TenantAccountJoin) + .where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.role == "owner") + .limit(1) + ).scalar_one_or_none() + + if not result: + # Owner may not exist in some tenant configurations, fallback to admin + result = session.execute( + select(TenantAccountJoin) + .where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.role == "admin") + .limit(1) + ).scalar_one_or_none() + + if result: + account = session.get(Account, result.account_id) + if not account: + raise AccountNotFoundError(f"Account not found: {result.account_id}") + return account + else: + raise AccountNotFoundError(f"Account not found for tenant: {tenant_id}") + + @staticmethod + def update_next_run_at( + session: Session, + schedule_id: str, + ) -> datetime: + """ + Advances the schedule to its next execution time after a successful trigger. + Uses current time as base to prevent missing executions during delays. + """ + schedule = session.get(WorkflowSchedulePlan, schedule_id) + if not schedule: + raise ScheduleNotFoundError(f"Schedule not found: {schedule_id}") + + # Base on current time to handle execution delays gracefully + next_run_at = calculate_next_run_at( + schedule.cron_expression, + schedule.timezone, + ) + + schedule.next_run_at = next_run_at + session.flush() + return next_run_at + + @staticmethod + def to_schedule_config(node_config: Mapping[str, Any]) -> ScheduleConfig: + """ + Converts user-friendly visual schedule settings to cron expression. + Maintains consistency with frontend UI expectations while supporting croniter's extended syntax. + """ + node_data = node_config.get("data", {}) + mode = node_data.get("mode", "visual") + timezone = node_data.get("timezone", "UTC") + node_id = node_config.get("id", "start") + + cron_expression = None + if mode == "cron": + cron_expression = node_data.get("cron_expression") + if not cron_expression: + raise ScheduleConfigError("Cron expression is required for cron mode") + elif mode == "visual": + frequency = str(node_data.get("frequency")) + if not frequency: + raise ScheduleConfigError("Frequency is required for visual mode") + visual_config = VisualConfig(**node_data.get("visual_config", {})) + cron_expression = ScheduleService.visual_to_cron(frequency=frequency, visual_config=visual_config) + if not cron_expression: + raise ScheduleConfigError("Cron expression is required for visual mode") + else: + raise ScheduleConfigError(f"Invalid schedule mode: {mode}") + return ScheduleConfig(node_id=node_id, cron_expression=cron_expression, timezone=timezone) + + @staticmethod + def extract_schedule_config(workflow: Workflow) -> ScheduleConfig | None: + """ + Extracts schedule configuration from workflow graph. + + Searches for the first schedule trigger node in the workflow and converts + its configuration (either visual or cron mode) into a unified ScheduleConfig. + + Args: + workflow: The workflow containing the graph definition + + Returns: + ScheduleConfig if a valid schedule node is found, None if no schedule node exists + + Raises: + ScheduleConfigError: If graph parsing fails or schedule configuration is invalid + + Note: + Currently only returns the first schedule node found. + Multiple schedule nodes in the same workflow are not supported. + """ + try: + graph_data = workflow.graph_dict + except (json.JSONDecodeError, TypeError, AttributeError) as e: + raise ScheduleConfigError(f"Failed to parse workflow graph: {e}") + + if not graph_data: + raise ScheduleConfigError("Workflow graph is empty") + + nodes = graph_data.get("nodes", []) + for node in nodes: + node_data = node.get("data", {}) + + if node_data.get("type") != NodeType.TRIGGER_SCHEDULE.value: + continue + + mode = node_data.get("mode", "visual") + timezone = node_data.get("timezone", "UTC") + node_id = node.get("id", "start") + + cron_expression = None + if mode == "cron": + cron_expression = node_data.get("cron_expression") + if not cron_expression: + raise ScheduleConfigError("Cron expression is required for cron mode") + elif mode == "visual": + frequency = node_data.get("frequency") + visual_config_dict = node_data.get("visual_config", {}) + visual_config = VisualConfig(**visual_config_dict) + cron_expression = ScheduleService.visual_to_cron(frequency, visual_config) + else: + raise ScheduleConfigError(f"Invalid schedule mode: {mode}") + + return ScheduleConfig(node_id=node_id, cron_expression=cron_expression, timezone=timezone) + + return None + + @staticmethod + def visual_to_cron(frequency: str, visual_config: VisualConfig) -> str: + """ + Converts user-friendly visual schedule settings to cron expression. + Maintains consistency with frontend UI expectations while supporting croniter's extended syntax. + """ + if frequency == "hourly": + if visual_config.on_minute is None: + raise ScheduleConfigError("on_minute is required for hourly schedules") + return f"{visual_config.on_minute} * * * *" + + elif frequency == "daily": + if not visual_config.time: + raise ScheduleConfigError("time is required for daily schedules") + hour, minute = convert_12h_to_24h(visual_config.time) + return f"{minute} {hour} * * *" + + elif frequency == "weekly": + if not visual_config.time: + raise ScheduleConfigError("time is required for weekly schedules") + if not visual_config.weekdays: + raise ScheduleConfigError("Weekdays are required for weekly schedules") + hour, minute = convert_12h_to_24h(visual_config.time) + weekday_map = {"sun": "0", "mon": "1", "tue": "2", "wed": "3", "thu": "4", "fri": "5", "sat": "6"} + cron_weekdays = [weekday_map[day] for day in visual_config.weekdays] + return f"{minute} {hour} * * {','.join(sorted(cron_weekdays))}" + + elif frequency == "monthly": + if not visual_config.time: + raise ScheduleConfigError("time is required for monthly schedules") + if not visual_config.monthly_days: + raise ScheduleConfigError("Monthly days are required for monthly schedules") + hour, minute = convert_12h_to_24h(visual_config.time) + + numeric_days: list[int] = [] + has_last = False + for day in visual_config.monthly_days: + if day == "last": + has_last = True + else: + numeric_days.append(day) + + result_days = [str(d) for d in sorted(set(numeric_days))] + if has_last: + result_days.append("L") + + return f"{minute} {hour} {','.join(result_days)} * *" + + else: + raise ScheduleConfigError(f"Unsupported frequency: {frequency}") diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py new file mode 100644 index 0000000000..076cc7e776 --- /dev/null +++ b/api/services/trigger/trigger_provider_service.py @@ -0,0 +1,687 @@ +import json +import logging +import time as _time +import uuid +from collections.abc import Mapping +from typing import Any + +from sqlalchemy import desc, func +from sqlalchemy.orm import Session + +from configs import dify_config +from constants import HIDDEN_VALUE, UNKNOWN_VALUE +from core.helper.provider_cache import NoOpProviderCredentialCache +from core.helper.provider_encryption import ProviderConfigEncrypter, create_provider_encrypter +from core.plugin.entities.plugin_daemon import CredentialType +from core.plugin.impl.oauth import OAuthHandler +from core.tools.utils.system_oauth_encryption import decrypt_system_oauth_params +from core.trigger.entities.api_entities import ( + TriggerProviderApiEntity, + TriggerProviderSubscriptionApiEntity, +) +from core.trigger.entities.entities import Subscription as TriggerSubscriptionEntity +from core.trigger.provider import PluginTriggerProviderController +from core.trigger.trigger_manager import TriggerManager +from core.trigger.utils.encryption import ( + create_trigger_provider_encrypter_for_properties, + create_trigger_provider_encrypter_for_subscription, + delete_cache_for_subscription, +) +from core.trigger.utils.endpoint import generate_plugin_trigger_endpoint_url +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.provider_ids import TriggerProviderID +from models.trigger import ( + TriggerOAuthSystemClient, + TriggerOAuthTenantClient, + TriggerSubscription, + WorkflowPluginTrigger, +) +from services.plugin.plugin_service import PluginService + +logger = logging.getLogger(__name__) + + +class TriggerProviderService: + """Service for managing trigger providers and credentials""" + + ########################## + # Trigger provider + ########################## + __MAX_TRIGGER_PROVIDER_COUNT__ = 10 + + @classmethod + def get_trigger_provider(cls, tenant_id: str, provider: TriggerProviderID) -> TriggerProviderApiEntity: + """Get info for a trigger provider""" + return TriggerManager.get_trigger_provider(tenant_id, provider).to_api_entity() + + @classmethod + def list_trigger_providers(cls, tenant_id: str) -> list[TriggerProviderApiEntity]: + """List all trigger providers for the current tenant""" + return [provider.to_api_entity() for provider in TriggerManager.list_all_trigger_providers(tenant_id)] + + @classmethod + def list_trigger_provider_subscriptions( + cls, tenant_id: str, provider_id: TriggerProviderID + ) -> list[TriggerProviderSubscriptionApiEntity]: + """List all trigger subscriptions for the current tenant""" + subscriptions: list[TriggerProviderSubscriptionApiEntity] = [] + workflows_in_use_map: dict[str, int] = {} + with Session(db.engine, expire_on_commit=False) as session: + # Get all subscriptions + subscriptions_db = ( + session.query(TriggerSubscription) + .filter_by(tenant_id=tenant_id, provider_id=str(provider_id)) + .order_by(desc(TriggerSubscription.created_at)) + .all() + ) + subscriptions = [subscription.to_api_entity() for subscription in subscriptions_db] + if not subscriptions: + return [] + usage_counts = ( + session.query( + WorkflowPluginTrigger.subscription_id, + func.count(func.distinct(WorkflowPluginTrigger.app_id)).label("app_count"), + ) + .filter( + WorkflowPluginTrigger.tenant_id == tenant_id, + WorkflowPluginTrigger.subscription_id.in_([s.id for s in subscriptions]), + ) + .group_by(WorkflowPluginTrigger.subscription_id) + .all() + ) + workflows_in_use_map = {str(row.subscription_id): int(row.app_count) for row in usage_counts} + + provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) + for subscription in subscriptions: + encrypter, _ = create_trigger_provider_encrypter_for_subscription( + tenant_id=tenant_id, + controller=provider_controller, + subscription=subscription, + ) + subscription.credentials = dict( + encrypter.mask_credentials(dict(encrypter.decrypt(subscription.credentials))) + ) + subscription.properties = dict(encrypter.mask_credentials(dict(encrypter.decrypt(subscription.properties)))) + subscription.parameters = dict(encrypter.mask_credentials(dict(encrypter.decrypt(subscription.parameters)))) + count = workflows_in_use_map.get(subscription.id) + subscription.workflows_in_use = count if count is not None else 0 + + return subscriptions + + @classmethod + def add_trigger_subscription( + cls, + tenant_id: str, + user_id: str, + name: str, + provider_id: TriggerProviderID, + endpoint_id: str, + credential_type: CredentialType, + parameters: Mapping[str, Any], + properties: Mapping[str, Any], + credentials: Mapping[str, str], + subscription_id: str | None = None, + credential_expires_at: int = -1, + expires_at: int = -1, + ) -> Mapping[str, Any]: + """ + Add a new trigger provider with credentials. + Supports multiple credential instances per provider. + + :param tenant_id: Tenant ID + :param provider_id: Provider identifier (e.g., "plugin_id/provider_name") + :param credential_type: Type of credential (oauth or api_key) + :param credentials: Credential data to encrypt and store + :param name: Optional name for this credential instance + :param expires_at: OAuth token expiration timestamp + :return: Success response + """ + try: + provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) + with Session(db.engine, expire_on_commit=False) as session: + # Use distributed lock to prevent race conditions + lock_key = f"trigger_provider_create_lock:{tenant_id}_{provider_id}" + with redis_client.lock(lock_key, timeout=20): + # Check provider count limit + provider_count = ( + session.query(TriggerSubscription) + .filter_by(tenant_id=tenant_id, provider_id=str(provider_id)) + .count() + ) + + if provider_count >= cls.__MAX_TRIGGER_PROVIDER_COUNT__: + raise ValueError( + f"Maximum number of providers ({cls.__MAX_TRIGGER_PROVIDER_COUNT__}) " + f"reached for {provider_id}" + ) + + # Check if name already exists + existing = ( + session.query(TriggerSubscription) + .filter_by(tenant_id=tenant_id, provider_id=str(provider_id), name=name) + .first() + ) + if existing: + raise ValueError(f"Credential name '{name}' already exists for this provider") + + credential_encrypter: ProviderConfigEncrypter | None = None + if credential_type != CredentialType.UNAUTHORIZED: + credential_encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=provider_controller.get_credential_schema_config(credential_type), + cache=NoOpProviderCredentialCache(), + ) + + properties_encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=provider_controller.get_properties_schema(), + cache=NoOpProviderCredentialCache(), + ) + + # Create provider record + subscription = TriggerSubscription( + id=subscription_id or str(uuid.uuid4()), + tenant_id=tenant_id, + user_id=user_id, + name=name, + endpoint_id=endpoint_id, + provider_id=str(provider_id), + parameters=parameters, + properties=properties_encrypter.encrypt(dict(properties)), + credentials=credential_encrypter.encrypt(dict(credentials)) if credential_encrypter else {}, + credential_type=credential_type.value, + credential_expires_at=credential_expires_at, + expires_at=expires_at, + ) + + session.add(subscription) + session.commit() + + return { + "result": "success", + "id": str(subscription.id), + } + + except Exception as e: + logger.exception("Failed to add trigger provider") + raise ValueError(str(e)) + + @classmethod + def get_subscription_by_id(cls, tenant_id: str, subscription_id: str | None = None) -> TriggerSubscription | None: + """ + Get a trigger subscription by the ID. + """ + with Session(db.engine, expire_on_commit=False) as session: + subscription: TriggerSubscription | None = None + if subscription_id: + subscription = ( + session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() + ) + else: + subscription = session.query(TriggerSubscription).filter_by(tenant_id=tenant_id).first() + if subscription: + provider_controller = TriggerManager.get_trigger_provider( + tenant_id, TriggerProviderID(subscription.provider_id) + ) + encrypter, _ = create_trigger_provider_encrypter_for_subscription( + tenant_id=tenant_id, + controller=provider_controller, + subscription=subscription, + ) + subscription.credentials = dict(encrypter.decrypt(subscription.credentials)) + properties_encrypter, _ = create_trigger_provider_encrypter_for_properties( + tenant_id=subscription.tenant_id, + controller=provider_controller, + subscription=subscription, + ) + subscription.properties = dict(properties_encrypter.decrypt(subscription.properties)) + return subscription + + @classmethod + def delete_trigger_provider(cls, session: Session, tenant_id: str, subscription_id: str): + """ + Delete a trigger provider subscription within an existing session. + + :param session: Database session + :param tenant_id: Tenant ID + :param subscription_id: Subscription instance ID + :return: Success response + """ + subscription: TriggerSubscription | None = ( + session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() + ) + if not subscription: + raise ValueError(f"Trigger provider subscription {subscription_id} not found") + + credential_type: CredentialType = CredentialType.of(subscription.credential_type) + is_auto_created: bool = credential_type in [CredentialType.OAUTH2, CredentialType.API_KEY] + if is_auto_created: + provider_id = TriggerProviderID(subscription.provider_id) + provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id=tenant_id, provider_id=provider_id + ) + encrypter, _ = create_trigger_provider_encrypter_for_subscription( + tenant_id=tenant_id, + controller=provider_controller, + subscription=subscription, + ) + try: + TriggerManager.unsubscribe_trigger( + tenant_id=tenant_id, + user_id=subscription.user_id, + provider_id=provider_id, + subscription=subscription.to_entity(), + credentials=encrypter.decrypt(subscription.credentials), + credential_type=credential_type, + ) + except Exception as e: + logger.exception("Error unsubscribing trigger", exc_info=e) + + # Clear cache + session.delete(subscription) + delete_cache_for_subscription( + tenant_id=tenant_id, + provider_id=subscription.provider_id, + subscription_id=subscription.id, + ) + + @classmethod + def refresh_oauth_token( + cls, + tenant_id: str, + subscription_id: str, + ) -> Mapping[str, Any]: + """ + Refresh OAuth token for a trigger provider. + + :param tenant_id: Tenant ID + :param subscription_id: Subscription instance ID + :return: New token info + """ + with Session(db.engine) as session: + subscription = session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() + + if not subscription: + raise ValueError(f"Trigger provider subscription {subscription_id} not found") + + if subscription.credential_type != CredentialType.OAUTH2.value: + raise ValueError("Only OAuth credentials can be refreshed") + + provider_id = TriggerProviderID(subscription.provider_id) + provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id=tenant_id, provider_id=provider_id + ) + # Create encrypter + encrypter, cache = create_provider_encrypter( + tenant_id=tenant_id, + config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()], + cache=NoOpProviderCredentialCache(), + ) + + # Decrypt current credentials + current_credentials = encrypter.decrypt(subscription.credentials) + + # Get OAuth client configuration + redirect_uri = ( + f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{subscription.provider_id}/trigger/callback" + ) + system_credentials = cls.get_oauth_client(tenant_id, provider_id) + + # Refresh token + oauth_handler = OAuthHandler() + refreshed_credentials = oauth_handler.refresh_credentials( + tenant_id=tenant_id, + user_id=subscription.user_id, + plugin_id=provider_id.plugin_id, + provider=provider_id.provider_name, + redirect_uri=redirect_uri, + system_credentials=system_credentials or {}, + credentials=current_credentials, + ) + + # Update credentials + subscription.credentials = dict(encrypter.encrypt(dict(refreshed_credentials.credentials))) + subscription.credential_expires_at = refreshed_credentials.expires_at + session.commit() + + # Clear cache + cache.delete() + + return { + "result": "success", + "expires_at": refreshed_credentials.expires_at, + } + + @classmethod + def refresh_subscription( + cls, + tenant_id: str, + subscription_id: str, + now: int | None = None, + ) -> Mapping[str, Any]: + """ + Refresh trigger subscription if expired. + + Args: + tenant_id: Tenant ID + subscription_id: Subscription instance ID + now: Current timestamp, defaults to `int(time.time())` + + Returns: + Mapping with keys: `result` ("success"|"skipped") and `expires_at` (new or existing value) + """ + now_ts: int = int(now if now is not None else _time.time()) + + with Session(db.engine) as session: + subscription: TriggerSubscription | None = ( + session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() + ) + if subscription is None: + raise ValueError(f"Trigger provider subscription {subscription_id} not found") + + if subscription.expires_at == -1 or int(subscription.expires_at) > now_ts: + logger.debug( + "Subscription not due for refresh: tenant=%s id=%s expires_at=%s now=%s", + tenant_id, + subscription_id, + subscription.expires_at, + now_ts, + ) + return {"result": "skipped", "expires_at": int(subscription.expires_at)} + + provider_id = TriggerProviderID(subscription.provider_id) + controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id=tenant_id, provider_id=provider_id + ) + + # Decrypt credentials and properties for runtime + credential_encrypter, _ = create_trigger_provider_encrypter_for_subscription( + tenant_id=tenant_id, + controller=controller, + subscription=subscription, + ) + properties_encrypter, properties_cache = create_trigger_provider_encrypter_for_properties( + tenant_id=tenant_id, + controller=controller, + subscription=subscription, + ) + + decrypted_credentials = credential_encrypter.decrypt(subscription.credentials) + decrypted_properties = properties_encrypter.decrypt(subscription.properties) + + sub_entity: TriggerSubscriptionEntity = TriggerSubscriptionEntity( + expires_at=int(subscription.expires_at), + endpoint=generate_plugin_trigger_endpoint_url(subscription.endpoint_id), + parameters=subscription.parameters, + properties=decrypted_properties, + ) + + refreshed: TriggerSubscriptionEntity = controller.refresh_trigger( + subscription=sub_entity, + credentials=decrypted_credentials, + credential_type=CredentialType.of(subscription.credential_type), + ) + + # Persist refreshed properties and expires_at + subscription.properties = dict(properties_encrypter.encrypt(dict(refreshed.properties))) + subscription.expires_at = int(refreshed.expires_at) + session.commit() + properties_cache.delete() + + logger.info( + "Subscription refreshed (service): tenant=%s id=%s new_expires_at=%s", + tenant_id, + subscription_id, + subscription.expires_at, + ) + + return {"result": "success", "expires_at": int(refreshed.expires_at)} + + @classmethod + def get_oauth_client(cls, tenant_id: str, provider_id: TriggerProviderID) -> Mapping[str, Any] | None: + """ + Get OAuth client configuration for a provider. + First tries tenant-level OAuth, then falls back to system OAuth. + + :param tenant_id: Tenant ID + :param provider_id: Provider identifier + :return: OAuth client configuration or None + """ + provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id=tenant_id, provider_id=provider_id + ) + with Session(db.engine, expire_on_commit=False) as session: + tenant_client: TriggerOAuthTenantClient | None = ( + session.query(TriggerOAuthTenantClient) + .filter_by( + tenant_id=tenant_id, + provider=provider_id.provider_name, + plugin_id=provider_id.plugin_id, + enabled=True, + ) + .first() + ) + + oauth_params: Mapping[str, Any] | None = None + if tenant_client: + encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()], + cache=NoOpProviderCredentialCache(), + ) + oauth_params = encrypter.decrypt(dict(tenant_client.oauth_params)) + return oauth_params + + is_verified = PluginService.is_plugin_verified(tenant_id, provider_id.plugin_id) + if not is_verified: + return None + + # Check for system-level OAuth client + system_client: TriggerOAuthSystemClient | None = ( + session.query(TriggerOAuthSystemClient) + .filter_by(plugin_id=provider_id.plugin_id, provider=provider_id.provider_name) + .first() + ) + + if system_client: + try: + oauth_params = decrypt_system_oauth_params(system_client.encrypted_oauth_params) + except Exception as e: + raise ValueError(f"Error decrypting system oauth params: {e}") + + return oauth_params + + @classmethod + def is_oauth_system_client_exists(cls, tenant_id: str, provider_id: TriggerProviderID) -> bool: + """ + Check if system OAuth client exists for a trigger provider. + """ + is_verified = PluginService.is_plugin_verified(tenant_id, provider_id.plugin_id) + if not is_verified: + return False + with Session(db.engine, expire_on_commit=False) as session: + system_client: TriggerOAuthSystemClient | None = ( + session.query(TriggerOAuthSystemClient) + .filter_by(plugin_id=provider_id.plugin_id, provider=provider_id.provider_name) + .first() + ) + return system_client is not None + + @classmethod + def save_custom_oauth_client_params( + cls, + tenant_id: str, + provider_id: TriggerProviderID, + client_params: Mapping[str, Any] | None = None, + enabled: bool | None = None, + ) -> Mapping[str, Any]: + """ + Save or update custom OAuth client parameters for a trigger provider. + + :param tenant_id: Tenant ID + :param provider_id: Provider identifier + :param client_params: OAuth client parameters (client_id, client_secret, etc.) + :param enabled: Enable/disable the custom OAuth client + :return: Success response + """ + if client_params is None and enabled is None: + return {"result": "success"} + + # Get provider controller to access schema + provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id=tenant_id, provider_id=provider_id + ) + + with Session(db.engine) as session: + # Find existing custom client params + custom_client = ( + session.query(TriggerOAuthTenantClient) + .filter_by( + tenant_id=tenant_id, + plugin_id=provider_id.plugin_id, + provider=provider_id.provider_name, + ) + .first() + ) + + # Create new record if doesn't exist + if custom_client is None: + custom_client = TriggerOAuthTenantClient( + tenant_id=tenant_id, + plugin_id=provider_id.plugin_id, + provider=provider_id.provider_name, + ) + session.add(custom_client) + + # Update client params if provided + if client_params is None: + custom_client.encrypted_oauth_params = json.dumps({}) + else: + encrypter, cache = create_provider_encrypter( + tenant_id=tenant_id, + config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()], + cache=NoOpProviderCredentialCache(), + ) + + # Handle hidden values + original_params = encrypter.decrypt(dict(custom_client.oauth_params)) + new_params: dict[str, Any] = { + key: value if value != HIDDEN_VALUE else original_params.get(key, UNKNOWN_VALUE) + for key, value in client_params.items() + } + custom_client.encrypted_oauth_params = json.dumps(encrypter.encrypt(new_params)) + cache.delete() + + # Update enabled status if provided + if enabled is not None: + custom_client.enabled = enabled + + session.commit() + + return {"result": "success"} + + @classmethod + def get_custom_oauth_client_params(cls, tenant_id: str, provider_id: TriggerProviderID) -> Mapping[str, Any]: + """ + Get custom OAuth client parameters for a trigger provider. + + :param tenant_id: Tenant ID + :param provider_id: Provider identifier + :return: Masked OAuth client parameters + """ + with Session(db.engine) as session: + custom_client = ( + session.query(TriggerOAuthTenantClient) + .filter_by( + tenant_id=tenant_id, + plugin_id=provider_id.plugin_id, + provider=provider_id.provider_name, + ) + .first() + ) + + if custom_client is None: + return {} + + # Get provider controller to access schema + provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id=tenant_id, provider_id=provider_id + ) + + # Create encrypter to decrypt and mask values + encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()], + cache=NoOpProviderCredentialCache(), + ) + + return encrypter.mask_plugin_credentials(encrypter.decrypt(dict(custom_client.oauth_params))) + + @classmethod + def delete_custom_oauth_client_params(cls, tenant_id: str, provider_id: TriggerProviderID) -> Mapping[str, Any]: + """ + Delete custom OAuth client parameters for a trigger provider. + + :param tenant_id: Tenant ID + :param provider_id: Provider identifier + :return: Success response + """ + with Session(db.engine) as session: + session.query(TriggerOAuthTenantClient).filter_by( + tenant_id=tenant_id, + provider=provider_id.provider_name, + plugin_id=provider_id.plugin_id, + ).delete() + session.commit() + + return {"result": "success"} + + @classmethod + def is_oauth_custom_client_enabled(cls, tenant_id: str, provider_id: TriggerProviderID) -> bool: + """ + Check if custom OAuth client is enabled for a trigger provider. + + :param tenant_id: Tenant ID + :param provider_id: Provider identifier + :return: True if enabled, False otherwise + """ + with Session(db.engine, expire_on_commit=False) as session: + custom_client = ( + session.query(TriggerOAuthTenantClient) + .filter_by( + tenant_id=tenant_id, + plugin_id=provider_id.plugin_id, + provider=provider_id.provider_name, + enabled=True, + ) + .first() + ) + return custom_client is not None + + @classmethod + def get_subscription_by_endpoint(cls, endpoint_id: str) -> TriggerSubscription | None: + """ + Get a trigger subscription by the endpoint ID. + """ + with Session(db.engine, expire_on_commit=False) as session: + subscription = session.query(TriggerSubscription).filter_by(endpoint_id=endpoint_id).first() + if not subscription: + return None + provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id=subscription.tenant_id, provider_id=TriggerProviderID(subscription.provider_id) + ) + credential_encrypter, _ = create_trigger_provider_encrypter_for_subscription( + tenant_id=subscription.tenant_id, + controller=provider_controller, + subscription=subscription, + ) + subscription.credentials = dict(credential_encrypter.decrypt(subscription.credentials)) + + properties_encrypter, _ = create_trigger_provider_encrypter_for_properties( + tenant_id=subscription.tenant_id, + controller=provider_controller, + subscription=subscription, + ) + subscription.properties = dict(properties_encrypter.decrypt(subscription.properties)) + return subscription diff --git a/api/services/trigger/trigger_request_service.py b/api/services/trigger/trigger_request_service.py new file mode 100644 index 0000000000..91a838c265 --- /dev/null +++ b/api/services/trigger/trigger_request_service.py @@ -0,0 +1,65 @@ +from collections.abc import Mapping +from typing import Any + +from flask import Request +from pydantic import TypeAdapter + +from core.plugin.utils.http_parser import deserialize_request, serialize_request +from extensions.ext_storage import storage + + +class TriggerHttpRequestCachingService: + """ + Service for caching trigger requests. + """ + + _TRIGGER_STORAGE_PATH = "triggers" + + @classmethod + def get_request(cls, request_id: str) -> Request: + """ + Get the request object from the storage. + + Args: + request_id: The ID of the request. + + Returns: + The request object. + """ + return deserialize_request(storage.load_once(f"{cls._TRIGGER_STORAGE_PATH}/{request_id}.raw")) + + @classmethod + def get_payload(cls, request_id: str) -> Mapping[str, Any]: + """ + Get the payload from the storage. + + Args: + request_id: The ID of the request. + + Returns: + The payload. + """ + return TypeAdapter(Mapping[str, Any]).validate_json( + storage.load_once(f"{cls._TRIGGER_STORAGE_PATH}/{request_id}.payload") + ) + + @classmethod + def persist_request(cls, request_id: str, request: Request) -> None: + """ + Persist the request in the storage. + + Args: + request_id: The ID of the request. + request: The request object. + """ + storage.save(f"{cls._TRIGGER_STORAGE_PATH}/{request_id}.raw", serialize_request(request)) + + @classmethod + def persist_payload(cls, request_id: str, payload: Mapping[str, Any]) -> None: + """ + Persist the payload in the storage. + """ + storage.save( + f"{cls._TRIGGER_STORAGE_PATH}/{request_id}.payload", + TypeAdapter(Mapping[str, Any]).dump_json(payload), # type: ignore + ) diff --git a/api/services/trigger/trigger_service.py b/api/services/trigger/trigger_service.py new file mode 100644 index 0000000000..0255e42546 --- /dev/null +++ b/api/services/trigger/trigger_service.py @@ -0,0 +1,307 @@ +import logging +import secrets +import time +from collections.abc import Mapping +from typing import Any + +from flask import Request, Response +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.plugin.entities.plugin_daemon import CredentialType +from core.plugin.entities.request import TriggerDispatchResponse, TriggerInvokeEventResponse +from core.plugin.impl.exc import PluginNotFoundError +from core.trigger.debug.events import PluginTriggerDebugEvent +from core.trigger.provider import PluginTriggerProviderController +from core.trigger.trigger_manager import TriggerManager +from core.trigger.utils.encryption import create_trigger_provider_encrypter_for_subscription +from core.workflow.enums import NodeType +from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.model import App +from models.provider_ids import TriggerProviderID +from models.trigger import TriggerSubscription, WorkflowPluginTrigger +from models.workflow import Workflow +from services.trigger.trigger_provider_service import TriggerProviderService +from services.trigger.trigger_request_service import TriggerHttpRequestCachingService +from services.workflow.entities import PluginTriggerDispatchData +from tasks.trigger_processing_tasks import dispatch_triggered_workflows_async + +logger = logging.getLogger(__name__) + + +class TriggerService: + __TEMPORARY_ENDPOINT_EXPIRE_MS__ = 5 * 60 * 1000 + __ENDPOINT_REQUEST_CACHE_COUNT__ = 10 + __ENDPOINT_REQUEST_CACHE_EXPIRE_MS__ = 5 * 60 * 1000 + __PLUGIN_TRIGGER_NODE_CACHE_KEY__ = "plugin_trigger_nodes" + MAX_PLUGIN_TRIGGER_NODES_PER_WORKFLOW = 5 # Maximum allowed plugin trigger nodes per workflow + + @classmethod + def invoke_trigger_event( + cls, tenant_id: str, user_id: str, node_config: Mapping[str, Any], event: PluginTriggerDebugEvent + ) -> TriggerInvokeEventResponse: + """Invoke a trigger event.""" + subscription: TriggerSubscription | None = TriggerProviderService.get_subscription_by_id( + tenant_id=tenant_id, + subscription_id=event.subscription_id, + ) + if not subscription: + raise ValueError("Subscription not found") + node_data: TriggerEventNodeData = TriggerEventNodeData.model_validate(node_config.get("data", {})) + request = TriggerHttpRequestCachingService.get_request(event.request_id) + payload = TriggerHttpRequestCachingService.get_payload(event.request_id) + # invoke triger + provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id, TriggerProviderID(subscription.provider_id) + ) + return TriggerManager.invoke_trigger_event( + tenant_id=tenant_id, + user_id=user_id, + provider_id=TriggerProviderID(event.provider_id), + event_name=event.name, + parameters=node_data.resolve_parameters( + parameter_schemas=provider_controller.get_event_parameters(event_name=event.name) + ), + credentials=subscription.credentials, + credential_type=CredentialType.of(subscription.credential_type), + subscription=subscription.to_entity(), + request=request, + payload=payload, + ) + + @classmethod + def process_endpoint(cls, endpoint_id: str, request: Request) -> Response | None: + """ + Extract and process data from incoming endpoint request. + + Args: + endpoint_id: Endpoint ID + request: Request + """ + timestamp = int(time.time()) + subscription: TriggerSubscription | None = None + try: + subscription = TriggerProviderService.get_subscription_by_endpoint(endpoint_id) + except PluginNotFoundError: + return Response(status=404, response="Trigger provider not found") + except Exception: + return Response(status=500, response="Failed to get subscription by endpoint") + + if not subscription: + return None + + provider_id = TriggerProviderID(subscription.provider_id) + controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id=subscription.tenant_id, provider_id=provider_id + ) + encrypter, _ = create_trigger_provider_encrypter_for_subscription( + tenant_id=subscription.tenant_id, + controller=controller, + subscription=subscription, + ) + dispatch_response: TriggerDispatchResponse = controller.dispatch( + request=request, + subscription=subscription.to_entity(), + credentials=encrypter.decrypt(subscription.credentials), + credential_type=CredentialType.of(subscription.credential_type), + ) + + if dispatch_response.events: + request_id = f"trigger_request_{timestamp}_{secrets.token_hex(6)}" + + # save the request and payload to storage as persistent data + TriggerHttpRequestCachingService.persist_request(request_id, request) + TriggerHttpRequestCachingService.persist_payload(request_id, dispatch_response.payload) + + # Validate event names + for event_name in dispatch_response.events: + if controller.get_event(event_name) is None: + logger.error( + "Event name %s not found in provider %s for endpoint %s", + event_name, + subscription.provider_id, + endpoint_id, + ) + raise ValueError(f"Event name {event_name} not found in provider {subscription.provider_id}") + + plugin_trigger_dispatch_data = PluginTriggerDispatchData( + user_id=dispatch_response.user_id, + tenant_id=subscription.tenant_id, + endpoint_id=endpoint_id, + provider_id=subscription.provider_id, + subscription_id=subscription.id, + timestamp=timestamp, + events=list(dispatch_response.events), + request_id=request_id, + ) + dispatch_data = plugin_trigger_dispatch_data.model_dump(mode="json") + dispatch_triggered_workflows_async.delay(dispatch_data) + + logger.info( + "Queued async dispatching for %d triggers on endpoint %s with request_id %s", + len(dispatch_response.events), + endpoint_id, + request_id, + ) + return dispatch_response.response + + @classmethod + def sync_plugin_trigger_relationships(cls, app: App, workflow: Workflow): + """ + Sync plugin trigger relationships in DB. + + 1. Check if the workflow has any plugin trigger nodes + 2. Fetch the nodes from DB, see if there were any plugin trigger records already + 3. Diff the nodes and the plugin trigger records, create/update/delete the records as needed + + Approach: + Frequent DB operations may cause performance issues, using Redis to cache it instead. + If any record exists, cache it. + + Limits: + - Maximum 5 plugin trigger nodes per workflow + """ + + class Cache(BaseModel): + """ + Cache model for plugin trigger nodes + """ + + record_id: str + node_id: str + provider_id: str + event_name: str + subscription_id: str + + # Walk nodes to find plugin triggers + nodes_in_graph: list[Mapping[str, Any]] = [] + for node_id, node_config in workflow.walk_nodes(NodeType.TRIGGER_PLUGIN): + # Extract plugin trigger configuration from node + plugin_id = node_config.get("plugin_id", "") + provider_id = node_config.get("provider_id", "") + event_name = node_config.get("event_name", "") + subscription_id = node_config.get("subscription_id", "") + + if not subscription_id: + continue + + nodes_in_graph.append( + { + "node_id": node_id, + "plugin_id": plugin_id, + "provider_id": provider_id, + "event_name": event_name, + "subscription_id": subscription_id, + } + ) + + # Check plugin trigger node limit + if len(nodes_in_graph) > cls.MAX_PLUGIN_TRIGGER_NODES_PER_WORKFLOW: + raise ValueError( + f"Workflow exceeds maximum plugin trigger node limit. " + f"Found {len(nodes_in_graph)} plugin trigger nodes, " + f"maximum allowed is {cls.MAX_PLUGIN_TRIGGER_NODES_PER_WORKFLOW}" + ) + + not_found_in_cache: list[Mapping[str, Any]] = [] + for node_info in nodes_in_graph: + node_id = node_info["node_id"] + # firstly check if the node exists in cache + if not redis_client.get(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:{node_id}"): + not_found_in_cache.append(node_info) + continue + + with Session(db.engine) as session: + try: + # lock the concurrent plugin trigger creation + redis_client.lock(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:apps:{app.id}:lock", timeout=10) + # fetch the non-cached nodes from DB + all_records = session.scalars( + select(WorkflowPluginTrigger).where( + WorkflowPluginTrigger.app_id == app.id, + WorkflowPluginTrigger.tenant_id == app.tenant_id, + ) + ).all() + + nodes_id_in_db = {node.node_id: node for node in all_records} + nodes_id_in_graph = {node["node_id"] for node in nodes_in_graph} + + # get the nodes not found both in cache and DB + nodes_not_found = [ + node_info for node_info in not_found_in_cache if node_info["node_id"] not in nodes_id_in_db + ] + + # create new plugin trigger records + for node_info in nodes_not_found: + plugin_trigger = WorkflowPluginTrigger( + app_id=app.id, + tenant_id=app.tenant_id, + node_id=node_info["node_id"], + provider_id=node_info["provider_id"], + event_name=node_info["event_name"], + subscription_id=node_info["subscription_id"], + ) + session.add(plugin_trigger) + session.flush() # Get the ID for caching + + cache = Cache( + record_id=plugin_trigger.id, + node_id=node_info["node_id"], + provider_id=node_info["provider_id"], + event_name=node_info["event_name"], + subscription_id=node_info["subscription_id"], + ) + redis_client.set( + f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:{node_info['node_id']}", + cache.model_dump_json(), + ex=60 * 60, + ) + session.commit() + + # Update existing records if subscription_id changed + for node_info in nodes_in_graph: + node_id = node_info["node_id"] + if node_id in nodes_id_in_db: + existing_record = nodes_id_in_db[node_id] + if ( + existing_record.subscription_id != node_info["subscription_id"] + or existing_record.provider_id != node_info["provider_id"] + or existing_record.event_name != node_info["event_name"] + ): + existing_record.subscription_id = node_info["subscription_id"] + existing_record.provider_id = node_info["provider_id"] + existing_record.event_name = node_info["event_name"] + session.add(existing_record) + + # Update cache + cache = Cache( + record_id=existing_record.id, + node_id=node_id, + provider_id=node_info["provider_id"], + event_name=node_info["event_name"], + subscription_id=node_info["subscription_id"], + ) + redis_client.set( + f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:{node_id}", + cache.model_dump_json(), + ex=60 * 60, + ) + session.commit() + + # delete the nodes not found in the graph + for node_id in nodes_id_in_db: + if node_id not in nodes_id_in_graph: + session.delete(nodes_id_in_db[node_id]) + redis_client.delete(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:{node_id}") + session.commit() + except Exception: + import logging + + logger = logging.getLogger(__name__) + logger.exception("Failed to sync plugin trigger relationships for app %s", app.id) + raise + finally: + redis_client.delete(f"{cls.__PLUGIN_TRIGGER_NODE_CACHE_KEY__}:apps:{app.id}:lock") diff --git a/api/services/trigger/trigger_subscription_builder_service.py b/api/services/trigger/trigger_subscription_builder_service.py new file mode 100644 index 0000000000..571393c782 --- /dev/null +++ b/api/services/trigger/trigger_subscription_builder_service.py @@ -0,0 +1,492 @@ +import json +import logging +import uuid +from collections.abc import Mapping +from contextlib import contextmanager +from datetime import datetime +from typing import Any + +from flask import Request, Response + +from core.plugin.entities.plugin_daemon import CredentialType +from core.plugin.entities.request import TriggerDispatchResponse +from core.tools.errors import ToolProviderCredentialValidationError +from core.trigger.entities.api_entities import SubscriptionBuilderApiEntity +from core.trigger.entities.entities import ( + RequestLog, + Subscription, + SubscriptionBuilder, + SubscriptionBuilderUpdater, + SubscriptionConstructor, +) +from core.trigger.provider import PluginTriggerProviderController +from core.trigger.trigger_manager import TriggerManager +from core.trigger.utils.encryption import masked_credentials +from core.trigger.utils.endpoint import generate_plugin_trigger_endpoint_url +from extensions.ext_redis import redis_client +from models.provider_ids import TriggerProviderID +from services.trigger.trigger_provider_service import TriggerProviderService + +logger = logging.getLogger(__name__) + + +class TriggerSubscriptionBuilderService: + """Service for managing trigger providers and credentials""" + + ########################## + # Trigger provider + ########################## + __MAX_TRIGGER_PROVIDER_COUNT__ = 10 + + ########################## + # Builder endpoint + ########################## + __BUILDER_CACHE_EXPIRE_SECONDS__ = 30 * 60 + + __VALIDATION_REQUEST_CACHE_COUNT__ = 10 + __VALIDATION_REQUEST_CACHE_EXPIRE_SECONDS__ = 30 * 60 + + ########################## + # Distributed lock + ########################## + __LOCK_EXPIRE_SECONDS__ = 30 + + @classmethod + def encode_cache_key(cls, subscription_id: str) -> str: + return f"trigger:subscription:builder:{subscription_id}" + + @classmethod + def encode_lock_key(cls, subscription_id: str) -> str: + return f"trigger:subscription:builder:lock:{subscription_id}" + + @classmethod + @contextmanager + def acquire_builder_lock(cls, subscription_id: str): + """ + Acquire a distributed lock for a subscription builder. + + :param subscription_id: The subscription builder ID + """ + lock_key = cls.encode_lock_key(subscription_id) + with redis_client.lock(lock_key, timeout=cls.__LOCK_EXPIRE_SECONDS__): + yield + + @classmethod + def verify_trigger_subscription_builder( + cls, + tenant_id: str, + user_id: str, + provider_id: TriggerProviderID, + subscription_builder_id: str, + ) -> Mapping[str, Any]: + """Verify a trigger subscription builder""" + provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) + if not provider_controller: + raise ValueError(f"Provider {provider_id} not found") + + subscription_builder = cls.get_subscription_builder(subscription_builder_id) + if not subscription_builder: + raise ValueError(f"Subscription builder {subscription_builder_id} not found") + + if subscription_builder.credential_type == CredentialType.OAUTH2: + return {"verified": bool(subscription_builder.credentials)} + + if subscription_builder.credential_type == CredentialType.API_KEY: + credentials_to_validate = subscription_builder.credentials + try: + provider_controller.validate_credentials(user_id, credentials_to_validate) + except ToolProviderCredentialValidationError as e: + raise ValueError(f"Invalid credentials: {e}") + return {"verified": True} + + return {"verified": True} + + @classmethod + def build_trigger_subscription_builder( + cls, tenant_id: str, user_id: str, provider_id: TriggerProviderID, subscription_builder_id: str + ) -> None: + """Build a trigger subscription builder""" + provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) + if not provider_controller: + raise ValueError(f"Provider {provider_id} not found") + + # Acquire lock to prevent concurrent build operations + with cls.acquire_builder_lock(subscription_builder_id): + subscription_builder = cls.get_subscription_builder(subscription_builder_id) + if not subscription_builder: + raise ValueError(f"Subscription builder {subscription_builder_id} not found") + + if not subscription_builder.name: + raise ValueError("Subscription builder name is required") + + credential_type = CredentialType.of( + subscription_builder.credential_type or CredentialType.UNAUTHORIZED.value + ) + if credential_type == CredentialType.UNAUTHORIZED: + # manually create + TriggerProviderService.add_trigger_subscription( + subscription_id=subscription_builder.id, + tenant_id=tenant_id, + user_id=user_id, + name=subscription_builder.name, + provider_id=provider_id, + endpoint_id=subscription_builder.endpoint_id, + parameters=subscription_builder.parameters, + properties=subscription_builder.properties, + credential_expires_at=subscription_builder.credential_expires_at or -1, + expires_at=subscription_builder.expires_at, + credentials=subscription_builder.credentials, + credential_type=credential_type, + ) + else: + # automatically create + subscription: Subscription = TriggerManager.subscribe_trigger( + tenant_id=tenant_id, + user_id=user_id, + provider_id=provider_id, + endpoint=generate_plugin_trigger_endpoint_url(subscription_builder.endpoint_id), + parameters=subscription_builder.parameters, + credentials=subscription_builder.credentials, + credential_type=credential_type, + ) + + TriggerProviderService.add_trigger_subscription( + subscription_id=subscription_builder.id, + tenant_id=tenant_id, + user_id=user_id, + name=subscription_builder.name, + provider_id=provider_id, + endpoint_id=subscription_builder.endpoint_id, + parameters=subscription_builder.parameters, + properties=subscription.properties, + credentials=subscription_builder.credentials, + credential_type=credential_type, + credential_expires_at=subscription_builder.credential_expires_at or -1, + expires_at=subscription_builder.expires_at, + ) + + # Delete the builder after successful subscription creation + cache_key = cls.encode_cache_key(subscription_builder_id) + redis_client.delete(cache_key) + + @classmethod + def create_trigger_subscription_builder( + cls, + tenant_id: str, + user_id: str, + provider_id: TriggerProviderID, + credential_type: CredentialType, + ) -> SubscriptionBuilderApiEntity: + """ + Add a new trigger subscription validation. + """ + provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) + if not provider_controller: + raise ValueError(f"Provider {provider_id} not found") + + subscription_constructor: SubscriptionConstructor | None = provider_controller.get_subscription_constructor() + subscription_id = str(uuid.uuid4()) + subscription_builder = SubscriptionBuilder( + id=subscription_id, + name=None, + endpoint_id=subscription_id, + tenant_id=tenant_id, + user_id=user_id, + provider_id=str(provider_id), + parameters=subscription_constructor.get_default_parameters() if subscription_constructor else {}, + properties=provider_controller.get_subscription_default_properties(), + credentials={}, + credential_type=credential_type, + credential_expires_at=-1, + expires_at=-1, + ) + cache_key = cls.encode_cache_key(subscription_id) + redis_client.setex(cache_key, cls.__BUILDER_CACHE_EXPIRE_SECONDS__, subscription_builder.model_dump_json()) + return cls.builder_to_api_entity(controller=provider_controller, entity=subscription_builder) + + @classmethod + def update_trigger_subscription_builder( + cls, + tenant_id: str, + provider_id: TriggerProviderID, + subscription_builder_id: str, + subscription_builder_updater: SubscriptionBuilderUpdater, + ) -> SubscriptionBuilderApiEntity: + """ + Update a trigger subscription validation. + """ + subscription_id = subscription_builder_id + provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) + if not provider_controller: + raise ValueError(f"Provider {provider_id} not found") + + # Acquire lock to prevent concurrent updates + with cls.acquire_builder_lock(subscription_id): + cache_key = cls.encode_cache_key(subscription_id) + subscription_builder_cache = cls.get_subscription_builder(subscription_builder_id) + if not subscription_builder_cache or subscription_builder_cache.tenant_id != tenant_id: + raise ValueError(f"Subscription {subscription_id} expired or not found") + + subscription_builder_updater.update(subscription_builder_cache) + + redis_client.setex( + cache_key, cls.__BUILDER_CACHE_EXPIRE_SECONDS__, subscription_builder_cache.model_dump_json() + ) + return cls.builder_to_api_entity(controller=provider_controller, entity=subscription_builder_cache) + + @classmethod + def update_and_verify_builder( + cls, + tenant_id: str, + user_id: str, + provider_id: TriggerProviderID, + subscription_builder_id: str, + subscription_builder_updater: SubscriptionBuilderUpdater, + ) -> Mapping[str, Any]: + """ + Atomically update and verify a subscription builder. + This ensures the verification is done on the exact data that was just updated. + """ + subscription_id = subscription_builder_id + provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) + if not provider_controller: + raise ValueError(f"Provider {provider_id} not found") + + # Acquire lock for the entire update + verify operation + with cls.acquire_builder_lock(subscription_id): + cache_key = cls.encode_cache_key(subscription_id) + subscription_builder_cache = cls.get_subscription_builder(subscription_builder_id) + if not subscription_builder_cache or subscription_builder_cache.tenant_id != tenant_id: + raise ValueError(f"Subscription {subscription_id} expired or not found") + + # Update + subscription_builder_updater.update(subscription_builder_cache) + redis_client.setex( + cache_key, cls.__BUILDER_CACHE_EXPIRE_SECONDS__, subscription_builder_cache.model_dump_json() + ) + + # Verify (using the just-updated data) + if subscription_builder_cache.credential_type == CredentialType.OAUTH2: + return {"verified": bool(subscription_builder_cache.credentials)} + + if subscription_builder_cache.credential_type == CredentialType.API_KEY: + credentials_to_validate = subscription_builder_cache.credentials + try: + provider_controller.validate_credentials(user_id, credentials_to_validate) + except ToolProviderCredentialValidationError as e: + raise ValueError(f"Invalid credentials: {e}") + return {"verified": True} + + return {"verified": True} + + @classmethod + def update_and_build_builder( + cls, + tenant_id: str, + user_id: str, + provider_id: TriggerProviderID, + subscription_builder_id: str, + subscription_builder_updater: SubscriptionBuilderUpdater, + ) -> None: + """ + Atomically update and build a subscription builder. + This ensures the build uses the exact data that was just updated. + """ + subscription_id = subscription_builder_id + provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) + if not provider_controller: + raise ValueError(f"Provider {provider_id} not found") + + # Acquire lock for the entire update + build operation + with cls.acquire_builder_lock(subscription_id): + cache_key = cls.encode_cache_key(subscription_id) + subscription_builder_cache = cls.get_subscription_builder(subscription_builder_id) + if not subscription_builder_cache or subscription_builder_cache.tenant_id != tenant_id: + raise ValueError(f"Subscription {subscription_id} expired or not found") + + # Update + subscription_builder_updater.update(subscription_builder_cache) + redis_client.setex( + cache_key, cls.__BUILDER_CACHE_EXPIRE_SECONDS__, subscription_builder_cache.model_dump_json() + ) + + # Re-fetch to ensure we have the latest data + subscription_builder = cls.get_subscription_builder(subscription_builder_id) + if not subscription_builder: + raise ValueError(f"Subscription builder {subscription_builder_id} not found") + + if not subscription_builder.name: + raise ValueError("Subscription builder name is required") + + # Build + credential_type = CredentialType.of( + subscription_builder.credential_type or CredentialType.UNAUTHORIZED.value + ) + if credential_type == CredentialType.UNAUTHORIZED: + # manually create + TriggerProviderService.add_trigger_subscription( + subscription_id=subscription_builder.id, + tenant_id=tenant_id, + user_id=user_id, + name=subscription_builder.name, + provider_id=provider_id, + endpoint_id=subscription_builder.endpoint_id, + parameters=subscription_builder.parameters, + properties=subscription_builder.properties, + credential_expires_at=subscription_builder.credential_expires_at or -1, + expires_at=subscription_builder.expires_at, + credentials=subscription_builder.credentials, + credential_type=credential_type, + ) + else: + # automatically create + subscription: Subscription = TriggerManager.subscribe_trigger( + tenant_id=tenant_id, + user_id=user_id, + provider_id=provider_id, + endpoint=generate_plugin_trigger_endpoint_url(subscription_builder.endpoint_id), + parameters=subscription_builder.parameters, + credentials=subscription_builder.credentials, + credential_type=credential_type, + ) + + TriggerProviderService.add_trigger_subscription( + subscription_id=subscription_builder.id, + tenant_id=tenant_id, + user_id=user_id, + name=subscription_builder.name, + provider_id=provider_id, + endpoint_id=subscription_builder.endpoint_id, + parameters=subscription_builder.parameters, + properties=subscription.properties, + credentials=subscription_builder.credentials, + credential_type=credential_type, + credential_expires_at=subscription_builder.credential_expires_at or -1, + expires_at=subscription_builder.expires_at, + ) + + # Delete the builder after successful subscription creation + cache_key = cls.encode_cache_key(subscription_builder_id) + redis_client.delete(cache_key) + + @classmethod + def builder_to_api_entity( + cls, controller: PluginTriggerProviderController, entity: SubscriptionBuilder + ) -> SubscriptionBuilderApiEntity: + credential_type = CredentialType.of(entity.credential_type or CredentialType.UNAUTHORIZED.value) + return SubscriptionBuilderApiEntity( + id=entity.id, + name=entity.name or "", + provider=entity.provider_id, + endpoint=generate_plugin_trigger_endpoint_url(entity.endpoint_id), + parameters=entity.parameters, + properties=entity.properties, + credential_type=credential_type, + credentials=masked_credentials( + schemas=controller.get_credentials_schema(credential_type), + credentials=entity.credentials, + ) + if controller.get_subscription_constructor() + else {}, + ) + + @classmethod + def get_subscription_builder(cls, endpoint_id: str) -> SubscriptionBuilder | None: + """ + Get a trigger subscription by the endpoint ID. + """ + cache_key = cls.encode_cache_key(endpoint_id) + subscription_cache = redis_client.get(cache_key) + if subscription_cache: + return SubscriptionBuilder.model_validate(json.loads(subscription_cache)) + + return None + + @classmethod + def append_log(cls, endpoint_id: str, request: Request, response: Response) -> None: + """Append validation request log to Redis.""" + log = RequestLog( + id=str(uuid.uuid4()), + endpoint=endpoint_id, + request={ + "method": request.method, + "url": request.url, + "headers": dict(request.headers), + "data": request.get_data(as_text=True), + }, + response={ + "status_code": response.status_code, + "headers": dict(response.headers), + "data": response.get_data(as_text=True), + }, + created_at=datetime.now(), + ) + + key = f"trigger:subscription:builder:logs:{endpoint_id}" + logs = json.loads(redis_client.get(key) or "[]") + logs.append(log.model_dump(mode="json")) + + # Keep last N logs + logs = logs[-cls.__VALIDATION_REQUEST_CACHE_COUNT__ :] + redis_client.setex(key, cls.__VALIDATION_REQUEST_CACHE_EXPIRE_SECONDS__, json.dumps(logs, default=str)) + + @classmethod + def list_logs(cls, endpoint_id: str) -> list[RequestLog]: + """List request logs for validation endpoint.""" + key = f"trigger:subscription:builder:logs:{endpoint_id}" + logs_json = redis_client.get(key) + if not logs_json: + return [] + return [RequestLog.model_validate(log) for log in json.loads(logs_json)] + + @classmethod + def process_builder_validation_endpoint(cls, endpoint_id: str, request: Request) -> Response | None: + """ + Process a temporary endpoint request. + + :param endpoint_id: The endpoint identifier + :param request: The Flask request object + :return: The Flask response object + """ + # check if validation endpoint exists + subscription_builder: SubscriptionBuilder | None = cls.get_subscription_builder(endpoint_id) + if not subscription_builder: + return None + + # response to validation endpoint + controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id=subscription_builder.tenant_id, provider_id=TriggerProviderID(subscription_builder.provider_id) + ) + try: + dispatch_response: TriggerDispatchResponse = controller.dispatch( + request=request, + subscription=subscription_builder.to_subscription(), + credentials={}, + credential_type=CredentialType.UNAUTHORIZED, + ) + response: Response = dispatch_response.response + # append the request log + cls.append_log( + endpoint_id=endpoint_id, + request=request, + response=response, + ) + return response + except Exception: + logger.exception("Error during validation endpoint dispatch for endpoint_id=%s", endpoint_id) + error_response = Response(status=500, response="An internal error has occurred.") + cls.append_log(endpoint_id=endpoint_id, request=request, response=error_response) + return error_response + + @classmethod + def get_subscription_builder_by_id(cls, subscription_builder_id: str) -> SubscriptionBuilderApiEntity: + """Get a trigger subscription builder API entity.""" + subscription_builder = cls.get_subscription_builder(subscription_builder_id) + if not subscription_builder: + raise ValueError(f"Subscription builder {subscription_builder_id} not found") + return cls.builder_to_api_entity( + controller=TriggerManager.get_trigger_provider( + subscription_builder.tenant_id, TriggerProviderID(subscription_builder.provider_id) + ), + entity=subscription_builder, + ) diff --git a/api/services/trigger/trigger_subscription_operator_service.py b/api/services/trigger/trigger_subscription_operator_service.py new file mode 100644 index 0000000000..5d7785549e --- /dev/null +++ b/api/services/trigger/trigger_subscription_operator_service.py @@ -0,0 +1,70 @@ +from sqlalchemy import and_, select +from sqlalchemy.orm import Session + +from extensions.ext_database import db +from models.enums import AppTriggerStatus +from models.trigger import AppTrigger, WorkflowPluginTrigger + + +class TriggerSubscriptionOperatorService: + @classmethod + def get_subscriber_triggers( + cls, tenant_id: str, subscription_id: str, event_name: str + ) -> list[WorkflowPluginTrigger]: + """ + Get WorkflowPluginTriggers for a subscription and trigger. + + Args: + tenant_id: Tenant ID + subscription_id: Subscription ID + event_name: Event name + """ + with Session(db.engine, expire_on_commit=False) as session: + subscribers = session.scalars( + select(WorkflowPluginTrigger) + .join( + AppTrigger, + and_( + AppTrigger.tenant_id == WorkflowPluginTrigger.tenant_id, + AppTrigger.app_id == WorkflowPluginTrigger.app_id, + AppTrigger.node_id == WorkflowPluginTrigger.node_id, + ), + ) + .where( + WorkflowPluginTrigger.tenant_id == tenant_id, + WorkflowPluginTrigger.subscription_id == subscription_id, + WorkflowPluginTrigger.event_name == event_name, + AppTrigger.status == AppTriggerStatus.ENABLED, + ) + ).all() + return list(subscribers) + + @classmethod + def delete_plugin_trigger_by_subscription( + cls, + session: Session, + tenant_id: str, + subscription_id: str, + ) -> None: + """Delete a plugin trigger by tenant_id and subscription_id within an existing session + + Args: + session: Database session + tenant_id: The tenant ID + subscription_id: The subscription ID + + Raises: + NotFound: If plugin trigger not found + """ + # Find plugin trigger using indexed columns + plugin_trigger = session.scalar( + select(WorkflowPluginTrigger).where( + WorkflowPluginTrigger.tenant_id == tenant_id, + WorkflowPluginTrigger.subscription_id == subscription_id, + ) + ) + + if not plugin_trigger: + return + + session.delete(plugin_trigger) diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py new file mode 100644 index 0000000000..946764c35c --- /dev/null +++ b/api/services/trigger/webhook_service.py @@ -0,0 +1,871 @@ +import json +import logging +import mimetypes +import secrets +from collections.abc import Mapping +from typing import Any + +from flask import request +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session +from werkzeug.datastructures import FileStorage +from werkzeug.exceptions import RequestEntityTooLarge + +from configs import dify_config +from core.app.entities.app_invoke_entities import InvokeFrom +from core.file.models import FileTransferMethod +from core.tools.tool_file_manager import ToolFileManager +from core.variables.types import SegmentType +from core.workflow.enums import NodeType +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from factories import file_factory +from models.enums import AppTriggerStatus, AppTriggerType +from models.model import App +from models.trigger import AppTrigger, WorkflowWebhookTrigger +from models.workflow import Workflow +from services.async_workflow_service import AsyncWorkflowService +from services.end_user_service import EndUserService +from services.workflow.entities import WebhookTriggerData + +logger = logging.getLogger(__name__) + + +class WebhookService: + """Service for handling webhook operations.""" + + __WEBHOOK_NODE_CACHE_KEY__ = "webhook_nodes" + MAX_WEBHOOK_NODES_PER_WORKFLOW = 5 # Maximum allowed webhook nodes per workflow + + @staticmethod + def _sanitize_key(key: str) -> str: + """Normalize external keys (headers/params) to workflow-safe variables.""" + if not isinstance(key, str): + return key + return key.replace("-", "_") + + @classmethod + def get_webhook_trigger_and_workflow( + cls, webhook_id: str, is_debug: bool = False + ) -> tuple[WorkflowWebhookTrigger, Workflow, Mapping[str, Any]]: + """Get webhook trigger, workflow, and node configuration. + + Args: + webhook_id: The webhook ID to look up + is_debug: If True, use the draft workflow graph and skip the trigger enabled status check + + Returns: + A tuple containing: + - WorkflowWebhookTrigger: The webhook trigger object + - Workflow: The associated workflow object + - Mapping[str, Any]: The node configuration data + + Raises: + ValueError: If webhook not found, app trigger not found, trigger disabled, or workflow not found + """ + with Session(db.engine) as session: + # Get webhook trigger + webhook_trigger = ( + session.query(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.webhook_id == webhook_id).first() + ) + if not webhook_trigger: + raise ValueError(f"Webhook not found: {webhook_id}") + + if is_debug: + workflow = ( + session.query(Workflow) + .filter( + Workflow.app_id == webhook_trigger.app_id, + Workflow.version == Workflow.VERSION_DRAFT, + ) + .order_by(Workflow.created_at.desc()) + .first() + ) + else: + # Check if the corresponding AppTrigger exists + app_trigger = ( + session.query(AppTrigger) + .filter( + AppTrigger.app_id == webhook_trigger.app_id, + AppTrigger.node_id == webhook_trigger.node_id, + AppTrigger.trigger_type == AppTriggerType.TRIGGER_WEBHOOK, + ) + .first() + ) + + if not app_trigger: + raise ValueError(f"App trigger not found for webhook {webhook_id}") + + # Only check enabled status if not in debug mode + if app_trigger.status != AppTriggerStatus.ENABLED: + raise ValueError(f"Webhook trigger is disabled for webhook {webhook_id}") + + # Get workflow + workflow = ( + session.query(Workflow) + .filter( + Workflow.app_id == webhook_trigger.app_id, + Workflow.version != Workflow.VERSION_DRAFT, + ) + .order_by(Workflow.created_at.desc()) + .first() + ) + if not workflow: + raise ValueError(f"Workflow not found for app {webhook_trigger.app_id}") + + node_config = workflow.get_node_config_by_id(webhook_trigger.node_id) + + return webhook_trigger, workflow, node_config + + @classmethod + def extract_and_validate_webhook_data( + cls, webhook_trigger: WorkflowWebhookTrigger, node_config: Mapping[str, Any] + ) -> dict[str, Any]: + """Extract and validate webhook data in a single unified process. + + Args: + webhook_trigger: The webhook trigger object containing metadata + node_config: The node configuration containing validation rules + + Returns: + dict[str, Any]: Processed and validated webhook data with correct types + + Raises: + ValueError: If validation fails (HTTP method mismatch, missing required fields, type errors) + """ + # Extract raw data first + raw_data = cls.extract_webhook_data(webhook_trigger) + + # Validate HTTP metadata (method, content-type) + node_data = node_config.get("data", {}) + validation_result = cls._validate_http_metadata(raw_data, node_data) + if not validation_result["valid"]: + raise ValueError(validation_result["error"]) + + # Process and validate data according to configuration + processed_data = cls._process_and_validate_data(raw_data, node_data) + + return processed_data + + @classmethod + def extract_webhook_data(cls, webhook_trigger: WorkflowWebhookTrigger) -> dict[str, Any]: + """Extract raw data from incoming webhook request without type conversion. + + Args: + webhook_trigger: The webhook trigger object for file processing context + + Returns: + dict[str, Any]: Raw webhook data containing: + - method: HTTP method + - headers: Request headers + - query_params: Query parameters as strings + - body: Request body (varies by content type) + - files: Uploaded files (if any) + """ + cls._validate_content_length() + + data = { + "method": request.method, + "headers": dict(request.headers), + "query_params": dict(request.args), + "body": {}, + "files": {}, + } + + # Extract and normalize content type + content_type = cls._extract_content_type(dict(request.headers)) + + # Route to appropriate extractor based on content type + extractors = { + "application/json": cls._extract_json_body, + "application/x-www-form-urlencoded": cls._extract_form_body, + "multipart/form-data": lambda: cls._extract_multipart_body(webhook_trigger), + "application/octet-stream": lambda: cls._extract_octet_stream_body(webhook_trigger), + "text/plain": cls._extract_text_body, + } + + extractor = extractors.get(content_type) + if not extractor: + # Default to text/plain for unknown content types + logger.warning("Unknown Content-Type: %s, treating as text/plain", content_type) + extractor = cls._extract_text_body + + # Extract body and files + body_data, files_data = extractor() + data["body"] = body_data + data["files"] = files_data + + return data + + @classmethod + def _process_and_validate_data(cls, raw_data: dict[str, Any], node_data: dict[str, Any]) -> dict[str, Any]: + """Process and validate webhook data according to node configuration. + + Args: + raw_data: Raw webhook data from extraction + node_data: Node configuration containing validation and type rules + + Returns: + dict[str, Any]: Processed data with validated types + + Raises: + ValueError: If validation fails or required fields are missing + """ + result = raw_data.copy() + + # Validate and process headers + cls._validate_required_headers(raw_data["headers"], node_data.get("headers", [])) + + # Process query parameters with type conversion and validation + result["query_params"] = cls._process_parameters( + raw_data["query_params"], node_data.get("params", []), is_form_data=True + ) + + # Process body parameters based on content type + configured_content_type = node_data.get("content_type", "application/json").lower() + result["body"] = cls._process_body_parameters( + raw_data["body"], node_data.get("body", []), configured_content_type + ) + + return result + + @classmethod + def _validate_content_length(cls) -> None: + """Validate request content length against maximum allowed size.""" + content_length = request.content_length + if content_length and content_length > dify_config.WEBHOOK_REQUEST_BODY_MAX_SIZE: + raise RequestEntityTooLarge( + f"Webhook request too large: {content_length} bytes exceeds maximum allowed size " + f"of {dify_config.WEBHOOK_REQUEST_BODY_MAX_SIZE} bytes" + ) + + @classmethod + def _extract_json_body(cls) -> tuple[dict[str, Any], dict[str, Any]]: + """Extract JSON body from request. + + Returns: + tuple: (body_data, files_data) where: + - body_data: Parsed JSON content or empty dict if parsing fails + - files_data: Empty dict (JSON requests don't contain files) + """ + try: + body = request.get_json() or {} + except Exception: + logger.warning("Failed to parse JSON body") + body = {} + return body, {} + + @classmethod + def _extract_form_body(cls) -> tuple[dict[str, Any], dict[str, Any]]: + """Extract form-urlencoded body from request. + + Returns: + tuple: (body_data, files_data) where: + - body_data: Form data as key-value pairs + - files_data: Empty dict (form-urlencoded requests don't contain files) + """ + return dict(request.form), {} + + @classmethod + def _extract_multipart_body(cls, webhook_trigger: WorkflowWebhookTrigger) -> tuple[dict[str, Any], dict[str, Any]]: + """Extract multipart/form-data body and files from request. + + Args: + webhook_trigger: Webhook trigger for file processing context + + Returns: + tuple: (body_data, files_data) where: + - body_data: Form data as key-value pairs + - files_data: Processed file objects indexed by field name + """ + body = dict(request.form) + files = cls._process_file_uploads(request.files, webhook_trigger) if request.files else {} + return body, files + + @classmethod + def _extract_octet_stream_body( + cls, webhook_trigger: WorkflowWebhookTrigger + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Extract binary data as file from request. + + Args: + webhook_trigger: Webhook trigger for file processing context + + Returns: + tuple: (body_data, files_data) where: + - body_data: Dict with 'raw' key containing file object or None + - files_data: Empty dict + """ + try: + file_content = request.get_data() + if file_content: + file_obj = cls._create_file_from_binary(file_content, "application/octet-stream", webhook_trigger) + return {"raw": file_obj.to_dict()}, {} + else: + return {"raw": None}, {} + except Exception: + logger.exception("Failed to process octet-stream data") + return {"raw": None}, {} + + @classmethod + def _extract_text_body(cls) -> tuple[dict[str, Any], dict[str, Any]]: + """Extract text/plain body from request. + + Returns: + tuple: (body_data, files_data) where: + - body_data: Dict with 'raw' key containing text content + - files_data: Empty dict (text requests don't contain files) + """ + try: + body = {"raw": request.get_data(as_text=True)} + except Exception: + logger.warning("Failed to extract text body") + body = {"raw": ""} + return body, {} + + @classmethod + def _process_file_uploads( + cls, files: Mapping[str, FileStorage], webhook_trigger: WorkflowWebhookTrigger + ) -> dict[str, Any]: + """Process file uploads using ToolFileManager. + + Args: + files: Flask request files object containing uploaded files + webhook_trigger: Webhook trigger for tenant and user context + + Returns: + dict[str, Any]: Processed file objects indexed by field name + """ + processed_files = {} + + for name, file in files.items(): + if file and file.filename: + try: + file_content = file.read() + mimetype = file.content_type or mimetypes.guess_type(file.filename)[0] or "application/octet-stream" + file_obj = cls._create_file_from_binary(file_content, mimetype, webhook_trigger) + processed_files[name] = file_obj.to_dict() + except Exception: + logger.exception("Failed to process file upload '%s'", name) + # Continue processing other files + + return processed_files + + @classmethod + def _create_file_from_binary( + cls, file_content: bytes, mimetype: str, webhook_trigger: WorkflowWebhookTrigger + ) -> Any: + """Create a file object from binary content using ToolFileManager. + + Args: + file_content: The binary content of the file + mimetype: The MIME type of the file + webhook_trigger: Webhook trigger for tenant and user context + + Returns: + Any: A file object built from the binary content + """ + tool_file_manager = ToolFileManager() + + # Create file using ToolFileManager + tool_file = tool_file_manager.create_file_by_raw( + user_id=webhook_trigger.created_by, + tenant_id=webhook_trigger.tenant_id, + conversation_id=None, + file_binary=file_content, + mimetype=mimetype, + ) + + # Build File object + mapping = { + "tool_file_id": tool_file.id, + "transfer_method": FileTransferMethod.TOOL_FILE.value, + } + return file_factory.build_from_mapping( + mapping=mapping, + tenant_id=webhook_trigger.tenant_id, + ) + + @classmethod + def _process_parameters( + cls, raw_params: dict[str, str], param_configs: list, is_form_data: bool = False + ) -> dict[str, Any]: + """Process parameters with unified validation and type conversion. + + Args: + raw_params: Raw parameter values as strings + param_configs: List of parameter configuration dictionaries + is_form_data: Whether the parameters are from form data (requiring string conversion) + + Returns: + dict[str, Any]: Processed parameters with validated types + + Raises: + ValueError: If required parameters are missing or validation fails + """ + processed = {} + configured_params = {config.get("name", ""): config for config in param_configs} + + # Process configured parameters + for param_config in param_configs: + name = param_config.get("name", "") + param_type = param_config.get("type", SegmentType.STRING) + required = param_config.get("required", False) + + # Check required parameters + if required and name not in raw_params: + raise ValueError(f"Required parameter missing: {name}") + + if name in raw_params: + raw_value = raw_params[name] + processed[name] = cls._validate_and_convert_value(name, raw_value, param_type, is_form_data) + + # Include unconfigured parameters as strings + for name, value in raw_params.items(): + if name not in configured_params: + processed[name] = value + + return processed + + @classmethod + def _process_body_parameters( + cls, raw_body: dict[str, Any], body_configs: list, content_type: str + ) -> dict[str, Any]: + """Process body parameters based on content type and configuration. + + Args: + raw_body: Raw body data from request + body_configs: List of body parameter configuration dictionaries + content_type: The request content type + + Returns: + dict[str, Any]: Processed body parameters with validated types + + Raises: + ValueError: If required body parameters are missing or validation fails + """ + if content_type in ["text/plain", "application/octet-stream"]: + # For text/plain and octet-stream, validate required content exists + if body_configs and any(config.get("required", False) for config in body_configs): + raw_content = raw_body.get("raw") + if not raw_content: + raise ValueError(f"Required body content missing for {content_type} request") + return raw_body + + # For structured data (JSON, form-data, etc.) + processed = {} + configured_params = {config.get("name", ""): config for config in body_configs} + + for body_config in body_configs: + name = body_config.get("name", "") + param_type = body_config.get("type", SegmentType.STRING) + required = body_config.get("required", False) + + # Handle file parameters for multipart data + if param_type == SegmentType.FILE and content_type == "multipart/form-data": + # File validation is handled separately in extract phase + continue + + # Check required parameters + if required and name not in raw_body: + raise ValueError(f"Required body parameter missing: {name}") + + if name in raw_body: + raw_value = raw_body[name] + is_form_data = content_type in ["application/x-www-form-urlencoded", "multipart/form-data"] + processed[name] = cls._validate_and_convert_value(name, raw_value, param_type, is_form_data) + + # Include unconfigured parameters + for name, value in raw_body.items(): + if name not in configured_params: + processed[name] = value + + return processed + + @classmethod + def _validate_and_convert_value(cls, param_name: str, value: Any, param_type: str, is_form_data: bool) -> Any: + """Unified validation and type conversion for parameter values. + + Args: + param_name: Name of the parameter for error reporting + value: The value to validate and convert + param_type: The expected parameter type (SegmentType) + is_form_data: Whether the value is from form data (requiring string conversion) + + Returns: + Any: The validated and converted value + + Raises: + ValueError: If validation or conversion fails + """ + try: + if is_form_data: + # Form data comes as strings and needs conversion + return cls._convert_form_value(param_name, value, param_type) + else: + # JSON data should already be in correct types, just validate + return cls._validate_json_value(param_name, value, param_type) + except Exception as e: + raise ValueError(f"Parameter '{param_name}' validation failed: {str(e)}") + + @classmethod + def _convert_form_value(cls, param_name: str, value: str, param_type: str) -> Any: + """Convert form data string values to specified types. + + Args: + param_name: Name of the parameter for error reporting + value: The string value to convert + param_type: The target type to convert to (SegmentType) + + Returns: + Any: The converted value in the appropriate type + + Raises: + ValueError: If the value cannot be converted to the specified type + """ + if param_type == SegmentType.STRING: + return value + elif param_type == SegmentType.NUMBER: + if not cls._can_convert_to_number(value): + raise ValueError(f"Cannot convert '{value}' to number") + numeric_value = float(value) + return int(numeric_value) if numeric_value.is_integer() else numeric_value + elif param_type == SegmentType.BOOLEAN: + lower_value = value.lower() + bool_map = {"true": True, "false": False, "1": True, "0": False, "yes": True, "no": False} + if lower_value not in bool_map: + raise ValueError(f"Cannot convert '{value}' to boolean") + return bool_map[lower_value] + else: + raise ValueError(f"Unsupported type '{param_type}' for form data parameter '{param_name}'") + + @classmethod + def _validate_json_value(cls, param_name: str, value: Any, param_type: str) -> Any: + """Validate JSON values against expected types. + + Args: + param_name: Name of the parameter for error reporting + value: The value to validate + param_type: The expected parameter type (SegmentType) + + Returns: + Any: The validated value (unchanged if valid) + + Raises: + ValueError: If the value type doesn't match the expected type + """ + type_validators = { + SegmentType.STRING: (lambda v: isinstance(v, str), "string"), + SegmentType.NUMBER: (lambda v: isinstance(v, (int, float)), "number"), + SegmentType.BOOLEAN: (lambda v: isinstance(v, bool), "boolean"), + SegmentType.OBJECT: (lambda v: isinstance(v, dict), "object"), + SegmentType.ARRAY_STRING: ( + lambda v: isinstance(v, list) and all(isinstance(item, str) for item in v), + "array of strings", + ), + SegmentType.ARRAY_NUMBER: ( + lambda v: isinstance(v, list) and all(isinstance(item, (int, float)) for item in v), + "array of numbers", + ), + SegmentType.ARRAY_BOOLEAN: ( + lambda v: isinstance(v, list) and all(isinstance(item, bool) for item in v), + "array of booleans", + ), + SegmentType.ARRAY_OBJECT: ( + lambda v: isinstance(v, list) and all(isinstance(item, dict) for item in v), + "array of objects", + ), + } + + validator_info = type_validators.get(SegmentType(param_type)) + if not validator_info: + logger.warning("Unknown parameter type: %s for parameter %s", param_type, param_name) + return value + + validator, expected_type = validator_info + if not validator(value): + actual_type = type(value).__name__ + raise ValueError(f"Expected {expected_type}, got {actual_type}") + + return value + + @classmethod + def _validate_required_headers(cls, headers: dict[str, Any], header_configs: list) -> None: + """Validate required headers are present. + + Args: + headers: Request headers dictionary + header_configs: List of header configuration dictionaries + + Raises: + ValueError: If required headers are missing + """ + headers_lower = {k.lower(): v for k, v in headers.items()} + headers_sanitized = {cls._sanitize_key(k).lower(): v for k, v in headers.items()} + for header_config in header_configs: + if header_config.get("required", False): + header_name = header_config.get("name", "") + sanitized_name = cls._sanitize_key(header_name).lower() + if header_name.lower() not in headers_lower and sanitized_name not in headers_sanitized: + raise ValueError(f"Required header missing: {header_name}") + + @classmethod + def _validate_http_metadata(cls, webhook_data: dict[str, Any], node_data: dict[str, Any]) -> dict[str, Any]: + """Validate HTTP method and content-type. + + Args: + webhook_data: Extracted webhook data containing method and headers + node_data: Node configuration containing expected method and content-type + + Returns: + dict[str, Any]: Validation result with 'valid' key and optional 'error' key + """ + # Validate HTTP method + configured_method = node_data.get("method", "get").upper() + request_method = webhook_data["method"].upper() + if configured_method != request_method: + return cls._validation_error(f"HTTP method mismatch. Expected {configured_method}, got {request_method}") + + # Validate Content-type + configured_content_type = node_data.get("content_type", "application/json").lower() + request_content_type = cls._extract_content_type(webhook_data["headers"]) + + if configured_content_type != request_content_type: + return cls._validation_error( + f"Content-type mismatch. Expected {configured_content_type}, got {request_content_type}" + ) + + return {"valid": True} + + @classmethod + def _extract_content_type(cls, headers: dict[str, Any]) -> str: + """Extract and normalize content-type from headers. + + Args: + headers: Request headers dictionary + + Returns: + str: Normalized content-type (main type without parameters) + """ + content_type = headers.get("Content-Type", "").lower() + if not content_type: + content_type = headers.get("content-type", "application/json").lower() + # Extract the main content type (ignore parameters like boundary) + return content_type.split(";")[0].strip() + + @classmethod + def _validation_error(cls, error_message: str) -> dict[str, Any]: + """Create a standard validation error response. + + Args: + error_message: The error message to include + + Returns: + dict[str, Any]: Validation error response with 'valid' and 'error' keys + """ + return {"valid": False, "error": error_message} + + @classmethod + def _can_convert_to_number(cls, value: str) -> bool: + """Check if a string can be converted to a number.""" + try: + float(value) + return True + except ValueError: + return False + + @classmethod + def build_workflow_inputs(cls, webhook_data: dict[str, Any]) -> dict[str, Any]: + """Construct workflow inputs payload from webhook data. + + Args: + webhook_data: Processed webhook data containing headers, query params, and body + + Returns: + dict[str, Any]: Workflow inputs formatted for execution + """ + return { + "webhook_data": webhook_data, + "webhook_headers": webhook_data.get("headers", {}), + "webhook_query_params": webhook_data.get("query_params", {}), + "webhook_body": webhook_data.get("body", {}), + } + + @classmethod + def trigger_workflow_execution( + cls, webhook_trigger: WorkflowWebhookTrigger, webhook_data: dict[str, Any], workflow: Workflow + ) -> None: + """Trigger workflow execution via AsyncWorkflowService. + + Args: + webhook_trigger: The webhook trigger object + webhook_data: Processed webhook data for workflow inputs + workflow: The workflow to execute + + Raises: + ValueError: If tenant owner is not found + Exception: If workflow execution fails + """ + try: + with Session(db.engine) as session: + # Prepare inputs for the webhook node + # The webhook node expects webhook_data in the inputs + workflow_inputs = cls.build_workflow_inputs(webhook_data) + + # Create trigger data + trigger_data = WebhookTriggerData( + app_id=webhook_trigger.app_id, + workflow_id=workflow.id, + root_node_id=webhook_trigger.node_id, # Start from the webhook node + inputs=workflow_inputs, + tenant_id=webhook_trigger.tenant_id, + ) + + end_user = EndUserService.get_or_create_end_user_by_type( + type=InvokeFrom.TRIGGER, + tenant_id=webhook_trigger.tenant_id, + app_id=webhook_trigger.app_id, + user_id=None, + ) + + # Trigger workflow execution asynchronously + AsyncWorkflowService.trigger_workflow_async( + session, + end_user, + trigger_data, + ) + + except Exception: + logger.exception("Failed to trigger workflow for webhook %s", webhook_trigger.webhook_id) + raise + + @classmethod + def generate_webhook_response(cls, node_config: Mapping[str, Any]) -> tuple[dict[str, Any], int]: + """Generate HTTP response based on node configuration. + + Args: + node_config: Node configuration containing response settings + + Returns: + tuple[dict[str, Any], int]: Response data and HTTP status code + """ + node_data = node_config.get("data", {}) + + # Get configured status code and response body + status_code = node_data.get("status_code", 200) + response_body = node_data.get("response_body", "") + + # Parse response body as JSON if it's valid JSON, otherwise return as text + try: + if response_body: + try: + response_data = ( + json.loads(response_body) + if response_body.strip().startswith(("{", "[")) + else {"message": response_body} + ) + except json.JSONDecodeError: + response_data = {"message": response_body} + else: + response_data = {"status": "success", "message": "Webhook processed successfully"} + except: + response_data = {"message": response_body or "Webhook processed successfully"} + + return response_data, status_code + + @classmethod + def sync_webhook_relationships(cls, app: App, workflow: Workflow): + """ + Sync webhook relationships in DB. + + 1. Check if the workflow has any webhook trigger nodes + 2. Fetch the nodes from DB, see if there were any webhook records already + 3. Diff the nodes and the webhook records, create/update/delete the webhook records as needed + + Approach: + Frequent DB operations may cause performance issues, using Redis to cache it instead. + If any record exists, cache it. + + Limits: + - Maximum 5 webhook nodes per workflow + """ + + class Cache(BaseModel): + """ + Cache model for webhook nodes + """ + + record_id: str + node_id: str + webhook_id: str + + nodes_id_in_graph = [node_id for node_id, _ in workflow.walk_nodes(NodeType.TRIGGER_WEBHOOK)] + + # Check webhook node limit + if len(nodes_id_in_graph) > cls.MAX_WEBHOOK_NODES_PER_WORKFLOW: + raise ValueError( + f"Workflow exceeds maximum webhook node limit. " + f"Found {len(nodes_id_in_graph)} webhook nodes, maximum allowed is {cls.MAX_WEBHOOK_NODES_PER_WORKFLOW}" + ) + + not_found_in_cache: list[str] = [] + for node_id in nodes_id_in_graph: + # firstly check if the node exists in cache + if not redis_client.get(f"{cls.__WEBHOOK_NODE_CACHE_KEY__}:{node_id}"): + not_found_in_cache.append(node_id) + continue + + with Session(db.engine) as session: + try: + # lock the concurrent webhook trigger creation + redis_client.lock(f"{cls.__WEBHOOK_NODE_CACHE_KEY__}:apps:{app.id}:lock", timeout=10) + # fetch the non-cached nodes from DB + all_records = session.scalars( + select(WorkflowWebhookTrigger).where( + WorkflowWebhookTrigger.app_id == app.id, + WorkflowWebhookTrigger.tenant_id == app.tenant_id, + ) + ).all() + + nodes_id_in_db = {node.node_id: node for node in all_records} + + # get the nodes not found both in cache and DB + nodes_not_found = [node_id for node_id in not_found_in_cache if node_id not in nodes_id_in_db] + + # create new webhook records + for node_id in nodes_not_found: + webhook_record = WorkflowWebhookTrigger( + app_id=app.id, + tenant_id=app.tenant_id, + node_id=node_id, + webhook_id=cls.generate_webhook_id(), + created_by=app.created_by, + ) + session.add(webhook_record) + session.flush() + cache = Cache(record_id=webhook_record.id, node_id=node_id, webhook_id=webhook_record.webhook_id) + redis_client.set(f"{cls.__WEBHOOK_NODE_CACHE_KEY__}:{node_id}", cache.model_dump_json(), ex=60 * 60) + session.commit() + + # delete the nodes not found in the graph + for node_id in nodes_id_in_db: + if node_id not in nodes_id_in_graph: + session.delete(nodes_id_in_db[node_id]) + redis_client.delete(f"{cls.__WEBHOOK_NODE_CACHE_KEY__}:{node_id}") + session.commit() + except Exception: + logger.exception("Failed to sync webhook relationships for app %s", app.id) + raise + finally: + redis_client.delete(f"{cls.__WEBHOOK_NODE_CACHE_KEY__}:apps:{app.id}:lock") + + @classmethod + def generate_webhook_id(cls) -> str: + """ + Generate unique 24-character webhook ID + + Deduplication is not needed, DB already has unique constraint on webhook_id. + """ + # Generate 24-character random string + return secrets.token_urlsafe(18)[:24] # token_urlsafe gives base64url, take first 24 chars diff --git a/api/services/workflow/entities.py b/api/services/workflow/entities.py new file mode 100644 index 0000000000..70ec8d6e2a --- /dev/null +++ b/api/services/workflow/entities.py @@ -0,0 +1,165 @@ +""" +Pydantic models for async workflow trigger system. +""" + +from collections.abc import Mapping, Sequence +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from models.enums import AppTriggerType, WorkflowRunTriggeredFrom + + +class AsyncTriggerStatus(StrEnum): + """Async trigger execution status""" + + COMPLETED = "completed" + FAILED = "failed" + TIMEOUT = "timeout" + + +class TriggerMetadata(BaseModel): + """Trigger metadata""" + + type: AppTriggerType = Field(default=AppTriggerType.UNKNOWN) + + +class TriggerData(BaseModel): + """Base trigger data model for async workflow execution""" + + app_id: str + tenant_id: str + workflow_id: str | None = None + root_node_id: str + inputs: Mapping[str, Any] + files: Sequence[Mapping[str, Any]] = Field(default_factory=list) + trigger_type: AppTriggerType + trigger_from: WorkflowRunTriggeredFrom + trigger_metadata: TriggerMetadata | None = None + + model_config = ConfigDict(use_enum_values=True) + + +class WebhookTriggerData(TriggerData): + """Webhook-specific trigger data""" + + trigger_type: AppTriggerType = AppTriggerType.TRIGGER_WEBHOOK + trigger_from: WorkflowRunTriggeredFrom = WorkflowRunTriggeredFrom.WEBHOOK + + +class ScheduleTriggerData(TriggerData): + """Schedule-specific trigger data""" + + trigger_type: AppTriggerType = AppTriggerType.TRIGGER_SCHEDULE + trigger_from: WorkflowRunTriggeredFrom = WorkflowRunTriggeredFrom.SCHEDULE + + +class PluginTriggerMetadata(TriggerMetadata): + """Plugin trigger metadata""" + + type: AppTriggerType = AppTriggerType.TRIGGER_PLUGIN + + endpoint_id: str + plugin_unique_identifier: str + provider_id: str + event_name: str + icon_filename: str + icon_dark_filename: str + + +class PluginTriggerData(TriggerData): + """Plugin webhook trigger data""" + + trigger_type: AppTriggerType = AppTriggerType.TRIGGER_PLUGIN + trigger_from: WorkflowRunTriggeredFrom = WorkflowRunTriggeredFrom.PLUGIN + plugin_id: str + endpoint_id: str + + +class PluginTriggerDispatchData(BaseModel): + """Plugin trigger dispatch data for Celery tasks""" + + user_id: str + tenant_id: str + endpoint_id: str + provider_id: str + subscription_id: str + timestamp: int + events: list[str] + request_id: str + + +class WorkflowTaskData(BaseModel): + """Lightweight data structure for Celery workflow tasks""" + + workflow_trigger_log_id: str # Primary tracking ID - all other data can be fetched from DB + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class AsyncTriggerExecutionResult(BaseModel): + """Result from async trigger-based workflow execution""" + + execution_id: str + status: AsyncTriggerStatus + result: Mapping[str, Any] | None = None + error: str | None = None + elapsed_time: float | None = None + total_tokens: int | None = None + + model_config = ConfigDict(use_enum_values=True) + + +class AsyncTriggerResponse(BaseModel): + """Response from triggering an async workflow""" + + workflow_trigger_log_id: str + task_id: str + status: str + queue: str + + model_config = ConfigDict(use_enum_values=True) + + +class TriggerLogResponse(BaseModel): + """Response model for trigger log data""" + + id: str + tenant_id: str + app_id: str + workflow_id: str + trigger_type: WorkflowRunTriggeredFrom + status: str + queue_name: str + retry_count: int + celery_task_id: str | None = None + workflow_run_id: str | None = None + error: str | None = None + outputs: str | None = None + elapsed_time: float | None = None + total_tokens: int | None = None + created_at: str | None = None + triggered_at: str | None = None + finished_at: str | None = None + + model_config = ConfigDict(use_enum_values=True) + + +class WorkflowScheduleCFSPlanEntity(BaseModel): + """ + CFS plan entity. + Ensure each workflow run inside Dify is associated with a CFS(Completely Fair Scheduler) plan. + + """ + + class Strategy(StrEnum): + """ + CFS plan strategy. + """ + + TimeSlice = "time-slice" # time-slice based plan + Nop = "nop" # no plan, just run the workflow + + schedule_strategy: Strategy + granularity: int = Field(default=-1) # -1 means infinite diff --git a/api/services/workflow/queue_dispatcher.py b/api/services/workflow/queue_dispatcher.py new file mode 100644 index 0000000000..c55de7a085 --- /dev/null +++ b/api/services/workflow/queue_dispatcher.py @@ -0,0 +1,151 @@ +""" +Queue dispatcher system for async workflow execution. + +Implements an ABC-based pattern for handling different subscription tiers +with appropriate queue routing and rate limiting. +""" + +from abc import ABC, abstractmethod +from enum import StrEnum + +from configs import dify_config +from extensions.ext_redis import redis_client +from services.billing_service import BillingService +from services.workflow.rate_limiter import TenantDailyRateLimiter + + +class QueuePriority(StrEnum): + """Queue priorities for different subscription tiers""" + + PROFESSIONAL = "workflow_professional" # Highest priority + TEAM = "workflow_team" + SANDBOX = "workflow_sandbox" # Free tier + + +class BaseQueueDispatcher(ABC): + """Abstract base class for queue dispatchers""" + + def __init__(self): + self.rate_limiter = TenantDailyRateLimiter(redis_client) + + @abstractmethod + def get_queue_name(self) -> str: + """Get the queue name for this dispatcher""" + pass + + @abstractmethod + def get_daily_limit(self) -> int: + """Get daily execution limit""" + pass + + @abstractmethod + def get_priority(self) -> int: + """Get task priority level""" + pass + + def check_daily_quota(self, tenant_id: str) -> bool: + """ + Check if tenant has remaining daily quota + + Args: + tenant_id: The tenant identifier + + Returns: + True if quota available, False otherwise + """ + # Check without consuming + remaining = self.rate_limiter.get_remaining_quota(tenant_id=tenant_id, max_daily_limit=self.get_daily_limit()) + return remaining > 0 + + def consume_quota(self, tenant_id: str) -> bool: + """ + Consume one execution from daily quota + + Args: + tenant_id: The tenant identifier + + Returns: + True if quota consumed successfully, False if limit reached + """ + return self.rate_limiter.check_and_consume(tenant_id=tenant_id, max_daily_limit=self.get_daily_limit()) + + +class ProfessionalQueueDispatcher(BaseQueueDispatcher): + """Dispatcher for professional tier""" + + def get_queue_name(self) -> str: + return QueuePriority.PROFESSIONAL + + def get_daily_limit(self) -> int: + return int(1e9) + + def get_priority(self) -> int: + return 100 + + +class TeamQueueDispatcher(BaseQueueDispatcher): + """Dispatcher for team tier""" + + def get_queue_name(self) -> str: + return QueuePriority.TEAM + + def get_daily_limit(self) -> int: + return int(1e9) + + def get_priority(self) -> int: + return 50 + + +class SandboxQueueDispatcher(BaseQueueDispatcher): + """Dispatcher for free/sandbox tier""" + + def get_queue_name(self) -> str: + return QueuePriority.SANDBOX + + def get_daily_limit(self) -> int: + return dify_config.APP_DAILY_RATE_LIMIT + + def get_priority(self) -> int: + return 10 + + +class QueueDispatcherManager: + """Factory for creating appropriate dispatcher based on tenant subscription""" + + # Mapping of billing plans to dispatchers + PLAN_DISPATCHER_MAP = { + "professional": ProfessionalQueueDispatcher, + "team": TeamQueueDispatcher, + "sandbox": SandboxQueueDispatcher, + # Add new tiers here as they're created + # For any unknown plan, default to sandbox + } + + @classmethod + def get_dispatcher(cls, tenant_id: str) -> BaseQueueDispatcher: + """ + Get dispatcher based on tenant's subscription plan + + Args: + tenant_id: The tenant identifier + + Returns: + Appropriate queue dispatcher instance + """ + if dify_config.BILLING_ENABLED: + try: + billing_info = BillingService.get_info(tenant_id) + plan = billing_info.get("subscription", {}).get("plan", "sandbox") + except Exception: + # If billing service fails, default to sandbox + plan = "sandbox" + else: + # If billing is disabled, use team tier as default + plan = "team" + + dispatcher_class = cls.PLAN_DISPATCHER_MAP.get( + plan, + SandboxQueueDispatcher, # Default to sandbox for unknown plans + ) + + return dispatcher_class() # type: ignore diff --git a/api/services/workflow/rate_limiter.py b/api/services/workflow/rate_limiter.py new file mode 100644 index 0000000000..1ccb4e1961 --- /dev/null +++ b/api/services/workflow/rate_limiter.py @@ -0,0 +1,183 @@ +""" +Day-based rate limiter for workflow executions. + +Implements UTC-based daily quotas that reset at midnight UTC for consistent rate limiting. +""" + +from datetime import UTC, datetime, time, timedelta +from typing import Union + +import pytz +from redis import Redis +from sqlalchemy import select + +from extensions.ext_database import db +from extensions.ext_redis import RedisClientWrapper +from models.account import Account, TenantAccountJoin, TenantAccountRole + + +class TenantDailyRateLimiter: + """ + Day-based rate limiter that resets at midnight UTC + + This class provides Redis-based rate limiting with the following features: + - Daily quotas that reset at midnight UTC for consistency + - Atomic check-and-consume operations + - Automatic cleanup of stale counters + - Timezone-aware error messages for better UX + """ + + def __init__(self, redis_client: Union[Redis, RedisClientWrapper]): + self.redis = redis_client + + def get_tenant_owner_timezone(self, tenant_id: str) -> str: + """ + Get timezone of tenant owner + + Args: + tenant_id: The tenant identifier + + Returns: + Timezone string (e.g., 'America/New_York', 'UTC') + """ + # Query to get tenant owner's timezone using scalar and select + owner = db.session.scalar( + select(Account) + .join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id) + .where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.role == TenantAccountRole.OWNER) + ) + + if not owner: + return "UTC" + + return owner.timezone or "UTC" + + def _get_day_key(self, tenant_id: str) -> str: + """ + Get Redis key for current UTC day + + Args: + tenant_id: The tenant identifier + + Returns: + Redis key for the current UTC day + """ + utc_now = datetime.now(UTC) + date_str = utc_now.strftime("%Y-%m-%d") + return f"workflow:daily_limit:{tenant_id}:{date_str}" + + def _get_ttl_seconds(self) -> int: + """ + Calculate seconds until UTC midnight + + Returns: + Number of seconds until UTC midnight + """ + utc_now = datetime.now(UTC) + + # Get next midnight in UTC + next_midnight = datetime.combine(utc_now.date() + timedelta(days=1), time.min) + next_midnight = next_midnight.replace(tzinfo=UTC) + + return int((next_midnight - utc_now).total_seconds()) + + def check_and_consume(self, tenant_id: str, max_daily_limit: int) -> bool: + """ + Check if quota available and consume one execution + + Args: + tenant_id: The tenant identifier + max_daily_limit: Maximum daily limit + + Returns: + True if quota consumed successfully, False if limit reached + """ + key = self._get_day_key(tenant_id) + ttl = self._get_ttl_seconds() + + # Check current usage + current = self.redis.get(key) + + if current is None: + # First execution of the day - set to 1 + self.redis.setex(key, ttl, 1) + return True + + current_count = int(current) + if current_count < max_daily_limit: + # Within limit, increment + new_count = self.redis.incr(key) + # Update TTL + self.redis.expire(key, ttl) + + # Double-check in case of race condition + if new_count <= max_daily_limit: + return True + else: + # Race condition occurred, decrement back + self.redis.decr(key) + return False + else: + # Limit exceeded + return False + + def get_remaining_quota(self, tenant_id: str, max_daily_limit: int) -> int: + """ + Get remaining quota for the day + + Args: + tenant_id: The tenant identifier + max_daily_limit: Maximum daily limit + + Returns: + Number of remaining executions for the day + """ + key = self._get_day_key(tenant_id) + used = int(self.redis.get(key) or 0) + return max(0, max_daily_limit - used) + + def get_current_usage(self, tenant_id: str) -> int: + """ + Get current usage for the day + + Args: + tenant_id: The tenant identifier + + Returns: + Number of executions used today + """ + key = self._get_day_key(tenant_id) + return int(self.redis.get(key) or 0) + + def reset_quota(self, tenant_id: str) -> bool: + """ + Reset quota for testing purposes + + Args: + tenant_id: The tenant identifier + + Returns: + True if key was deleted, False if key didn't exist + """ + key = self._get_day_key(tenant_id) + return bool(self.redis.delete(key)) + + def get_quota_reset_time(self, tenant_id: str, timezone_str: str) -> datetime: + """ + Get the time when quota will reset (next UTC midnight in tenant's timezone) + + Args: + tenant_id: The tenant identifier + timezone_str: Tenant's timezone for display purposes + + Returns: + Datetime when quota resets (next UTC midnight in tenant's timezone) + """ + tz = pytz.timezone(timezone_str) + utc_now = datetime.now(UTC) + + # Get next midnight in UTC, then convert to tenant's timezone + next_utc_midnight = datetime.combine(utc_now.date() + timedelta(days=1), time.min) + next_utc_midnight = pytz.UTC.localize(next_utc_midnight) + + return next_utc_midnight.astimezone(tz) diff --git a/api/services/workflow/scheduler.py b/api/services/workflow/scheduler.py new file mode 100644 index 0000000000..7728c7f470 --- /dev/null +++ b/api/services/workflow/scheduler.py @@ -0,0 +1,34 @@ +from abc import ABC, abstractmethod +from enum import StrEnum + +from services.workflow.entities import WorkflowScheduleCFSPlanEntity + + +class SchedulerCommand(StrEnum): + """ + Scheduler command. + """ + + RESOURCE_LIMIT_REACHED = "resource_limit_reached" + NONE = "none" + + +class CFSPlanScheduler(ABC): + """ + CFS plan scheduler. + """ + + def __init__(self, plan: WorkflowScheduleCFSPlanEntity): + """ + Initialize the CFS plan scheduler. + + Args: + plan: The CFS plan. + """ + self.plan = plan + + @abstractmethod + def can_schedule(self) -> SchedulerCommand: + """ + Whether a workflow run can be scheduled. + """ diff --git a/api/services/workflow_app_service.py b/api/services/workflow_app_service.py index 23dd436675..01f0c7a55a 100644 --- a/api/services/workflow_app_service.py +++ b/api/services/workflow_app_service.py @@ -1,12 +1,37 @@ +import json import uuid from datetime import datetime +from typing import Any from sqlalchemy import and_, func, or_, select from sqlalchemy.orm import Session from core.workflow.enums import WorkflowExecutionStatus from models import Account, App, EndUser, WorkflowAppLog, WorkflowRun -from models.enums import CreatorUserRole +from models.enums import AppTriggerType, CreatorUserRole +from models.trigger import WorkflowTriggerLog +from services.plugin.plugin_service import PluginService +from services.workflow.entities import TriggerMetadata + + +# Since the workflow_app_log table has exceeded 100 million records, we use an additional details field to extend it +class LogView: + """Lightweight wrapper for WorkflowAppLog with computed details. + + - Exposes `details_` for marshalling to `details` in API response + - Proxies all other attributes to the underlying `WorkflowAppLog` + """ + + def __init__(self, log: WorkflowAppLog, details: dict | None): + self.log = log + self.details_ = details + + @property + def details(self) -> dict | None: + return self.details_ + + def __getattr__(self, name): + return getattr(self.log, name) class WorkflowAppService: @@ -21,6 +46,7 @@ class WorkflowAppService: created_at_after: datetime | None = None, page: int = 1, limit: int = 20, + detail: bool = False, created_by_end_user_session_id: str | None = None, created_by_account: str | None = None, ): @@ -34,6 +60,7 @@ class WorkflowAppService: :param created_at_after: filter logs created after this timestamp :param page: page number :param limit: items per page + :param detail: whether to return detailed logs :param created_by_end_user_session_id: filter by end user session id :param created_by_account: filter by account email :return: Pagination object @@ -43,8 +70,20 @@ class WorkflowAppService: WorkflowAppLog.tenant_id == app_model.tenant_id, WorkflowAppLog.app_id == app_model.id ) + if detail: + # Simple left join by workflow_run_id to fetch trigger_metadata + stmt = stmt.outerjoin( + WorkflowTriggerLog, + and_( + WorkflowTriggerLog.tenant_id == app_model.tenant_id, + WorkflowTriggerLog.app_id == app_model.id, + WorkflowTriggerLog.workflow_run_id == WorkflowAppLog.workflow_run_id, + ), + ).add_columns(WorkflowTriggerLog.trigger_metadata) + if keyword or status: stmt = stmt.join(WorkflowRun, WorkflowRun.id == WorkflowAppLog.workflow_run_id) + # Join to workflow run for filtering when needed. if keyword: keyword_like_val = f"%{keyword[:30].encode('unicode_escape').decode('utf-8')}%".replace(r"\u", r"\\u") @@ -108,9 +147,17 @@ class WorkflowAppService: # Apply pagination limits offset_stmt = stmt.offset((page - 1) * limit).limit(limit) - # Execute query and get items - items = list(session.scalars(offset_stmt).all()) + # wrapper moved to module scope as `LogView` + # Execute query and get items + if detail: + rows = session.execute(offset_stmt).all() + items = [ + LogView(log, {"trigger_metadata": self.handle_trigger_metadata(app_model.tenant_id, meta_val)}) + for log, meta_val in rows + ] + else: + items = [LogView(log, None) for log in session.scalars(offset_stmt).all()] return { "page": page, "limit": limit, @@ -119,6 +166,31 @@ class WorkflowAppService: "data": items, } + def handle_trigger_metadata(self, tenant_id: str, meta_val: str) -> dict[str, Any]: + metadata: dict[str, Any] | None = self._safe_json_loads(meta_val) + if not metadata: + return {} + trigger_metadata = TriggerMetadata.model_validate(metadata) + if trigger_metadata.type == AppTriggerType.TRIGGER_PLUGIN: + icon = metadata.get("icon_filename") + icon_dark = metadata.get("icon_dark_filename") + metadata["icon"] = PluginService.get_plugin_icon_url(tenant_id=tenant_id, filename=icon) if icon else None + metadata["icon_dark"] = ( + PluginService.get_plugin_icon_url(tenant_id=tenant_id, filename=icon_dark) if icon_dark else None + ) + return metadata + + @staticmethod + def _safe_json_loads(val): + if not val: + return None + if isinstance(val, str): + try: + return json.loads(val) + except Exception: + return None + return val + @staticmethod def _safe_parse_uuid(value: str): # fast check diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index 5e63a83bb1..2690b55dbc 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -1026,7 +1026,7 @@ class DraftVariableSaver: return if self._node_type == NodeType.VARIABLE_ASSIGNER: draft_vars = self._build_from_variable_assigner_mapping(process_data=process_data) - elif self._node_type == NodeType.START: + elif self._node_type == NodeType.START or self._node_type.is_trigger_node: draft_vars = self._build_variables_from_start_mapping(outputs) else: draft_vars = self._build_variables_from_mapping(outputs) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 2f69e46074..b6d64d95da 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -10,20 +10,22 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.app_config.entities import VariableEntityType from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager +from core.app.entities.app_invoke_entities import InvokeFrom from core.file import File from core.repositories import DifyCoreRepositoryFactory from core.variables import Variable from core.variables.variables import VariableUnion -from core.workflow.entities import WorkflowNodeExecution +from core.workflow.entities import GraphInitParams, GraphRuntimeState, VariablePool, WorkflowNodeExecution from core.workflow.enums import ErrorStrategy, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from core.workflow.errors import WorkflowNodeRunFailedError +from core.workflow.graph.graph import Graph from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent, NodeRunSucceededEvent from core.workflow.node_events import NodeRunResult from core.workflow.nodes import NodeType from core.workflow.nodes.base.node import Node +from core.workflow.nodes.node_factory import DifyNodeFactory from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING from core.workflow.nodes.start.entities import StartNodeData -from core.workflow.runtime import VariablePool from core.workflow.system_variable import SystemVariable from core.workflow.workflow_entry import WorkflowEntry from events.app_event import app_draft_workflow_was_synced, app_published_workflow_was_updated @@ -32,6 +34,7 @@ from extensions.ext_storage import storage from factories.file_factory import build_from_mapping, build_from_mappings from libs.datetime_utils import naive_utc_now from models import Account +from models.enums import UserFrom from models.model import App, AppMode from models.tools import WorkflowToolProvider from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowType @@ -211,6 +214,9 @@ class WorkflowService: # validate features structure self.validate_features_structure(app_model=app_model, features=features) + # validate graph structure + self.validate_graph_structure(user_id=account.id, app_model=app_model, graph=graph) + # create draft workflow if not found if not workflow: workflow = Workflow( @@ -267,6 +273,9 @@ class WorkflowService: if FeatureService.get_system_features().plugin_manager.enabled: self._validate_workflow_credentials(draft_workflow) + # validate graph structure + self.validate_graph_structure(user_id=account.id, app_model=app_model, graph=draft_workflow.graph_dict) + # create new workflow workflow = Workflow.new( tenant_id=app_model.tenant_id, @@ -623,7 +632,7 @@ class WorkflowService: node_config = draft_workflow.get_node_config_by_id(node_id) node_type = Workflow.get_node_type_from_node_config(node_config) node_data = node_config.get("data", {}) - if node_type == NodeType.START: + if node_type.is_start_node: with Session(bind=db.engine) as session, session.begin(): draft_var_srv = WorkflowDraftVariableService(session) conversation_id = draft_var_srv.get_or_create_conversation( @@ -631,10 +640,11 @@ class WorkflowService: app=app_model, workflow=draft_workflow, ) - start_data = StartNodeData.model_validate(node_data) - user_inputs = _rebuild_file_for_user_inputs_in_start_node( - tenant_id=draft_workflow.tenant_id, start_node_data=start_data, user_inputs=user_inputs - ) + if node_type is NodeType.START: + start_data = StartNodeData.model_validate(node_data) + user_inputs = _rebuild_file_for_user_inputs_in_start_node( + tenant_id=draft_workflow.tenant_id, start_node_data=start_data, user_inputs=user_inputs + ) # init variable pool variable_pool = _setup_variable_pool( query=query, @@ -895,6 +905,43 @@ class WorkflowService: return new_app + def validate_graph_structure(self, user_id: str, app_model: App, graph: Mapping[str, Any]): + """ + Validate workflow graph structure by instantiating the Graph object. + + This leverages the built-in graph validators (including trigger/UserInput exclusivity) + and raises any structural errors before persisting the workflow. + """ + node_configs = graph.get("nodes", []) + node_configs = cast(list[dict[str, object]], node_configs) + + # is empty graph + if not node_configs: + return + + workflow_id = app_model.workflow_id or "UNKNOWN" + Graph.init( + graph_config=graph, + # TODO(Mairuis): Add root node id + root_node_id=None, + node_factory=DifyNodeFactory( + graph_init_params=GraphInitParams( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + workflow_id=workflow_id, + graph_config=graph, + user_id=user_id, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.VALIDATION, + call_depth=0, + ), + graph_runtime_state=GraphRuntimeState( + variable_pool=VariablePool(), + start_at=time.perf_counter(), + ), + ), + ) + def validate_features_structure(self, app_model: App, features: dict): if app_model.mode == AppMode.ADVANCED_CHAT: return AdvancedChatAppConfigManager.config_validate( @@ -997,10 +1044,11 @@ def _setup_variable_pool( conversation_variables: list[Variable], ): # Only inject system variables for START node type. - if node_type == NodeType.START: + if node_type == NodeType.START or node_type.is_trigger_node: system_variable = SystemVariable( user_id=user_id, app_id=workflow.app_id, + timestamp=int(naive_utc_now().timestamp()), workflow_id=workflow.id, files=files or [], workflow_execution_id=str(uuid.uuid4()), diff --git a/api/tasks/async_workflow_tasks.py b/api/tasks/async_workflow_tasks.py new file mode 100644 index 0000000000..a9907ac981 --- /dev/null +++ b/api/tasks/async_workflow_tasks.py @@ -0,0 +1,186 @@ +""" +Celery tasks for async workflow execution. + +These tasks handle workflow execution for different subscription tiers +with appropriate retry policies and error handling. +""" + +from datetime import UTC, datetime +from typing import Any + +from celery import shared_task +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from configs import dify_config +from core.app.apps.workflow.app_generator import WorkflowAppGenerator +from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.layers.timeslice_layer import TimeSliceLayer +from core.app.layers.trigger_post_layer import TriggerPostLayer +from extensions.ext_database import db +from models.account import Account +from models.enums import CreatorUserRole, WorkflowTriggerStatus +from models.model import App, EndUser, Tenant +from models.trigger import WorkflowTriggerLog +from models.workflow import Workflow +from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository +from services.errors.app import WorkflowNotFoundError +from services.workflow.entities import ( + TriggerData, + WorkflowTaskData, +) +from tasks.workflow_cfs_scheduler.cfs_scheduler import AsyncWorkflowCFSPlanEntity, AsyncWorkflowCFSPlanScheduler +from tasks.workflow_cfs_scheduler.entities import AsyncWorkflowQueue, AsyncWorkflowSystemStrategy + + +@shared_task(queue=AsyncWorkflowQueue.PROFESSIONAL_QUEUE) +def execute_workflow_professional(task_data_dict: dict[str, Any]): + """Execute workflow for professional tier with highest priority""" + task_data = WorkflowTaskData.model_validate(task_data_dict) + cfs_plan_scheduler_entity = AsyncWorkflowCFSPlanEntity( + queue=AsyncWorkflowQueue.PROFESSIONAL_QUEUE, + schedule_strategy=AsyncWorkflowSystemStrategy, + granularity=dify_config.ASYNC_WORKFLOW_SCHEDULER_GRANULARITY, + ) + _execute_workflow_common( + task_data, + AsyncWorkflowCFSPlanScheduler(plan=cfs_plan_scheduler_entity), + cfs_plan_scheduler_entity, + ) + + +@shared_task(queue=AsyncWorkflowQueue.TEAM_QUEUE) +def execute_workflow_team(task_data_dict: dict[str, Any]): + """Execute workflow for team tier""" + task_data = WorkflowTaskData.model_validate(task_data_dict) + cfs_plan_scheduler_entity = AsyncWorkflowCFSPlanEntity( + queue=AsyncWorkflowQueue.TEAM_QUEUE, + schedule_strategy=AsyncWorkflowSystemStrategy, + granularity=dify_config.ASYNC_WORKFLOW_SCHEDULER_GRANULARITY, + ) + _execute_workflow_common( + task_data, + AsyncWorkflowCFSPlanScheduler(plan=cfs_plan_scheduler_entity), + cfs_plan_scheduler_entity, + ) + + +@shared_task(queue=AsyncWorkflowQueue.SANDBOX_QUEUE) +def execute_workflow_sandbox(task_data_dict: dict[str, Any]): + """Execute workflow for free tier with lower retry limit""" + task_data = WorkflowTaskData.model_validate(task_data_dict) + cfs_plan_scheduler_entity = AsyncWorkflowCFSPlanEntity( + queue=AsyncWorkflowQueue.SANDBOX_QUEUE, + schedule_strategy=AsyncWorkflowSystemStrategy, + granularity=dify_config.ASYNC_WORKFLOW_SCHEDULER_GRANULARITY, + ) + _execute_workflow_common( + task_data, + AsyncWorkflowCFSPlanScheduler(plan=cfs_plan_scheduler_entity), + cfs_plan_scheduler_entity, + ) + + +def _execute_workflow_common( + task_data: WorkflowTaskData, + cfs_plan_scheduler: AsyncWorkflowCFSPlanScheduler, + cfs_plan_scheduler_entity: AsyncWorkflowCFSPlanEntity, +): + """Execute workflow with common logic and trigger log updates.""" + + # Create a new session for this task + session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) + + with session_factory() as session: + trigger_log_repo = SQLAlchemyWorkflowTriggerLogRepository(session) + + # Get trigger log + trigger_log = trigger_log_repo.get_by_id(task_data.workflow_trigger_log_id) + + if not trigger_log: + # This should not happen, but handle gracefully + return + + # Reconstruct execution data from trigger log + trigger_data = TriggerData.model_validate_json(trigger_log.trigger_data) + + # Update status to running + trigger_log.status = WorkflowTriggerStatus.RUNNING + trigger_log_repo.update(trigger_log) + session.commit() + + start_time = datetime.now(UTC) + + try: + # Get app and workflow models + app_model = session.scalar(select(App).where(App.id == trigger_log.app_id)) + + if not app_model: + raise WorkflowNotFoundError(f"App not found: {trigger_log.app_id}") + + workflow = session.scalar(select(Workflow).where(Workflow.id == trigger_log.workflow_id)) + if not workflow: + raise WorkflowNotFoundError(f"Workflow not found: {trigger_log.workflow_id}") + + user = _get_user(session, trigger_log) + + # Execute workflow using WorkflowAppGenerator + generator = WorkflowAppGenerator() + + # Prepare args matching AppGenerateService.generate format + args: dict[str, Any] = {"inputs": dict(trigger_data.inputs), "files": list(trigger_data.files)} + + # If workflow_id was specified, add it to args + if trigger_data.workflow_id: + args["workflow_id"] = str(trigger_data.workflow_id) + + # Execute the workflow with the trigger type + generator.generate( + app_model=app_model, + workflow=workflow, + user=user, + args=args, + invoke_from=InvokeFrom.SERVICE_API, + streaming=False, + call_depth=0, + triggered_from=trigger_data.trigger_from, + root_node_id=trigger_data.root_node_id, + graph_engine_layers=[ + TimeSliceLayer(cfs_plan_scheduler), + TriggerPostLayer(cfs_plan_scheduler_entity, start_time, trigger_log.id, session_factory), + ], + ) + + except Exception as e: + # Calculate elapsed time for failed execution + elapsed_time = (datetime.now(UTC) - start_time).total_seconds() + + # Update trigger log with failure + trigger_log.status = WorkflowTriggerStatus.FAILED + trigger_log.error = str(e) + trigger_log.finished_at = datetime.now(UTC) + trigger_log.elapsed_time = elapsed_time + trigger_log_repo.update(trigger_log) + + # Final failure - no retry logic (simplified like RAG tasks) + session.commit() + + +def _get_user(session: Session, trigger_log: WorkflowTriggerLog) -> Account | EndUser: + """Compose user from trigger log""" + tenant = session.scalar(select(Tenant).where(Tenant.id == trigger_log.tenant_id)) + if not tenant: + raise ValueError(f"Tenant not found: {trigger_log.tenant_id}") + + # Get user from trigger log + if trigger_log.created_by_role == CreatorUserRole.ACCOUNT: + user = session.scalar(select(Account).where(Account.id == trigger_log.created_by)) + if user: + user.current_tenant = tenant + else: # CreatorUserRole.END_USER + user = session.scalar(select(EndUser).where(EndUser.id == trigger_log.created_by)) + + if not user: + raise ValueError(f"User not found: {trigger_log.created_by} (role: {trigger_log.created_by_role})") + + return user diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index f8f39583ac..3227f6da96 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -17,6 +17,7 @@ from models import ( AppDatasetJoin, AppMCPServer, AppModelConfig, + AppTrigger, Conversation, EndUser, InstalledApp, @@ -30,8 +31,10 @@ from models import ( Site, TagBinding, TraceAppConfig, + WorkflowSchedulePlan, ) from models.tools import WorkflowToolProvider +from models.trigger import WorkflowPluginTrigger, WorkflowTriggerLog, WorkflowWebhookTrigger from models.web import PinnedConversation, SavedMessage from models.workflow import ( ConversationVariable, @@ -69,6 +72,11 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): _delete_trace_app_configs(tenant_id, app_id) _delete_conversation_variables(app_id=app_id) _delete_draft_variables(app_id) + _delete_app_triggers(tenant_id, app_id) + _delete_workflow_plugin_triggers(tenant_id, app_id) + _delete_workflow_webhook_triggers(tenant_id, app_id) + _delete_workflow_schedule_plans(tenant_id, app_id) + _delete_workflow_trigger_logs(tenant_id, app_id) end_at = time.perf_counter() logger.info(click.style(f"App and related data deleted: {app_id} latency: {end_at - start_at}", fg="green")) @@ -484,6 +492,72 @@ def _delete_draft_variable_offload_data(conn, file_ids: list[str]) -> int: return files_deleted +def _delete_app_triggers(tenant_id: str, app_id: str): + def del_app_trigger(trigger_id: str): + db.session.query(AppTrigger).where(AppTrigger.id == trigger_id).delete(synchronize_session=False) + + _delete_records( + """select id from app_triggers where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_app_trigger, + "app trigger", + ) + + +def _delete_workflow_plugin_triggers(tenant_id: str, app_id: str): + def del_plugin_trigger(trigger_id: str): + db.session.query(WorkflowPluginTrigger).where(WorkflowPluginTrigger.id == trigger_id).delete( + synchronize_session=False + ) + + _delete_records( + """select id from workflow_plugin_triggers where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_plugin_trigger, + "workflow plugin trigger", + ) + + +def _delete_workflow_webhook_triggers(tenant_id: str, app_id: str): + def del_webhook_trigger(trigger_id: str): + db.session.query(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.id == trigger_id).delete( + synchronize_session=False + ) + + _delete_records( + """select id from workflow_webhook_triggers where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_webhook_trigger, + "workflow webhook trigger", + ) + + +def _delete_workflow_schedule_plans(tenant_id: str, app_id: str): + def del_schedule_plan(plan_id: str): + db.session.query(WorkflowSchedulePlan).where(WorkflowSchedulePlan.id == plan_id).delete( + synchronize_session=False + ) + + _delete_records( + """select id from workflow_schedule_plans where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_schedule_plan, + "workflow schedule plan", + ) + + +def _delete_workflow_trigger_logs(tenant_id: str, app_id: str): + def del_trigger_log(log_id: str): + db.session.query(WorkflowTriggerLog).where(WorkflowTriggerLog.id == log_id).delete(synchronize_session=False) + + _delete_records( + """select id from workflow_trigger_logs where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_trigger_log, + "workflow trigger log", + ) + + def _delete_records(query_sql: str, params: dict, delete_func: Callable, name: str) -> None: while True: with db.engine.begin() as conn: diff --git a/api/tasks/trigger_processing_tasks.py b/api/tasks/trigger_processing_tasks.py new file mode 100644 index 0000000000..985125e66b --- /dev/null +++ b/api/tasks/trigger_processing_tasks.py @@ -0,0 +1,492 @@ +""" +Celery tasks for async trigger processing. + +These tasks handle trigger workflow execution asynchronously +to avoid blocking the main request thread. +""" + +import json +import logging +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from typing import Any + +from celery import shared_task +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from core.app.entities.app_invoke_entities import InvokeFrom +from core.plugin.entities.plugin_daemon import CredentialType +from core.plugin.entities.request import TriggerInvokeEventResponse +from core.plugin.impl.exc import PluginInvokeError +from core.trigger.debug.event_bus import TriggerDebugEventBus +from core.trigger.debug.events import PluginTriggerDebugEvent, build_plugin_pool_key +from core.trigger.entities.entities import TriggerProviderEntity +from core.trigger.provider import PluginTriggerProviderController +from core.trigger.trigger_manager import TriggerManager +from core.workflow.enums import NodeType, WorkflowExecutionStatus +from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData +from extensions.ext_database import db +from models.enums import AppTriggerType, CreatorUserRole, WorkflowRunTriggeredFrom, WorkflowTriggerStatus +from models.model import EndUser +from models.provider_ids import TriggerProviderID +from models.trigger import TriggerSubscription, WorkflowPluginTrigger, WorkflowTriggerLog +from models.workflow import Workflow, WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun +from services.async_workflow_service import AsyncWorkflowService +from services.end_user_service import EndUserService +from services.trigger.trigger_provider_service import TriggerProviderService +from services.trigger.trigger_request_service import TriggerHttpRequestCachingService +from services.trigger.trigger_subscription_operator_service import TriggerSubscriptionOperatorService +from services.workflow.entities import PluginTriggerData, PluginTriggerDispatchData, PluginTriggerMetadata +from services.workflow.queue_dispatcher import QueueDispatcherManager + +logger = logging.getLogger(__name__) + +# Use workflow queue for trigger processing +TRIGGER_QUEUE = "triggered_workflow_dispatcher" + + +def dispatch_trigger_debug_event( + events: list[str], + user_id: str, + timestamp: int, + request_id: str, + subscription: TriggerSubscription, +) -> int: + debug_dispatched = 0 + try: + for event_name in events: + pool_key: str = build_plugin_pool_key( + name=event_name, + tenant_id=subscription.tenant_id, + subscription_id=subscription.id, + provider_id=subscription.provider_id, + ) + trigger_debug_event: PluginTriggerDebugEvent = PluginTriggerDebugEvent( + timestamp=timestamp, + user_id=user_id, + name=event_name, + request_id=request_id, + subscription_id=subscription.id, + provider_id=subscription.provider_id, + ) + debug_dispatched += TriggerDebugEventBus.dispatch( + tenant_id=subscription.tenant_id, + event=trigger_debug_event, + pool_key=pool_key, + ) + logger.debug( + "Trigger debug dispatched %d sessions to pool %s for event %s for subscription %s provider %s", + debug_dispatched, + pool_key, + event_name, + subscription.id, + subscription.provider_id, + ) + return debug_dispatched + except Exception: + logger.exception("Failed to dispatch to debug sessions") + return 0 + + +def _get_latest_workflows_by_app_ids( + session: Session, subscribers: Sequence[WorkflowPluginTrigger] +) -> Mapping[str, Workflow]: + """Get the latest workflows by app_ids""" + workflow_query = ( + select(Workflow.app_id, func.max(Workflow.created_at).label("max_created_at")) + .where( + Workflow.app_id.in_({t.app_id for t in subscribers}), + Workflow.version != Workflow.VERSION_DRAFT, + ) + .group_by(Workflow.app_id) + .subquery() + ) + workflows = session.scalars( + select(Workflow).join( + workflow_query, + (Workflow.app_id == workflow_query.c.app_id) & (Workflow.created_at == workflow_query.c.max_created_at), + ) + ).all() + return {w.app_id: w for w in workflows} + + +def _record_trigger_failure_log( + *, + session: Session, + workflow: Workflow, + plugin_trigger: WorkflowPluginTrigger, + subscription: TriggerSubscription, + trigger_metadata: PluginTriggerMetadata, + end_user: EndUser | None, + error_message: str, + event_name: str, + request_id: str, +) -> None: + """ + Persist a workflow run, workflow app log, and trigger log entry for failed trigger invocations. + """ + now = datetime.now(UTC) + if end_user: + created_by_role = CreatorUserRole.END_USER + created_by = end_user.id + else: + created_by_role = CreatorUserRole.ACCOUNT + created_by = subscription.user_id + + failure_inputs = { + "event_name": event_name, + "subscription_id": subscription.id, + "request_id": request_id, + "plugin_trigger_id": plugin_trigger.id, + } + + workflow_run = WorkflowRun( + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + workflow_id=workflow.id, + type=workflow.type, + triggered_from=WorkflowRunTriggeredFrom.PLUGIN.value, + version=workflow.version, + graph=workflow.graph, + inputs=json.dumps(failure_inputs), + status=WorkflowExecutionStatus.FAILED.value, + outputs="{}", + error=error_message, + elapsed_time=0.0, + total_tokens=0, + total_steps=0, + created_by_role=created_by_role.value, + created_by=created_by, + created_at=now, + finished_at=now, + exceptions_count=0, + ) + session.add(workflow_run) + session.flush() + + workflow_app_log = WorkflowAppLog( + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + workflow_id=workflow.id, + workflow_run_id=workflow_run.id, + created_from=WorkflowAppLogCreatedFrom.SERVICE_API.value, + created_by_role=created_by_role.value, + created_by=created_by, + ) + session.add(workflow_app_log) + + dispatcher = QueueDispatcherManager.get_dispatcher(subscription.tenant_id) + queue_name = dispatcher.get_queue_name() + + trigger_data = PluginTriggerData( + app_id=plugin_trigger.app_id, + tenant_id=subscription.tenant_id, + workflow_id=workflow.id, + root_node_id=plugin_trigger.node_id, + inputs={}, + trigger_metadata=trigger_metadata, + plugin_id=subscription.provider_id, + endpoint_id=subscription.endpoint_id, + ) + + trigger_log = WorkflowTriggerLog( + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + workflow_id=workflow.id, + workflow_run_id=workflow_run.id, + root_node_id=plugin_trigger.node_id, + trigger_metadata=trigger_metadata.model_dump_json(), + trigger_type=AppTriggerType.TRIGGER_PLUGIN, + trigger_data=trigger_data.model_dump_json(), + inputs=json.dumps({}), + status=WorkflowTriggerStatus.FAILED, + error=error_message, + queue_name=queue_name, + retry_count=0, + created_by_role=created_by_role.value, + created_by=created_by, + triggered_at=now, + finished_at=now, + elapsed_time=0.0, + total_tokens=0, + ) + session.add(trigger_log) + session.commit() + + +def dispatch_triggered_workflow( + user_id: str, + subscription: TriggerSubscription, + event_name: str, + request_id: str, +) -> int: + """Process triggered workflows. + + Args: + subscription: The trigger subscription + event: The trigger entity that was activated + request_id: The ID of the stored request in storage system + """ + request = TriggerHttpRequestCachingService.get_request(request_id) + payload = TriggerHttpRequestCachingService.get_payload(request_id) + + subscribers: list[WorkflowPluginTrigger] = TriggerSubscriptionOperatorService.get_subscriber_triggers( + tenant_id=subscription.tenant_id, subscription_id=subscription.id, event_name=event_name + ) + if not subscribers: + logger.warning( + "No workflows found for trigger event '%s' in subscription '%s'", + event_name, + subscription.id, + ) + return 0 + + dispatched_count = 0 + provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id=subscription.tenant_id, provider_id=TriggerProviderID(subscription.provider_id) + ) + trigger_entity: TriggerProviderEntity = provider_controller.entity + with Session(db.engine) as session: + workflows: Mapping[str, Workflow] = _get_latest_workflows_by_app_ids(session, subscribers) + + end_users: Mapping[str, EndUser] = EndUserService.create_end_user_batch( + type=InvokeFrom.TRIGGER, + tenant_id=subscription.tenant_id, + app_ids=[plugin_trigger.app_id for plugin_trigger in subscribers], + user_id=user_id, + ) + for plugin_trigger in subscribers: + # Get workflow from mapping + workflow: Workflow | None = workflows.get(plugin_trigger.app_id) + if not workflow: + logger.error( + "Workflow not found for app %s", + plugin_trigger.app_id, + ) + continue + + # Find the trigger node in the workflow + event_node = None + for node_id, node_config in workflow.walk_nodes(NodeType.TRIGGER_PLUGIN): + if node_id == plugin_trigger.node_id: + event_node = node_config + break + + if not event_node: + logger.error("Trigger event node not found for app %s", plugin_trigger.app_id) + continue + + # invoke trigger + trigger_metadata = PluginTriggerMetadata( + plugin_unique_identifier=provider_controller.plugin_unique_identifier or "", + endpoint_id=subscription.endpoint_id, + provider_id=subscription.provider_id, + event_name=event_name, + icon_filename=trigger_entity.identity.icon or "", + icon_dark_filename=trigger_entity.identity.icon_dark or "", + ) + + node_data: TriggerEventNodeData = TriggerEventNodeData.model_validate(event_node) + invoke_response: TriggerInvokeEventResponse | None = None + try: + invoke_response = TriggerManager.invoke_trigger_event( + tenant_id=subscription.tenant_id, + user_id=user_id, + provider_id=TriggerProviderID(subscription.provider_id), + event_name=event_name, + parameters=node_data.resolve_parameters( + parameter_schemas=provider_controller.get_event_parameters(event_name=event_name) + ), + credentials=subscription.credentials, + credential_type=CredentialType.of(subscription.credential_type), + subscription=subscription.to_entity(), + request=request, + payload=payload, + ) + except PluginInvokeError as e: + error_message = e.to_user_friendly_error(plugin_name=trigger_entity.identity.name) + try: + end_user = end_users.get(plugin_trigger.app_id) + _record_trigger_failure_log( + session=session, + workflow=workflow, + plugin_trigger=plugin_trigger, + subscription=subscription, + trigger_metadata=trigger_metadata, + end_user=end_user, + error_message=error_message, + event_name=event_name, + request_id=request_id, + ) + except Exception: + logger.exception( + "Failed to record trigger failure log for app %s", + plugin_trigger.app_id, + ) + continue + except Exception: + logger.exception( + "Failed to invoke trigger event for app %s", + plugin_trigger.app_id, + ) + continue + + if invoke_response is not None and invoke_response.cancelled: + logger.info( + "Trigger ignored for app %s with trigger event %s", + plugin_trigger.app_id, + event_name, + ) + continue + + # Create trigger data for async execution + trigger_data = PluginTriggerData( + app_id=plugin_trigger.app_id, + tenant_id=subscription.tenant_id, + workflow_id=workflow.id, + root_node_id=plugin_trigger.node_id, + plugin_id=subscription.provider_id, + endpoint_id=subscription.endpoint_id, + inputs=invoke_response.variables, + trigger_metadata=trigger_metadata, + ) + + # Trigger async workflow + try: + end_user = end_users.get(plugin_trigger.app_id) + if not end_user: + raise ValueError(f"End user not found for app {plugin_trigger.app_id}") + + AsyncWorkflowService.trigger_workflow_async(session=session, user=end_user, trigger_data=trigger_data) + dispatched_count += 1 + logger.info( + "Triggered workflow for app %s with trigger event %s", + plugin_trigger.app_id, + event_name, + ) + except Exception: + logger.exception( + "Failed to trigger workflow for app %s", + plugin_trigger.app_id, + ) + + return dispatched_count + + +def dispatch_triggered_workflows( + user_id: str, + events: list[str], + subscription: TriggerSubscription, + request_id: str, +) -> int: + dispatched_count = 0 + for event_name in events: + try: + dispatched_count += dispatch_triggered_workflow( + user_id=user_id, + subscription=subscription, + event_name=event_name, + request_id=request_id, + ) + except Exception: + logger.exception( + "Failed to dispatch trigger '%s' for subscription %s and provider %s. Continuing...", + event_name, + subscription.id, + subscription.provider_id, + ) + # Continue processing other triggers even if one fails + continue + + logger.info( + "Completed async trigger dispatching: processed %d/%d triggers for subscription %s and provider %s", + dispatched_count, + len(events), + subscription.id, + subscription.provider_id, + ) + return dispatched_count + + +@shared_task(queue=TRIGGER_QUEUE) +def dispatch_triggered_workflows_async( + dispatch_data: Mapping[str, Any], +) -> Mapping[str, Any]: + """ + Dispatch triggers asynchronously. + + Args: + endpoint_id: Endpoint ID + provider_id: Provider ID + subscription_id: Subscription ID + timestamp: Timestamp of the event + triggers: List of triggers to dispatch + request_id: Unique ID of the stored request + + Returns: + dict: Execution result with status and dispatched trigger count + """ + dispatch_params: PluginTriggerDispatchData = PluginTriggerDispatchData.model_validate(dispatch_data) + user_id = dispatch_params.user_id + tenant_id = dispatch_params.tenant_id + endpoint_id = dispatch_params.endpoint_id + provider_id = dispatch_params.provider_id + subscription_id = dispatch_params.subscription_id + timestamp = dispatch_params.timestamp + events = dispatch_params.events + request_id = dispatch_params.request_id + + try: + logger.info( + "Starting trigger dispatching uid=%s, endpoint=%s, events=%s, req_id=%s, sub_id=%s, provider_id=%s", + user_id, + endpoint_id, + events, + request_id, + subscription_id, + provider_id, + ) + + subscription: TriggerSubscription | None = TriggerProviderService.get_subscription_by_id( + tenant_id=tenant_id, + subscription_id=subscription_id, + ) + if not subscription: + logger.error("Subscription not found: %s", subscription_id) + return {"status": "failed", "error": "Subscription not found"} + + workflow_dispatched = dispatch_triggered_workflows( + user_id=user_id, + events=events, + subscription=subscription, + request_id=request_id, + ) + + debug_dispatched = dispatch_trigger_debug_event( + events=events, + user_id=user_id, + timestamp=timestamp, + request_id=request_id, + subscription=subscription, + ) + + return { + "status": "completed", + "total_count": len(events), + "workflows": workflow_dispatched, + "debug_events": debug_dispatched, + } + + except Exception as e: + logger.exception( + "Error in async trigger dispatching for endpoint %s data %s for subscription %s and provider %s", + endpoint_id, + dispatch_data, + subscription_id, + provider_id, + ) + return { + "status": "failed", + "error": str(e), + } diff --git a/api/tasks/trigger_subscription_refresh_tasks.py b/api/tasks/trigger_subscription_refresh_tasks.py new file mode 100644 index 0000000000..11324df881 --- /dev/null +++ b/api/tasks/trigger_subscription_refresh_tasks.py @@ -0,0 +1,115 @@ +import logging +import time +from collections.abc import Mapping +from typing import Any + +from celery import shared_task +from sqlalchemy.orm import Session + +from core.plugin.entities.plugin_daemon import CredentialType +from core.trigger.utils.locks import build_trigger_refresh_lock_key +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.trigger import TriggerSubscription +from services.trigger.trigger_provider_service import TriggerProviderService + +logger = logging.getLogger(__name__) + + +def _now_ts() -> int: + return int(time.time()) + + +def _load_subscription(session: Session, tenant_id: str, subscription_id: str) -> TriggerSubscription | None: + return session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() + + +def _refresh_oauth_if_expired(tenant_id: str, subscription: TriggerSubscription, now: int) -> None: + if ( + subscription.credential_expires_at != -1 + and int(subscription.credential_expires_at) <= now + and CredentialType.of(subscription.credential_type) == CredentialType.OAUTH2 + ): + logger.info( + "Refreshing OAuth token: tenant=%s subscription_id=%s expires_at=%s now=%s", + tenant_id, + subscription.id, + subscription.credential_expires_at, + now, + ) + try: + result: Mapping[str, Any] = TriggerProviderService.refresh_oauth_token( + tenant_id=tenant_id, subscription_id=subscription.id + ) + logger.info( + "OAuth token refreshed: tenant=%s subscription_id=%s result=%s", tenant_id, subscription.id, result + ) + except Exception: + logger.exception("OAuth refresh failed: tenant=%s subscription_id=%s", tenant_id, subscription.id) + + +def _refresh_subscription_if_expired( + tenant_id: str, + subscription: TriggerSubscription, + now: int, +) -> None: + if subscription.expires_at == -1 or int(subscription.expires_at) > now: + logger.debug( + "Subscription not due: tenant=%s subscription_id=%s expires_at=%s now=%s", + tenant_id, + subscription.id, + subscription.expires_at, + now, + ) + return + + try: + result: Mapping[str, Any] = TriggerProviderService.refresh_subscription( + tenant_id=tenant_id, subscription_id=subscription.id, now=now + ) + logger.info( + "Subscription refreshed: tenant=%s subscription_id=%s result=%s", + tenant_id, + subscription.id, + result.get("result"), + ) + except Exception: + logger.exception("Subscription refresh failed: tenant=%s id=%s", tenant_id, subscription.id) + + +@shared_task(queue="trigger_refresh_executor") +def trigger_subscription_refresh(tenant_id: str, subscription_id: str) -> None: + """Refresh a trigger subscription if needed, guarded by a Redis in-flight lock.""" + lock_key: str = build_trigger_refresh_lock_key(tenant_id, subscription_id) + if not redis_client.get(lock_key): + logger.debug("Refresh lock missing, skip: %s", lock_key) + return + + logger.info("Begin subscription refresh: tenant=%s id=%s", tenant_id, subscription_id) + try: + now: int = _now_ts() + with Session(db.engine) as session: + subscription: TriggerSubscription | None = _load_subscription(session, tenant_id, subscription_id) + + if not subscription: + logger.warning("Subscription not found: tenant=%s id=%s", tenant_id, subscription_id) + return + + logger.debug( + "Loaded subscription: tenant=%s id=%s cred_exp=%s sub_exp=%s now=%s", + tenant_id, + subscription.id, + subscription.credential_expires_at, + subscription.expires_at, + now, + ) + + _refresh_oauth_if_expired(tenant_id=tenant_id, subscription=subscription, now=now) + _refresh_subscription_if_expired(tenant_id=tenant_id, subscription=subscription, now=now) + finally: + try: + redis_client.delete(lock_key) + logger.debug("Lock released: %s", lock_key) + except Exception: + # Best-effort lock cleanup + logger.warning("Failed to release lock: %s", lock_key, exc_info=True) diff --git a/api/tasks/workflow_cfs_scheduler/cfs_scheduler.py b/api/tasks/workflow_cfs_scheduler/cfs_scheduler.py new file mode 100644 index 0000000000..218e61f6d9 --- /dev/null +++ b/api/tasks/workflow_cfs_scheduler/cfs_scheduler.py @@ -0,0 +1,32 @@ +from services.workflow.entities import WorkflowScheduleCFSPlanEntity +from services.workflow.scheduler import CFSPlanScheduler, SchedulerCommand +from tasks.workflow_cfs_scheduler.entities import AsyncWorkflowQueue + + +class AsyncWorkflowCFSPlanEntity(WorkflowScheduleCFSPlanEntity): + """ + Trigger workflow CFS plan entity. + """ + + queue: AsyncWorkflowQueue + + +class AsyncWorkflowCFSPlanScheduler(CFSPlanScheduler): + """ + Trigger workflow CFS plan scheduler. + """ + + plan: AsyncWorkflowCFSPlanEntity + + def can_schedule(self) -> SchedulerCommand: + """ + Check if the workflow can be scheduled. + """ + if self.plan.queue in [AsyncWorkflowQueue.PROFESSIONAL_QUEUE, AsyncWorkflowQueue.TEAM_QUEUE]: + """ + permitted all paid users to schedule the workflow any time + """ + return SchedulerCommand.NONE + + # FIXME: avoid the sandbox user's workflow at a running state for ever + return SchedulerCommand.RESOURCE_LIMIT_REACHED diff --git a/api/tasks/workflow_cfs_scheduler/entities.py b/api/tasks/workflow_cfs_scheduler/entities.py new file mode 100644 index 0000000000..6990f6968a --- /dev/null +++ b/api/tasks/workflow_cfs_scheduler/entities.py @@ -0,0 +1,25 @@ +from enum import StrEnum + +from configs import dify_config +from services.workflow.entities import WorkflowScheduleCFSPlanEntity + +# Determine queue names based on edition +if dify_config.EDITION == "CLOUD": + # Cloud edition: separate queues for different tiers + _professional_queue = "workflow_professional" + _team_queue = "workflow_team" + _sandbox_queue = "workflow_sandbox" + AsyncWorkflowSystemStrategy = WorkflowScheduleCFSPlanEntity.Strategy.TimeSlice +else: + # Community edition: single workflow queue (not dataset) + _professional_queue = "workflow" + _team_queue = "workflow" + _sandbox_queue = "workflow" + AsyncWorkflowSystemStrategy = WorkflowScheduleCFSPlanEntity.Strategy.Nop + + +class AsyncWorkflowQueue(StrEnum): + # Define constants + PROFESSIONAL_QUEUE = _professional_queue + TEAM_QUEUE = _team_queue + SANDBOX_QUEUE = _sandbox_queue diff --git a/api/tasks/workflow_schedule_tasks.py b/api/tasks/workflow_schedule_tasks.py new file mode 100644 index 0000000000..f0596a8f4a --- /dev/null +++ b/api/tasks/workflow_schedule_tasks.py @@ -0,0 +1,60 @@ +import logging + +from celery import shared_task +from sqlalchemy.orm import sessionmaker + +from core.workflow.nodes.trigger_schedule.exc import ( + ScheduleExecutionError, + ScheduleNotFoundError, + TenantOwnerNotFoundError, +) +from extensions.ext_database import db +from models.trigger import WorkflowSchedulePlan +from services.async_workflow_service import AsyncWorkflowService +from services.trigger.schedule_service import ScheduleService +from services.workflow.entities import ScheduleTriggerData + +logger = logging.getLogger(__name__) + + +@shared_task(queue="schedule_executor") +def run_schedule_trigger(schedule_id: str) -> None: + """ + Execute a scheduled workflow trigger. + + Note: No retry logic needed as schedules will run again at next interval. + The execution result is tracked via WorkflowTriggerLog. + + Raises: + ScheduleNotFoundError: If schedule doesn't exist + TenantOwnerNotFoundError: If no owner/admin for tenant + ScheduleExecutionError: If workflow trigger fails + """ + session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) + + with session_factory() as session: + schedule = session.get(WorkflowSchedulePlan, schedule_id) + if not schedule: + raise ScheduleNotFoundError(f"Schedule {schedule_id} not found") + + tenant_owner = ScheduleService.get_tenant_owner(session, schedule.tenant_id) + if not tenant_owner: + raise TenantOwnerNotFoundError(f"No owner or admin found for tenant {schedule.tenant_id}") + + try: + # Production dispatch: Trigger the workflow normally + response = AsyncWorkflowService.trigger_workflow_async( + session=session, + user=tenant_owner, + trigger_data=ScheduleTriggerData( + app_id=schedule.app_id, + root_node_id=schedule.node_id, + inputs={}, + tenant_id=schedule.tenant_id, + ), + ) + logger.info("Schedule %s triggered workflow: %s", schedule_id, response.workflow_trigger_log_id) + except Exception as e: + raise ScheduleExecutionError( + f"Failed to trigger workflow for schedule {schedule_id}, app {schedule.app_id}" + ) from e diff --git a/api/tests/integration_tests/.env.example b/api/tests/integration_tests/.env.example index 23a0ecf714..e4c534f046 100644 --- a/api/tests/integration_tests/.env.example +++ b/api/tests/integration_tests/.env.example @@ -144,6 +144,9 @@ HTTP_REQUEST_MAX_WRITE_TIMEOUT=600 HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760 HTTP_REQUEST_NODE_MAX_TEXT_SIZE=1048576 +# Webhook configuration +WEBHOOK_REQUEST_BODY_MAX_SIZE=10485760 + # Respect X-* headers to redirect clients RESPECT_XFORWARD_HEADERS_ENABLED=false diff --git a/api/tests/test_containers_integration_tests/services/test_webhook_service.py b/api/tests/test_containers_integration_tests/services/test_webhook_service.py new file mode 100644 index 0000000000..09a2deb8cc --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/test_webhook_service.py @@ -0,0 +1,569 @@ +import json +from io import BytesIO +from unittest.mock import MagicMock, patch + +import pytest +from faker import Faker +from flask import Flask +from werkzeug.datastructures import FileStorage + +from models.enums import AppTriggerStatus, AppTriggerType +from models.model import App +from models.trigger import AppTrigger, WorkflowWebhookTrigger +from models.workflow import Workflow +from services.account_service import AccountService, TenantService +from services.trigger.webhook_service import WebhookService + + +class TestWebhookService: + """Integration tests for WebhookService using testcontainers.""" + + @pytest.fixture + def mock_external_dependencies(self): + """Mock external service dependencies.""" + with ( + patch("services.trigger.webhook_service.AsyncWorkflowService") as mock_async_service, + patch("services.trigger.webhook_service.ToolFileManager") as mock_tool_file_manager, + patch("services.trigger.webhook_service.file_factory") as mock_file_factory, + patch("services.account_service.FeatureService") as mock_feature_service, + ): + # Mock ToolFileManager + mock_tool_file_instance = MagicMock() + mock_tool_file_manager.return_value = mock_tool_file_instance + + # Mock file creation + mock_tool_file = MagicMock() + mock_tool_file.id = "test_file_id" + mock_tool_file_instance.create_file_by_raw.return_value = mock_tool_file + + # Mock file factory + mock_file_obj = MagicMock() + mock_file_factory.build_from_mapping.return_value = mock_file_obj + + # Mock feature service + mock_feature_service.get_system_features.return_value.is_allow_register = True + mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True + + yield { + "async_service": mock_async_service, + "tool_file_manager": mock_tool_file_manager, + "file_factory": mock_file_factory, + "tool_file": mock_tool_file, + "file_obj": mock_file_obj, + "feature_service": mock_feature_service, + } + + @pytest.fixture + def test_data(self, db_session_with_containers, mock_external_dependencies): + """Create test data for webhook service tests.""" + fake = Faker() + + # Create account and tenant + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=fake.password(length=12), + ) + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + tenant = account.current_tenant + + # Create app + app = App( + tenant_id=tenant.id, + name=fake.company(), + description=fake.text(), + mode="workflow", + icon="", + icon_background="", + enable_site=True, + enable_api=True, + ) + db_session_with_containers.add(app) + db_session_with_containers.flush() + + # Create workflow + workflow_data = { + "nodes": [ + { + "id": "webhook_node", + "type": "webhook", + "data": { + "title": "Test Webhook", + "method": "post", + "content_type": "application/json", + "headers": [ + {"name": "Authorization", "required": True}, + {"name": "Content-Type", "required": False}, + ], + "params": [{"name": "version", "required": True}, {"name": "format", "required": False}], + "body": [ + {"name": "message", "type": "string", "required": True}, + {"name": "count", "type": "number", "required": False}, + {"name": "upload", "type": "file", "required": False}, + ], + "status_code": 200, + "response_body": '{"status": "success"}', + "timeout": 30, + }, + } + ], + "edges": [], + } + + workflow = Workflow( + tenant_id=tenant.id, + app_id=app.id, + type="workflow", + graph=json.dumps(workflow_data), + features=json.dumps({}), + created_by=account.id, + environment_variables=[], + conversation_variables=[], + version="1.0", + ) + db_session_with_containers.add(workflow) + db_session_with_containers.flush() + + # Create webhook trigger + webhook_id = fake.uuid4()[:16] + webhook_trigger = WorkflowWebhookTrigger( + app_id=app.id, + node_id="webhook_node", + tenant_id=tenant.id, + webhook_id=webhook_id, + created_by=account.id, + ) + db_session_with_containers.add(webhook_trigger) + db_session_with_containers.flush() + + # Create app trigger (required for non-debug mode) + app_trigger = AppTrigger( + tenant_id=tenant.id, + app_id=app.id, + node_id="webhook_node", + trigger_type=AppTriggerType.TRIGGER_WEBHOOK, + title="Test Webhook", + status=AppTriggerStatus.ENABLED, + ) + db_session_with_containers.add(app_trigger) + db_session_with_containers.commit() + + return { + "tenant": tenant, + "account": account, + "app": app, + "workflow": workflow, + "webhook_trigger": webhook_trigger, + "webhook_id": webhook_id, + "app_trigger": app_trigger, + } + + def test_get_webhook_trigger_and_workflow_success(self, test_data, flask_app_with_containers): + """Test successful retrieval of webhook trigger and workflow.""" + webhook_id = test_data["webhook_id"] + + with flask_app_with_containers.app_context(): + webhook_trigger, workflow, node_config = WebhookService.get_webhook_trigger_and_workflow(webhook_id) + + assert webhook_trigger is not None + assert webhook_trigger.webhook_id == webhook_id + assert workflow is not None + assert workflow.app_id == test_data["app"].id + assert node_config is not None + assert node_config["id"] == "webhook_node" + assert node_config["data"]["title"] == "Test Webhook" + + def test_get_webhook_trigger_and_workflow_not_found(self, flask_app_with_containers): + """Test webhook trigger not found scenario.""" + with flask_app_with_containers.app_context(): + with pytest.raises(ValueError, match="Webhook not found"): + WebhookService.get_webhook_trigger_and_workflow("nonexistent_webhook") + + def test_extract_webhook_data_json(self): + """Test webhook data extraction from JSON request.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/json", "Authorization": "Bearer token"}, + query_string="version=1&format=json", + json={"message": "hello", "count": 42}, + ): + webhook_trigger = MagicMock() + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + + assert webhook_data["method"] == "POST" + assert webhook_data["headers"]["Authorization"] == "Bearer token" + assert webhook_data["query_params"]["version"] == "1" + assert webhook_data["query_params"]["format"] == "json" + assert webhook_data["body"]["message"] == "hello" + assert webhook_data["body"]["count"] == 42 + assert webhook_data["files"] == {} + + def test_extract_webhook_data_form_urlencoded(self): + """Test webhook data extraction from form URL encoded request.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={"username": "test", "password": "secret"}, + ): + webhook_trigger = MagicMock() + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + + assert webhook_data["method"] == "POST" + assert webhook_data["body"]["username"] == "test" + assert webhook_data["body"]["password"] == "secret" + + def test_extract_webhook_data_multipart_with_files(self, mock_external_dependencies): + """Test webhook data extraction from multipart form with files.""" + app = Flask(__name__) + + # Create a mock file + file_content = b"test file content" + file_storage = FileStorage(stream=BytesIO(file_content), filename="test.txt", content_type="text/plain") + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "multipart/form-data"}, + data={"message": "test", "upload": file_storage}, + ): + webhook_trigger = MagicMock() + webhook_trigger.tenant_id = "test_tenant" + + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + + assert webhook_data["method"] == "POST" + assert webhook_data["body"]["message"] == "test" + assert "upload" in webhook_data["files"] + + # Verify file processing was called + mock_external_dependencies["tool_file_manager"].assert_called_once() + mock_external_dependencies["file_factory"].build_from_mapping.assert_called_once() + + def test_extract_webhook_data_raw_text(self): + """Test webhook data extraction from raw text request.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", method="POST", headers={"Content-Type": "text/plain"}, data="raw text content" + ): + webhook_trigger = MagicMock() + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + + assert webhook_data["method"] == "POST" + assert webhook_data["body"]["raw"] == "raw text content" + + def test_extract_and_validate_webhook_request_success(self): + """Test successful webhook request validation and type conversion.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/json", "Authorization": "Bearer token"}, + query_string="version=1", + json={"message": "hello"}, + ): + webhook_trigger = MagicMock() + node_config = { + "data": { + "method": "post", + "content_type": "application/json", + "headers": [ + {"name": "Authorization", "required": True}, + {"name": "Content-Type", "required": False}, + ], + "params": [{"name": "version", "required": True}], + "body": [{"name": "message", "type": "string", "required": True}], + } + } + + result = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) + + assert result["headers"]["Authorization"] == "Bearer token" + assert result["query_params"]["version"] == "1" + assert result["body"]["message"] == "hello" + + def test_extract_and_validate_webhook_request_method_mismatch(self): + """Test webhook validation with HTTP method mismatch.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="GET", + headers={"Content-Type": "application/json"}, + ): + webhook_trigger = MagicMock() + node_config = {"data": {"method": "post", "content_type": "application/json"}} + + with pytest.raises(ValueError, match="HTTP method mismatch"): + WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) + + def test_extract_and_validate_webhook_request_missing_required_header(self): + """Test webhook validation with missing required header.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/json"}, + ): + webhook_trigger = MagicMock() + node_config = { + "data": { + "method": "post", + "content_type": "application/json", + "headers": [{"name": "Authorization", "required": True}], + } + } + + with pytest.raises(ValueError, match="Required header missing: Authorization"): + WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) + + def test_extract_and_validate_webhook_request_case_insensitive_headers(self): + """Test webhook validation with case-insensitive header matching.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/json", "authorization": "Bearer token"}, + json={"message": "hello"}, + ): + webhook_trigger = MagicMock() + node_config = { + "data": { + "method": "post", + "content_type": "application/json", + "headers": [{"name": "Authorization", "required": True}], + "body": [{"name": "message", "type": "string", "required": True}], + } + } + + result = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) + + assert result["headers"].get("Authorization") == "Bearer token" + + def test_extract_and_validate_webhook_request_missing_required_param(self): + """Test webhook validation with missing required query parameter.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/json"}, + json={"message": "hello"}, + ): + webhook_trigger = MagicMock() + node_config = { + "data": { + "method": "post", + "content_type": "application/json", + "params": [{"name": "version", "required": True}], + "body": [{"name": "message", "type": "string", "required": True}], + } + } + + with pytest.raises(ValueError, match="Required parameter missing: version"): + WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) + + def test_extract_and_validate_webhook_request_missing_required_body_param(self): + """Test webhook validation with missing required body parameter.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/json"}, + json={}, + ): + webhook_trigger = MagicMock() + node_config = { + "data": { + "method": "post", + "content_type": "application/json", + "body": [{"name": "message", "type": "string", "required": True}], + } + } + + with pytest.raises(ValueError, match="Required body parameter missing: message"): + WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) + + def test_extract_and_validate_webhook_request_missing_required_file(self): + """Test webhook validation when required file is missing from multipart request.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="POST", + data={"note": "test"}, + content_type="multipart/form-data", + ): + webhook_trigger = MagicMock() + webhook_trigger.tenant_id = "tenant" + webhook_trigger.created_by = "user" + node_config = { + "data": { + "method": "post", + "content_type": "multipart/form-data", + "body": [{"name": "upload", "type": "file", "required": True}], + } + } + + result = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) + + assert result["files"] == {} + + def test_trigger_workflow_execution_success(self, test_data, mock_external_dependencies, flask_app_with_containers): + """Test successful workflow execution trigger.""" + webhook_data = { + "method": "POST", + "headers": {"Authorization": "Bearer token"}, + "query_params": {"version": "1"}, + "body": {"message": "hello"}, + "files": {}, + } + + with flask_app_with_containers.app_context(): + # Mock tenant owner lookup to return the test account + with patch("services.trigger.webhook_service.select") as mock_select: + mock_query = MagicMock() + mock_select.return_value.join.return_value.where.return_value = mock_query + + # Mock the session to return our test account + with patch("services.trigger.webhook_service.Session") as mock_session: + mock_session_instance = MagicMock() + mock_session.return_value.__enter__.return_value = mock_session_instance + mock_session_instance.scalar.return_value = test_data["account"] + + # Should not raise any exceptions + WebhookService.trigger_workflow_execution( + test_data["webhook_trigger"], webhook_data, test_data["workflow"] + ) + + # Verify AsyncWorkflowService was called + mock_external_dependencies["async_service"].trigger_workflow_async.assert_called_once() + + def test_trigger_workflow_execution_end_user_service_failure( + self, test_data, mock_external_dependencies, flask_app_with_containers + ): + """Test workflow execution trigger when EndUserService fails.""" + webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}} + + with flask_app_with_containers.app_context(): + # Mock EndUserService to raise an exception + with patch( + "services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type" + ) as mock_end_user: + mock_end_user.side_effect = ValueError("Failed to create end user") + + with pytest.raises(ValueError, match="Failed to create end user"): + WebhookService.trigger_workflow_execution( + test_data["webhook_trigger"], webhook_data, test_data["workflow"] + ) + + def test_generate_webhook_response_default(self): + """Test webhook response generation with default values.""" + node_config = {"data": {}} + + response_data, status_code = WebhookService.generate_webhook_response(node_config) + + assert status_code == 200 + assert response_data["status"] == "success" + assert "Webhook processed successfully" in response_data["message"] + + def test_generate_webhook_response_custom_json(self): + """Test webhook response generation with custom JSON response.""" + node_config = {"data": {"status_code": 201, "response_body": '{"result": "created", "id": 123}'}} + + response_data, status_code = WebhookService.generate_webhook_response(node_config) + + assert status_code == 201 + assert response_data["result"] == "created" + assert response_data["id"] == 123 + + def test_generate_webhook_response_custom_text(self): + """Test webhook response generation with custom text response.""" + node_config = {"data": {"status_code": 202, "response_body": "Request accepted for processing"}} + + response_data, status_code = WebhookService.generate_webhook_response(node_config) + + assert status_code == 202 + assert response_data["message"] == "Request accepted for processing" + + def test_generate_webhook_response_invalid_json(self): + """Test webhook response generation with invalid JSON response.""" + node_config = {"data": {"status_code": 400, "response_body": '{"invalid": json}'}} + + response_data, status_code = WebhookService.generate_webhook_response(node_config) + + assert status_code == 400 + assert response_data["message"] == '{"invalid": json}' + + def test_process_file_uploads_success(self, mock_external_dependencies): + """Test successful file upload processing.""" + # Create mock files + files = { + "file1": MagicMock(filename="test1.txt", content_type="text/plain"), + "file2": MagicMock(filename="test2.jpg", content_type="image/jpeg"), + } + + # Mock file reads + files["file1"].read.return_value = b"content1" + files["file2"].read.return_value = b"content2" + + webhook_trigger = MagicMock() + webhook_trigger.tenant_id = "test_tenant" + + result = WebhookService._process_file_uploads(files, webhook_trigger) + + assert len(result) == 2 + assert "file1" in result + assert "file2" in result + + # Verify file processing was called for each file + assert mock_external_dependencies["tool_file_manager"].call_count == 2 + assert mock_external_dependencies["file_factory"].build_from_mapping.call_count == 2 + + def test_process_file_uploads_with_errors(self, mock_external_dependencies): + """Test file upload processing with errors.""" + # Create mock files, one will fail + files = { + "good_file": MagicMock(filename="test.txt", content_type="text/plain"), + "bad_file": MagicMock(filename="test.bad", content_type="text/plain"), + } + + files["good_file"].read.return_value = b"content" + files["bad_file"].read.side_effect = Exception("Read error") + + webhook_trigger = MagicMock() + webhook_trigger.tenant_id = "test_tenant" + + result = WebhookService._process_file_uploads(files, webhook_trigger) + + # Should process the good file and skip the bad one + assert len(result) == 1 + assert "good_file" in result + assert "bad_file" not in result + + def test_process_file_uploads_empty_filename(self, mock_external_dependencies): + """Test file upload processing with empty filename.""" + files = { + "no_filename": MagicMock(filename="", content_type="text/plain"), + "none_filename": MagicMock(filename=None, content_type="text/plain"), + } + + webhook_trigger = MagicMock() + webhook_trigger.tenant_id = "test_tenant" + + result = WebhookService._process_file_uploads(files, webhook_trigger) + + # Should skip files without filenames + assert len(result) == 0 + mock_external_dependencies["tool_file_manager"].assert_not_called() diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_service.py index 4741eba1f5..88c6313f64 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_service.py @@ -584,7 +584,16 @@ class TestWorkflowService: account = self._create_test_account(db_session_with_containers, fake) app = self._create_test_app(db_session_with_containers, fake) - graph = {"nodes": [{"id": "start", "type": "start"}], "edges": []} + graph = { + "nodes": [ + { + "id": "start", + "type": "start", + "data": {"type": "start", "title": "Start"}, + } + ], + "edges": [], + } features = {"features": ["feature1", "feature2"]} # Don't pre-calculate hash, let the service generate it unique_hash = None @@ -632,7 +641,25 @@ class TestWorkflowService: # Get the actual hash that was generated original_hash = existing_workflow.unique_hash - new_graph = {"nodes": [{"id": "start", "type": "start"}, {"id": "end", "type": "end"}], "edges": []} + new_graph = { + "nodes": [ + { + "id": "start", + "type": "start", + "data": {"type": "start", "title": "Start"}, + }, + { + "id": "end", + "type": "end", + "data": { + "type": "end", + "title": "End", + "outputs": [{"variable": "output", "value_selector": ["start", "text"]}], + }, + }, + ], + "edges": [], + } new_features = {"features": ["feature1", "feature2", "feature3"]} environment_variables = [] @@ -679,7 +706,16 @@ class TestWorkflowService: # Get the actual hash that was generated original_hash = existing_workflow.unique_hash - new_graph = {"nodes": [{"id": "start", "type": "start"}], "edges": []} + new_graph = { + "nodes": [ + { + "id": "start", + "type": "start", + "data": {"type": "start", "title": "Start"}, + } + ], + "edges": [], + } new_features = {"features": ["feature1"]} # Use a different hash to trigger the error mismatched_hash = "different_hash_12345" diff --git a/api/tests/test_containers_integration_tests/services/tools/test_tools_transform_service.py b/api/tests/test_containers_integration_tests/services/tools/test_tools_transform_service.py index e2c616420f..9b86671954 100644 --- a/api/tests/test_containers_integration_tests/services/tools/test_tools_transform_service.py +++ b/api/tests/test_containers_integration_tests/services/tools/test_tools_transform_service.py @@ -8,6 +8,7 @@ from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ToolProviderType from libs.uuid_utils import uuidv7 from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider +from services.plugin.plugin_service import PluginService from services.tools.tools_transform_service import ToolTransformService @@ -17,15 +18,14 @@ class TestToolTransformService: @pytest.fixture def mock_external_service_dependencies(self): """Mock setup for external service dependencies.""" - with ( - patch("services.tools.tools_transform_service.dify_config") as mock_dify_config, - ): - # Setup default mock returns - mock_dify_config.CONSOLE_API_URL = "https://console.example.com" + with patch("services.tools.tools_transform_service.dify_config") as mock_dify_config: + with patch("services.plugin.plugin_service.dify_config", new=mock_dify_config): + # Setup default mock returns + mock_dify_config.CONSOLE_API_URL = "https://console.example.com" - yield { - "dify_config": mock_dify_config, - } + yield { + "dify_config": mock_dify_config, + } def _create_test_tool_provider( self, db_session_with_containers, mock_external_service_dependencies, provider_type="api" @@ -113,13 +113,13 @@ class TestToolTransformService: filename = "test_icon.png" # Act: Execute the method under test - result = ToolTransformService.get_plugin_icon_url(tenant_id, filename) + result = PluginService.get_plugin_icon_url(str(tenant_id), filename) # Assert: Verify the expected outcomes assert result is not None assert isinstance(result, str) assert "console/api/workspaces/current/plugin/icon" in result - assert tenant_id in result + assert str(tenant_id) in result assert filename in result assert result.startswith("https://console.example.com") @@ -144,13 +144,13 @@ class TestToolTransformService: filename = "test_icon.png" # Act: Execute the method under test - result = ToolTransformService.get_plugin_icon_url(tenant_id, filename) + result = PluginService.get_plugin_icon_url(str(tenant_id), filename) # Assert: Verify the expected outcomes assert result is not None assert isinstance(result, str) assert result.startswith("/console/api/workspaces/current/plugin/icon") - assert tenant_id in result + assert str(tenant_id) in result assert filename in result # Verify URL structure @@ -334,7 +334,7 @@ class TestToolTransformService: provider = {"type": ToolProviderType.BUILT_IN, "name": fake.company(), "icon": "🔧"} # Act: Execute the method under test - ToolTransformService.repack_provider(tenant_id, provider) + ToolTransformService.repack_provider(str(tenant_id), provider) # Assert: Verify the expected outcomes assert "icon" in provider @@ -358,7 +358,7 @@ class TestToolTransformService: # Create provider entity with plugin_id provider = ToolProviderApiEntity( - id=fake.uuid4(), + id=str(fake.uuid4()), author=fake.name(), name=fake.company(), description=I18nObject(en_US=fake.text(max_nb_chars=100)), @@ -380,14 +380,14 @@ class TestToolTransformService: assert provider.icon is not None assert isinstance(provider.icon, str) assert "console/api/workspaces/current/plugin/icon" in provider.icon - assert tenant_id in provider.icon + assert str(tenant_id) in provider.icon assert "test_icon.png" in provider.icon # Verify dark icon handling assert provider.icon_dark is not None assert isinstance(provider.icon_dark, str) assert "console/api/workspaces/current/plugin/icon" in provider.icon_dark - assert tenant_id in provider.icon_dark + assert str(tenant_id) in provider.icon_dark assert "test_icon_dark.png" in provider.icon_dark def test_repack_provider_entity_no_plugin_success( @@ -423,7 +423,7 @@ class TestToolTransformService: ) # Act: Execute the method under test - ToolTransformService.repack_provider(tenant_id, provider) + ToolTransformService.repack_provider(str(tenant_id), provider) # Assert: Verify the expected outcomes assert provider.icon is not None @@ -521,7 +521,7 @@ class TestToolTransformService: with patch("services.tools.tools_transform_service.create_provider_encrypter") as mock_encrypter: mock_encrypter_instance = Mock() mock_encrypter_instance.decrypt.return_value = {"api_key": "decrypted_key"} - mock_encrypter_instance.mask_tool_credentials.return_value = {"api_key": ""} + mock_encrypter_instance.mask_plugin_credentials.return_value = {"api_key": ""} mock_encrypter.return_value = (mock_encrypter_instance, None) # Act: Execute the method under test diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py new file mode 100644 index 0000000000..83ac3a5591 --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py @@ -0,0 +1,19 @@ +from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY, WorkflowAppGenerator + + +def test_should_prepare_user_inputs_defaults_to_true(): + args = {"inputs": {}} + + assert WorkflowAppGenerator()._should_prepare_user_inputs(args) + + +def test_should_prepare_user_inputs_skips_when_flag_truthy(): + args = {"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: True} + + assert not WorkflowAppGenerator()._should_prepare_user_inputs(args) + + +def test_should_prepare_user_inputs_keeps_validation_when_flag_false(): + args = {"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: False} + + assert WorkflowAppGenerator()._should_prepare_user_inputs(args) diff --git a/api/tests/unit_tests/core/plugin/utils/test_http_parser.py b/api/tests/unit_tests/core/plugin/utils/test_http_parser.py new file mode 100644 index 0000000000..1c2e0c96f8 --- /dev/null +++ b/api/tests/unit_tests/core/plugin/utils/test_http_parser.py @@ -0,0 +1,655 @@ +import pytest +from flask import Request, Response + +from core.plugin.utils.http_parser import ( + deserialize_request, + deserialize_response, + serialize_request, + serialize_response, +) + + +class TestSerializeRequest: + def test_serialize_simple_get_request(self): + # Create a simple GET request + environ = { + "REQUEST_METHOD": "GET", + "PATH_INFO": "/api/test", + "QUERY_STRING": "", + "SERVER_NAME": "localhost", + "SERVER_PORT": "8000", + "wsgi.input": None, + "wsgi.url_scheme": "http", + } + request = Request(environ) + + raw_data = serialize_request(request) + + assert raw_data.startswith(b"GET /api/test HTTP/1.1\r\n") + assert b"\r\n\r\n" in raw_data # Empty line between headers and body + + def test_serialize_request_with_query_params(self): + # Create a GET request with query parameters + environ = { + "REQUEST_METHOD": "GET", + "PATH_INFO": "/api/search", + "QUERY_STRING": "q=test&limit=10", + "SERVER_NAME": "localhost", + "SERVER_PORT": "8000", + "wsgi.input": None, + "wsgi.url_scheme": "http", + } + request = Request(environ) + + raw_data = serialize_request(request) + + assert raw_data.startswith(b"GET /api/search?q=test&limit=10 HTTP/1.1\r\n") + + def test_serialize_post_request_with_body(self): + # Create a POST request with body + from io import BytesIO + + body = b'{"name": "test", "value": 123}' + environ = { + "REQUEST_METHOD": "POST", + "PATH_INFO": "/api/data", + "QUERY_STRING": "", + "SERVER_NAME": "localhost", + "SERVER_PORT": "8000", + "wsgi.input": BytesIO(body), + "wsgi.url_scheme": "http", + "CONTENT_LENGTH": str(len(body)), + "CONTENT_TYPE": "application/json", + "HTTP_CONTENT_TYPE": "application/json", + } + request = Request(environ) + + raw_data = serialize_request(request) + + assert b"POST /api/data HTTP/1.1\r\n" in raw_data + assert b"Content-Type: application/json" in raw_data + assert raw_data.endswith(body) + + def test_serialize_request_with_custom_headers(self): + # Create a request with custom headers + environ = { + "REQUEST_METHOD": "GET", + "PATH_INFO": "/api/test", + "QUERY_STRING": "", + "SERVER_NAME": "localhost", + "SERVER_PORT": "8000", + "wsgi.input": None, + "wsgi.url_scheme": "http", + "HTTP_AUTHORIZATION": "Bearer token123", + "HTTP_X_CUSTOM_HEADER": "custom-value", + } + request = Request(environ) + + raw_data = serialize_request(request) + + assert b"Authorization: Bearer token123" in raw_data + assert b"X-Custom-Header: custom-value" in raw_data + + +class TestDeserializeRequest: + def test_deserialize_simple_get_request(self): + raw_data = b"GET /api/test HTTP/1.1\r\nHost: localhost:8000\r\n\r\n" + + request = deserialize_request(raw_data) + + assert request.method == "GET" + assert request.path == "/api/test" + assert request.headers.get("Host") == "localhost:8000" + + def test_deserialize_request_with_query_params(self): + raw_data = b"GET /api/search?q=test&limit=10 HTTP/1.1\r\nHost: example.com\r\n\r\n" + + request = deserialize_request(raw_data) + + assert request.method == "GET" + assert request.path == "/api/search" + assert request.query_string == b"q=test&limit=10" + assert request.args.get("q") == "test" + assert request.args.get("limit") == "10" + + def test_deserialize_post_request_with_body(self): + body = b'{"name": "test", "value": 123}' + raw_data = ( + b"POST /api/data HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"\r\n" + body + ) + + request = deserialize_request(raw_data) + + assert request.method == "POST" + assert request.path == "/api/data" + assert request.content_type == "application/json" + assert request.get_data() == body + + def test_deserialize_request_with_custom_headers(self): + raw_data = ( + b"GET /api/protected HTTP/1.1\r\n" + b"Host: api.example.com\r\n" + b"Authorization: Bearer token123\r\n" + b"X-Custom-Header: custom-value\r\n" + b"User-Agent: TestClient/1.0\r\n" + b"\r\n" + ) + + request = deserialize_request(raw_data) + + assert request.method == "GET" + assert request.headers.get("Authorization") == "Bearer token123" + assert request.headers.get("X-Custom-Header") == "custom-value" + assert request.headers.get("User-Agent") == "TestClient/1.0" + + def test_deserialize_request_with_multiline_body(self): + body = b"line1\r\nline2\r\nline3" + raw_data = b"PUT /api/text HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\n\r\n" + body + + request = deserialize_request(raw_data) + + assert request.method == "PUT" + assert request.get_data() == body + + def test_deserialize_invalid_request_line(self): + raw_data = b"INVALID\r\n\r\n" # Only one part, should fail + + with pytest.raises(ValueError, match="Invalid request line"): + deserialize_request(raw_data) + + def test_roundtrip_request(self): + # Test that serialize -> deserialize produces equivalent request + from io import BytesIO + + body = b"test body content" + environ = { + "REQUEST_METHOD": "POST", + "PATH_INFO": "/api/echo", + "QUERY_STRING": "format=json", + "SERVER_NAME": "localhost", + "SERVER_PORT": "8080", + "wsgi.input": BytesIO(body), + "wsgi.url_scheme": "http", + "CONTENT_LENGTH": str(len(body)), + "CONTENT_TYPE": "text/plain", + "HTTP_CONTENT_TYPE": "text/plain", + "HTTP_X_REQUEST_ID": "req-123", + } + original_request = Request(environ) + + # Serialize and deserialize + raw_data = serialize_request(original_request) + restored_request = deserialize_request(raw_data) + + # Verify key properties are preserved + assert restored_request.method == original_request.method + assert restored_request.path == original_request.path + assert restored_request.query_string == original_request.query_string + assert restored_request.get_data() == body + assert restored_request.headers.get("X-Request-Id") == "req-123" + + +class TestSerializeResponse: + def test_serialize_simple_response(self): + response = Response("Hello, World!", status=200) + + raw_data = serialize_response(response) + + assert raw_data.startswith(b"HTTP/1.1 200 OK\r\n") + assert b"\r\n\r\n" in raw_data + assert raw_data.endswith(b"Hello, World!") + + def test_serialize_response_with_headers(self): + response = Response( + '{"status": "success"}', + status=201, + headers={ + "Content-Type": "application/json", + "X-Request-Id": "req-456", + }, + ) + + raw_data = serialize_response(response) + + assert b"HTTP/1.1 201 CREATED\r\n" in raw_data + assert b"Content-Type: application/json" in raw_data + assert b"X-Request-Id: req-456" in raw_data + assert raw_data.endswith(b'{"status": "success"}') + + def test_serialize_error_response(self): + response = Response( + "Not Found", + status=404, + headers={"Content-Type": "text/plain"}, + ) + + raw_data = serialize_response(response) + + assert b"HTTP/1.1 404 NOT FOUND\r\n" in raw_data + assert b"Content-Type: text/plain" in raw_data + assert raw_data.endswith(b"Not Found") + + def test_serialize_response_without_body(self): + response = Response(status=204) # No Content + + raw_data = serialize_response(response) + + assert b"HTTP/1.1 204 NO CONTENT\r\n" in raw_data + assert raw_data.endswith(b"\r\n\r\n") # Should end with empty line + + def test_serialize_response_with_binary_body(self): + binary_data = b"\x00\x01\x02\x03\x04\x05" + response = Response( + binary_data, + status=200, + headers={"Content-Type": "application/octet-stream"}, + ) + + raw_data = serialize_response(response) + + assert b"HTTP/1.1 200 OK\r\n" in raw_data + assert b"Content-Type: application/octet-stream" in raw_data + assert raw_data.endswith(binary_data) + + +class TestDeserializeResponse: + def test_deserialize_simple_response(self): + raw_data = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello, World!" + + response = deserialize_response(raw_data) + + assert response.status_code == 200 + assert response.get_data() == b"Hello, World!" + assert response.headers.get("Content-Type") == "text/plain" + + def test_deserialize_response_with_json(self): + body = b'{"result": "success", "data": [1, 2, 3]}' + raw_data = ( + b"HTTP/1.1 201 Created\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"X-Custom-Header: test-value\r\n" + b"\r\n" + body + ) + + response = deserialize_response(raw_data) + + assert response.status_code == 201 + assert response.get_data() == body + assert response.headers.get("Content-Type") == "application/json" + assert response.headers.get("X-Custom-Header") == "test-value" + + def test_deserialize_error_response(self): + raw_data = b"HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n\r\nPage not found" + + response = deserialize_response(raw_data) + + assert response.status_code == 404 + assert response.get_data() == b"Page not found" + + def test_deserialize_response_without_body(self): + raw_data = b"HTTP/1.1 204 No Content\r\n\r\n" + + response = deserialize_response(raw_data) + + assert response.status_code == 204 + assert response.get_data() == b"" + + def test_deserialize_response_with_multiline_body(self): + body = b"Line 1\r\nLine 2\r\nLine 3" + raw_data = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n" + body + + response = deserialize_response(raw_data) + + assert response.status_code == 200 + assert response.get_data() == body + + def test_deserialize_response_minimal_status_line(self): + # Test with minimal status line (no status text) + raw_data = b"HTTP/1.1 200\r\n\r\nOK" + + response = deserialize_response(raw_data) + + assert response.status_code == 200 + assert response.get_data() == b"OK" + + def test_deserialize_invalid_status_line(self): + raw_data = b"INVALID\r\n\r\n" + + with pytest.raises(ValueError, match="Invalid status line"): + deserialize_response(raw_data) + + def test_roundtrip_response(self): + # Test that serialize -> deserialize produces equivalent response + original_response = Response( + '{"message": "test"}', + status=200, + headers={ + "Content-Type": "application/json", + "X-Request-Id": "abc-123", + "Cache-Control": "no-cache", + }, + ) + + # Serialize and deserialize + raw_data = serialize_response(original_response) + restored_response = deserialize_response(raw_data) + + # Verify key properties are preserved + assert restored_response.status_code == original_response.status_code + assert restored_response.get_data() == original_response.get_data() + assert restored_response.headers.get("Content-Type") == "application/json" + assert restored_response.headers.get("X-Request-Id") == "abc-123" + assert restored_response.headers.get("Cache-Control") == "no-cache" + + +class TestEdgeCases: + def test_request_with_empty_headers(self): + raw_data = b"GET / HTTP/1.1\r\n\r\n" + + request = deserialize_request(raw_data) + + assert request.method == "GET" + assert request.path == "/" + + def test_response_with_empty_headers(self): + raw_data = b"HTTP/1.1 200 OK\r\n\r\nSuccess" + + response = deserialize_response(raw_data) + + assert response.status_code == 200 + assert response.get_data() == b"Success" + + def test_request_with_special_characters_in_path(self): + raw_data = b"GET /api/test%20path?key=%26value HTTP/1.1\r\n\r\n" + + request = deserialize_request(raw_data) + + assert request.method == "GET" + assert "/api/test%20path" in request.full_path + + def test_response_with_binary_content(self): + binary_body = bytes(range(256)) # All possible byte values + raw_data = b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\r\n" + binary_body + + response = deserialize_response(raw_data) + + assert response.status_code == 200 + assert response.get_data() == binary_body + + +class TestFileUploads: + def test_serialize_request_with_text_file_upload(self): + # Test multipart/form-data request with text file + from io import BytesIO + + boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW" + text_content = "Hello, this is a test file content!\nWith multiple lines." + body = ( + f"------{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + f"Content-Type: text/plain\r\n" + f"\r\n" + f"{text_content}\r\n" + f"------{boundary}\r\n" + f'Content-Disposition: form-data; name="description"\r\n' + f"\r\n" + f"Test file upload\r\n" + f"------{boundary}--\r\n" + ).encode() + + environ = { + "REQUEST_METHOD": "POST", + "PATH_INFO": "/api/upload", + "QUERY_STRING": "", + "SERVER_NAME": "localhost", + "SERVER_PORT": "8000", + "wsgi.input": BytesIO(body), + "wsgi.url_scheme": "http", + "CONTENT_LENGTH": str(len(body)), + "CONTENT_TYPE": f"multipart/form-data; boundary={boundary}", + "HTTP_CONTENT_TYPE": f"multipart/form-data; boundary={boundary}", + } + request = Request(environ) + + raw_data = serialize_request(request) + + assert b"POST /api/upload HTTP/1.1\r\n" in raw_data + assert f"Content-Type: multipart/form-data; boundary={boundary}".encode() in raw_data + assert b'Content-Disposition: form-data; name="file"; filename="test.txt"' in raw_data + assert text_content.encode() in raw_data + + def test_deserialize_request_with_text_file_upload(self): + # Test deserializing multipart/form-data request with text file + boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW" + text_content = "Sample text file content\nLine 2\nLine 3" + body = ( + f"------{boundary}\r\n" + f'Content-Disposition: form-data; name="document"; filename="document.txt"\r\n' + f"Content-Type: text/plain\r\n" + f"\r\n" + f"{text_content}\r\n" + f"------{boundary}\r\n" + f'Content-Disposition: form-data; name="title"\r\n' + f"\r\n" + f"My Document\r\n" + f"------{boundary}--\r\n" + ).encode() + + raw_data = ( + b"POST /api/documents HTTP/1.1\r\n" + b"Host: example.com\r\n" + b"Content-Type: multipart/form-data; boundary=" + boundary.encode() + b"\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"\r\n" + body + ) + + request = deserialize_request(raw_data) + + assert request.method == "POST" + assert request.path == "/api/documents" + assert "multipart/form-data" in request.content_type + # The body should contain the multipart data + request_body = request.get_data() + assert b"document.txt" in request_body + assert text_content.encode() in request_body + + def test_serialize_request_with_binary_file_upload(self): + # Test multipart/form-data request with binary file (e.g., image) + from io import BytesIO + + boundary = "----BoundaryString123" + # Simulate a small PNG file header + binary_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x10\x00\x00\x00\x10" + + # Build multipart body + body_parts = [] + body_parts.append(f"------{boundary}".encode()) + body_parts.append(b'Content-Disposition: form-data; name="image"; filename="test.png"') + body_parts.append(b"Content-Type: image/png") + body_parts.append(b"") + body_parts.append(binary_content) + body_parts.append(f"------{boundary}".encode()) + body_parts.append(b'Content-Disposition: form-data; name="caption"') + body_parts.append(b"") + body_parts.append(b"Test image") + body_parts.append(f"------{boundary}--".encode()) + + body = b"\r\n".join(body_parts) + + environ = { + "REQUEST_METHOD": "POST", + "PATH_INFO": "/api/images", + "QUERY_STRING": "", + "SERVER_NAME": "localhost", + "SERVER_PORT": "8000", + "wsgi.input": BytesIO(body), + "wsgi.url_scheme": "http", + "CONTENT_LENGTH": str(len(body)), + "CONTENT_TYPE": f"multipart/form-data; boundary={boundary}", + "HTTP_CONTENT_TYPE": f"multipart/form-data; boundary={boundary}", + } + request = Request(environ) + + raw_data = serialize_request(request) + + assert b"POST /api/images HTTP/1.1\r\n" in raw_data + assert f"Content-Type: multipart/form-data; boundary={boundary}".encode() in raw_data + assert b'filename="test.png"' in raw_data + assert b"Content-Type: image/png" in raw_data + assert binary_content in raw_data + + def test_deserialize_request_with_binary_file_upload(self): + # Test deserializing multipart/form-data request with binary file + boundary = "----BoundaryABC123" + # Simulate a small JPEG file header + binary_content = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + + body_parts = [] + body_parts.append(f"------{boundary}".encode()) + body_parts.append(b'Content-Disposition: form-data; name="photo"; filename="photo.jpg"') + body_parts.append(b"Content-Type: image/jpeg") + body_parts.append(b"") + body_parts.append(binary_content) + body_parts.append(f"------{boundary}".encode()) + body_parts.append(b'Content-Disposition: form-data; name="album"') + body_parts.append(b"") + body_parts.append(b"Vacation 2024") + body_parts.append(f"------{boundary}--".encode()) + + body = b"\r\n".join(body_parts) + + raw_data = ( + b"POST /api/photos HTTP/1.1\r\n" + b"Host: api.example.com\r\n" + b"Content-Type: multipart/form-data; boundary=" + boundary.encode() + b"\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"Accept: application/json\r\n" + b"\r\n" + body + ) + + request = deserialize_request(raw_data) + + assert request.method == "POST" + assert request.path == "/api/photos" + assert "multipart/form-data" in request.content_type + assert request.headers.get("Accept") == "application/json" + + # Verify the binary content is preserved + request_body = request.get_data() + assert b"photo.jpg" in request_body + assert b"image/jpeg" in request_body + assert binary_content in request_body + assert b"Vacation 2024" in request_body + + def test_serialize_request_with_multiple_files(self): + # Test request with multiple file uploads + from io import BytesIO + + boundary = "----MultiFilesBoundary" + text_file = b"Text file contents" + binary_file = b"\x00\x01\x02\x03\x04\x05" + + body_parts = [] + # First file (text) + body_parts.append(f"------{boundary}".encode()) + body_parts.append(b'Content-Disposition: form-data; name="files"; filename="doc.txt"') + body_parts.append(b"Content-Type: text/plain") + body_parts.append(b"") + body_parts.append(text_file) + # Second file (binary) + body_parts.append(f"------{boundary}".encode()) + body_parts.append(b'Content-Disposition: form-data; name="files"; filename="data.bin"') + body_parts.append(b"Content-Type: application/octet-stream") + body_parts.append(b"") + body_parts.append(binary_file) + # Additional form field + body_parts.append(f"------{boundary}".encode()) + body_parts.append(b'Content-Disposition: form-data; name="folder"') + body_parts.append(b"") + body_parts.append(b"uploads/2024") + body_parts.append(f"------{boundary}--".encode()) + + body = b"\r\n".join(body_parts) + + environ = { + "REQUEST_METHOD": "POST", + "PATH_INFO": "/api/batch-upload", + "QUERY_STRING": "", + "SERVER_NAME": "localhost", + "SERVER_PORT": "8000", + "wsgi.input": BytesIO(body), + "wsgi.url_scheme": "https", + "CONTENT_LENGTH": str(len(body)), + "CONTENT_TYPE": f"multipart/form-data; boundary={boundary}", + "HTTP_CONTENT_TYPE": f"multipart/form-data; boundary={boundary}", + "HTTP_X_FORWARDED_PROTO": "https", + } + request = Request(environ) + + raw_data = serialize_request(request) + + assert b"POST /api/batch-upload HTTP/1.1\r\n" in raw_data + assert b"doc.txt" in raw_data + assert b"data.bin" in raw_data + assert text_file in raw_data + assert binary_file in raw_data + assert b"uploads/2024" in raw_data + + def test_roundtrip_file_upload_request(self): + # Test that file upload request survives serialize -> deserialize + from io import BytesIO + + boundary = "----RoundTripBoundary" + file_content = b"This is my file content with special chars: \xf0\x9f\x98\x80" + + body_parts = [] + body_parts.append(f"------{boundary}".encode()) + body_parts.append(b'Content-Disposition: form-data; name="upload"; filename="emoji.txt"') + body_parts.append(b"Content-Type: text/plain; charset=utf-8") + body_parts.append(b"") + body_parts.append(file_content) + body_parts.append(f"------{boundary}".encode()) + body_parts.append(b'Content-Disposition: form-data; name="metadata"') + body_parts.append(b"") + body_parts.append(b'{"encoding": "utf-8", "size": 42}') + body_parts.append(f"------{boundary}--".encode()) + + body = b"\r\n".join(body_parts) + + environ = { + "REQUEST_METHOD": "PUT", + "PATH_INFO": "/api/files/123", + "QUERY_STRING": "version=2", + "SERVER_NAME": "storage.example.com", + "SERVER_PORT": "443", + "wsgi.input": BytesIO(body), + "wsgi.url_scheme": "https", + "CONTENT_LENGTH": str(len(body)), + "CONTENT_TYPE": f"multipart/form-data; boundary={boundary}", + "HTTP_CONTENT_TYPE": f"multipart/form-data; boundary={boundary}", + "HTTP_AUTHORIZATION": "Bearer token123", + "HTTP_X_FORWARDED_PROTO": "https", + } + original_request = Request(environ) + + # Serialize and deserialize + raw_data = serialize_request(original_request) + restored_request = deserialize_request(raw_data) + + # Verify the request is preserved + assert restored_request.method == "PUT" + assert restored_request.path == "/api/files/123" + assert restored_request.query_string == b"version=2" + assert "multipart/form-data" in restored_request.content_type + assert boundary in restored_request.content_type + + # Verify file content is preserved + restored_body = restored_request.get_data() + assert b"emoji.txt" in restored_body + assert file_content in restored_body + assert b'{"encoding": "utf-8", "size": 42}' in restored_body diff --git a/api/tests/unit_tests/core/test_trigger_debug_event_selectors.py b/api/tests/unit_tests/core/test_trigger_debug_event_selectors.py new file mode 100644 index 0000000000..2b508ca654 --- /dev/null +++ b/api/tests/unit_tests/core/test_trigger_debug_event_selectors.py @@ -0,0 +1,102 @@ +import hashlib +import json +from datetime import UTC, datetime + +import pytest +import pytz + +from core.trigger.debug import event_selectors +from core.workflow.nodes.trigger_schedule.entities import ScheduleConfig + + +class _DummyRedis: + def __init__(self): + self.store: dict[str, str] = {} + + def get(self, key: str): + return self.store.get(key) + + def setex(self, name: str, time: int, value: str): + self.store[name] = value + + def expire(self, name: str, ttl: int): + # Expiration not required for these tests. + pass + + def delete(self, name: str): + self.store.pop(name, None) + + +@pytest.fixture +def dummy_schedule_config() -> ScheduleConfig: + return ScheduleConfig( + node_id="node-1", + cron_expression="* * * * *", + timezone="Asia/Shanghai", + ) + + +@pytest.fixture(autouse=True) +def patch_schedule_service(monkeypatch: pytest.MonkeyPatch, dummy_schedule_config: ScheduleConfig): + # Ensure poller always receives the deterministic config. + monkeypatch.setattr( + "services.trigger.schedule_service.ScheduleService.to_schedule_config", + staticmethod(lambda *_args, **_kwargs: dummy_schedule_config), + ) + + +def _make_poller( + monkeypatch: pytest.MonkeyPatch, redis_client: _DummyRedis +) -> event_selectors.ScheduleTriggerDebugEventPoller: + monkeypatch.setattr(event_selectors, "redis_client", redis_client) + return event_selectors.ScheduleTriggerDebugEventPoller( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + node_config={"id": "node-1", "data": {"mode": "cron"}}, + node_id="node-1", + ) + + +def test_schedule_poller_handles_aware_next_run(monkeypatch: pytest.MonkeyPatch): + redis_client = _DummyRedis() + poller = _make_poller(monkeypatch, redis_client) + + base_now = datetime(2025, 1, 1, 12, 0, 10) + aware_next_run = datetime(2025, 1, 1, 12, 0, 5, tzinfo=UTC) + + monkeypatch.setattr(event_selectors, "naive_utc_now", lambda: base_now) + monkeypatch.setattr(event_selectors, "calculate_next_run_at", lambda *_: aware_next_run) + + event = poller.poll() + + assert event is not None + assert event.node_id == "node-1" + assert event.workflow_args["inputs"] == {} + + +def test_schedule_runtime_cache_normalizes_timezone( + monkeypatch: pytest.MonkeyPatch, dummy_schedule_config: ScheduleConfig +): + redis_client = _DummyRedis() + poller = _make_poller(monkeypatch, redis_client) + + localized_time = pytz.timezone("Asia/Shanghai").localize(datetime(2025, 1, 1, 20, 0, 0)) + + cron_hash = hashlib.sha256(dummy_schedule_config.cron_expression.encode()).hexdigest() + cache_key = poller.schedule_debug_runtime_key(cron_hash) + + redis_client.store[cache_key] = json.dumps( + { + "cache_key": cache_key, + "timezone": dummy_schedule_config.timezone, + "cron_expression": dummy_schedule_config.cron_expression, + "next_run_at": localized_time.isoformat(), + } + ) + + runtime = poller.get_or_create_schedule_debug_runtime() + + expected = localized_time.astimezone(UTC).replace(tzinfo=None) + assert runtime.next_run_at == expected + assert runtime.next_run_at.tzinfo is None diff --git a/api/tests/unit_tests/core/tools/utils/test_encryption.py b/api/tests/unit_tests/core/tools/utils/test_encryption.py index 6425ab0b8d..94be0bb573 100644 --- a/api/tests/unit_tests/core/tools/utils/test_encryption.py +++ b/api/tests/unit_tests/core/tools/utils/test_encryption.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest from core.entities.provider_entities import BasicProviderConfig -from core.tools.utils.encryption import ProviderConfigEncrypter +from core.helper.provider_encryption import ProviderConfigEncrypter # --------------------------- @@ -70,7 +70,7 @@ def test_encrypt_only_secret_is_encrypted_and_non_secret_unchanged(encrypter_obj data_in = {"username": "alice", "password": "plain_pwd"} data_copy = copy.deepcopy(data_in) - with patch("core.tools.utils.encryption.encrypter.encrypt_token", return_value="CIPHERTEXT") as mock_encrypt: + with patch("core.helper.provider_encryption.encrypter.encrypt_token", return_value="CIPHERTEXT") as mock_encrypt: out = encrypter_obj.encrypt(data_in) assert out["username"] == "alice" @@ -81,14 +81,14 @@ def test_encrypt_only_secret_is_encrypted_and_non_secret_unchanged(encrypter_obj def test_encrypt_missing_secret_key_is_ok(encrypter_obj): """If secret field missing in input, no error and no encryption called.""" - with patch("core.tools.utils.encryption.encrypter.encrypt_token") as mock_encrypt: + with patch("core.helper.provider_encryption.encrypter.encrypt_token") as mock_encrypt: out = encrypter_obj.encrypt({"username": "alice"}) assert out["username"] == "alice" mock_encrypt.assert_not_called() # ============================================================ -# ProviderConfigEncrypter.mask_tool_credentials() +# ProviderConfigEncrypter.mask_plugin_credentials() # ============================================================ @@ -107,7 +107,7 @@ def test_mask_tool_credentials_long_secret(encrypter_obj, raw, prefix, suffix): data_in = {"username": "alice", "password": raw} data_copy = copy.deepcopy(data_in) - out = encrypter_obj.mask_tool_credentials(data_in) + out = encrypter_obj.mask_plugin_credentials(data_in) masked = out["password"] assert masked.startswith(prefix) @@ -122,7 +122,7 @@ def test_mask_tool_credentials_short_secret(encrypter_obj, raw): """ For length <= 6: fully mask with '*' of same length. """ - out = encrypter_obj.mask_tool_credentials({"password": raw}) + out = encrypter_obj.mask_plugin_credentials({"password": raw}) assert out["password"] == ("*" * len(raw)) @@ -131,7 +131,7 @@ def test_mask_tool_credentials_missing_key_noop(encrypter_obj): data_in = {"username": "alice"} data_copy = copy.deepcopy(data_in) - out = encrypter_obj.mask_tool_credentials(data_in) + out = encrypter_obj.mask_plugin_credentials(data_in) assert out["username"] == "alice" assert data_in == data_copy @@ -151,7 +151,7 @@ def test_decrypt_normal_flow(encrypter_obj): data_in = {"username": "alice", "password": "ENC"} data_copy = copy.deepcopy(data_in) - with patch("core.tools.utils.encryption.encrypter.decrypt_token", return_value="PLAIN") as mock_decrypt: + with patch("core.helper.provider_encryption.encrypter.decrypt_token", return_value="PLAIN") as mock_decrypt: out = encrypter_obj.decrypt(data_in) assert out["username"] == "alice" @@ -163,7 +163,7 @@ def test_decrypt_normal_flow(encrypter_obj): @pytest.mark.parametrize("empty_val", ["", None]) def test_decrypt_skip_empty_values(encrypter_obj, empty_val): """Skip decrypt if value is empty or None, keep original.""" - with patch("core.tools.utils.encryption.encrypter.decrypt_token") as mock_decrypt: + with patch("core.helper.provider_encryption.encrypter.decrypt_token") as mock_decrypt: out = encrypter_obj.decrypt({"password": empty_val}) mock_decrypt.assert_not_called() @@ -175,7 +175,7 @@ def test_decrypt_swallow_exception_and_keep_original(encrypter_obj): If decrypt_token raises, exception should be swallowed, and original value preserved. """ - with patch("core.tools.utils.encryption.encrypter.decrypt_token", side_effect=Exception("boom")): + with patch("core.helper.provider_encryption.encrypter.decrypt_token", side_effect=Exception("boom")): out = encrypter_obj.decrypt({"password": "ENC_ERR"}) assert out["password"] == "ENC_ERR" diff --git a/api/tests/unit_tests/core/workflow/graph/test_graph_validation.py b/api/tests/unit_tests/core/workflow/graph/test_graph_validation.py index b55d4998c4..c55c40c5b4 100644 --- a/api/tests/unit_tests/core/workflow/graph/test_graph_validation.py +++ b/api/tests/unit_tests/core/workflow/graph/test_graph_validation.py @@ -64,6 +64,15 @@ class _TestNode(Node): ) self.data = dict(data) + node_type_value = data.get("type") + if isinstance(node_type_value, NodeType): + self.node_type = node_type_value + elif isinstance(node_type_value, str): + try: + self.node_type = NodeType(node_type_value) + except ValueError: + pass + def _run(self): raise NotImplementedError @@ -179,3 +188,22 @@ def test_graph_promotes_fail_branch_nodes_to_branch_execution_type( graph = Graph.init(graph_config=graph_config, node_factory=node_factory) assert graph.nodes["branch"].execution_type == NodeExecutionType.BRANCH + + +def test_graph_validation_blocks_start_and_trigger_coexistence( + graph_init_dependencies: tuple[_SimpleNodeFactory, dict[str, object]], +) -> None: + node_factory, graph_config = graph_init_dependencies + graph_config["nodes"] = [ + {"id": "start", "data": {"type": NodeType.START, "title": "Start", "execution_type": NodeExecutionType.ROOT}}, + { + "id": "trigger", + "data": {"type": NodeType.TRIGGER_WEBHOOK, "title": "Webhook", "execution_type": NodeExecutionType.ROOT}, + }, + ] + graph_config["edges"] = [] + + with pytest.raises(GraphValidationError) as exc_info: + Graph.init(graph_config=graph_config, node_factory=node_factory) + + assert any(issue.code == "TRIGGER_START_NODE_CONFLICT" for issue in exc_info.value.issues) diff --git a/api/core/workflow/nodes/enums.py b/api/tests/unit_tests/core/workflow/nodes/webhook/__init__.py similarity index 100% rename from api/core/workflow/nodes/enums.py rename to api/tests/unit_tests/core/workflow/nodes/webhook/__init__.py diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_entities.py new file mode 100644 index 0000000000..4fa9a01b61 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_entities.py @@ -0,0 +1,308 @@ +import pytest +from pydantic import ValidationError + +from core.workflow.nodes.trigger_webhook.entities import ( + ContentType, + Method, + WebhookBodyParameter, + WebhookData, + WebhookParameter, +) + + +def test_method_enum(): + """Test Method enum values.""" + assert Method.GET == "get" + assert Method.POST == "post" + assert Method.HEAD == "head" + assert Method.PATCH == "patch" + assert Method.PUT == "put" + assert Method.DELETE == "delete" + + # Test all enum values are strings + for method in Method: + assert isinstance(method.value, str) + + +def test_content_type_enum(): + """Test ContentType enum values.""" + assert ContentType.JSON == "application/json" + assert ContentType.FORM_DATA == "multipart/form-data" + assert ContentType.FORM_URLENCODED == "application/x-www-form-urlencoded" + assert ContentType.TEXT == "text/plain" + assert ContentType.BINARY == "application/octet-stream" + + # Test all enum values are strings + for content_type in ContentType: + assert isinstance(content_type.value, str) + + +def test_webhook_parameter_creation(): + """Test WebhookParameter model creation and validation.""" + # Test with all fields + param = WebhookParameter(name="api_key", required=True) + assert param.name == "api_key" + assert param.required is True + + # Test with defaults + param_default = WebhookParameter(name="optional_param") + assert param_default.name == "optional_param" + assert param_default.required is False + + # Test validation - name is required + with pytest.raises(ValidationError): + WebhookParameter() + + +def test_webhook_body_parameter_creation(): + """Test WebhookBodyParameter model creation and validation.""" + # Test with all fields + body_param = WebhookBodyParameter( + name="user_data", + type="object", + required=True, + ) + assert body_param.name == "user_data" + assert body_param.type == "object" + assert body_param.required is True + + # Test with defaults + body_param_default = WebhookBodyParameter(name="message") + assert body_param_default.name == "message" + assert body_param_default.type == "string" # Default type + assert body_param_default.required is False + + # Test validation - name is required + with pytest.raises(ValidationError): + WebhookBodyParameter() + + +def test_webhook_body_parameter_types(): + """Test WebhookBodyParameter type validation.""" + valid_types = [ + "string", + "number", + "boolean", + "object", + "array[string]", + "array[number]", + "array[boolean]", + "array[object]", + "file", + ] + + for param_type in valid_types: + param = WebhookBodyParameter(name="test", type=param_type) + assert param.type == param_type + + # Test invalid type + with pytest.raises(ValidationError): + WebhookBodyParameter(name="test", type="invalid_type") + + +def test_webhook_data_creation_minimal(): + """Test WebhookData creation with minimal required fields.""" + data = WebhookData(title="Test Webhook") + + assert data.title == "Test Webhook" + assert data.method == Method.GET # Default + assert data.content_type == ContentType.JSON # Default + assert data.headers == [] # Default + assert data.params == [] # Default + assert data.body == [] # Default + assert data.status_code == 200 # Default + assert data.response_body == "" # Default + assert data.webhook_id is None # Default + assert data.timeout == 30 # Default + + +def test_webhook_data_creation_full(): + """Test WebhookData creation with all fields.""" + headers = [ + WebhookParameter(name="Authorization", required=True), + WebhookParameter(name="Content-Type", required=False), + ] + params = [ + WebhookParameter(name="version", required=True), + WebhookParameter(name="format", required=False), + ] + body = [ + WebhookBodyParameter(name="message", type="string", required=True), + WebhookBodyParameter(name="count", type="number", required=False), + WebhookBodyParameter(name="upload", type="file", required=True), + ] + + # Use the alias for content_type to test it properly + data = WebhookData( + title="Full Webhook Test", + desc="A comprehensive webhook test", + method=Method.POST, + content_type=ContentType.FORM_DATA, + headers=headers, + params=params, + body=body, + status_code=201, + response_body='{"success": true}', + webhook_id="webhook_123", + timeout=60, + ) + + assert data.title == "Full Webhook Test" + assert data.desc == "A comprehensive webhook test" + assert data.method == Method.POST + assert data.content_type == ContentType.FORM_DATA + assert len(data.headers) == 2 + assert len(data.params) == 2 + assert len(data.body) == 3 + assert data.status_code == 201 + assert data.response_body == '{"success": true}' + assert data.webhook_id == "webhook_123" + assert data.timeout == 60 + + +def test_webhook_data_content_type_alias(): + """Test WebhookData content_type accepts both strings and enum values.""" + data1 = WebhookData(title="Test", content_type="application/json") + assert data1.content_type == ContentType.JSON + + data2 = WebhookData(title="Test", content_type=ContentType.FORM_DATA) + assert data2.content_type == ContentType.FORM_DATA + + +def test_webhook_data_model_dump(): + """Test WebhookData model serialization.""" + data = WebhookData( + title="Test Webhook", + method=Method.POST, + content_type=ContentType.JSON, + headers=[WebhookParameter(name="Authorization", required=True)], + params=[WebhookParameter(name="version", required=False)], + body=[WebhookBodyParameter(name="message", type="string", required=True)], + status_code=200, + response_body="OK", + timeout=30, + ) + + dumped = data.model_dump() + + assert dumped["title"] == "Test Webhook" + assert dumped["method"] == "post" + assert dumped["content_type"] == "application/json" + assert len(dumped["headers"]) == 1 + assert dumped["headers"][0]["name"] == "Authorization" + assert dumped["headers"][0]["required"] is True + assert len(dumped["params"]) == 1 + assert len(dumped["body"]) == 1 + assert dumped["body"][0]["type"] == "string" + + +def test_webhook_data_model_dump_with_alias(): + """Test WebhookData model serialization includes alias.""" + data = WebhookData( + title="Test Webhook", + content_type=ContentType.FORM_DATA, + ) + + dumped = data.model_dump(by_alias=True) + assert "content_type" in dumped + assert dumped["content_type"] == "multipart/form-data" + + +def test_webhook_data_validation_errors(): + """Test WebhookData validation errors.""" + # Title is required (inherited from BaseNodeData) + with pytest.raises(ValidationError): + WebhookData() + + # Invalid method + with pytest.raises(ValidationError): + WebhookData(title="Test", method="invalid_method") + + # Invalid content_type + with pytest.raises(ValidationError): + WebhookData(title="Test", content_type="invalid/type") + + # Invalid status_code (should be int) - use non-numeric string + with pytest.raises(ValidationError): + WebhookData(title="Test", status_code="invalid") + + # Invalid timeout (should be int) - use non-numeric string + with pytest.raises(ValidationError): + WebhookData(title="Test", timeout="invalid") + + # Valid cases that should NOT raise errors + # These should work fine (pydantic converts string numbers to int) + valid_data = WebhookData(title="Test", status_code="200", timeout="30") + assert valid_data.status_code == 200 + assert valid_data.timeout == 30 + + +def test_webhook_data_sequence_fields(): + """Test WebhookData sequence field behavior.""" + # Test empty sequences + data = WebhookData(title="Test") + assert data.headers == [] + assert data.params == [] + assert data.body == [] + + # Test immutable sequences + headers = [WebhookParameter(name="test")] + data = WebhookData(title="Test", headers=headers) + + # Original list shouldn't affect the model + headers.append(WebhookParameter(name="test2")) + assert len(data.headers) == 1 # Should still be 1 + + +def test_webhook_data_sync_mode(): + """Test WebhookData SyncMode nested enum.""" + # Test that SyncMode enum exists and has expected value + assert hasattr(WebhookData, "SyncMode") + assert WebhookData.SyncMode.SYNC == "async" # Note: confusingly named but correct + + +def test_webhook_parameter_edge_cases(): + """Test WebhookParameter edge cases.""" + # Test with special characters in name + param = WebhookParameter(name="X-Custom-Header-123", required=True) + assert param.name == "X-Custom-Header-123" + + # Test with empty string name (should be valid if pydantic allows it) + param_empty = WebhookParameter(name="", required=False) + assert param_empty.name == "" + + +def test_webhook_body_parameter_edge_cases(): + """Test WebhookBodyParameter edge cases.""" + # Test file type parameter + file_param = WebhookBodyParameter(name="upload", type="file", required=True) + assert file_param.type == "file" + assert file_param.required is True + + # Test all valid types + for param_type in [ + "string", + "number", + "boolean", + "object", + "array[string]", + "array[number]", + "array[boolean]", + "array[object]", + "file", + ]: + param = WebhookBodyParameter(name=f"test_{param_type}", type=param_type) + assert param.type == param_type + + +def test_webhook_data_inheritance(): + """Test WebhookData inherits from BaseNodeData correctly.""" + from core.workflow.nodes.base import BaseNodeData + + # Test that WebhookData is a subclass of BaseNodeData + assert issubclass(WebhookData, BaseNodeData) + + # Test that instances have BaseNodeData properties + data = WebhookData(title="Test") + assert hasattr(data, "title") + assert hasattr(data, "desc") # Inherited from BaseNodeData diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_exceptions.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_exceptions.py new file mode 100644 index 0000000000..374d5183c8 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_exceptions.py @@ -0,0 +1,195 @@ +import pytest + +from core.workflow.nodes.base.exc import BaseNodeError +from core.workflow.nodes.trigger_webhook.exc import ( + WebhookConfigError, + WebhookNodeError, + WebhookNotFoundError, + WebhookTimeoutError, +) + + +def test_webhook_node_error_inheritance(): + """Test WebhookNodeError inherits from BaseNodeError.""" + assert issubclass(WebhookNodeError, BaseNodeError) + + # Test instantiation + error = WebhookNodeError("Test error message") + assert str(error) == "Test error message" + assert isinstance(error, BaseNodeError) + + +def test_webhook_timeout_error(): + """Test WebhookTimeoutError functionality.""" + # Test inheritance + assert issubclass(WebhookTimeoutError, WebhookNodeError) + assert issubclass(WebhookTimeoutError, BaseNodeError) + + # Test instantiation with message + error = WebhookTimeoutError("Webhook request timed out") + assert str(error) == "Webhook request timed out" + + # Test instantiation without message + error_no_msg = WebhookTimeoutError() + assert isinstance(error_no_msg, WebhookTimeoutError) + + +def test_webhook_not_found_error(): + """Test WebhookNotFoundError functionality.""" + # Test inheritance + assert issubclass(WebhookNotFoundError, WebhookNodeError) + assert issubclass(WebhookNotFoundError, BaseNodeError) + + # Test instantiation with message + error = WebhookNotFoundError("Webhook trigger not found") + assert str(error) == "Webhook trigger not found" + + # Test instantiation without message + error_no_msg = WebhookNotFoundError() + assert isinstance(error_no_msg, WebhookNotFoundError) + + +def test_webhook_config_error(): + """Test WebhookConfigError functionality.""" + # Test inheritance + assert issubclass(WebhookConfigError, WebhookNodeError) + assert issubclass(WebhookConfigError, BaseNodeError) + + # Test instantiation with message + error = WebhookConfigError("Invalid webhook configuration") + assert str(error) == "Invalid webhook configuration" + + # Test instantiation without message + error_no_msg = WebhookConfigError() + assert isinstance(error_no_msg, WebhookConfigError) + + +def test_webhook_error_hierarchy(): + """Test the complete webhook error hierarchy.""" + # All webhook errors should inherit from WebhookNodeError + webhook_errors = [ + WebhookTimeoutError, + WebhookNotFoundError, + WebhookConfigError, + ] + + for error_class in webhook_errors: + assert issubclass(error_class, WebhookNodeError) + assert issubclass(error_class, BaseNodeError) + + +def test_webhook_error_instantiation_with_args(): + """Test webhook error instantiation with various arguments.""" + # Test with single string argument + error1 = WebhookNodeError("Simple error message") + assert str(error1) == "Simple error message" + + # Test with multiple arguments + error2 = WebhookTimeoutError("Timeout after", 30, "seconds") + # Note: The exact string representation depends on Exception.__str__ implementation + assert "Timeout after" in str(error2) + + # Test with keyword arguments (if supported by base Exception) + error3 = WebhookConfigError("Config error in field: timeout") + assert "Config error in field: timeout" in str(error3) + + +def test_webhook_error_as_exceptions(): + """Test that webhook errors can be raised and caught properly.""" + # Test raising and catching WebhookNodeError + with pytest.raises(WebhookNodeError) as exc_info: + raise WebhookNodeError("Base webhook error") + assert str(exc_info.value) == "Base webhook error" + + # Test raising and catching specific errors + with pytest.raises(WebhookTimeoutError) as exc_info: + raise WebhookTimeoutError("Request timeout") + assert str(exc_info.value) == "Request timeout" + + with pytest.raises(WebhookNotFoundError) as exc_info: + raise WebhookNotFoundError("Webhook not found") + assert str(exc_info.value) == "Webhook not found" + + with pytest.raises(WebhookConfigError) as exc_info: + raise WebhookConfigError("Invalid config") + assert str(exc_info.value) == "Invalid config" + + +def test_webhook_error_catching_hierarchy(): + """Test that webhook errors can be caught by their parent classes.""" + # WebhookTimeoutError should be catchable as WebhookNodeError + with pytest.raises(WebhookNodeError): + raise WebhookTimeoutError("Timeout error") + + # WebhookNotFoundError should be catchable as WebhookNodeError + with pytest.raises(WebhookNodeError): + raise WebhookNotFoundError("Not found error") + + # WebhookConfigError should be catchable as WebhookNodeError + with pytest.raises(WebhookNodeError): + raise WebhookConfigError("Config error") + + # All webhook errors should be catchable as BaseNodeError + with pytest.raises(BaseNodeError): + raise WebhookTimeoutError("Timeout as base error") + + with pytest.raises(BaseNodeError): + raise WebhookNotFoundError("Not found as base error") + + with pytest.raises(BaseNodeError): + raise WebhookConfigError("Config as base error") + + +def test_webhook_error_attributes(): + """Test webhook error class attributes.""" + # Test that all error classes have proper __name__ + assert WebhookNodeError.__name__ == "WebhookNodeError" + assert WebhookTimeoutError.__name__ == "WebhookTimeoutError" + assert WebhookNotFoundError.__name__ == "WebhookNotFoundError" + assert WebhookConfigError.__name__ == "WebhookConfigError" + + # Test that all error classes have proper __module__ + expected_module = "core.workflow.nodes.trigger_webhook.exc" + assert WebhookNodeError.__module__ == expected_module + assert WebhookTimeoutError.__module__ == expected_module + assert WebhookNotFoundError.__module__ == expected_module + assert WebhookConfigError.__module__ == expected_module + + +def test_webhook_error_docstrings(): + """Test webhook error class docstrings.""" + assert WebhookNodeError.__doc__ == "Base webhook node error." + assert WebhookTimeoutError.__doc__ == "Webhook timeout error." + assert WebhookNotFoundError.__doc__ == "Webhook not found error." + assert WebhookConfigError.__doc__ == "Webhook configuration error." + + +def test_webhook_error_repr_and_str(): + """Test webhook error string representations.""" + error = WebhookNodeError("Test message") + + # Test __str__ method + assert str(error) == "Test message" + + # Test __repr__ method (should include class name) + repr_str = repr(error) + assert "WebhookNodeError" in repr_str + assert "Test message" in repr_str + + +def test_webhook_error_with_no_message(): + """Test webhook errors with no message.""" + # Test that errors can be instantiated without messages + errors = [ + WebhookNodeError(), + WebhookTimeoutError(), + WebhookNotFoundError(), + WebhookConfigError(), + ] + + for error in errors: + # Should be instances of their respective classes + assert isinstance(error, type(error)) + # Should be able to be raised + with pytest.raises(type(error)): + raise error diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py new file mode 100644 index 0000000000..d7094ae5f2 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py @@ -0,0 +1,468 @@ +import pytest + +from core.app.entities.app_invoke_entities import InvokeFrom +from core.file import File, FileTransferMethod, FileType +from core.variables import StringVariable +from core.workflow.entities.graph_init_params import GraphInitParams +from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus +from core.workflow.nodes.trigger_webhook.entities import ( + ContentType, + Method, + WebhookBodyParameter, + WebhookData, + WebhookParameter, +) +from core.workflow.nodes.trigger_webhook.node import TriggerWebhookNode +from core.workflow.runtime.graph_runtime_state import GraphRuntimeState +from core.workflow.runtime.variable_pool import VariablePool +from core.workflow.system_variable import SystemVariable +from models.enums import UserFrom +from models.workflow import WorkflowType + + +def create_webhook_node(webhook_data: WebhookData, variable_pool: VariablePool) -> TriggerWebhookNode: + """Helper function to create a webhook node with proper initialization.""" + node_config = { + "id": "1", + "data": webhook_data.model_dump(), + } + + node = TriggerWebhookNode( + id="1", + config=node_config, + graph_init_params=GraphInitParams( + tenant_id="1", + app_id="1", + workflow_type=WorkflowType.WORKFLOW, + workflow_id="1", + graph_config={}, + user_id="1", + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.SERVICE_API, + call_depth=0, + ), + graph_runtime_state=GraphRuntimeState( + variable_pool=variable_pool, + start_at=0, + ), + ) + + node.init_node_data(node_config["data"]) + return node + + +def test_webhook_node_basic_initialization(): + """Test basic webhook node initialization and configuration.""" + data = WebhookData( + title="Test Webhook", + method=Method.POST, + content_type=ContentType.JSON, + headers=[WebhookParameter(name="X-API-Key", required=True)], + params=[WebhookParameter(name="version", required=False)], + body=[WebhookBodyParameter(name="message", type="string", required=True)], + status_code=200, + response_body="OK", + timeout=30, + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={}, + ) + + node = create_webhook_node(data, variable_pool) + + assert node.node_type.value == "trigger-webhook" + assert node.version() == "1" + assert node._get_title() == "Test Webhook" + assert node._node_data.method == Method.POST + assert node._node_data.content_type == ContentType.JSON + assert len(node._node_data.headers) == 1 + assert len(node._node_data.params) == 1 + assert len(node._node_data.body) == 1 + + +def test_webhook_node_default_config(): + """Test webhook node default configuration.""" + config = TriggerWebhookNode.get_default_config() + + assert config["type"] == "webhook" + assert config["config"]["method"] == "get" + assert config["config"]["content_type"] == "application/json" + assert config["config"]["headers"] == [] + assert config["config"]["params"] == [] + assert config["config"]["body"] == [] + assert config["config"]["async_mode"] is True + assert config["config"]["status_code"] == 200 + assert config["config"]["response_body"] == "" + assert config["config"]["timeout"] == 30 + + +def test_webhook_node_run_with_headers(): + """Test webhook node execution with header extraction.""" + data = WebhookData( + title="Test Webhook", + headers=[ + WebhookParameter(name="Authorization", required=True), + WebhookParameter(name="Content-Type", required=False), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": { + "Authorization": "Bearer token123", + "content-type": "application/json", # Different case + "X-Custom": "custom-value", + }, + "query_params": {}, + "body": {}, + "files": {}, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["Authorization"] == "Bearer token123" + assert result.outputs["Content_Type"] == "application/json" # Case-insensitive match + assert "_webhook_raw" in result.outputs + + +def test_webhook_node_run_with_query_params(): + """Test webhook node execution with query parameter extraction.""" + data = WebhookData( + title="Test Webhook", + params=[ + WebhookParameter(name="page", required=True), + WebhookParameter(name="limit", required=False), + WebhookParameter(name="missing", required=False), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {}, + "query_params": { + "page": "1", + "limit": "10", + }, + "body": {}, + "files": {}, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["page"] == "1" + assert result.outputs["limit"] == "10" + assert result.outputs["missing"] is None # Missing parameter should be None + + +def test_webhook_node_run_with_body_params(): + """Test webhook node execution with body parameter extraction.""" + data = WebhookData( + title="Test Webhook", + body=[ + WebhookBodyParameter(name="message", type="string", required=True), + WebhookBodyParameter(name="count", type="number", required=False), + WebhookBodyParameter(name="active", type="boolean", required=False), + WebhookBodyParameter(name="metadata", type="object", required=False), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {}, + "query_params": {}, + "body": { + "message": "Hello World", + "count": 42, + "active": True, + "metadata": {"key": "value"}, + }, + "files": {}, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["message"] == "Hello World" + assert result.outputs["count"] == 42 + assert result.outputs["active"] is True + assert result.outputs["metadata"] == {"key": "value"} + + +def test_webhook_node_run_with_file_params(): + """Test webhook node execution with file parameter extraction.""" + # Create mock file objects + file1 = File( + tenant_id="1", + type=FileType.IMAGE, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="file1", + filename="image.jpg", + mime_type="image/jpeg", + storage_key="", + ) + + file2 = File( + tenant_id="1", + type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="file2", + filename="document.pdf", + mime_type="application/pdf", + storage_key="", + ) + + data = WebhookData( + title="Test Webhook", + body=[ + WebhookBodyParameter(name="upload", type="file", required=True), + WebhookBodyParameter(name="document", type="file", required=False), + WebhookBodyParameter(name="missing_file", type="file", required=False), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {}, + "query_params": {}, + "body": {}, + "files": { + "upload": file1, + "document": file2, + }, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["upload"] == file1 + assert result.outputs["document"] == file2 + assert result.outputs["missing_file"] is None + + +def test_webhook_node_run_mixed_parameters(): + """Test webhook node execution with mixed parameter types.""" + file_obj = File( + tenant_id="1", + type=FileType.IMAGE, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="file1", + filename="test.jpg", + mime_type="image/jpeg", + storage_key="", + ) + + data = WebhookData( + title="Test Webhook", + headers=[WebhookParameter(name="Authorization", required=True)], + params=[WebhookParameter(name="version", required=False)], + body=[ + WebhookBodyParameter(name="message", type="string", required=True), + WebhookBodyParameter(name="upload", type="file", required=False), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {"Authorization": "Bearer token"}, + "query_params": {"version": "v1"}, + "body": {"message": "Test message"}, + "files": {"upload": file_obj}, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["Authorization"] == "Bearer token" + assert result.outputs["version"] == "v1" + assert result.outputs["message"] == "Test message" + assert result.outputs["upload"] == file_obj + assert "_webhook_raw" in result.outputs + + +def test_webhook_node_run_empty_webhook_data(): + """Test webhook node execution with empty webhook data.""" + data = WebhookData( + title="Test Webhook", + headers=[WebhookParameter(name="Authorization", required=False)], + params=[WebhookParameter(name="page", required=False)], + body=[WebhookBodyParameter(name="message", type="string", required=False)], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={}, # No webhook_data + ) + + node = create_webhook_node(data, variable_pool) + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["Authorization"] is None + assert result.outputs["page"] is None + assert result.outputs["message"] is None + assert result.outputs["_webhook_raw"] == {} + + +def test_webhook_node_run_case_insensitive_headers(): + """Test webhook node header extraction is case-insensitive.""" + data = WebhookData( + title="Test Webhook", + headers=[ + WebhookParameter(name="Content-Type", required=True), + WebhookParameter(name="X-API-KEY", required=True), + WebhookParameter(name="authorization", required=True), + ], + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": { + "content-type": "application/json", # lowercase + "x-api-key": "key123", # lowercase + "Authorization": "Bearer token", # different case + }, + "query_params": {}, + "body": {}, + "files": {}, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["Content_Type"] == "application/json" + assert result.outputs["X_API_KEY"] == "key123" + assert result.outputs["authorization"] == "Bearer token" + + +def test_webhook_node_variable_pool_user_inputs(): + """Test that webhook node uses user_inputs from variable pool correctly.""" + data = WebhookData(title="Test Webhook") + + # Add some additional variables to the pool + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": {"headers": {}, "query_params": {}, "body": {}, "files": {}}, + "other_var": "should_be_included", + }, + ) + variable_pool.add(["node1", "extra"], StringVariable(name="extra", value="extra_value")) + + node = create_webhook_node(data, variable_pool) + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + # Check that all user_inputs are included in the inputs (they get converted to dict) + inputs_dict = dict(result.inputs) + assert "webhook_data" in inputs_dict + assert "other_var" in inputs_dict + assert inputs_dict["other_var"] == "should_be_included" + + +@pytest.mark.parametrize( + "method", + [Method.GET, Method.POST, Method.PUT, Method.DELETE, Method.PATCH, Method.HEAD], +) +def test_webhook_node_different_methods(method): + """Test webhook node with different HTTP methods.""" + data = WebhookData( + title="Test Webhook", + method=method, + ) + + variable_pool = VariablePool( + system_variables=SystemVariable.empty(), + user_inputs={ + "webhook_data": { + "headers": {}, + "query_params": {}, + "body": {}, + "files": {}, + } + }, + ) + + node = create_webhook_node(data, variable_pool) + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert node._node_data.method == method + + +def test_webhook_data_content_type_field(): + """Test that content_type accepts both raw strings and enum values.""" + data1 = WebhookData(title="Test", content_type="application/json") + assert data1.content_type == ContentType.JSON + + data2 = WebhookData(title="Test", content_type=ContentType.FORM_DATA) + assert data2.content_type == ContentType.FORM_DATA + + +def test_webhook_parameter_models(): + """Test webhook parameter model validation.""" + # Test WebhookParameter + param = WebhookParameter(name="test_param", required=True) + assert param.name == "test_param" + assert param.required is True + + param_default = WebhookParameter(name="test_param") + assert param_default.required is False + + # Test WebhookBodyParameter + body_param = WebhookBodyParameter(name="test_body", type="string", required=True) + assert body_param.name == "test_body" + assert body_param.type == "string" + assert body_param.required is True + + body_param_default = WebhookBodyParameter(name="test_body") + assert body_param_default.type == "string" # Default type + assert body_param_default.required is False + + +def test_webhook_data_field_defaults(): + """Test webhook data model field defaults.""" + data = WebhookData(title="Minimal Webhook") + + assert data.method == Method.GET + assert data.content_type == ContentType.JSON + assert data.headers == [] + assert data.params == [] + assert data.body == [] + assert data.status_code == 200 + assert data.response_body == "" + assert data.webhook_id is None + assert data.timeout == 30 diff --git a/api/tests/unit_tests/extensions/test_celery_ssl.py b/api/tests/unit_tests/extensions/test_celery_ssl.py index bc46fe8322..fc7a090ef9 100644 --- a/api/tests/unit_tests/extensions/test_celery_ssl.py +++ b/api/tests/unit_tests/extensions/test_celery_ssl.py @@ -131,6 +131,12 @@ class TestCelerySSLConfiguration: mock_config.ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK = False mock_config.ENABLE_DATASETS_QUEUE_MONITOR = False mock_config.ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK = False + mock_config.ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK = False + mock_config.WORKFLOW_SCHEDULE_POLLER_INTERVAL = 1 + mock_config.WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE = 100 + mock_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK = 0 + mock_config.ENABLE_TRIGGER_PROVIDER_REFRESH_TASK = False + mock_config.TRIGGER_PROVIDER_REFRESH_INTERVAL = 15 with patch("extensions.ext_celery.dify_config", mock_config): from dify_app import DifyApp diff --git a/api/tests/unit_tests/libs/test_cron_compatibility.py b/api/tests/unit_tests/libs/test_cron_compatibility.py new file mode 100644 index 0000000000..6f3a94f6dc --- /dev/null +++ b/api/tests/unit_tests/libs/test_cron_compatibility.py @@ -0,0 +1,381 @@ +""" +Enhanced cron syntax compatibility tests for croniter backend. + +This test suite mirrors the frontend cron-parser tests to ensure +complete compatibility between frontend and backend cron processing. +""" + +import unittest +from datetime import UTC, datetime, timedelta + +import pytest +import pytz +from croniter import CroniterBadCronError + +from libs.schedule_utils import calculate_next_run_at + + +class TestCronCompatibility(unittest.TestCase): + """Test enhanced cron syntax compatibility with frontend.""" + + def setUp(self): + """Set up test environment with fixed time.""" + self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC) + + def test_enhanced_dayofweek_syntax(self): + """Test enhanced day-of-week syntax compatibility.""" + test_cases = [ + ("0 9 * * 7", 0), # Sunday as 7 + ("0 9 * * 0", 0), # Sunday as 0 + ("0 9 * * MON", 1), # Monday abbreviation + ("0 9 * * TUE", 2), # Tuesday abbreviation + ("0 9 * * WED", 3), # Wednesday abbreviation + ("0 9 * * THU", 4), # Thursday abbreviation + ("0 9 * * FRI", 5), # Friday abbreviation + ("0 9 * * SAT", 6), # Saturday abbreviation + ("0 9 * * SUN", 0), # Sunday abbreviation + ] + + for expr, expected_weekday in test_cases: + with self.subTest(expr=expr): + next_time = calculate_next_run_at(expr, "UTC", self.base_time) + assert next_time is not None + assert (next_time.weekday() + 1 if next_time.weekday() < 6 else 0) == expected_weekday + assert next_time.hour == 9 + assert next_time.minute == 0 + + def test_enhanced_month_syntax(self): + """Test enhanced month syntax compatibility.""" + test_cases = [ + ("0 9 1 JAN *", 1), # January abbreviation + ("0 9 1 FEB *", 2), # February abbreviation + ("0 9 1 MAR *", 3), # March abbreviation + ("0 9 1 APR *", 4), # April abbreviation + ("0 9 1 MAY *", 5), # May abbreviation + ("0 9 1 JUN *", 6), # June abbreviation + ("0 9 1 JUL *", 7), # July abbreviation + ("0 9 1 AUG *", 8), # August abbreviation + ("0 9 1 SEP *", 9), # September abbreviation + ("0 9 1 OCT *", 10), # October abbreviation + ("0 9 1 NOV *", 11), # November abbreviation + ("0 9 1 DEC *", 12), # December abbreviation + ] + + for expr, expected_month in test_cases: + with self.subTest(expr=expr): + next_time = calculate_next_run_at(expr, "UTC", self.base_time) + assert next_time is not None + assert next_time.month == expected_month + assert next_time.day == 1 + assert next_time.hour == 9 + + def test_predefined_expressions(self): + """Test predefined cron expressions compatibility.""" + test_cases = [ + ("@yearly", lambda dt: dt.month == 1 and dt.day == 1 and dt.hour == 0), + ("@annually", lambda dt: dt.month == 1 and dt.day == 1 and dt.hour == 0), + ("@monthly", lambda dt: dt.day == 1 and dt.hour == 0), + ("@weekly", lambda dt: dt.weekday() == 6 and dt.hour == 0), # Sunday = 6 in weekday() + ("@daily", lambda dt: dt.hour == 0 and dt.minute == 0), + ("@midnight", lambda dt: dt.hour == 0 and dt.minute == 0), + ("@hourly", lambda dt: dt.minute == 0), + ] + + for expr, validator in test_cases: + with self.subTest(expr=expr): + next_time = calculate_next_run_at(expr, "UTC", self.base_time) + assert next_time is not None + assert validator(next_time), f"Validator failed for {expr}: {next_time}" + + def test_special_characters(self): + """Test special characters in cron expressions.""" + test_cases = [ + "0 9 ? * 1", # ? wildcard + "0 12 * * 7", # Sunday as 7 + "0 15 L * *", # Last day of month + ] + + for expr in test_cases: + with self.subTest(expr=expr): + try: + next_time = calculate_next_run_at(expr, "UTC", self.base_time) + assert next_time is not None + assert next_time > self.base_time + except Exception as e: + self.fail(f"Expression '{expr}' should be valid but raised: {e}") + + def test_range_and_list_syntax(self): + """Test range and list syntax with abbreviations.""" + test_cases = [ + "0 9 * * MON-FRI", # Weekday range with abbreviations + "0 9 * JAN-MAR *", # Month range with abbreviations + "0 9 * * SUN,WED,FRI", # Weekday list with abbreviations + "0 9 1 JAN,JUN,DEC *", # Month list with abbreviations + ] + + for expr in test_cases: + with self.subTest(expr=expr): + try: + next_time = calculate_next_run_at(expr, "UTC", self.base_time) + assert next_time is not None + assert next_time > self.base_time + except Exception as e: + self.fail(f"Expression '{expr}' should be valid but raised: {e}") + + def test_invalid_enhanced_syntax(self): + """Test that invalid enhanced syntax is properly rejected.""" + invalid_expressions = [ + "0 12 * JANUARY *", # Full month name (not supported) + "0 12 * * MONDAY", # Full day name (not supported) + "0 12 32 JAN *", # Invalid day with valid month + "15 10 1 * 8", # Invalid day of week + "15 10 1 INVALID *", # Invalid month abbreviation + "15 10 1 * INVALID", # Invalid day abbreviation + "@invalid", # Invalid predefined expression + ] + + for expr in invalid_expressions: + with self.subTest(expr=expr): + with pytest.raises((CroniterBadCronError, ValueError)): + calculate_next_run_at(expr, "UTC", self.base_time) + + def test_edge_cases_with_enhanced_syntax(self): + """Test edge cases with enhanced syntax.""" + test_cases = [ + ("0 0 29 FEB *", lambda dt: dt.month == 2 and dt.day == 29), # Feb 29 with month abbreviation + ] + + for expr, validator in test_cases: + with self.subTest(expr=expr): + try: + next_time = calculate_next_run_at(expr, "UTC", self.base_time) + if next_time: # Some combinations might not occur soon + assert validator(next_time), f"Validator failed for {expr}: {next_time}" + except (CroniterBadCronError, ValueError): + # Some edge cases might be valid but not have upcoming occurrences + pass + + # Test complex expressions that have specific constraints + complex_expr = "59 23 31 DEC SAT" # December 31st at 23:59 on Saturday + try: + next_time = calculate_next_run_at(complex_expr, "UTC", self.base_time) + if next_time: + # The next occurrence might not be exactly Dec 31 if it's not a Saturday + # Just verify it's a valid result + assert next_time is not None + assert next_time.hour == 23 + assert next_time.minute == 59 + except Exception: + # Complex date constraints might not have near-future occurrences + pass + + +class TestTimezoneCompatibility(unittest.TestCase): + """Test timezone compatibility between frontend and backend.""" + + def setUp(self): + """Set up test environment.""" + self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC) + + def test_timezone_consistency(self): + """Test that calculations are consistent across different timezones.""" + timezones = [ + "UTC", + "America/New_York", + "Europe/London", + "Asia/Tokyo", + "Asia/Kolkata", + "Australia/Sydney", + ] + + expression = "0 12 * * *" # Daily at noon + + for timezone in timezones: + with self.subTest(timezone=timezone): + next_time = calculate_next_run_at(expression, timezone, self.base_time) + assert next_time is not None + + # Convert back to the target timezone to verify it's noon + tz = pytz.timezone(timezone) + local_time = next_time.astimezone(tz) + assert local_time.hour == 12 + assert local_time.minute == 0 + + def test_dst_handling(self): + """Test DST boundary handling.""" + # Test around DST spring forward (March 2024) + dst_base = datetime(2024, 3, 8, 10, 0, 0, tzinfo=UTC) + expression = "0 2 * * *" # 2 AM daily (problematic during DST) + timezone = "America/New_York" + + try: + next_time = calculate_next_run_at(expression, timezone, dst_base) + assert next_time is not None + + # During DST spring forward, 2 AM becomes 3 AM - both are acceptable + tz = pytz.timezone(timezone) + local_time = next_time.astimezone(tz) + assert local_time.hour in [2, 3] # Either 2 AM or 3 AM is acceptable + except Exception as e: + self.fail(f"DST handling failed: {e}") + + def test_half_hour_timezones(self): + """Test timezones with half-hour offsets.""" + timezones_with_offsets = [ + ("Asia/Kolkata", 17, 30), # UTC+5:30 -> 12:00 UTC = 17:30 IST + ("Australia/Adelaide", 22, 30), # UTC+10:30 -> 12:00 UTC = 22:30 ACDT (summer time) + ] + + expression = "0 12 * * *" # Noon UTC + + for timezone, expected_hour, expected_minute in timezones_with_offsets: + with self.subTest(timezone=timezone): + try: + next_time = calculate_next_run_at(expression, timezone, self.base_time) + assert next_time is not None + + tz = pytz.timezone(timezone) + local_time = next_time.astimezone(tz) + assert local_time.hour == expected_hour + assert local_time.minute == expected_minute + except Exception: + # Some complex timezone calculations might vary + pass + + def test_invalid_timezone_handling(self): + """Test handling of invalid timezones.""" + expression = "0 12 * * *" + invalid_timezone = "Invalid/Timezone" + + with pytest.raises((ValueError, Exception)): # Should raise an exception + calculate_next_run_at(expression, invalid_timezone, self.base_time) + + +class TestFrontendBackendIntegration(unittest.TestCase): + """Test integration patterns that mirror frontend usage.""" + + def setUp(self): + """Set up test environment.""" + self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC) + + def test_execution_time_calculator_pattern(self): + """Test the pattern used by execution-time-calculator.ts.""" + # This mirrors the exact usage from execution-time-calculator.ts:47 + test_data = { + "cron_expression": "30 14 * * 1-5", # 2:30 PM weekdays + "timezone": "America/New_York", + } + + # Get next 5 execution times (like the frontend does) + execution_times = [] + current_base = self.base_time + + for _ in range(5): + next_time = calculate_next_run_at(test_data["cron_expression"], test_data["timezone"], current_base) + assert next_time is not None + execution_times.append(next_time) + current_base = next_time + timedelta(seconds=1) # Move slightly forward + + assert len(execution_times) == 5 + + # Validate each execution time + for exec_time in execution_times: + # Convert to local timezone + tz = pytz.timezone(test_data["timezone"]) + local_time = exec_time.astimezone(tz) + + # Should be weekdays (1-5) + assert local_time.weekday() in [0, 1, 2, 3, 4] # Mon-Fri in Python weekday + + # Should be 2:30 PM in local time + assert local_time.hour == 14 + assert local_time.minute == 30 + assert local_time.second == 0 + + def test_schedule_service_integration(self): + """Test integration with ScheduleService patterns.""" + from core.workflow.nodes.trigger_schedule.entities import VisualConfig + from services.trigger.schedule_service import ScheduleService + + # Test enhanced syntax through visual config conversion + visual_configs = [ + # Test with month abbreviations + { + "frequency": "monthly", + "config": VisualConfig(time="9:00 AM", monthly_days=[1]), + "expected_cron": "0 9 1 * *", + }, + # Test with weekday abbreviations + { + "frequency": "weekly", + "config": VisualConfig(time="2:30 PM", weekdays=["mon", "wed", "fri"]), + "expected_cron": "30 14 * * 1,3,5", + }, + ] + + for test_case in visual_configs: + with self.subTest(frequency=test_case["frequency"]): + cron_expr = ScheduleService.visual_to_cron(test_case["frequency"], test_case["config"]) + assert cron_expr == test_case["expected_cron"] + + # Verify the generated cron expression is valid + next_time = calculate_next_run_at(cron_expr, "UTC", self.base_time) + assert next_time is not None + + def test_error_handling_consistency(self): + """Test that error handling matches frontend expectations.""" + invalid_expressions = [ + "60 10 1 * *", # Invalid minute + "15 25 1 * *", # Invalid hour + "15 10 32 * *", # Invalid day + "15 10 1 13 *", # Invalid month + "15 10 1", # Too few fields + "15 10 1 * * *", # 6 fields (not supported in frontend) + "0 15 10 1 * * *", # 7 fields (not supported in frontend) + "invalid expression", # Completely invalid + ] + + for expr in invalid_expressions: + with self.subTest(expr=repr(expr)): + with pytest.raises((CroniterBadCronError, ValueError, Exception)): + calculate_next_run_at(expr, "UTC", self.base_time) + + # Note: Empty/whitespace expressions are not tested here as they are + # not expected in normal usage due to database constraints (nullable=False) + + def test_performance_requirements(self): + """Test that complex expressions parse within reasonable time.""" + import time + + complex_expressions = [ + "*/5 9-17 * * 1-5", # Every 5 minutes, weekdays, business hours + "0 */2 1,15 * *", # Every 2 hours on 1st and 15th + "30 14 * * 1,3,5", # Mon, Wed, Fri at 14:30 + "15,45 8-18 * * 1-5", # 15 and 45 minutes past hour, weekdays + "0 9 * JAN-MAR MON-FRI", # Enhanced syntax: Q1 weekdays at 9 AM + "0 12 ? * SUN", # Enhanced syntax: Sundays at noon with ? + ] + + start_time = time.time() + + for expr in complex_expressions: + with self.subTest(expr=expr): + try: + next_time = calculate_next_run_at(expr, "UTC", self.base_time) + assert next_time is not None + except CroniterBadCronError: + # Some enhanced syntax might not be supported, that's OK + pass + + end_time = time.time() + execution_time = (end_time - start_time) * 1000 # Convert to milliseconds + + # Should complete within reasonable time (less than 150ms like frontend) + assert execution_time < 150, "Complex expressions should parse quickly" + + +if __name__ == "__main__": + # Import timedelta for the test + from datetime import timedelta + + unittest.main() diff --git a/api/tests/unit_tests/libs/test_schedule_utils_enhanced.py b/api/tests/unit_tests/libs/test_schedule_utils_enhanced.py new file mode 100644 index 0000000000..9a14cdd0fe --- /dev/null +++ b/api/tests/unit_tests/libs/test_schedule_utils_enhanced.py @@ -0,0 +1,411 @@ +""" +Enhanced schedule_utils tests for new cron syntax support. + +These tests verify that the backend schedule_utils functions properly support +the enhanced cron syntax introduced in the frontend, ensuring full compatibility. +""" + +import unittest +from datetime import UTC, datetime, timedelta + +import pytest +import pytz +from croniter import CroniterBadCronError + +from libs.schedule_utils import calculate_next_run_at, convert_12h_to_24h + + +class TestEnhancedCronSyntax(unittest.TestCase): + """Test enhanced cron syntax in calculate_next_run_at.""" + + def setUp(self): + """Set up test with fixed time.""" + # Monday, January 15, 2024, 10:00 AM UTC + self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC) + + def test_month_abbreviations(self): + """Test month abbreviations (JAN, FEB, etc.).""" + test_cases = [ + ("0 12 1 JAN *", 1), # January + ("0 12 1 FEB *", 2), # February + ("0 12 1 MAR *", 3), # March + ("0 12 1 APR *", 4), # April + ("0 12 1 MAY *", 5), # May + ("0 12 1 JUN *", 6), # June + ("0 12 1 JUL *", 7), # July + ("0 12 1 AUG *", 8), # August + ("0 12 1 SEP *", 9), # September + ("0 12 1 OCT *", 10), # October + ("0 12 1 NOV *", 11), # November + ("0 12 1 DEC *", 12), # December + ] + + for expr, expected_month in test_cases: + with self.subTest(expr=expr): + result = calculate_next_run_at(expr, "UTC", self.base_time) + assert result is not None, f"Failed to parse: {expr}" + assert result.month == expected_month + assert result.day == 1 + assert result.hour == 12 + assert result.minute == 0 + + def test_weekday_abbreviations(self): + """Test weekday abbreviations (SUN, MON, etc.).""" + test_cases = [ + ("0 9 * * SUN", 6), # Sunday (weekday() = 6) + ("0 9 * * MON", 0), # Monday (weekday() = 0) + ("0 9 * * TUE", 1), # Tuesday + ("0 9 * * WED", 2), # Wednesday + ("0 9 * * THU", 3), # Thursday + ("0 9 * * FRI", 4), # Friday + ("0 9 * * SAT", 5), # Saturday + ] + + for expr, expected_weekday in test_cases: + with self.subTest(expr=expr): + result = calculate_next_run_at(expr, "UTC", self.base_time) + assert result is not None, f"Failed to parse: {expr}" + assert result.weekday() == expected_weekday + assert result.hour == 9 + assert result.minute == 0 + + def test_sunday_dual_representation(self): + """Test Sunday as both 0 and 7.""" + base_time = datetime(2024, 1, 14, 10, 0, 0, tzinfo=UTC) # Sunday + + # Both should give the same next Sunday + result_0 = calculate_next_run_at("0 10 * * 0", "UTC", base_time) + result_7 = calculate_next_run_at("0 10 * * 7", "UTC", base_time) + result_SUN = calculate_next_run_at("0 10 * * SUN", "UTC", base_time) + + assert result_0 is not None + assert result_7 is not None + assert result_SUN is not None + + # All should be Sundays + assert result_0.weekday() == 6 # Sunday = 6 in weekday() + assert result_7.weekday() == 6 + assert result_SUN.weekday() == 6 + + # Times should be identical + assert result_0 == result_7 + assert result_0 == result_SUN + + def test_predefined_expressions(self): + """Test predefined expressions (@daily, @weekly, etc.).""" + test_cases = [ + ("@yearly", lambda dt: dt.month == 1 and dt.day == 1 and dt.hour == 0 and dt.minute == 0), + ("@annually", lambda dt: dt.month == 1 and dt.day == 1 and dt.hour == 0 and dt.minute == 0), + ("@monthly", lambda dt: dt.day == 1 and dt.hour == 0 and dt.minute == 0), + ("@weekly", lambda dt: dt.weekday() == 6 and dt.hour == 0 and dt.minute == 0), # Sunday + ("@daily", lambda dt: dt.hour == 0 and dt.minute == 0), + ("@midnight", lambda dt: dt.hour == 0 and dt.minute == 0), + ("@hourly", lambda dt: dt.minute == 0), + ] + + for expr, validator in test_cases: + with self.subTest(expr=expr): + result = calculate_next_run_at(expr, "UTC", self.base_time) + assert result is not None, f"Failed to parse: {expr}" + assert validator(result), f"Validator failed for {expr}: {result}" + + def test_question_mark_wildcard(self): + """Test ? wildcard character.""" + # ? in day position with specific weekday + result_question = calculate_next_run_at("0 9 ? * 1", "UTC", self.base_time) # Monday + result_star = calculate_next_run_at("0 9 * * 1", "UTC", self.base_time) # Monday + + assert result_question is not None + assert result_star is not None + + # Both should return Mondays at 9:00 + assert result_question.weekday() == 0 # Monday + assert result_star.weekday() == 0 + assert result_question.hour == 9 + assert result_star.hour == 9 + + # Results should be identical + assert result_question == result_star + + def test_last_day_of_month(self): + """Test 'L' for last day of month.""" + expr = "0 12 L * *" # Last day of month at noon + + # Test for February (28 days in 2024 - not a leap year check) + feb_base = datetime(2024, 2, 15, 10, 0, 0, tzinfo=UTC) + result = calculate_next_run_at(expr, "UTC", feb_base) + assert result is not None + assert result.month == 2 + assert result.day == 29 # 2024 is a leap year + assert result.hour == 12 + + def test_range_with_abbreviations(self): + """Test ranges using abbreviations.""" + test_cases = [ + "0 9 * * MON-FRI", # Weekday range + "0 12 * JAN-MAR *", # Q1 months + "0 15 * APR-JUN *", # Q2 months + ] + + for expr in test_cases: + with self.subTest(expr=expr): + result = calculate_next_run_at(expr, "UTC", self.base_time) + assert result is not None, f"Failed to parse range expression: {expr}" + assert result > self.base_time + + def test_list_with_abbreviations(self): + """Test lists using abbreviations.""" + test_cases = [ + ("0 9 * * SUN,WED,FRI", [6, 2, 4]), # Specific weekdays + ("0 12 1 JAN,JUN,DEC *", [1, 6, 12]), # Specific months + ] + + for expr, expected_values in test_cases: + with self.subTest(expr=expr): + result = calculate_next_run_at(expr, "UTC", self.base_time) + assert result is not None, f"Failed to parse list expression: {expr}" + + if "* *" in expr: # Weekday test + assert result.weekday() in expected_values + else: # Month test + assert result.month in expected_values + + def test_mixed_syntax(self): + """Test mixed traditional and enhanced syntax.""" + test_cases = [ + "30 14 15 JAN,JUN,DEC *", # Numbers + month abbreviations + "0 9 * JAN-MAR MON-FRI", # Month range + weekday range + "45 8 1,15 * MON", # Numbers + weekday abbreviation + ] + + for expr in test_cases: + with self.subTest(expr=expr): + result = calculate_next_run_at(expr, "UTC", self.base_time) + assert result is not None, f"Failed to parse mixed syntax: {expr}" + assert result > self.base_time + + def test_complex_enhanced_expressions(self): + """Test complex expressions with multiple enhanced features.""" + # Note: Some of these might not be supported by croniter, that's OK + complex_expressions = [ + "0 9 L JAN *", # Last day of January + "30 14 * * FRI#1", # First Friday of month (if supported) + "0 12 15 JAN-DEC/3 *", # 15th of every 3rd month (quarterly) + ] + + for expr in complex_expressions: + with self.subTest(expr=expr): + try: + result = calculate_next_run_at(expr, "UTC", self.base_time) + if result: # If supported, should return valid result + assert result > self.base_time + except Exception: + # Some complex expressions might not be supported - that's acceptable + pass + + +class TestTimezoneHandlingEnhanced(unittest.TestCase): + """Test timezone handling with enhanced syntax.""" + + def setUp(self): + """Set up test with fixed time.""" + self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC) + + def test_enhanced_syntax_with_timezones(self): + """Test enhanced syntax works correctly across timezones.""" + timezones = ["UTC", "America/New_York", "Asia/Tokyo", "Europe/London"] + expression = "0 12 * * MON" # Monday at noon + + for timezone in timezones: + with self.subTest(timezone=timezone): + result = calculate_next_run_at(expression, timezone, self.base_time) + assert result is not None + + # Convert to local timezone to verify it's Monday at noon + tz = pytz.timezone(timezone) + local_time = result.astimezone(tz) + assert local_time.weekday() == 0 # Monday + assert local_time.hour == 12 + assert local_time.minute == 0 + + def test_predefined_expressions_with_timezones(self): + """Test predefined expressions work with different timezones.""" + expression = "@daily" + timezones = ["UTC", "America/New_York", "Asia/Tokyo"] + + for timezone in timezones: + with self.subTest(timezone=timezone): + result = calculate_next_run_at(expression, timezone, self.base_time) + assert result is not None + + # Should be midnight in the specified timezone + tz = pytz.timezone(timezone) + local_time = result.astimezone(tz) + assert local_time.hour == 0 + assert local_time.minute == 0 + + def test_dst_with_enhanced_syntax(self): + """Test DST handling with enhanced syntax.""" + # DST spring forward date in 2024 + dst_base = datetime(2024, 3, 8, 10, 0, 0, tzinfo=UTC) + expression = "0 2 * * SUN" # Sunday at 2 AM (problematic during DST) + timezone = "America/New_York" + + result = calculate_next_run_at(expression, timezone, dst_base) + assert result is not None + + # Should handle DST transition gracefully + tz = pytz.timezone(timezone) + local_time = result.astimezone(tz) + assert local_time.weekday() == 6 # Sunday + + # During DST spring forward, 2 AM might become 3 AM + assert local_time.hour in [2, 3] + + +class TestErrorHandlingEnhanced(unittest.TestCase): + """Test error handling for enhanced syntax.""" + + def setUp(self): + """Set up test with fixed time.""" + self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC) + + def test_invalid_enhanced_syntax(self): + """Test that invalid enhanced syntax raises appropriate errors.""" + invalid_expressions = [ + "0 12 * JANUARY *", # Full month name + "0 12 * * MONDAY", # Full day name + "0 12 32 JAN *", # Invalid day with valid month + "0 12 * * MON-SUN-FRI", # Invalid range syntax + "0 12 * JAN- *", # Incomplete range + "0 12 * * ,MON", # Invalid list syntax + "@INVALID", # Invalid predefined + ] + + for expr in invalid_expressions: + with self.subTest(expr=expr): + with pytest.raises((CroniterBadCronError, ValueError)): + calculate_next_run_at(expr, "UTC", self.base_time) + + def test_boundary_values_with_enhanced_syntax(self): + """Test boundary values work with enhanced syntax.""" + # Valid boundary expressions + valid_expressions = [ + "0 0 1 JAN *", # Minimum: January 1st midnight + "59 23 31 DEC *", # Maximum: December 31st 23:59 + "0 12 29 FEB *", # Leap year boundary + ] + + for expr in valid_expressions: + with self.subTest(expr=expr): + try: + result = calculate_next_run_at(expr, "UTC", self.base_time) + if result: # Some dates might not occur soon + assert result > self.base_time + except Exception as e: + # Some boundary cases might be complex to calculate + self.fail(f"Valid boundary expression failed: {expr} - {e}") + + +class TestPerformanceEnhanced(unittest.TestCase): + """Test performance with enhanced syntax.""" + + def setUp(self): + """Set up test with fixed time.""" + self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC) + + def test_complex_expression_performance(self): + """Test that complex enhanced expressions parse within reasonable time.""" + import time + + complex_expressions = [ + "*/5 9-17 * * MON-FRI", # Every 5 min, weekdays, business hours + "0 9 * JAN-MAR MON-FRI", # Q1 weekdays at 9 AM + "30 14 1,15 * * ", # 1st and 15th at 14:30 + "0 12 ? * SUN", # Sundays at noon with ? + "@daily", # Predefined expression + ] + + start_time = time.time() + + for expr in complex_expressions: + with self.subTest(expr=expr): + try: + result = calculate_next_run_at(expr, "UTC", self.base_time) + assert result is not None + except Exception: + # Some expressions might not be supported - acceptable + pass + + end_time = time.time() + execution_time = (end_time - start_time) * 1000 # milliseconds + + # Should be fast (less than 100ms for all expressions) + assert execution_time < 100, "Enhanced expressions should parse quickly" + + def test_multiple_calculations_performance(self): + """Test performance when calculating multiple next times.""" + import time + + expression = "0 9 * * MON-FRI" # Weekdays at 9 AM + iterations = 20 + + start_time = time.time() + + current_time = self.base_time + for _ in range(iterations): + result = calculate_next_run_at(expression, "UTC", current_time) + assert result is not None + current_time = result + timedelta(seconds=1) # Move forward slightly + + end_time = time.time() + total_time = (end_time - start_time) * 1000 # milliseconds + avg_time = total_time / iterations + + # Average should be very fast (less than 5ms per calculation) + assert avg_time < 5, f"Average calculation time too slow: {avg_time}ms" + + +class TestRegressionEnhanced(unittest.TestCase): + """Regression tests to ensure enhanced syntax doesn't break existing functionality.""" + + def setUp(self): + """Set up test with fixed time.""" + self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC) + + def test_traditional_syntax_still_works(self): + """Ensure traditional cron syntax continues to work.""" + traditional_expressions = [ + "15 10 1 * *", # Monthly 1st at 10:15 + "0 0 * * 0", # Weekly Sunday midnight + "*/5 * * * *", # Every 5 minutes + "0 9-17 * * 1-5", # Business hours weekdays + "30 14 * * 1", # Monday 14:30 + "0 0 1,15 * *", # 1st and 15th midnight + ] + + for expr in traditional_expressions: + with self.subTest(expr=expr): + result = calculate_next_run_at(expr, "UTC", self.base_time) + assert result is not None, f"Traditional expression failed: {expr}" + assert result > self.base_time + + def test_convert_12h_to_24h_unchanged(self): + """Ensure convert_12h_to_24h function is unchanged.""" + test_cases = [ + ("12:00 AM", (0, 0)), # Midnight + ("12:00 PM", (12, 0)), # Noon + ("1:30 AM", (1, 30)), # Early morning + ("11:45 PM", (23, 45)), # Late evening + ("6:15 AM", (6, 15)), # Morning + ("3:30 PM", (15, 30)), # Afternoon + ] + + for time_str, expected in test_cases: + with self.subTest(time_str=time_str): + result = convert_12h_to_24h(time_str) + assert result == expected, f"12h conversion failed: {time_str}" + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/unit_tests/models/test_plugin_entities.py b/api/tests/unit_tests/models/test_plugin_entities.py new file mode 100644 index 0000000000..0c61144deb --- /dev/null +++ b/api/tests/unit_tests/models/test_plugin_entities.py @@ -0,0 +1,22 @@ +import binascii +from collections.abc import Mapping +from typing import Any + +from core.plugin.entities.request import TriggerDispatchResponse + + +def test_trigger_dispatch_response(): + raw_http_response = b'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{"message": "Hello, world!"}' + + data: Mapping[str, Any] = { + "user_id": "123", + "events": ["event1", "event2"], + "response": binascii.hexlify(raw_http_response).decode(), + "payload": {"key": "value"}, + } + + response = TriggerDispatchResponse(**data) + + assert response.response.status_code == 200 + assert response.response.headers["Content-Type"] == "application/json" + assert response.response.get_data(as_text=True) == '{"message": "Hello, world!"}' diff --git a/api/tests/unit_tests/services/test_schedule_service.py b/api/tests/unit_tests/services/test_schedule_service.py new file mode 100644 index 0000000000..e28965ea2c --- /dev/null +++ b/api/tests/unit_tests/services/test_schedule_service.py @@ -0,0 +1,779 @@ +import unittest +from datetime import UTC, datetime +from unittest.mock import MagicMock, Mock, patch + +import pytest +from sqlalchemy.orm import Session + +from core.workflow.nodes.trigger_schedule.entities import ScheduleConfig, SchedulePlanUpdate, VisualConfig +from core.workflow.nodes.trigger_schedule.exc import ScheduleConfigError +from events.event_handlers.sync_workflow_schedule_when_app_published import ( + sync_schedule_from_workflow, +) +from libs.schedule_utils import calculate_next_run_at, convert_12h_to_24h +from models.account import Account, TenantAccountJoin +from models.trigger import WorkflowSchedulePlan +from models.workflow import Workflow +from services.trigger.schedule_service import ScheduleService + + +class TestScheduleService(unittest.TestCase): + """Test cases for ScheduleService class.""" + + def test_calculate_next_run_at_valid_cron(self): + """Test calculating next run time with valid cron expression.""" + # Test daily cron at 10:30 AM + cron_expr = "30 10 * * *" + timezone = "UTC" + base_time = datetime(2025, 8, 29, 9, 0, 0, tzinfo=UTC) + + next_run = calculate_next_run_at(cron_expr, timezone, base_time) + + assert next_run is not None + assert next_run.hour == 10 + assert next_run.minute == 30 + assert next_run.day == 29 + + def test_calculate_next_run_at_with_timezone(self): + """Test calculating next run time with different timezone.""" + cron_expr = "0 9 * * *" # 9:00 AM + timezone = "America/New_York" + base_time = datetime(2025, 8, 29, 12, 0, 0, tzinfo=UTC) # 8:00 AM EDT + + next_run = calculate_next_run_at(cron_expr, timezone, base_time) + + assert next_run is not None + # 9:00 AM EDT = 13:00 UTC (during EDT) + expected_utc_hour = 13 + assert next_run.hour == expected_utc_hour + + def test_calculate_next_run_at_with_last_day_of_month(self): + """Test calculating next run time with 'L' (last day) syntax.""" + cron_expr = "0 10 L * *" # 10:00 AM on last day of month + timezone = "UTC" + base_time = datetime(2025, 2, 15, 9, 0, 0, tzinfo=UTC) + + next_run = calculate_next_run_at(cron_expr, timezone, base_time) + + assert next_run is not None + # February 2025 has 28 days + assert next_run.day == 28 + assert next_run.month == 2 + + def test_calculate_next_run_at_invalid_cron(self): + """Test calculating next run time with invalid cron expression.""" + cron_expr = "invalid cron" + timezone = "UTC" + + with pytest.raises(ValueError): + calculate_next_run_at(cron_expr, timezone) + + def test_calculate_next_run_at_invalid_timezone(self): + """Test calculating next run time with invalid timezone.""" + from pytz import UnknownTimeZoneError + + cron_expr = "30 10 * * *" + timezone = "Invalid/Timezone" + + with pytest.raises(UnknownTimeZoneError): + calculate_next_run_at(cron_expr, timezone) + + @patch("libs.schedule_utils.calculate_next_run_at") + def test_create_schedule(self, mock_calculate_next_run): + """Test creating a new schedule.""" + mock_session = MagicMock(spec=Session) + mock_calculate_next_run.return_value = datetime(2025, 8, 30, 10, 30, 0, tzinfo=UTC) + + config = ScheduleConfig( + node_id="start", + cron_expression="30 10 * * *", + timezone="UTC", + ) + + schedule = ScheduleService.create_schedule( + session=mock_session, + tenant_id="test-tenant", + app_id="test-app", + config=config, + ) + + assert schedule is not None + assert schedule.tenant_id == "test-tenant" + assert schedule.app_id == "test-app" + assert schedule.node_id == "start" + assert schedule.cron_expression == "30 10 * * *" + assert schedule.timezone == "UTC" + assert schedule.next_run_at is not None + mock_session.add.assert_called_once() + mock_session.flush.assert_called_once() + + @patch("services.trigger.schedule_service.calculate_next_run_at") + def test_update_schedule(self, mock_calculate_next_run): + """Test updating an existing schedule.""" + mock_session = MagicMock(spec=Session) + mock_schedule = Mock(spec=WorkflowSchedulePlan) + mock_schedule.cron_expression = "0 12 * * *" + mock_schedule.timezone = "America/New_York" + mock_session.get.return_value = mock_schedule + mock_calculate_next_run.return_value = datetime(2025, 8, 30, 12, 0, 0, tzinfo=UTC) + + updates = SchedulePlanUpdate( + cron_expression="0 12 * * *", + timezone="America/New_York", + ) + + result = ScheduleService.update_schedule( + session=mock_session, + schedule_id="test-schedule-id", + updates=updates, + ) + + assert result is not None + assert result.cron_expression == "0 12 * * *" + assert result.timezone == "America/New_York" + mock_calculate_next_run.assert_called_once() + mock_session.flush.assert_called_once() + + def test_update_schedule_not_found(self): + """Test updating a non-existent schedule raises exception.""" + from core.workflow.nodes.trigger_schedule.exc import ScheduleNotFoundError + + mock_session = MagicMock(spec=Session) + mock_session.get.return_value = None + + updates = SchedulePlanUpdate( + cron_expression="0 12 * * *", + ) + + with pytest.raises(ScheduleNotFoundError) as context: + ScheduleService.update_schedule( + session=mock_session, + schedule_id="non-existent-id", + updates=updates, + ) + + assert "Schedule not found: non-existent-id" in str(context.value) + mock_session.flush.assert_not_called() + + def test_delete_schedule(self): + """Test deleting a schedule.""" + mock_session = MagicMock(spec=Session) + mock_schedule = Mock(spec=WorkflowSchedulePlan) + mock_session.get.return_value = mock_schedule + + # Should not raise exception and complete successfully + ScheduleService.delete_schedule( + session=mock_session, + schedule_id="test-schedule-id", + ) + + mock_session.delete.assert_called_once_with(mock_schedule) + mock_session.flush.assert_called_once() + + def test_delete_schedule_not_found(self): + """Test deleting a non-existent schedule raises exception.""" + from core.workflow.nodes.trigger_schedule.exc import ScheduleNotFoundError + + mock_session = MagicMock(spec=Session) + mock_session.get.return_value = None + + # Should raise ScheduleNotFoundError + with pytest.raises(ScheduleNotFoundError) as context: + ScheduleService.delete_schedule( + session=mock_session, + schedule_id="non-existent-id", + ) + + assert "Schedule not found: non-existent-id" in str(context.value) + mock_session.delete.assert_not_called() + + @patch("services.trigger.schedule_service.select") + def test_get_tenant_owner(self, mock_select): + """Test getting tenant owner account.""" + mock_session = MagicMock(spec=Session) + mock_account = Mock(spec=Account) + mock_account.id = "owner-account-id" + + # Mock owner query + mock_owner_result = Mock(spec=TenantAccountJoin) + mock_owner_result.account_id = "owner-account-id" + + mock_session.execute.return_value.scalar_one_or_none.return_value = mock_owner_result + mock_session.get.return_value = mock_account + + result = ScheduleService.get_tenant_owner( + session=mock_session, + tenant_id="test-tenant", + ) + + assert result is not None + assert result.id == "owner-account-id" + + @patch("services.trigger.schedule_service.select") + def test_get_tenant_owner_fallback_to_admin(self, mock_select): + """Test getting tenant owner falls back to admin if no owner.""" + mock_session = MagicMock(spec=Session) + mock_account = Mock(spec=Account) + mock_account.id = "admin-account-id" + + # Mock admin query (owner returns None) + mock_admin_result = Mock(spec=TenantAccountJoin) + mock_admin_result.account_id = "admin-account-id" + + mock_session.execute.return_value.scalar_one_or_none.side_effect = [None, mock_admin_result] + mock_session.get.return_value = mock_account + + result = ScheduleService.get_tenant_owner( + session=mock_session, + tenant_id="test-tenant", + ) + + assert result is not None + assert result.id == "admin-account-id" + + @patch("services.trigger.schedule_service.calculate_next_run_at") + def test_update_next_run_at(self, mock_calculate_next_run): + """Test updating next run time after schedule triggered.""" + mock_session = MagicMock(spec=Session) + mock_schedule = Mock(spec=WorkflowSchedulePlan) + mock_schedule.cron_expression = "30 10 * * *" + mock_schedule.timezone = "UTC" + mock_session.get.return_value = mock_schedule + + next_time = datetime(2025, 8, 31, 10, 30, 0, tzinfo=UTC) + mock_calculate_next_run.return_value = next_time + + result = ScheduleService.update_next_run_at( + session=mock_session, + schedule_id="test-schedule-id", + ) + + assert result == next_time + assert mock_schedule.next_run_at == next_time + mock_session.flush.assert_called_once() + + +class TestVisualToCron(unittest.TestCase): + """Test cases for visual configuration to cron conversion.""" + + def test_visual_to_cron_hourly(self): + """Test converting hourly visual config to cron.""" + visual_config = VisualConfig(on_minute=15) + result = ScheduleService.visual_to_cron("hourly", visual_config) + assert result == "15 * * * *" + + def test_visual_to_cron_daily(self): + """Test converting daily visual config to cron.""" + visual_config = VisualConfig(time="2:30 PM") + result = ScheduleService.visual_to_cron("daily", visual_config) + assert result == "30 14 * * *" + + def test_visual_to_cron_weekly(self): + """Test converting weekly visual config to cron.""" + visual_config = VisualConfig( + time="10:00 AM", + weekdays=["mon", "wed", "fri"], + ) + result = ScheduleService.visual_to_cron("weekly", visual_config) + assert result == "0 10 * * 1,3,5" + + def test_visual_to_cron_monthly_with_specific_days(self): + """Test converting monthly visual config with specific days.""" + visual_config = VisualConfig( + time="11:30 AM", + monthly_days=[1, 15], + ) + result = ScheduleService.visual_to_cron("monthly", visual_config) + assert result == "30 11 1,15 * *" + + def test_visual_to_cron_monthly_with_last_day(self): + """Test converting monthly visual config with last day using 'L' syntax.""" + visual_config = VisualConfig( + time="11:30 AM", + monthly_days=[1, "last"], + ) + result = ScheduleService.visual_to_cron("monthly", visual_config) + assert result == "30 11 1,L * *" + + def test_visual_to_cron_monthly_only_last_day(self): + """Test converting monthly visual config with only last day.""" + visual_config = VisualConfig( + time="9:00 PM", + monthly_days=["last"], + ) + result = ScheduleService.visual_to_cron("monthly", visual_config) + assert result == "0 21 L * *" + + def test_visual_to_cron_monthly_with_end_days_and_last(self): + """Test converting monthly visual config with days 29, 30, 31 and 'last'.""" + visual_config = VisualConfig( + time="3:45 PM", + monthly_days=[29, 30, 31, "last"], + ) + result = ScheduleService.visual_to_cron("monthly", visual_config) + # Should have 29,30,31,L - the L handles all possible last days + assert result == "45 15 29,30,31,L * *" + + def test_visual_to_cron_invalid_frequency(self): + """Test converting with invalid frequency.""" + with pytest.raises(ScheduleConfigError, match="Unsupported frequency: invalid"): + ScheduleService.visual_to_cron("invalid", VisualConfig()) + + def test_visual_to_cron_weekly_no_weekdays(self): + """Test converting weekly with no weekdays specified.""" + visual_config = VisualConfig(time="10:00 AM") + with pytest.raises(ScheduleConfigError, match="Weekdays are required for weekly schedules"): + ScheduleService.visual_to_cron("weekly", visual_config) + + def test_visual_to_cron_hourly_no_minute(self): + """Test converting hourly with no on_minute specified.""" + visual_config = VisualConfig() # on_minute defaults to 0 + result = ScheduleService.visual_to_cron("hourly", visual_config) + assert result == "0 * * * *" # Should use default value 0 + + def test_visual_to_cron_daily_no_time(self): + """Test converting daily with no time specified.""" + visual_config = VisualConfig(time=None) + with pytest.raises(ScheduleConfigError, match="time is required for daily schedules"): + ScheduleService.visual_to_cron("daily", visual_config) + + def test_visual_to_cron_weekly_no_time(self): + """Test converting weekly with no time specified.""" + visual_config = VisualConfig(weekdays=["mon"]) + visual_config.time = None # Override default + with pytest.raises(ScheduleConfigError, match="time is required for weekly schedules"): + ScheduleService.visual_to_cron("weekly", visual_config) + + def test_visual_to_cron_monthly_no_time(self): + """Test converting monthly with no time specified.""" + visual_config = VisualConfig(monthly_days=[1]) + visual_config.time = None # Override default + with pytest.raises(ScheduleConfigError, match="time is required for monthly schedules"): + ScheduleService.visual_to_cron("monthly", visual_config) + + def test_visual_to_cron_monthly_duplicate_days(self): + """Test monthly with duplicate days should be deduplicated.""" + visual_config = VisualConfig( + time="10:00 AM", + monthly_days=[1, 15, 1, 15, 31], # Duplicates + ) + result = ScheduleService.visual_to_cron("monthly", visual_config) + assert result == "0 10 1,15,31 * *" # Should be deduplicated + + def test_visual_to_cron_monthly_unsorted_days(self): + """Test monthly with unsorted days should be sorted.""" + visual_config = VisualConfig( + time="2:30 PM", + monthly_days=[20, 5, 15, 1, 10], # Unsorted + ) + result = ScheduleService.visual_to_cron("monthly", visual_config) + assert result == "30 14 1,5,10,15,20 * *" # Should be sorted + + def test_visual_to_cron_weekly_all_weekdays(self): + """Test weekly with all weekdays.""" + visual_config = VisualConfig( + time="8:00 AM", + weekdays=["sun", "mon", "tue", "wed", "thu", "fri", "sat"], + ) + result = ScheduleService.visual_to_cron("weekly", visual_config) + assert result == "0 8 * * 0,1,2,3,4,5,6" + + def test_visual_to_cron_hourly_boundary_values(self): + """Test hourly with boundary minute values.""" + # Minimum value + visual_config = VisualConfig(on_minute=0) + result = ScheduleService.visual_to_cron("hourly", visual_config) + assert result == "0 * * * *" + + # Maximum value + visual_config = VisualConfig(on_minute=59) + result = ScheduleService.visual_to_cron("hourly", visual_config) + assert result == "59 * * * *" + + def test_visual_to_cron_daily_midnight_noon(self): + """Test daily at special times (midnight and noon).""" + # Midnight + visual_config = VisualConfig(time="12:00 AM") + result = ScheduleService.visual_to_cron("daily", visual_config) + assert result == "0 0 * * *" + + # Noon + visual_config = VisualConfig(time="12:00 PM") + result = ScheduleService.visual_to_cron("daily", visual_config) + assert result == "0 12 * * *" + + def test_visual_to_cron_monthly_mixed_with_last_and_duplicates(self): + """Test monthly with mixed days, 'last', and duplicates.""" + visual_config = VisualConfig( + time="11:45 PM", + monthly_days=[15, 1, "last", 15, 30, 1, "last"], # Mixed with duplicates + ) + result = ScheduleService.visual_to_cron("monthly", visual_config) + assert result == "45 23 1,15,30,L * *" # Deduplicated and sorted with L at end + + def test_visual_to_cron_weekly_single_day(self): + """Test weekly with single weekday.""" + visual_config = VisualConfig( + time="6:30 PM", + weekdays=["sun"], + ) + result = ScheduleService.visual_to_cron("weekly", visual_config) + assert result == "30 18 * * 0" + + def test_visual_to_cron_monthly_all_possible_days(self): + """Test monthly with all 31 days plus 'last'.""" + all_days = list(range(1, 32)) + ["last"] + visual_config = VisualConfig( + time="12:01 AM", + monthly_days=all_days, + ) + result = ScheduleService.visual_to_cron("monthly", visual_config) + expected_days = ",".join([str(i) for i in range(1, 32)]) + ",L" + assert result == f"1 0 {expected_days} * *" + + def test_visual_to_cron_monthly_no_days(self): + """Test monthly without any days specified should raise error.""" + visual_config = VisualConfig(time="10:00 AM", monthly_days=[]) + with pytest.raises(ScheduleConfigError, match="Monthly days are required for monthly schedules"): + ScheduleService.visual_to_cron("monthly", visual_config) + + def test_visual_to_cron_weekly_empty_weekdays_list(self): + """Test weekly with empty weekdays list should raise error.""" + visual_config = VisualConfig(time="10:00 AM", weekdays=[]) + with pytest.raises(ScheduleConfigError, match="Weekdays are required for weekly schedules"): + ScheduleService.visual_to_cron("weekly", visual_config) + + +class TestParseTime(unittest.TestCase): + """Test cases for time parsing function.""" + + def test_parse_time_am(self): + """Test parsing AM time.""" + hour, minute = convert_12h_to_24h("9:30 AM") + assert hour == 9 + assert minute == 30 + + def test_parse_time_pm(self): + """Test parsing PM time.""" + hour, minute = convert_12h_to_24h("2:45 PM") + assert hour == 14 + assert minute == 45 + + def test_parse_time_noon(self): + """Test parsing 12:00 PM (noon).""" + hour, minute = convert_12h_to_24h("12:00 PM") + assert hour == 12 + assert minute == 0 + + def test_parse_time_midnight(self): + """Test parsing 12:00 AM (midnight).""" + hour, minute = convert_12h_to_24h("12:00 AM") + assert hour == 0 + assert minute == 0 + + def test_parse_time_invalid_format(self): + """Test parsing invalid time format.""" + with pytest.raises(ValueError, match="Invalid time format"): + convert_12h_to_24h("25:00") + + def test_parse_time_invalid_hour(self): + """Test parsing invalid hour.""" + with pytest.raises(ValueError, match="Invalid hour: 13"): + convert_12h_to_24h("13:00 PM") + + def test_parse_time_invalid_minute(self): + """Test parsing invalid minute.""" + with pytest.raises(ValueError, match="Invalid minute: 60"): + convert_12h_to_24h("10:60 AM") + + def test_parse_time_empty_string(self): + """Test parsing empty string.""" + with pytest.raises(ValueError, match="Time string cannot be empty"): + convert_12h_to_24h("") + + def test_parse_time_invalid_period(self): + """Test parsing invalid period.""" + with pytest.raises(ValueError, match="Invalid period"): + convert_12h_to_24h("10:30 XM") + + +class TestExtractScheduleConfig(unittest.TestCase): + """Test cases for extracting schedule configuration from workflow.""" + + def test_extract_schedule_config_with_cron_mode(self): + """Test extracting schedule config in cron mode.""" + workflow = Mock(spec=Workflow) + workflow.graph_dict = { + "nodes": [ + { + "id": "schedule-node", + "data": { + "type": "trigger-schedule", + "mode": "cron", + "cron_expression": "0 10 * * *", + "timezone": "America/New_York", + }, + } + ] + } + + config = ScheduleService.extract_schedule_config(workflow) + + assert config is not None + assert config.node_id == "schedule-node" + assert config.cron_expression == "0 10 * * *" + assert config.timezone == "America/New_York" + + def test_extract_schedule_config_with_visual_mode(self): + """Test extracting schedule config in visual mode.""" + workflow = Mock(spec=Workflow) + workflow.graph_dict = { + "nodes": [ + { + "id": "schedule-node", + "data": { + "type": "trigger-schedule", + "mode": "visual", + "frequency": "daily", + "visual_config": {"time": "10:30 AM"}, + "timezone": "UTC", + }, + } + ] + } + + config = ScheduleService.extract_schedule_config(workflow) + + assert config is not None + assert config.node_id == "schedule-node" + assert config.cron_expression == "30 10 * * *" + assert config.timezone == "UTC" + + def test_extract_schedule_config_no_schedule_node(self): + """Test extracting config when no schedule node exists.""" + workflow = Mock(spec=Workflow) + workflow.graph_dict = { + "nodes": [ + { + "id": "other-node", + "data": {"type": "llm"}, + } + ] + } + + config = ScheduleService.extract_schedule_config(workflow) + assert config is None + + def test_extract_schedule_config_invalid_graph(self): + """Test extracting config with invalid graph data.""" + workflow = Mock(spec=Workflow) + workflow.graph_dict = None + + with pytest.raises(ScheduleConfigError, match="Workflow graph is empty"): + ScheduleService.extract_schedule_config(workflow) + + +class TestScheduleWithTimezone(unittest.TestCase): + """Test cases for schedule with timezone handling.""" + + def test_visual_schedule_with_timezone_integration(self): + """Test complete flow: visual config → cron → execution in different timezones. + + This test verifies that when a user in Shanghai sets a schedule for 10:30 AM, + it runs at 10:30 AM Shanghai time, not 10:30 AM UTC. + """ + # User in Shanghai wants to run a task at 10:30 AM local time + visual_config = VisualConfig( + time="10:30 AM", # This is Shanghai time + monthly_days=[1], + ) + + # Convert to cron expression + cron_expr = ScheduleService.visual_to_cron("monthly", visual_config) + assert cron_expr is not None + + assert cron_expr == "30 10 1 * *" # Direct conversion + + # Now test execution with Shanghai timezone + shanghai_tz = "Asia/Shanghai" + # Base time: 2025-01-01 00:00:00 UTC (08:00:00 Shanghai) + base_time = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + + next_run = calculate_next_run_at(cron_expr, shanghai_tz, base_time) + + assert next_run is not None + + # Should run at 10:30 AM Shanghai time on Jan 1 + # 10:30 AM Shanghai = 02:30 AM UTC (Shanghai is UTC+8) + assert next_run.year == 2025 + assert next_run.month == 1 + assert next_run.day == 1 + assert next_run.hour == 2 # 02:30 UTC + assert next_run.minute == 30 + + def test_visual_schedule_different_timezones_same_local_time(self): + """Test that same visual config in different timezones runs at different UTC times. + + This verifies that a schedule set for "9:00 AM" runs at 9 AM local time + regardless of the timezone. + """ + visual_config = VisualConfig( + time="9:00 AM", + weekdays=["mon"], + ) + + cron_expr = ScheduleService.visual_to_cron("weekly", visual_config) + assert cron_expr is not None + assert cron_expr == "0 9 * * 1" + + # Base time: Sunday 2025-01-05 12:00:00 UTC + base_time = datetime(2025, 1, 5, 12, 0, 0, tzinfo=UTC) + + # Test New York (UTC-5 in January) + ny_next = calculate_next_run_at(cron_expr, "America/New_York", base_time) + assert ny_next is not None + # Monday 9 AM EST = Monday 14:00 UTC + assert ny_next.day == 6 + assert ny_next.hour == 14 # 9 AM EST = 2 PM UTC + + # Test Tokyo (UTC+9) + tokyo_next = calculate_next_run_at(cron_expr, "Asia/Tokyo", base_time) + assert tokyo_next is not None + # Monday 9 AM JST = Monday 00:00 UTC + assert tokyo_next.day == 6 + assert tokyo_next.hour == 0 # 9 AM JST = 0 AM UTC + + def test_visual_schedule_daily_across_dst_change(self): + """Test that daily schedules adjust correctly during DST changes. + + A schedule set for "10:00 AM" should always run at 10 AM local time, + even when DST changes. + """ + visual_config = VisualConfig( + time="10:00 AM", + ) + + cron_expr = ScheduleService.visual_to_cron("daily", visual_config) + assert cron_expr is not None + + assert cron_expr == "0 10 * * *" + + # Test before DST (EST - UTC-5) + winter_base = datetime(2025, 2, 1, 0, 0, 0, tzinfo=UTC) + winter_next = calculate_next_run_at(cron_expr, "America/New_York", winter_base) + assert winter_next is not None + # 10 AM EST = 15:00 UTC + assert winter_next.hour == 15 + + # Test during DST (EDT - UTC-4) + summer_base = datetime(2025, 6, 1, 0, 0, 0, tzinfo=UTC) + summer_next = calculate_next_run_at(cron_expr, "America/New_York", summer_base) + assert summer_next is not None + # 10 AM EDT = 14:00 UTC + assert summer_next.hour == 14 + + +class TestSyncScheduleFromWorkflow(unittest.TestCase): + """Test cases for syncing schedule from workflow.""" + + @patch("events.event_handlers.sync_workflow_schedule_when_app_published.db") + @patch("events.event_handlers.sync_workflow_schedule_when_app_published.ScheduleService") + @patch("events.event_handlers.sync_workflow_schedule_when_app_published.select") + def test_sync_schedule_create_new(self, mock_select, mock_service, mock_db): + """Test creating new schedule when none exists.""" + mock_session = MagicMock() + mock_db.engine = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock(return_value=None) + Session = MagicMock(return_value=mock_session) + with patch("events.event_handlers.sync_workflow_schedule_when_app_published.Session", Session): + mock_session.scalar.return_value = None # No existing plan + + # Mock extract_schedule_config to return a ScheduleConfig object + mock_config = Mock(spec=ScheduleConfig) + mock_config.node_id = "start" + mock_config.cron_expression = "30 10 * * *" + mock_config.timezone = "UTC" + mock_service.extract_schedule_config.return_value = mock_config + + mock_new_plan = Mock(spec=WorkflowSchedulePlan) + mock_service.create_schedule.return_value = mock_new_plan + + workflow = Mock(spec=Workflow) + result = sync_schedule_from_workflow("tenant-id", "app-id", workflow) + + assert result == mock_new_plan + mock_service.create_schedule.assert_called_once() + mock_session.commit.assert_called_once() + + @patch("events.event_handlers.sync_workflow_schedule_when_app_published.db") + @patch("events.event_handlers.sync_workflow_schedule_when_app_published.ScheduleService") + @patch("events.event_handlers.sync_workflow_schedule_when_app_published.select") + def test_sync_schedule_update_existing(self, mock_select, mock_service, mock_db): + """Test updating existing schedule.""" + mock_session = MagicMock() + mock_db.engine = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock(return_value=None) + Session = MagicMock(return_value=mock_session) + + with patch("events.event_handlers.sync_workflow_schedule_when_app_published.Session", Session): + mock_existing_plan = Mock(spec=WorkflowSchedulePlan) + mock_existing_plan.id = "existing-plan-id" + mock_session.scalar.return_value = mock_existing_plan + + # Mock extract_schedule_config to return a ScheduleConfig object + mock_config = Mock(spec=ScheduleConfig) + mock_config.node_id = "start" + mock_config.cron_expression = "0 12 * * *" + mock_config.timezone = "America/New_York" + mock_service.extract_schedule_config.return_value = mock_config + + mock_updated_plan = Mock(spec=WorkflowSchedulePlan) + mock_service.update_schedule.return_value = mock_updated_plan + + workflow = Mock(spec=Workflow) + result = sync_schedule_from_workflow("tenant-id", "app-id", workflow) + + assert result == mock_updated_plan + mock_service.update_schedule.assert_called_once() + # Verify the arguments passed to update_schedule + call_args = mock_service.update_schedule.call_args + assert call_args.kwargs["session"] == mock_session + assert call_args.kwargs["schedule_id"] == "existing-plan-id" + updates_obj = call_args.kwargs["updates"] + assert isinstance(updates_obj, SchedulePlanUpdate) + assert updates_obj.node_id == "start" + assert updates_obj.cron_expression == "0 12 * * *" + assert updates_obj.timezone == "America/New_York" + mock_session.commit.assert_called_once() + + @patch("events.event_handlers.sync_workflow_schedule_when_app_published.db") + @patch("events.event_handlers.sync_workflow_schedule_when_app_published.ScheduleService") + @patch("events.event_handlers.sync_workflow_schedule_when_app_published.select") + def test_sync_schedule_remove_when_no_config(self, mock_select, mock_service, mock_db): + """Test removing schedule when no schedule config in workflow.""" + mock_session = MagicMock() + mock_db.engine = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock(return_value=None) + Session = MagicMock(return_value=mock_session) + + with patch("events.event_handlers.sync_workflow_schedule_when_app_published.Session", Session): + mock_existing_plan = Mock(spec=WorkflowSchedulePlan) + mock_existing_plan.id = "existing-plan-id" + mock_session.scalar.return_value = mock_existing_plan + + mock_service.extract_schedule_config.return_value = None # No schedule config + + workflow = Mock(spec=Workflow) + result = sync_schedule_from_workflow("tenant-id", "app-id", workflow) + + assert result is None + # Now using ScheduleService.delete_schedule instead of session.delete + mock_service.delete_schedule.assert_called_once_with(session=mock_session, schedule_id="existing-plan-id") + mock_session.commit.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py new file mode 100644 index 0000000000..010295bcd6 --- /dev/null +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -0,0 +1,482 @@ +from io import BytesIO +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask +from werkzeug.datastructures import FileStorage + +from services.trigger.webhook_service import WebhookService + + +class TestWebhookServiceUnit: + """Unit tests for WebhookService focusing on business logic without database dependencies.""" + + def test_extract_webhook_data_json(self): + """Test webhook data extraction from JSON request.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/json", "Authorization": "Bearer token"}, + query_string="version=1&format=json", + json={"message": "hello", "count": 42}, + ): + webhook_trigger = MagicMock() + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + + assert webhook_data["method"] == "POST" + assert webhook_data["headers"]["Authorization"] == "Bearer token" + # Query params are now extracted as raw strings + assert webhook_data["query_params"]["version"] == "1" + assert webhook_data["query_params"]["format"] == "json" + assert webhook_data["body"]["message"] == "hello" + assert webhook_data["body"]["count"] == 42 + assert webhook_data["files"] == {} + + def test_extract_webhook_data_query_params_remain_strings(self): + """Query parameters should be extracted as raw strings without automatic conversion.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="GET", + headers={"Content-Type": "application/json"}, + query_string="count=42&threshold=3.14&enabled=true¬e=text", + ): + webhook_trigger = MagicMock() + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + + # After refactoring, raw extraction keeps query params as strings + assert webhook_data["query_params"]["count"] == "42" + assert webhook_data["query_params"]["threshold"] == "3.14" + assert webhook_data["query_params"]["enabled"] == "true" + assert webhook_data["query_params"]["note"] == "text" + + def test_extract_webhook_data_form_urlencoded(self): + """Test webhook data extraction from form URL encoded request.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={"username": "test", "password": "secret"}, + ): + webhook_trigger = MagicMock() + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + + assert webhook_data["method"] == "POST" + assert webhook_data["body"]["username"] == "test" + assert webhook_data["body"]["password"] == "secret" + + def test_extract_webhook_data_multipart_with_files(self): + """Test webhook data extraction from multipart form with files.""" + app = Flask(__name__) + + # Create a mock file + file_content = b"test file content" + file_storage = FileStorage(stream=BytesIO(file_content), filename="test.txt", content_type="text/plain") + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "multipart/form-data"}, + data={"message": "test", "upload": file_storage}, + ): + webhook_trigger = MagicMock() + webhook_trigger.tenant_id = "test_tenant" + + with patch.object(WebhookService, "_process_file_uploads") as mock_process_files: + mock_process_files.return_value = {"upload": "mocked_file_obj"} + + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + + assert webhook_data["method"] == "POST" + assert webhook_data["body"]["message"] == "test" + assert webhook_data["files"]["upload"] == "mocked_file_obj" + mock_process_files.assert_called_once() + + def test_extract_webhook_data_raw_text(self): + """Test webhook data extraction from raw text request.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", method="POST", headers={"Content-Type": "text/plain"}, data="raw text content" + ): + webhook_trigger = MagicMock() + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + + assert webhook_data["method"] == "POST" + assert webhook_data["body"]["raw"] == "raw text content" + + def test_extract_webhook_data_invalid_json(self): + """Test webhook data extraction with invalid JSON.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", method="POST", headers={"Content-Type": "application/json"}, data="invalid json" + ): + webhook_trigger = MagicMock() + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + + assert webhook_data["method"] == "POST" + assert webhook_data["body"] == {} # Should default to empty dict + + def test_generate_webhook_response_default(self): + """Test webhook response generation with default values.""" + node_config = {"data": {}} + + response_data, status_code = WebhookService.generate_webhook_response(node_config) + + assert status_code == 200 + assert response_data["status"] == "success" + assert "Webhook processed successfully" in response_data["message"] + + def test_generate_webhook_response_custom_json(self): + """Test webhook response generation with custom JSON response.""" + node_config = {"data": {"status_code": 201, "response_body": '{"result": "created", "id": 123}'}} + + response_data, status_code = WebhookService.generate_webhook_response(node_config) + + assert status_code == 201 + assert response_data["result"] == "created" + assert response_data["id"] == 123 + + def test_generate_webhook_response_custom_text(self): + """Test webhook response generation with custom text response.""" + node_config = {"data": {"status_code": 202, "response_body": "Request accepted for processing"}} + + response_data, status_code = WebhookService.generate_webhook_response(node_config) + + assert status_code == 202 + assert response_data["message"] == "Request accepted for processing" + + def test_generate_webhook_response_invalid_json(self): + """Test webhook response generation with invalid JSON response.""" + node_config = {"data": {"status_code": 400, "response_body": '{"invalid": json}'}} + + response_data, status_code = WebhookService.generate_webhook_response(node_config) + + assert status_code == 400 + assert response_data["message"] == '{"invalid": json}' + + def test_generate_webhook_response_empty_response_body(self): + """Test webhook response generation with empty response body.""" + node_config = {"data": {"status_code": 204, "response_body": ""}} + + response_data, status_code = WebhookService.generate_webhook_response(node_config) + + assert status_code == 204 + assert response_data["status"] == "success" + assert "Webhook processed successfully" in response_data["message"] + + def test_generate_webhook_response_array_json(self): + """Test webhook response generation with JSON array response.""" + node_config = {"data": {"status_code": 200, "response_body": '[{"id": 1}, {"id": 2}]'}} + + response_data, status_code = WebhookService.generate_webhook_response(node_config) + + assert status_code == 200 + assert isinstance(response_data, list) + assert len(response_data) == 2 + assert response_data[0]["id"] == 1 + assert response_data[1]["id"] == 2 + + @patch("services.trigger.webhook_service.ToolFileManager") + @patch("services.trigger.webhook_service.file_factory") + def test_process_file_uploads_success(self, mock_file_factory, mock_tool_file_manager): + """Test successful file upload processing.""" + # Mock ToolFileManager + mock_tool_file_instance = MagicMock() + mock_tool_file_manager.return_value = mock_tool_file_instance + + # Mock file creation + mock_tool_file = MagicMock() + mock_tool_file.id = "test_file_id" + mock_tool_file_instance.create_file_by_raw.return_value = mock_tool_file + + # Mock file factory + mock_file_obj = MagicMock() + mock_file_factory.build_from_mapping.return_value = mock_file_obj + + # Create mock files + files = { + "file1": MagicMock(filename="test1.txt", content_type="text/plain"), + "file2": MagicMock(filename="test2.jpg", content_type="image/jpeg"), + } + + # Mock file reads + files["file1"].read.return_value = b"content1" + files["file2"].read.return_value = b"content2" + + webhook_trigger = MagicMock() + webhook_trigger.tenant_id = "test_tenant" + + result = WebhookService._process_file_uploads(files, webhook_trigger) + + assert len(result) == 2 + assert "file1" in result + assert "file2" in result + + # Verify file processing was called for each file + assert mock_tool_file_manager.call_count == 2 + assert mock_file_factory.build_from_mapping.call_count == 2 + + @patch("services.trigger.webhook_service.ToolFileManager") + @patch("services.trigger.webhook_service.file_factory") + def test_process_file_uploads_with_errors(self, mock_file_factory, mock_tool_file_manager): + """Test file upload processing with errors.""" + # Mock ToolFileManager + mock_tool_file_instance = MagicMock() + mock_tool_file_manager.return_value = mock_tool_file_instance + + # Mock file creation + mock_tool_file = MagicMock() + mock_tool_file.id = "test_file_id" + mock_tool_file_instance.create_file_by_raw.return_value = mock_tool_file + + # Mock file factory + mock_file_obj = MagicMock() + mock_file_factory.build_from_mapping.return_value = mock_file_obj + + # Create mock files, one will fail + files = { + "good_file": MagicMock(filename="test.txt", content_type="text/plain"), + "bad_file": MagicMock(filename="test.bad", content_type="text/plain"), + } + + files["good_file"].read.return_value = b"content" + files["bad_file"].read.side_effect = Exception("Read error") + + webhook_trigger = MagicMock() + webhook_trigger.tenant_id = "test_tenant" + + result = WebhookService._process_file_uploads(files, webhook_trigger) + + # Should process the good file and skip the bad one + assert len(result) == 1 + assert "good_file" in result + assert "bad_file" not in result + + def test_process_file_uploads_empty_filename(self): + """Test file upload processing with empty filename.""" + files = { + "no_filename": MagicMock(filename="", content_type="text/plain"), + "none_filename": MagicMock(filename=None, content_type="text/plain"), + } + + webhook_trigger = MagicMock() + webhook_trigger.tenant_id = "test_tenant" + + result = WebhookService._process_file_uploads(files, webhook_trigger) + + # Should skip files without filenames + assert len(result) == 0 + + def test_validate_json_value_string(self): + """Test JSON value validation for string type.""" + # Valid string + result = WebhookService._validate_json_value("name", "hello", "string") + assert result == "hello" + + # Invalid string (number) - should raise ValueError + with pytest.raises(ValueError, match="Expected string, got int"): + WebhookService._validate_json_value("name", 123, "string") + + def test_validate_json_value_number(self): + """Test JSON value validation for number type.""" + # Valid integer + result = WebhookService._validate_json_value("count", 42, "number") + assert result == 42 + + # Valid float + result = WebhookService._validate_json_value("price", 19.99, "number") + assert result == 19.99 + + # Invalid number (string) - should raise ValueError + with pytest.raises(ValueError, match="Expected number, got str"): + WebhookService._validate_json_value("count", "42", "number") + + def test_validate_json_value_bool(self): + """Test JSON value validation for boolean type.""" + # Valid boolean + result = WebhookService._validate_json_value("enabled", True, "boolean") + assert result is True + + result = WebhookService._validate_json_value("enabled", False, "boolean") + assert result is False + + # Invalid boolean (string) - should raise ValueError + with pytest.raises(ValueError, match="Expected boolean, got str"): + WebhookService._validate_json_value("enabled", "true", "boolean") + + def test_validate_json_value_object(self): + """Test JSON value validation for object type.""" + # Valid object + result = WebhookService._validate_json_value("user", {"name": "John", "age": 30}, "object") + assert result == {"name": "John", "age": 30} + + # Invalid object (string) - should raise ValueError + with pytest.raises(ValueError, match="Expected object, got str"): + WebhookService._validate_json_value("user", "not_an_object", "object") + + def test_validate_json_value_array_string(self): + """Test JSON value validation for array[string] type.""" + # Valid array of strings + result = WebhookService._validate_json_value("tags", ["tag1", "tag2", "tag3"], "array[string]") + assert result == ["tag1", "tag2", "tag3"] + + # Invalid - not an array + with pytest.raises(ValueError, match="Expected array of strings, got str"): + WebhookService._validate_json_value("tags", "not_an_array", "array[string]") + + # Invalid - array with non-strings + with pytest.raises(ValueError, match="Expected array of strings, got list"): + WebhookService._validate_json_value("tags", ["tag1", 123, "tag3"], "array[string]") + + def test_validate_json_value_array_number(self): + """Test JSON value validation for array[number] type.""" + # Valid array of numbers + result = WebhookService._validate_json_value("scores", [1, 2.5, 3, 4.7], "array[number]") + assert result == [1, 2.5, 3, 4.7] + + # Invalid - array with non-numbers + with pytest.raises(ValueError, match="Expected array of numbers, got list"): + WebhookService._validate_json_value("scores", [1, "2", 3], "array[number]") + + def test_validate_json_value_array_bool(self): + """Test JSON value validation for array[boolean] type.""" + # Valid array of booleans + result = WebhookService._validate_json_value("flags", [True, False, True], "array[boolean]") + assert result == [True, False, True] + + # Invalid - array with non-booleans + with pytest.raises(ValueError, match="Expected array of booleans, got list"): + WebhookService._validate_json_value("flags", [True, "false", True], "array[boolean]") + + def test_validate_json_value_array_object(self): + """Test JSON value validation for array[object] type.""" + # Valid array of objects + result = WebhookService._validate_json_value("users", [{"name": "John"}, {"name": "Jane"}], "array[object]") + assert result == [{"name": "John"}, {"name": "Jane"}] + + # Invalid - array with non-objects + with pytest.raises(ValueError, match="Expected array of objects, got list"): + WebhookService._validate_json_value("users", [{"name": "John"}, "not_object"], "array[object]") + + def test_convert_form_value_string(self): + """Test form value conversion for string type.""" + result = WebhookService._convert_form_value("test", "hello", "string") + assert result == "hello" + + def test_convert_form_value_number(self): + """Test form value conversion for number type.""" + # Integer + result = WebhookService._convert_form_value("count", "42", "number") + assert result == 42 + + # Float + result = WebhookService._convert_form_value("price", "19.99", "number") + assert result == 19.99 + + # Invalid number + with pytest.raises(ValueError, match="Cannot convert 'not_a_number' to number"): + WebhookService._convert_form_value("count", "not_a_number", "number") + + def test_convert_form_value_boolean(self): + """Test form value conversion for boolean type.""" + # True values + assert WebhookService._convert_form_value("flag", "true", "boolean") is True + assert WebhookService._convert_form_value("flag", "1", "boolean") is True + assert WebhookService._convert_form_value("flag", "yes", "boolean") is True + + # False values + assert WebhookService._convert_form_value("flag", "false", "boolean") is False + assert WebhookService._convert_form_value("flag", "0", "boolean") is False + assert WebhookService._convert_form_value("flag", "no", "boolean") is False + + # Invalid boolean + with pytest.raises(ValueError, match="Cannot convert 'maybe' to boolean"): + WebhookService._convert_form_value("flag", "maybe", "boolean") + + def test_extract_and_validate_webhook_data_success(self): + """Test successful unified data extraction and validation.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="POST", + headers={"Content-Type": "application/json"}, + query_string="count=42&enabled=true", + json={"message": "hello", "age": 25}, + ): + webhook_trigger = MagicMock() + node_config = { + "data": { + "method": "post", + "content_type": "application/json", + "params": [ + {"name": "count", "type": "number", "required": True}, + {"name": "enabled", "type": "boolean", "required": True}, + ], + "body": [ + {"name": "message", "type": "string", "required": True}, + {"name": "age", "type": "number", "required": True}, + ], + } + } + + result = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) + + # Check that types are correctly converted + assert result["query_params"]["count"] == 42 # Converted to int + assert result["query_params"]["enabled"] is True # Converted to bool + assert result["body"]["message"] == "hello" # Already string + assert result["body"]["age"] == 25 # Already number + + def test_extract_and_validate_webhook_data_validation_error(self): + """Test unified data extraction with validation error.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="GET", # Wrong method + headers={"Content-Type": "application/json"}, + ): + webhook_trigger = MagicMock() + node_config = { + "data": { + "method": "post", # Expects POST + "content_type": "application/json", + } + } + + with pytest.raises(ValueError, match="HTTP method mismatch"): + WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) + + def test_debug_mode_parameter_handling(self): + """Test that the debug mode parameter is properly handled in _prepare_webhook_execution.""" + from controllers.trigger.webhook import _prepare_webhook_execution + + # Mock the WebhookService methods + with ( + patch.object(WebhookService, "get_webhook_trigger_and_workflow") as mock_get_trigger, + patch.object(WebhookService, "extract_and_validate_webhook_data") as mock_extract, + ): + mock_trigger = MagicMock() + mock_workflow = MagicMock() + mock_config = {"data": {"test": "config"}} + mock_data = {"test": "data"} + + mock_get_trigger.return_value = (mock_trigger, mock_workflow, mock_config) + mock_extract.return_value = mock_data + + result = _prepare_webhook_execution("test_webhook", is_debug=False) + assert result == (mock_trigger, mock_workflow, mock_config, mock_data, None) + + # Reset mock + mock_get_trigger.reset_mock() + + result = _prepare_webhook_execution("test_webhook", is_debug=True) + assert result == (mock_trigger, mock_workflow, mock_config, mock_data, None) diff --git a/api/uv.lock b/api/uv.lock index 65302a42f5..db4827e143 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version >= '3.12.4' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", @@ -1183,6 +1183,19 @@ version = "1.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" } +[[package]] +name = "croniter" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/2f/44d1ae153a0e27be56be43465e5cb39b9650c781e001e7864389deb25090/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577", size = 64481, upload-time = "2024-12-17T17:17:47.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" }, +] + [[package]] name = "cryptography" version = "46.0.2" @@ -1290,6 +1303,7 @@ name = "dify-api" version = "1.9.2" source = { virtual = "." } dependencies = [ + { name = "apscheduler" }, { name = "arize-phoenix-otel" }, { name = "azure-identity" }, { name = "beautifulsoup4" }, @@ -1298,6 +1312,7 @@ dependencies = [ { name = "cachetools" }, { name = "celery" }, { name = "chardet" }, + { name = "croniter" }, { name = "flask" }, { name = "flask-compress" }, { name = "flask-cors" }, @@ -1482,6 +1497,7 @@ vdb = [ [package.metadata] requires-dist = [ + { name = "apscheduler", specifier = ">=3.11.0" }, { name = "arize-phoenix-otel", specifier = "~=0.9.2" }, { name = "azure-identity", specifier = "==1.16.1" }, { name = "beautifulsoup4", specifier = "==4.12.2" }, @@ -1490,6 +1506,7 @@ requires-dist = [ { name = "cachetools", specifier = "~=5.3.0" }, { name = "celery", specifier = "~=5.5.2" }, { name = "chardet", specifier = "~=5.1.0" }, + { name = "croniter", specifier = ">=6.0.0" }, { name = "flask", specifier = "~=3.1.2" }, { name = "flask-compress", specifier = ">=1.17,<1.18" }, { name = "flask-cors", specifier = "~=6.0.0" }, diff --git a/dev/start-beat b/dev/start-beat new file mode 100755 index 0000000000..e417874b25 --- /dev/null +++ b/dev/start-beat @@ -0,0 +1,60 @@ +#!/bin/bash + +set -x + +# Help function +show_help() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --loglevel LEVEL Log level (default: INFO)" + echo " --scheduler SCHEDULER Scheduler class (default: celery.beat:PersistentScheduler)" + echo " -h, --help Show this help message" + echo "" + echo "Examples:" + echo " $0" + echo " $0 --loglevel DEBUG" + echo " $0 --scheduler django_celery_beat.schedulers:DatabaseScheduler" + echo "" + echo "Description:" + echo " Starts Celery Beat scheduler for periodic task execution." + echo " Beat sends scheduled tasks to worker queues at specified intervals." +} + +# Parse command line arguments +LOGLEVEL="INFO" +SCHEDULER="celery.beat:PersistentScheduler" + +while [[ $# -gt 0 ]]; do + case $1 in + --loglevel) + LOGLEVEL="$2" + shift 2 + ;; + --scheduler) + SCHEDULER="$2" + shift 2 + ;; + -h|--help) + show_help + exit 0 + ;; + *) + echo "Unknown option: $1" + show_help + exit 1 + ;; + esac +done + +SCRIPT_DIR="$(dirname "$(realpath "$0")")" +cd "$SCRIPT_DIR/.." + +echo "Starting Celery Beat with:" +echo " Log Level: ${LOGLEVEL}" +echo " Scheduler: ${SCHEDULER}" + +uv --directory api run \ + celery -A app.celery beat \ + --loglevel ${LOGLEVEL} \ + --scheduler ${SCHEDULER} \ No newline at end of file diff --git a/dev/start-web b/dev/start-web new file mode 100755 index 0000000000..dc06d6a59f --- /dev/null +++ b/dev/start-web @@ -0,0 +1,8 @@ +#!/bin/bash + +set -x + +SCRIPT_DIR="$(dirname "$(realpath "$0")")" +cd "$SCRIPT_DIR/../web" + +pnpm install && pnpm build && pnpm start diff --git a/dev/start-worker b/dev/start-worker index 9cf448c9c6..b1e010975b 100755 --- a/dev/start-worker +++ b/dev/start-worker @@ -2,9 +2,106 @@ set -x +# Help function +show_help() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " -q, --queues QUEUES Comma-separated list of queues to process" + echo " -c, --concurrency NUM Number of worker processes (default: 1)" + echo " -P, --pool POOL Pool implementation (default: gevent)" + echo " --loglevel LEVEL Log level (default: INFO)" + echo " -h, --help Show this help message" + echo "" + echo "Examples:" + echo " $0 --queues dataset,workflow" + echo " $0 --queues workflow_professional,workflow_team --concurrency 4" + echo " $0 --queues dataset --concurrency 2 --pool prefork" + echo "" + echo "Available queues:" + echo " dataset - RAG indexing and document processing" + echo " workflow - Workflow triggers (community edition)" + echo " workflow_professional - Professional tier workflows (cloud edition)" + echo " workflow_team - Team tier workflows (cloud edition)" + echo " workflow_sandbox - Sandbox tier workflows (cloud edition)" + echo " schedule_poller - Schedule polling tasks" + echo " schedule_executor - Schedule execution tasks" + echo " mail - Email notifications" + echo " ops_trace - Operations tracing" + echo " app_deletion - Application cleanup" + echo " plugin - Plugin operations" + echo " workflow_storage - Workflow storage tasks" + echo " conversation - Conversation tasks" + echo " priority_pipeline - High priority pipeline tasks" + echo " pipeline - Standard pipeline tasks" + echo " triggered_workflow_dispatcher - Trigger dispatcher tasks" + echo " trigger_refresh_executor - Trigger refresh tasks" +} + +# Parse command line arguments +QUEUES="" +CONCURRENCY=1 +POOL="gevent" +LOGLEVEL="INFO" + +while [[ $# -gt 0 ]]; do + case $1 in + -q|--queues) + QUEUES="$2" + shift 2 + ;; + -c|--concurrency) + CONCURRENCY="$2" + shift 2 + ;; + -P|--pool) + POOL="$2" + shift 2 + ;; + --loglevel) + LOGLEVEL="$2" + shift 2 + ;; + -h|--help) + show_help + exit 0 + ;; + *) + echo "Unknown option: $1" + show_help + exit 1 + ;; + esac +done + SCRIPT_DIR="$(dirname "$(realpath "$0")")" cd "$SCRIPT_DIR/.." +# If no queues specified, use edition-based defaults +if [[ -z "${QUEUES}" ]]; then + # Get EDITION from environment, default to SELF_HOSTED (community edition) + EDITION=${EDITION:-"SELF_HOSTED"} + + # Configure queues based on edition + if [[ "${EDITION}" == "CLOUD" ]]; then + # Cloud edition: separate queues for dataset and trigger tasks + QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor" + else + # Community edition (SELF_HOSTED): dataset and workflow have separate queues + QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor" + fi + + echo "No queues specified, using edition-based defaults: ${QUEUES}" +else + echo "Using specified queues: ${QUEUES}" +fi + +echo "Starting Celery worker with:" +echo " Queues: ${QUEUES}" +echo " Concurrency: ${CONCURRENCY}" +echo " Pool: ${POOL}" +echo " Log Level: ${LOGLEVEL}" + uv --directory api run \ - celery -A app.celery worker \ - -P gevent -c 1 --loglevel INFO -Q dataset,priority_dataset,generation,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,priority_pipeline,pipeline + celery -A app.celery worker \ + -P ${POOL} -c ${CONCURRENCY} --loglevel ${LOGLEVEL} -Q ${QUEUES} diff --git a/docker/.env.example b/docker/.env.example index 1ccc11d01b..519f4aa3e0 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -24,6 +24,11 @@ CONSOLE_WEB_URL= # Example: https://api.dify.ai SERVICE_API_URL= +# Trigger external URL +# used to display trigger endpoint API Base URL to the front-end. +# Example: https://api.dify.ai +TRIGGER_URL=http://localhost + # WebApp API backend Url, # used to declare the back-end URL for the front-end API. # If empty, it is the same domain. @@ -998,6 +1003,9 @@ HTTP_REQUEST_MAX_WRITE_TIMEOUT=600 # Base64 encoded client private key data for mutual TLS authentication (PEM format, optional) # HTTP_REQUEST_NODE_SSL_CLIENT_KEY_DATA=LS0tLS1CRUdJTi... +# Webhook request configuration +WEBHOOK_REQUEST_BODY_MAX_SIZE=10485760 + # Respect X-* headers to redirect clients RESPECT_XFORWARD_HEADERS_ENABLED=false @@ -1370,6 +1378,10 @@ ENABLE_CLEAN_MESSAGES=false ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK=false ENABLE_DATASETS_QUEUE_MONITOR=false ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK=true +ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK=true +WORKFLOW_SCHEDULE_POLLER_INTERVAL=1 +WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE=100 +WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK=0 # Tenant isolated task queue configuration TENANT_ISOLATED_TASK_CONCURRENCY=1 diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index f6c665a3cc..4703d7d344 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -2,7 +2,7 @@ x-shared-env: &shared-api-worker-env services: # API service api: - image: langgenius/dify-api:1.9.2 + image: langgenius/dify-api:1.10.0-rc1 restart: always environment: # Use the shared environment variables. @@ -29,14 +29,14 @@ services: - default # worker service - # The Celery worker for processing the queue. + # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.9.2 + image: langgenius/dify-api:1.10.0-rc1 restart: always environment: # Use the shared environment variables. <<: *shared-api-worker-env - # Startup mode, 'worker' starts the Celery worker for processing the queue. + # Startup mode, 'worker' starts the Celery worker for processing all queues. MODE: worker SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} @@ -58,7 +58,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.9.2 + image: langgenius/dify-api:1.10.0-rc1 restart: always environment: # Use the shared environment variables. @@ -76,7 +76,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.9.2 + image: langgenius/dify-web:1.10.0-rc1 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} @@ -182,7 +182,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.3.3-local + image: langgenius/dify-plugin-daemon:0.4.0-local restart: always environment: # Use the shared environment variables. diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 0497e9d1f6..b93457f8dc 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -87,7 +87,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.3.3-local + image: langgenius/dify-plugin-daemon:0.4.0-local restart: always env_file: - ./middleware.env diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 07d6cd46ab..b32f893a89 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -8,6 +8,7 @@ x-shared-env: &shared-api-worker-env CONSOLE_API_URL: ${CONSOLE_API_URL:-} CONSOLE_WEB_URL: ${CONSOLE_WEB_URL:-} SERVICE_API_URL: ${SERVICE_API_URL:-} + TRIGGER_URL: ${TRIGGER_URL:-http://localhost} APP_API_URL: ${APP_API_URL:-} APP_WEB_URL: ${APP_WEB_URL:-} FILES_URL: ${FILES_URL:-} @@ -435,6 +436,7 @@ x-shared-env: &shared-api-worker-env HTTP_REQUEST_MAX_CONNECT_TIMEOUT: ${HTTP_REQUEST_MAX_CONNECT_TIMEOUT:-10} HTTP_REQUEST_MAX_READ_TIMEOUT: ${HTTP_REQUEST_MAX_READ_TIMEOUT:-600} HTTP_REQUEST_MAX_WRITE_TIMEOUT: ${HTTP_REQUEST_MAX_WRITE_TIMEOUT:-600} + WEBHOOK_REQUEST_BODY_MAX_SIZE: ${WEBHOOK_REQUEST_BODY_MAX_SIZE:-10485760} RESPECT_XFORWARD_HEADERS_ENABLED: ${RESPECT_XFORWARD_HEADERS_ENABLED:-false} SSRF_PROXY_HTTP_URL: ${SSRF_PROXY_HTTP_URL:-http://ssrf_proxy:3128} SSRF_PROXY_HTTPS_URL: ${SSRF_PROXY_HTTPS_URL:-http://ssrf_proxy:3128} @@ -614,12 +616,16 @@ x-shared-env: &shared-api-worker-env ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: ${ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK:-false} ENABLE_DATASETS_QUEUE_MONITOR: ${ENABLE_DATASETS_QUEUE_MONITOR:-false} ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK: ${ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK:-true} + ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK: ${ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK:-true} + WORKFLOW_SCHEDULE_POLLER_INTERVAL: ${WORKFLOW_SCHEDULE_POLLER_INTERVAL:-1} + WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE: ${WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE:-100} + WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK: ${WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK:-0} TENANT_ISOLATED_TASK_CONCURRENCY: ${TENANT_ISOLATED_TASK_CONCURRENCY:-1} services: # API service api: - image: langgenius/dify-api:1.9.2 + image: langgenius/dify-api:1.10.0-rc1 restart: always environment: # Use the shared environment variables. @@ -646,14 +652,14 @@ services: - default # worker service - # The Celery worker for processing the queue. + # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.9.2 + image: langgenius/dify-api:1.10.0-rc1 restart: always environment: # Use the shared environment variables. <<: *shared-api-worker-env - # Startup mode, 'worker' starts the Celery worker for processing the queue. + # Startup mode, 'worker' starts the Celery worker for processing all queues. MODE: worker SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} @@ -675,7 +681,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.9.2 + image: langgenius/dify-api:1.10.0-rc1 restart: always environment: # Use the shared environment variables. @@ -693,7 +699,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.9.2 + image: langgenius/dify-web:1.10.0-rc1 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} @@ -799,7 +805,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.3.3-local + image: langgenius/dify-plugin-daemon:0.4.0-local restart: always environment: # Use the shared environment variables. diff --git a/docker/nginx/conf.d/default.conf.template b/docker/nginx/conf.d/default.conf.template index 48d7da8cf5..1d63c1b97d 100644 --- a/docker/nginx/conf.d/default.conf.template +++ b/docker/nginx/conf.d/default.conf.template @@ -39,10 +39,17 @@ server { proxy_pass http://web:3000; include proxy.conf; } + location /mcp { proxy_pass http://api:5001; include proxy.conf; } + + location /triggers { + proxy_pass http://api:5001; + include proxy.conf; + } + # placeholder for acme challenge location ${ACME_CHALLENGE_LOCATION} diff --git a/web/__tests__/workflow-onboarding-integration.test.tsx b/web/__tests__/workflow-onboarding-integration.test.tsx new file mode 100644 index 0000000000..c1a922bb1f --- /dev/null +++ b/web/__tests__/workflow-onboarding-integration.test.tsx @@ -0,0 +1,614 @@ +import { BlockEnum } from '@/app/components/workflow/types' +import { useWorkflowStore } from '@/app/components/workflow/store' + +// Mock zustand store +jest.mock('@/app/components/workflow/store') + +// Mock ReactFlow store +const mockGetNodes = jest.fn() +jest.mock('reactflow', () => ({ + useStoreApi: () => ({ + getState: () => ({ + getNodes: mockGetNodes, + }), + }), +})) + +describe('Workflow Onboarding Integration Logic', () => { + const mockSetShowOnboarding = jest.fn() + const mockSetHasSelectedStartNode = jest.fn() + const mockSetHasShownOnboarding = jest.fn() + const mockSetShouldAutoOpenStartNodeSelector = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + + // Mock store implementation + ;(useWorkflowStore as jest.Mock).mockReturnValue({ + showOnboarding: false, + setShowOnboarding: mockSetShowOnboarding, + hasSelectedStartNode: false, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + hasShownOnboarding: false, + setHasShownOnboarding: mockSetHasShownOnboarding, + notInitialWorkflow: false, + shouldAutoOpenStartNodeSelector: false, + setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector, + }) + }) + + describe('Onboarding State Management', () => { + it('should initialize onboarding state correctly', () => { + const store = useWorkflowStore() + + expect(store.showOnboarding).toBe(false) + expect(store.hasSelectedStartNode).toBe(false) + expect(store.hasShownOnboarding).toBe(false) + }) + + it('should update onboarding visibility', () => { + const store = useWorkflowStore() + + store.setShowOnboarding(true) + expect(mockSetShowOnboarding).toHaveBeenCalledWith(true) + + store.setShowOnboarding(false) + expect(mockSetShowOnboarding).toHaveBeenCalledWith(false) + }) + + it('should track node selection state', () => { + const store = useWorkflowStore() + + store.setHasSelectedStartNode(true) + expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(true) + }) + + it('should track onboarding show state', () => { + const store = useWorkflowStore() + + store.setHasShownOnboarding(true) + expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true) + }) + }) + + describe('Node Validation Logic', () => { + /** + * Test the critical fix in use-nodes-sync-draft.ts + * This ensures trigger nodes are recognized as valid start nodes + */ + it('should validate Start node as valid start node', () => { + const mockNode = { + data: { type: BlockEnum.Start }, + id: 'start-1', + } + + // Simulate the validation logic from use-nodes-sync-draft.ts + const isValidStartNode = mockNode.data.type === BlockEnum.Start + || mockNode.data.type === BlockEnum.TriggerSchedule + || mockNode.data.type === BlockEnum.TriggerWebhook + || mockNode.data.type === BlockEnum.TriggerPlugin + + expect(isValidStartNode).toBe(true) + }) + + it('should validate TriggerSchedule as valid start node', () => { + const mockNode = { + data: { type: BlockEnum.TriggerSchedule }, + id: 'trigger-schedule-1', + } + + const isValidStartNode = mockNode.data.type === BlockEnum.Start + || mockNode.data.type === BlockEnum.TriggerSchedule + || mockNode.data.type === BlockEnum.TriggerWebhook + || mockNode.data.type === BlockEnum.TriggerPlugin + + expect(isValidStartNode).toBe(true) + }) + + it('should validate TriggerWebhook as valid start node', () => { + const mockNode = { + data: { type: BlockEnum.TriggerWebhook }, + id: 'trigger-webhook-1', + } + + const isValidStartNode = mockNode.data.type === BlockEnum.Start + || mockNode.data.type === BlockEnum.TriggerSchedule + || mockNode.data.type === BlockEnum.TriggerWebhook + || mockNode.data.type === BlockEnum.TriggerPlugin + + expect(isValidStartNode).toBe(true) + }) + + it('should validate TriggerPlugin as valid start node', () => { + const mockNode = { + data: { type: BlockEnum.TriggerPlugin }, + id: 'trigger-plugin-1', + } + + const isValidStartNode = mockNode.data.type === BlockEnum.Start + || mockNode.data.type === BlockEnum.TriggerSchedule + || mockNode.data.type === BlockEnum.TriggerWebhook + || mockNode.data.type === BlockEnum.TriggerPlugin + + expect(isValidStartNode).toBe(true) + }) + + it('should reject non-trigger nodes as invalid start nodes', () => { + const mockNode = { + data: { type: BlockEnum.LLM }, + id: 'llm-1', + } + + const isValidStartNode = mockNode.data.type === BlockEnum.Start + || mockNode.data.type === BlockEnum.TriggerSchedule + || mockNode.data.type === BlockEnum.TriggerWebhook + || mockNode.data.type === BlockEnum.TriggerPlugin + + expect(isValidStartNode).toBe(false) + }) + + it('should handle array of nodes with mixed types', () => { + const mockNodes = [ + { data: { type: BlockEnum.LLM }, id: 'llm-1' }, + { data: { type: BlockEnum.TriggerWebhook }, id: 'webhook-1' }, + { data: { type: BlockEnum.Answer }, id: 'answer-1' }, + ] + + // Simulate hasStartNode logic from use-nodes-sync-draft.ts + const hasStartNode = mockNodes.find(node => + node.data.type === BlockEnum.Start + || node.data.type === BlockEnum.TriggerSchedule + || node.data.type === BlockEnum.TriggerWebhook + || node.data.type === BlockEnum.TriggerPlugin, + ) + + expect(hasStartNode).toBeTruthy() + expect(hasStartNode?.id).toBe('webhook-1') + }) + + it('should return undefined when no valid start nodes exist', () => { + const mockNodes = [ + { data: { type: BlockEnum.LLM }, id: 'llm-1' }, + { data: { type: BlockEnum.Answer }, id: 'answer-1' }, + ] + + const hasStartNode = mockNodes.find(node => + node.data.type === BlockEnum.Start + || node.data.type === BlockEnum.TriggerSchedule + || node.data.type === BlockEnum.TriggerWebhook + || node.data.type === BlockEnum.TriggerPlugin, + ) + + expect(hasStartNode).toBeUndefined() + }) + }) + + describe('Auto-open Logic for Node Handles', () => { + /** + * Test the auto-open logic from node-handle.tsx + * This ensures all trigger types auto-open the block selector when flagged + */ + it('should auto-expand for Start node in new workflow', () => { + const shouldAutoOpenStartNodeSelector = true + const nodeType = BlockEnum.Start + const isChatMode = false + + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(true) + }) + + it('should auto-expand for TriggerSchedule in new workflow', () => { + const shouldAutoOpenStartNodeSelector = true + const nodeType = BlockEnum.TriggerSchedule + const isChatMode = false + + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(true) + }) + + it('should auto-expand for TriggerWebhook in new workflow', () => { + const shouldAutoOpenStartNodeSelector = true + const nodeType = BlockEnum.TriggerWebhook + const isChatMode = false + + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(true) + }) + + it('should auto-expand for TriggerPlugin in new workflow', () => { + const shouldAutoOpenStartNodeSelector = true + const nodeType = BlockEnum.TriggerPlugin + const isChatMode = false + + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(true) + }) + + it('should not auto-expand for non-trigger nodes', () => { + const shouldAutoOpenStartNodeSelector = true + const nodeType = BlockEnum.LLM + const isChatMode = false + + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(false) + }) + + it('should not auto-expand in chat mode', () => { + const shouldAutoOpenStartNodeSelector = true + const nodeType = BlockEnum.Start + const isChatMode = true + + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(false) + }) + + it('should not auto-expand for existing workflows', () => { + const shouldAutoOpenStartNodeSelector = false + const nodeType = BlockEnum.Start + const isChatMode = false + + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(false) + }) + it('should reset auto-open flag after triggering once', () => { + let shouldAutoOpenStartNodeSelector = true + const nodeType = BlockEnum.Start + const isChatMode = false + + const shouldAutoExpand = shouldAutoOpenStartNodeSelector && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + if (shouldAutoExpand) + shouldAutoOpenStartNodeSelector = false + + expect(shouldAutoExpand).toBe(true) + expect(shouldAutoOpenStartNodeSelector).toBe(false) + }) + }) + + describe('Node Creation Without Auto-selection', () => { + /** + * Test that nodes are created without the 'selected: true' property + * This prevents auto-opening the properties panel + */ + it('should create Start node without auto-selection', () => { + const nodeData = { type: BlockEnum.Start, title: 'Start' } + + // Simulate node creation logic from workflow-children.tsx + const createdNodeData = { + ...nodeData, + // Note: 'selected: true' should NOT be added + } + + expect(createdNodeData.selected).toBeUndefined() + expect(createdNodeData.type).toBe(BlockEnum.Start) + }) + + it('should create TriggerWebhook node without auto-selection', () => { + const nodeData = { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' } + const toolConfig = { webhook_url: 'https://example.com/webhook' } + + const createdNodeData = { + ...nodeData, + ...toolConfig, + // Note: 'selected: true' should NOT be added + } + + expect(createdNodeData.selected).toBeUndefined() + expect(createdNodeData.type).toBe(BlockEnum.TriggerWebhook) + expect(createdNodeData.webhook_url).toBe('https://example.com/webhook') + }) + + it('should preserve other node properties while avoiding auto-selection', () => { + const nodeData = { + type: BlockEnum.TriggerSchedule, + title: 'Schedule Trigger', + config: { interval: '1h' }, + } + + const createdNodeData = { + ...nodeData, + } + + expect(createdNodeData.selected).toBeUndefined() + expect(createdNodeData.type).toBe(BlockEnum.TriggerSchedule) + expect(createdNodeData.title).toBe('Schedule Trigger') + expect(createdNodeData.config).toEqual({ interval: '1h' }) + }) + }) + + describe('Workflow Initialization Logic', () => { + /** + * Test the initialization logic from use-workflow-init.ts + * This ensures onboarding is triggered correctly for new workflows + */ + it('should trigger onboarding for new workflow when draft does not exist', () => { + // Simulate the error handling logic from use-workflow-init.ts + const error = { + json: jest.fn().mockResolvedValue({ code: 'draft_workflow_not_exist' }), + bodyUsed: false, + } + + const mockWorkflowStore = { + setState: jest.fn(), + } + + // Simulate error handling + if (error && error.json && !error.bodyUsed) { + error.json().then((err: any) => { + if (err.code === 'draft_workflow_not_exist') { + mockWorkflowStore.setState({ + notInitialWorkflow: true, + showOnboarding: true, + }) + } + }) + } + + return error.json().then(() => { + expect(mockWorkflowStore.setState).toHaveBeenCalledWith({ + notInitialWorkflow: true, + showOnboarding: true, + }) + }) + }) + + it('should not trigger onboarding for existing workflows', () => { + // Simulate successful draft fetch + const mockWorkflowStore = { + setState: jest.fn(), + } + + // Normal initialization path should not set showOnboarding: true + mockWorkflowStore.setState({ + environmentVariables: [], + conversationVariables: [], + }) + + expect(mockWorkflowStore.setState).not.toHaveBeenCalledWith( + expect.objectContaining({ showOnboarding: true }), + ) + }) + + it('should create empty draft with proper structure', () => { + const mockSyncWorkflowDraft = jest.fn() + const appId = 'test-app-id' + + // Simulate the syncWorkflowDraft call from use-workflow-init.ts + const draftParams = { + url: `/apps/${appId}/workflows/draft`, + params: { + graph: { + nodes: [], // Empty nodes initially + edges: [], + }, + features: { + retriever_resource: { enabled: true }, + }, + environment_variables: [], + conversation_variables: [], + }, + } + + mockSyncWorkflowDraft(draftParams) + + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({ + url: `/apps/${appId}/workflows/draft`, + params: { + graph: { + nodes: [], + edges: [], + }, + features: { + retriever_resource: { enabled: true }, + }, + environment_variables: [], + conversation_variables: [], + }, + }) + }) + }) + + describe('Auto-Detection for Empty Canvas', () => { + beforeEach(() => { + mockGetNodes.mockClear() + }) + + it('should detect empty canvas and trigger onboarding', () => { + // Mock empty canvas + mockGetNodes.mockReturnValue([]) + + // Mock store with proper state for auto-detection + ;(useWorkflowStore as jest.Mock).mockReturnValue({ + showOnboarding: false, + hasShownOnboarding: false, + notInitialWorkflow: false, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + hasSelectedStartNode: false, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + shouldAutoOpenStartNodeSelector: false, + setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector, + getState: () => ({ + showOnboarding: false, + hasShownOnboarding: false, + notInitialWorkflow: false, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + hasSelectedStartNode: false, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector, + }), + }) + + // Simulate empty canvas check logic + const nodes = mockGetNodes() + const startNodeTypes = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ] + const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data?.type)) + const isEmpty = nodes.length === 0 || !hasStartNode + + expect(isEmpty).toBe(true) + expect(nodes.length).toBe(0) + }) + + it('should detect canvas with non-start nodes as empty', () => { + // Mock canvas with non-start nodes + mockGetNodes.mockReturnValue([ + { id: '1', data: { type: BlockEnum.LLM } }, + { id: '2', data: { type: BlockEnum.Code } }, + ]) + + const nodes = mockGetNodes() + const startNodeTypes = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ] + const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data.type)) + const isEmpty = nodes.length === 0 || !hasStartNode + + expect(isEmpty).toBe(true) + expect(hasStartNode).toBe(false) + }) + + it('should not detect canvas with start nodes as empty', () => { + // Mock canvas with start node + mockGetNodes.mockReturnValue([ + { id: '1', data: { type: BlockEnum.Start } }, + ]) + + const nodes = mockGetNodes() + const startNodeTypes = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ] + const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data.type)) + const isEmpty = nodes.length === 0 || !hasStartNode + + expect(isEmpty).toBe(false) + expect(hasStartNode).toBe(true) + }) + + it('should not trigger onboarding if already shown in session', () => { + // Mock empty canvas + mockGetNodes.mockReturnValue([]) + + // Mock store with hasShownOnboarding = true + ;(useWorkflowStore as jest.Mock).mockReturnValue({ + showOnboarding: false, + hasShownOnboarding: true, // Already shown in this session + notInitialWorkflow: false, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + hasSelectedStartNode: false, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + shouldAutoOpenStartNodeSelector: false, + setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector, + getState: () => ({ + showOnboarding: false, + hasShownOnboarding: true, + notInitialWorkflow: false, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + hasSelectedStartNode: false, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector, + }), + }) + + // Simulate the check logic with hasShownOnboarding = true + const store = useWorkflowStore() + const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow + + expect(shouldTrigger).toBe(false) + }) + + it('should not trigger onboarding during initial workflow creation', () => { + // Mock empty canvas + mockGetNodes.mockReturnValue([]) + + // Mock store with notInitialWorkflow = true (initial creation) + ;(useWorkflowStore as jest.Mock).mockReturnValue({ + showOnboarding: false, + hasShownOnboarding: false, + notInitialWorkflow: true, // Initial workflow creation + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + hasSelectedStartNode: false, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + shouldAutoOpenStartNodeSelector: false, + setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector, + getState: () => ({ + showOnboarding: false, + hasShownOnboarding: false, + notInitialWorkflow: true, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + hasSelectedStartNode: false, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector, + }), + }) + + // Simulate the check logic with notInitialWorkflow = true + const store = useWorkflowStore() + const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow + + expect(shouldTrigger).toBe(false) + }) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index a36a7e281d..1f836de6e6 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -24,7 +24,7 @@ import { fetchAppDetailDirect } from '@/service/apps' import { useAppContext } from '@/context/app-context' import Loading from '@/app/components/base/loading' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import type { App } from '@/types/app' +import { type App, AppModeEnum } from '@/types/app' import useDocumentTitle from '@/hooks/use-document-title' import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import dynamic from 'next/dynamic' @@ -64,12 +64,12 @@ const AppDetailLayout: FC = (props) => { selectedIcon: NavIcon }>>([]) - const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: string) => { + const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => { const navConfig = [ ...(isCurrentWorkspaceEditor ? [{ name: t('common.appMenus.promptEng'), - href: `/app/${appId}/${(mode === 'workflow' || mode === 'advanced-chat') ? 'workflow' : 'configuration'}`, + href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`, icon: RiTerminalWindowLine, selectedIcon: RiTerminalWindowFill, }] @@ -83,7 +83,7 @@ const AppDetailLayout: FC = (props) => { }, ...(isCurrentWorkspaceEditor ? [{ - name: mode !== 'workflow' + name: mode !== AppModeEnum.WORKFLOW ? t('common.appMenus.logAndAnn') : t('common.appMenus.logs'), href: `/app/${appId}/logs`, @@ -110,7 +110,7 @@ const AppDetailLayout: FC = (props) => { const mode = isMobile ? 'collapse' : 'expand' setAppSidebarExpand(isMobile ? mode : localeMode) // TODO: consider screen size and mode - // if ((appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow') && (pathname).endsWith('workflow')) + // if ((appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === 'workflow') && (pathname).endsWith('workflow')) // setAppSidebarExpand('collapse') } }, [appDetail, isMobile]) @@ -138,10 +138,10 @@ const AppDetailLayout: FC = (props) => { router.replace(`/app/${appId}/overview`) return } - if ((res.mode === 'workflow' || res.mode === 'advanced-chat') && (pathname).endsWith('configuration')) { + if ((res.mode === AppModeEnum.WORKFLOW || res.mode === AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('configuration')) { router.replace(`/app/${appId}/workflow`) } - else if ((res.mode !== 'workflow' && res.mode !== 'advanced-chat') && (pathname).endsWith('workflow')) { + else if ((res.mode !== AppModeEnum.WORKFLOW && res.mode !== AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('workflow')) { router.replace(`/app/${appId}/configuration`) } else { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx index e58e79918f..7e592729a5 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -1,11 +1,12 @@ 'use client' import type { FC } from 'react' -import React from 'react' +import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import AppCard from '@/app/components/app/overview/app-card' import Loading from '@/app/components/base/loading' import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card' +import TriggerCard from '@/app/components/app/overview/trigger-card' import { ToastContext } from '@/app/components/base/toast' import { fetchAppDetail, @@ -14,11 +15,15 @@ import { updateAppSiteStatus, } from '@/service/apps' import type { App } from '@/types/app' +import { AppModeEnum } from '@/types/app' import type { UpdateAppSiteCodeResponse } from '@/models/app' import { asyncRunSafe } from '@/utils' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import type { IAppCardProps } from '@/app/components/app/overview/app-card' import { useStore as useAppStore } from '@/app/components/app/store' +import { useAppWorkflow } from '@/service/use-workflow' +import type { BlockEnum } from '@/app/components/workflow/types' +import { isTriggerNode } from '@/app/components/workflow/types' export type ICardViewProps = { appId: string @@ -33,6 +38,17 @@ const CardView: FC = ({ appId, isInPanel, className }) => { const setAppDetail = useAppStore(state => state.setAppDetail) const showMCPCard = isInPanel + const showTriggerCard = isInPanel && appDetail?.mode === AppModeEnum.WORKFLOW + const { data: currentWorkflow } = useAppWorkflow(appDetail?.mode === AppModeEnum.WORKFLOW ? appDetail.id : '') + const hasTriggerNode = useMemo(() => { + if (appDetail?.mode !== AppModeEnum.WORKFLOW) + return false + const nodes = currentWorkflow?.graph?.nodes || [] + return nodes.some((node) => { + const nodeType = node.data?.type as BlockEnum | undefined + return !!nodeType && isTriggerNode(nodeType) + }) + }, [appDetail?.mode, currentWorkflow]) const updateAppDetail = async () => { try { @@ -106,23 +122,35 @@ const CardView: FC = ({ appId, isInPanel, className }) => { return (
- - - {showMCPCard && ( - + + + {showMCPCard && ( + + )} + + ) + } + {showTriggerCard && ( + )}
diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index bd5b9bead2..7f6bbb1f52 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -10,6 +10,7 @@ import { ProviderContextProvider } from '@/context/provider-context' import { ModalContextProvider } from '@/context/modal-context' import GotoAnything from '@/app/components/goto-anything' import Zendesk from '@/app/components/base/zendesk' +import ReadmePanel from '@/app/components/plugins/readme-panel' import Splash from '../components/splash' const Layout = ({ children }: { children: ReactNode }) => { @@ -25,6 +26,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
{children} + diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index c30ad68950..eb9538e49b 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -77,7 +77,7 @@ const Splash: FC = ({ children }) => { setWebAppPassport(shareCode!, access_token) redirectOrFinish() } - catch (error) { + catch { await webAppLogout(shareCode!) proceedToAuth() } diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index baf52946df..c2bda8d8fc 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -26,11 +26,11 @@ import { fetchWorkflowDraft } from '@/service/workflow' import ContentDialog from '@/app/components/base/content-dialog' import Button from '@/app/components/base/button' import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view' -import Divider from '../base/divider' import type { Operation } from './app-operations' import AppOperations from './app-operations' import dynamic from 'next/dynamic' import cn from '@/utils/classnames' +import { AppModeEnum } from '@/types/app' const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { ssr: false, @@ -158,7 +158,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx const exportCheck = async () => { if (!appDetail) return - if (appDetail.mode !== 'workflow' && appDetail.mode !== 'advanced-chat') { + if (appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.ADVANCED_CHAT) { onExport() return } @@ -208,7 +208,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx if (!appDetail) return null - const operations = [ + const primaryOperations = [ { id: 'edit', title: t('app.editApp'), @@ -235,7 +235,11 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx icon: , onClick: exportCheck, }, - (appDetail.mode !== 'agent-chat' && (appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow')) ? { + ] + + const secondaryOperations: Operation[] = [ + // Import DSL (conditional) + ...(appDetail.mode !== AppModeEnum.AGENT_CHAT && (appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)) ? [{ id: 'import', title: t('workflow.common.importDSL'), icon: , @@ -244,18 +248,39 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx onDetailExpand?.(false) setShowImportDSLModal(true) }, - } : undefined, - (appDetail.mode !== 'agent-chat' && (appDetail.mode === 'completion' || appDetail.mode === 'chat')) ? { - id: 'switch', - title: t('app.switch'), - icon: , + }] : [], + // Divider + { + id: 'divider-1', + title: '', + icon: <>, + onClick: () => { /* divider has no action */ }, + type: 'divider' as const, + }, + // Delete operation + { + id: 'delete', + title: t('common.operation.delete'), + icon: , onClick: () => { setOpen(false) onDetailExpand?.(false) - setShowSwitchModal(true) + setShowConfirmDelete(true) }, - } : undefined, - ].filter((op): op is Operation => Boolean(op)) + }, + ] + + // Keep the switch operation separate as it's not part of the main operations + const switchOperation = (appDetail.mode !== AppModeEnum.AGENT_CHAT && (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT)) ? { + id: 'switch', + title: t('app.switch'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowSwitchModal(true) + }, + } : null return (
@@ -298,7 +323,12 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
{appDetail.name}
-
{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}
+
+ {appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced') + : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent') + : appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot') + : appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion') + : t('app.types.workflow')}
)} @@ -323,7 +353,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx />
{appDetail.name}
-
{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}
+
{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced') : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent') : appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot') : appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion') : t('app.types.workflow')}
{/* description */} @@ -333,7 +363,8 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx {/* operations */} - -
- -
+ {/* Switch operation (if available) */} + {switchOperation && ( +
+ +
+ )} {showSwitchModal && ( void + id: string + title: string + icon: JSX.Element + onClick: () => void + type?: 'divider' } -const AppOperations = ({ operations, gap }: { - operations: Operation[] +type AppOperationsProps = { gap: number -}) => { + operations?: Operation[] + primaryOperations?: Operation[] + secondaryOperations?: Operation[] +} + +const EMPTY_OPERATIONS: Operation[] = [] + +const AppOperations = ({ + operations, + primaryOperations, + secondaryOperations, + gap, +}: AppOperationsProps) => { const { t } = useTranslation() const [visibleOpreations, setVisibleOperations] = useState([]) const [moreOperations, setMoreOperations] = useState([]) @@ -23,22 +37,59 @@ const AppOperations = ({ operations, gap }: { setShowMore(true) }, [setShowMore]) + const primaryOps = useMemo(() => { + if (operations) + return operations + if (primaryOperations) + return primaryOperations + return EMPTY_OPERATIONS + }, [operations, primaryOperations]) + + const secondaryOps = useMemo(() => { + if (operations) + return EMPTY_OPERATIONS + if (secondaryOperations) + return secondaryOperations + return EMPTY_OPERATIONS + }, [operations, secondaryOperations]) + const inlineOperations = primaryOps.filter(operation => operation.type !== 'divider') + useEffect(() => { - const moreElement = document.getElementById('more') - const navElement = document.getElementById('nav') + const applyState = (visible: Operation[], overflow: Operation[]) => { + const combinedMore = [...overflow, ...secondaryOps] + if (!overflow.length && combinedMore[0]?.type === 'divider') + combinedMore.shift() + setVisibleOperations(visible) + setMoreOperations(combinedMore) + } + + const inline = primaryOps.filter(operation => operation.type !== 'divider') + + if (!inline.length) { + applyState([], []) + return + } + + const navElement = navRef.current + const moreElement = document.getElementById('more-measure') + + if (!navElement || !moreElement) + return + let width = 0 - const containerWidth = navElement?.clientWidth ?? 0 - const moreWidth = moreElement?.clientWidth ?? 0 + const containerWidth = navElement.clientWidth + const moreWidth = moreElement.clientWidth - if (containerWidth === 0 || moreWidth === 0) return + if (containerWidth === 0 || moreWidth === 0) + return - const updatedEntries: Record = operations.reduce((pre, cur) => { + const updatedEntries: Record = inline.reduce((pre, cur) => { pre[cur.id] = false return pre }, {} as Record) - const childrens = Array.from(navRef.current!.children).slice(0, -1) + const childrens = Array.from(navElement.children).slice(0, -1) for (let i = 0; i < childrens.length; i++) { - const child: any = childrens[i] + const child = childrens[i] as HTMLElement const id = child.dataset.targetid if (!id) break const childWidth = child.clientWidth @@ -55,88 +106,106 @@ const AppOperations = ({ operations, gap }: { break } } - setVisibleOperations(operations.filter(item => updatedEntries[item.id])) - setMoreOperations(operations.filter(item => !updatedEntries[item.id])) - }, [operations, gap]) + + const visible = inline.filter(item => updatedEntries[item.id]) + const overflow = inline.filter(item => !updatedEntries[item.id]) + + applyState(visible, overflow) + }, [gap, primaryOps, secondaryOps]) + + const shouldShowMoreButton = moreOperations.length > 0 return ( <> - {!visibleOpreations.length && } -
- {visibleOpreations.map(operation => + {inlineOperations.map(operation => ( , - )} - {visibleOpreations.length < operations.length && - - - - -
- {moreOperations.map(item =>
+ ))} + +
+
+ {visibleOpreations.map(operation => ( + + ))} + {shouldShowMoreButton && ( + + +
)} -
-
-
} + + + {t('common.operation.more')} + + + + +
+ {moreOperations.map(item => item.type === 'divider' + ? ( +
+ ) + : ( +
+ {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} + {item.title} +
+ ))} +
+ + + )}
) diff --git a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx index b1da43ae14..3c5d38dd82 100644 --- a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx @@ -17,6 +17,7 @@ import NavLink from './navLink' import { useStore as useAppStore } from '@/app/components/app/store' import type { NavIcon } from './navLink' import cn from '@/utils/classnames' +import { AppModeEnum } from '@/types/app' type Props = { navigation: Array<{ @@ -97,7 +98,7 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
{appDetail.name}
-
{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}
+
{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced') : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent') : appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot') : appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion') : t('app.types.workflow')}
diff --git a/web/app/components/app-sidebar/basic.tsx b/web/app/components/app-sidebar/basic.tsx index 77a965c03e..da85fb154b 100644 --- a/web/app/components/app-sidebar/basic.tsx +++ b/web/app/components/app-sidebar/basic.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import AppIcon from '../base/app-icon' import Tooltip from '@/app/components/base/tooltip' import { - Code, + ApiAggregate, WindowCursor, } from '@/app/components/base/icons/src/vender/workflow' @@ -40,8 +40,8 @@ const NotionSvg = , - api:
- + api:
+
, dataset: , webapp:
@@ -56,12 +56,12 @@ export default function AppBasic({ icon, icon_background, name, isExternal, type return (
{icon && icon_background && iconType === 'app' && ( -
+
)} {iconType !== 'app' - &&
+ &&
{ICON_MAP[iconType]}
diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx index bc63b85f6d..8718890e35 100644 --- a/web/app/components/app/annotation/index.tsx +++ b/web/app/components/app/annotation/index.tsx @@ -24,7 +24,7 @@ import type { AnnotationReplyConfig } from '@/models/debug' import { sleep } from '@/utils' import { useProviderContext } from '@/context/provider-context' import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' -import type { App } from '@/types/app' +import { type App, AppModeEnum } from '@/types/app' import cn from '@/utils/classnames' import { delAnnotations } from '@/service/annotation' @@ -37,7 +37,7 @@ const Annotation: FC = (props) => { const { t } = useTranslation() const [isShowEdit, setIsShowEdit] = useState(false) const [annotationConfig, setAnnotationConfig] = useState(null) - const [isChatApp] = useState(appDetail.mode !== 'completion') + const [isChatApp] = useState(appDetail.mode !== AppModeEnum.COMPLETION) const [controlRefreshSwitch, setControlRefreshSwitch] = useState(() => Date.now()) const { plan, enableBilling } = useProviderContext() const isAnnotationFull = enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse diff --git a/web/app/components/app/app-publisher/features-wrapper.tsx b/web/app/components/app/app-publisher/features-wrapper.tsx index 409c390f4b..4b64558016 100644 --- a/web/app/components/app/app-publisher/features-wrapper.tsx +++ b/web/app/components/app/app-publisher/features-wrapper.tsx @@ -22,37 +22,39 @@ const FeaturesWrappedAppPublisher = (props: Props) => { const features = useFeatures(s => s.features) const featuresStore = useFeaturesStore() const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) + const { more_like_this, opening_statement, suggested_questions, sensitive_word_avoidance, speech_to_text, text_to_speech, suggested_questions_after_answer, retriever_resource, annotation_reply, file_upload, resetAppConfig } = props.publishedConfig.modelConfig + const handleConfirm = useCallback(() => { - props.resetAppConfig?.() + resetAppConfig?.() const { features, setFeatures, } = featuresStore!.getState() const newFeatures = produce(features, (draft) => { - draft.moreLikeThis = props.publishedConfig.modelConfig.more_like_this || { enabled: false } + draft.moreLikeThis = more_like_this || { enabled: false } draft.opening = { - enabled: !!props.publishedConfig.modelConfig.opening_statement, - opening_statement: props.publishedConfig.modelConfig.opening_statement || '', - suggested_questions: props.publishedConfig.modelConfig.suggested_questions || [], + enabled: !!opening_statement, + opening_statement: opening_statement || '', + suggested_questions: suggested_questions || [], } - draft.moderation = props.publishedConfig.modelConfig.sensitive_word_avoidance || { enabled: false } - draft.speech2text = props.publishedConfig.modelConfig.speech_to_text || { enabled: false } - draft.text2speech = props.publishedConfig.modelConfig.text_to_speech || { enabled: false } - draft.suggested = props.publishedConfig.modelConfig.suggested_questions_after_answer || { enabled: false } - draft.citation = props.publishedConfig.modelConfig.retriever_resource || { enabled: false } - draft.annotationReply = props.publishedConfig.modelConfig.annotation_reply || { enabled: false } + draft.moderation = sensitive_word_avoidance || { enabled: false } + draft.speech2text = speech_to_text || { enabled: false } + draft.text2speech = text_to_speech || { enabled: false } + draft.suggested = suggested_questions_after_answer || { enabled: false } + draft.citation = retriever_resource || { enabled: false } + draft.annotationReply = annotation_reply || { enabled: false } draft.file = { image: { - detail: props.publishedConfig.modelConfig.file_upload?.image?.detail || Resolution.high, - enabled: !!props.publishedConfig.modelConfig.file_upload?.image?.enabled, - number_limits: props.publishedConfig.modelConfig.file_upload?.image?.number_limits || 3, - transfer_methods: props.publishedConfig.modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + detail: file_upload?.image?.detail || Resolution.high, + enabled: !!file_upload?.image?.enabled, + number_limits: file_upload?.image?.number_limits || 3, + transfer_methods: file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], }, - enabled: !!(props.publishedConfig.modelConfig.file_upload?.enabled || props.publishedConfig.modelConfig.file_upload?.image?.enabled), - allowed_file_types: props.publishedConfig.modelConfig.file_upload?.allowed_file_types || [SupportUploadFileTypes.image], - allowed_file_extensions: props.publishedConfig.modelConfig.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`), - allowed_file_upload_methods: props.publishedConfig.modelConfig.file_upload?.allowed_file_upload_methods || props.publishedConfig.modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], - number_limits: props.publishedConfig.modelConfig.file_upload?.number_limits || props.publishedConfig.modelConfig.file_upload?.image?.number_limits || 3, + enabled: !!(file_upload?.enabled || file_upload?.image?.enabled), + allowed_file_types: file_upload?.allowed_file_types || [SupportUploadFileTypes.image], + allowed_file_extensions: file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`), + allowed_file_upload_methods: file_upload?.allowed_file_upload_methods || file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + number_limits: file_upload?.number_limits || file_upload?.image?.number_limits || 3, } as FileUpload }) setFeatures(newFeatures) @@ -69,7 +71,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => { ...props, onPublish: handlePublish, onRestore: () => setRestoreConfirmOpen(true), - }}/> + }} /> {restoreConfirmOpen && ( = { + [AccessMode.ORGANIZATION]: { + label: 'organization', + icon: RiBuildingLine, + }, + [AccessMode.SPECIFIC_GROUPS_MEMBERS]: { + label: 'specific', + icon: RiLockLine, + }, + [AccessMode.PUBLIC]: { + label: 'anyone', + icon: RiGlobalLine, + }, + [AccessMode.EXTERNAL_MEMBERS]: { + label: 'external', + icon: RiVerifiedBadgeLine, + }, +} + +const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => { + const { t } = useTranslation() + + if (!mode || !ACCESS_MODE_MAP[mode]) + return null + + const { icon: Icon, label } = ACCESS_MODE_MAP[mode] + + return ( + <> + +
+ {t(`app.accessControlDialog.accessItems.${label}`)} +
+ + ) +} export type AppPublisherProps = { disabled?: boolean @@ -64,6 +103,9 @@ export type AppPublisherProps = { toolPublished?: boolean inputs?: InputVar[] onRefreshData?: () => void + workflowToolAvailable?: boolean + missingStartNode?: boolean + hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist). } const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] @@ -82,28 +124,48 @@ const AppPublisher = ({ toolPublished, inputs, onRefreshData, + workflowToolAvailable = true, + missingStartNode = false, + hasTriggerNode = false, }: AppPublisherProps) => { const { t } = useTranslation() + const [published, setPublished] = useState(false) const [open, setOpen] = useState(false) + const [showAppAccessControl, setShowAppAccessControl] = useState(false) + const [isAppAccessSet, setIsAppAccessSet] = useState(true) + const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) + const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(s => s.setAppDetail) const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { formatTimeFromNow } = useFormatTimeFromNow() const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} - const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode + + const appMode = (appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appDetail.mode const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}` - const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '') + const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) + const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) + const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp]) + const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission]) + + const disabledFunctionTooltip = useMemo(() => { + if (!publishedAt) + return t('app.notPublishedYet') + if (missingStartNode) + return t('app.noUserInputNode') + if (noAccessPermission) + return t('app.noAccessPermission') + }, [missingStartNode, noAccessPermission, publishedAt]) + useEffect(() => { if (systemFeatures.webapp_auth.enabled && open && appDetail) refetch() }, [open, appDetail, refetch, systemFeatures]) - const [showAppAccessControl, setShowAppAccessControl] = useState(false) - const [isAppAccessSet, setIsAppAccessSet] = useState(true) useEffect(() => { if (appDetail && appAccessSubjects) { if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0) @@ -174,8 +236,6 @@ const AppPublisher = ({ } }, [appDetail, setAppDetail]) - const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) - useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { e.preventDefault() if (publishDisabled || published) @@ -183,6 +243,10 @@ const AppPublisher = ({ handlePublish() }, { exactMatch: true, useCapture: true }) + const hasPublishedVersion = !!publishedAt + const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable + const workflowToolMessage = workflowToolDisabled ? t('workflow.common.workflowAsToolDisabledHint') : undefined + return ( <>
} -
- - } - > - {t('workflow.common.runApp')} - - - {appDetail?.mode === 'workflow' || appDetail?.mode === 'completion' - ? ( - + { + // Hide run/batch run app buttons when there is a trigger node. + !hasTriggerNode && ( +
+ } + disabled={disabledFunctionButton} + link={appURL} + icon={} > - {t('workflow.common.batchRunApp')} + {t('workflow.common.runApp')} - ) - : ( - { - setEmbeddingModalOpen(true) - handleTrigger() - }} - disabled={!publishedAt} - icon={} - > - {t('workflow.common.embedIntoSite')} - - )} - - { - if (publishedAt) - handleOpenInExplore() - }} - disabled={!publishedAt || (systemFeatures.webapp_auth.enabled && !userCanAccessApp?.result)} - icon={} - > - {t('workflow.common.openInExplore')} - - - } - > - {t('workflow.common.accessAPIReference')} - - {appDetail?.mode === 'workflow' && ( - + {appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION + ? ( + + } + > + {t('workflow.common.batchRunApp')} + + + ) + : ( + { + setEmbeddingModalOpen(true) + handleTrigger() + }} + disabled={!publishedAt} + icon={} + > + {t('workflow.common.embedIntoSite')} + + )} + + { + if (publishedAt) + handleOpenInExplore() + }} + disabled={disabledFunctionButton} + icon={} + > + {t('workflow.common.openInExplore')} + + + + } + > + {t('workflow.common.accessAPIReference')} + + + {appDetail?.mode === AppModeEnum.WORKFLOW && ( + + )} +
)} -
}
diff --git a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx index aa8d0f65ca..5bf2f177ff 100644 --- a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx @@ -25,7 +25,7 @@ import Tooltip from '@/app/components/base/tooltip' import PromptEditor from '@/app/components/base/prompt-editor' import ConfigContext from '@/context/debug-configuration' import { getNewVar, getVars } from '@/utils/var' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { useModalContext } from '@/context/modal-context' import type { ExternalDataTool } from '@/models/common' import { useToastContext } from '@/app/components/base/toast' @@ -102,7 +102,7 @@ const AdvancedPromptInput: FC = ({ }, }) } - const isChatApp = mode !== AppType.completion + const isChatApp = mode !== AppModeEnum.COMPLETION const [isCopied, setIsCopied] = React.useState(false) const promptVariablesObj = (() => { diff --git a/web/app/components/app/configuration/config-prompt/index.tsx b/web/app/components/app/configuration/config-prompt/index.tsx index ec34588e41..416f87e135 100644 --- a/web/app/components/app/configuration/config-prompt/index.tsx +++ b/web/app/components/app/configuration/config-prompt/index.tsx @@ -12,11 +12,13 @@ import Button from '@/app/components/base/button' import AdvancedMessageInput from '@/app/components/app/configuration/config-prompt/advanced-prompt-input' import { PromptRole } from '@/models/debug' import type { PromptItem, PromptVariable } from '@/models/debug' -import { type AppType, ModelModeType } from '@/types/app' +import type { AppModeEnum } from '@/types/app' +import { ModelModeType } from '@/types/app' import ConfigContext from '@/context/debug-configuration' import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config' + export type IPromptProps = { - mode: AppType + mode: AppModeEnum promptTemplate: string promptVariables: PromptVariable[] readonly?: boolean diff --git a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx index 8634232b2b..68bf6dd7c2 100644 --- a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx @@ -10,7 +10,7 @@ import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap' import cn from '@/utils/classnames' import type { PromptVariable } from '@/models/debug' import Tooltip from '@/app/components/base/tooltip' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { getNewVar, getVars } from '@/utils/var' import AutomaticBtn from '@/app/components/app/configuration/config/automatic/automatic-btn' import type { GenRes } from '@/service/debug' @@ -29,7 +29,7 @@ import { useFeaturesStore } from '@/app/components/base/features/hooks' import { noop } from 'lodash-es' export type ISimplePromptInput = { - mode: AppType + mode: AppModeEnum promptTemplate: string promptVariables: PromptVariable[] readonly?: boolean @@ -155,7 +155,7 @@ const Prompt: FC = ({ setModelConfig(newModelConfig) setPrevPromptConfig(modelConfig.configs) - if (mode !== AppType.completion) { + if (mode !== AppModeEnum.COMPLETION) { setIntroduction(res.opening_statement || '') const newFeatures = produce(features, (draft) => { draft.opening = { @@ -177,7 +177,7 @@ const Prompt: FC = ({ {!noTitle && (
-
{mode !== AppType.completion ? t('appDebug.chatSubTitle') : t('appDebug.completionSubTitle')}
+
{mode !== AppModeEnum.COMPLETION ? t('appDebug.chatSubTitle') : t('appDebug.completionSubTitle')}
{!readonly && ( = ({ {showAutomatic && ( = ({ const { type, label, variable, options, max_length } = tempPayload const modalRef = useRef(null) const appDetail = useAppStore(state => state.appDetail) - const isBasicApp = appDetail?.mode !== 'advanced-chat' && appDetail?.mode !== 'workflow' + const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW const isSupportJSON = false const jsonSchemaStr = useMemo(() => { const isJsonObject = type === InputVarType.jsonObject diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index 0e453d5171..4090b39a3b 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -17,7 +17,7 @@ import { getNewVar, hasDuplicateStr } from '@/utils/var' import Toast from '@/app/components/base/toast' import Confirm from '@/app/components/base/confirm' import ConfigContext from '@/context/debug-configuration' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import type { ExternalDataTool } from '@/models/common' import { useModalContext } from '@/context/modal-context' import { useEventEmitterContextContext } from '@/context/event-emitter' @@ -201,7 +201,7 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar const handleRemoveVar = (index: number) => { const removeVar = promptVariables[index] - if (mode === AppType.completion && dataSets.length > 0 && removeVar.is_context_var) { + if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar.is_context_var) { showDeleteContextVarModal() setRemoveIndex(index) return diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx index 604b5532b0..ef28dd222c 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx @@ -28,6 +28,7 @@ import { AuthCategory, PluginAuthInAgent, } from '@/app/components/plugins/plugin-auth' +import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' type Props = { showBackButton?: boolean @@ -193,7 +194,7 @@ const SettingBuiltInTool: FC = ({ onClick={onHide} > - BACK + {t('plugin.detailPanel.operation.back')}
)}
@@ -215,6 +216,7 @@ const SettingBuiltInTool: FC = ({ provider: collection.name, category: AuthCategory.tool, providerType: collection.type, + detail: collection as any, }} credentialId={credentialId} onAuthorizationItemClick={onAuthorizationItemClick} @@ -244,13 +246,14 @@ const SettingBuiltInTool: FC = ({ )}
{isInfoActive ? infoUI : settingUI} + {!readonly && !isInfoActive && ( +
+ + +
+ )}
- {!readonly && !isInfoActive && ( -
- - -
- )} +
diff --git a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx index 5c87eea3ca..dfcaabf017 100644 --- a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx +++ b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx @@ -19,8 +19,7 @@ import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' import Toast from '@/app/components/base/toast' import { generateBasicAppFirstTimeRule, generateRule } from '@/service/debug' -import type { CompletionParams, Model } from '@/types/app' -import type { AppType } from '@/types/app' +import type { AppModeEnum, CompletionParams, Model } from '@/types/app' import Loading from '@/app/components/base/loading' import Confirm from '@/app/components/base/confirm' @@ -44,7 +43,7 @@ import { useGenerateRuleTemplate } from '@/service/use-apps' const i18nPrefix = 'appDebug.generate' export type IGetAutomaticResProps = { - mode: AppType + mode: AppModeEnum isShow: boolean onClose: () => void onFinished: (res: GenRes) => void @@ -299,7 +298,6 @@ const GetAutomaticRes: FC = ({ portalToFollowElemContentClassName='z-[1000]' isAdvancedMode={true} provider={model.provider} - mode={model.mode} completionParams={model.completion_params} modelId={model.name} setModel={handleModelChange} diff --git a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx index b581da979f..3612f89b02 100644 --- a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx +++ b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx @@ -5,8 +5,8 @@ import { useTranslation } from 'react-i18next' import { languageMap } from '../../../../workflow/nodes/_base/components/editor/code-editor/index' import { generateRule } from '@/service/debug' import type { GenRes } from '@/service/debug' -import type { ModelModeType } from '@/types/app' -import type { AppType, CompletionParams, Model } from '@/types/app' +import type { AppModeEnum, ModelModeType } from '@/types/app' +import type { CompletionParams, Model } from '@/types/app' import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' import { Generator } from '@/app/components/base/icons/src/vender/other' @@ -33,7 +33,7 @@ export type IGetCodeGeneratorResProps = { flowId: string nodeId: string currentCode?: string - mode: AppType + mode: AppModeEnum isShow: boolean codeLanguages: CodeLanguage onClose: () => void @@ -142,7 +142,7 @@ export const GetCodeGeneratorResModal: FC = ( ideal_output: ideaOutput, language: languageMap[codeLanguages] || 'javascript', }) - if((res as any).code) // not current or current is the same as the template would return a code field + if ((res as any).code) // not current or current is the same as the template would return a code field res.modified = (res as any).code if (error) { @@ -214,7 +214,6 @@ export const GetCodeGeneratorResModal: FC = ( portalToFollowElemContentClassName='z-[1000]' isAdvancedMode={true} provider={model.provider} - mode={model.mode} completionParams={model.completion_params} modelId={model.name} setModel={handleModelChange} diff --git a/web/app/components/app/configuration/config/index.tsx b/web/app/components/app/configuration/config/index.tsx index 7e130a4e95..4e67d1bd32 100644 --- a/web/app/components/app/configuration/config/index.tsx +++ b/web/app/components/app/configuration/config/index.tsx @@ -14,8 +14,7 @@ import ConfigContext from '@/context/debug-configuration' import ConfigPrompt from '@/app/components/app/configuration/config-prompt' import ConfigVar from '@/app/components/app/configuration/config-var' import type { ModelConfig, PromptVariable } from '@/models/debug' -import type { AppType } from '@/types/app' -import { ModelModeType } from '@/types/app' +import { AppModeEnum, ModelModeType } from '@/types/app' const Config: FC = () => { const { @@ -29,7 +28,7 @@ const Config: FC = () => { setModelConfig, setPrevPromptConfig, } = useContext(ConfigContext) - const isChatApp = ['advanced-chat', 'agent-chat', 'chat'].includes(mode) + const isChatApp = [AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(mode) const formattingChangedDispatcher = useFormattingChangedDispatcher() const promptTemplate = modelConfig.configs.prompt_template @@ -62,7 +61,7 @@ const Config: FC = () => { > {/* Template */} { draft.metadata_model_config = { provider: model.provider, name: model.modelId, - mode: model.mode || 'chat', + mode: model.mode || AppModeEnum.CHAT, completion_params: draft.metadata_model_config?.completion_params || { temperature: 0.7 }, } }) @@ -302,7 +302,7 @@ const DatasetConfig: FC = () => { />
- {mode === AppType.completion && dataSet.length > 0 && ( + {mode === AppModeEnum.COMPLETION && dataSet.length > 0 && ( = ({ popupClassName='!w-[387px]' portalToFollowElemContentClassName='!z-[1002]' isAdvancedMode={true} - mode={model?.mode} provider={model?.provider} completionParams={model?.completion_params} modelId={model?.name} diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index 62f1010b54..93d0384aee 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -16,6 +16,7 @@ import { useToastContext } from '@/app/components/base/toast' import { updateDatasetSetting } from '@/service/datasets' import { useAppContext } from '@/context/app-context' import { useModalContext } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import type { RetrievalConfig } from '@/types/app' import RetrievalSettings from '@/app/components/datasets/external-knowledge-base/create/RetrievalSettings' import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config' @@ -277,7 +278,7 @@ const SettingsModal: FC = ({
{t('datasetSettings.form.embeddingModelTip')} - setShowAccountSettingModal({ payload: 'provider' })}>{t('datasetSettings.form.embeddingModelTipLink')} + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}>{t('datasetSettings.form.embeddingModelTipLink')}
diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx index 95c43f5101..6148e2e808 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx @@ -11,6 +11,7 @@ import Dropdown from '@/app/components/base/dropdown' import type { Item } from '@/app/components/base/dropdown' import { useProviderContext } from '@/context/provider-context' import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { AppModeEnum } from '@/types/app' type DebugItemProps = { modelAndParameter: ModelAndParameter @@ -112,13 +113,13 @@ const DebugItem: FC = ({
{ - (mode === 'chat' || mode === 'agent-chat') && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && ( + (mode === AppModeEnum.CHAT || mode === AppModeEnum.AGENT_CHAT) && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && ( ) } { - mode === 'completion' && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && ( - + mode === AppModeEnum.COMPLETION && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && ( + ) }
diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx index b876adfa3d..6c388f5afa 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx @@ -18,6 +18,7 @@ import { useFeatures } from '@/app/components/base/features/hooks' import { useStore as useAppStore } from '@/app/components/app/store' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { InputForm } from '@/app/components/base/chat/chat/type' +import { AppModeEnum } from '@/types/app' const DebugWithMultipleModel = () => { const { @@ -33,7 +34,7 @@ const DebugWithMultipleModel = () => { } = useDebugWithMultipleModelContext() const { eventEmitter } = useEventEmitterContextContext() - const isChatMode = mode === 'chat' || mode === 'agent-chat' + const isChatMode = mode === AppModeEnum.CHAT || mode === AppModeEnum.AGENT_CHAT const handleSend = useCallback((message: string, files?: FileEntity[]) => { if (checkCanSend && !checkCanSend()) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx index 17d04acdc7..e7c4d98733 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/model-parameter-trigger.tsx @@ -26,7 +26,6 @@ const ModelParameterTrigger: FC = ({ }) => { const { t } = useTranslation() const { - mode, isAdvancedMode, } = useDebugConfigurationContext() const { @@ -57,7 +56,6 @@ const ModelParameterTrigger: FC = ({ return ( = ({ const [completionFiles, setCompletionFiles] = useState([]) const checkCanSend = useCallback(() => { - if (isAdvancedMode && mode !== AppType.completion) { + if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) { if (modelModeType === ModelModeType.completion) { if (!hasSetBlockStatus.history) { notify({ type: 'error', message: t('appDebug.otherError.historyNoBeEmpty') }) @@ -410,7 +410,7 @@ const Debug: FC = ({ ) : null } - {mode !== AppType.completion && ( + {mode !== AppModeEnum.COMPLETION && ( <> = ({ )} - {mode !== AppType.completion && expanded && ( + {mode !== AppModeEnum.COMPLETION && expanded && (
)} - {mode === AppType.completion && ( + {mode === AppModeEnum.COMPLETION && ( = ({ !debugWithMultipleModel && (
{/* Chat */} - {mode !== AppType.completion && ( + {mode !== AppModeEnum.COMPLETION && (
= ({
)} {/* Text Generation */} - {mode === AppType.completion && ( + {mode === AppModeEnum.COMPLETION && ( <> {(completionRes || isResponding) && ( <> @@ -528,7 +528,7 @@ const Debug: FC = ({ )} )} - {mode === AppType.completion && showPromptLogModal && ( + {mode === AppModeEnum.COMPLETION && showPromptLogModal && ( { const mode = modelModeType const toReplacePrePrompt = prePrompt || '' + if (!appMode) + return + if (!isAdvancedPrompt) { const { chat_prompt_config, completion_prompt_config, stop } = await fetchPromptTemplate({ appMode, @@ -122,7 +125,6 @@ const useAdvancedPromptConfig = ({ }) setChatPromptConfig(newPromptConfig) } - else { const newPromptConfig = produce(completion_prompt_config, (draft) => { draft.prompt.text = draft.prompt.text.replace(PRE_PROMPT_PLACEHOLDER_TEXT, toReplacePrePrompt) @@ -152,7 +154,7 @@ const useAdvancedPromptConfig = ({ else draft.prompt.text = completionPromptConfig.prompt?.text.replace(PRE_PROMPT_PLACEHOLDER_TEXT, toReplacePrePrompt) - if (['advanced-chat', 'agent-chat', 'chat'].includes(appMode) && completionPromptConfig.conversation_histories_role.assistant_prefix && completionPromptConfig.conversation_histories_role.user_prefix) + if ([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(appMode) && completionPromptConfig.conversation_histories_role.assistant_prefix && completionPromptConfig.conversation_histories_role.user_prefix) draft.conversation_histories_role = completionPromptConfig.conversation_histories_role }) setCompletionPromptConfig(newPromptConfig) diff --git a/web/app/components/app/configuration/index.tsx b/web/app/components/app/configuration/index.tsx index 4f47bfd883..afe640278e 100644 --- a/web/app/components/app/configuration/index.tsx +++ b/web/app/components/app/configuration/index.tsx @@ -47,11 +47,12 @@ import { fetchAppDetailDirect, updateAppModelConfig } from '@/service/apps' import { promptVariablesToUserInputsForm, userInputsFormToPromptVariables } from '@/utils/model-config' import { fetchDatasets } from '@/service/datasets' import { useProviderContext } from '@/context/provider-context' -import { AgentStrategy, AppType, ModelModeType, RETRIEVE_TYPE, Resolution, TransferMethod } from '@/types/app' +import { AgentStrategy, AppModeEnum, ModelModeType, RETRIEVE_TYPE, Resolution, TransferMethod } from '@/types/app' import { PromptMode } from '@/models/debug' import { ANNOTATION_DEFAULT, DATASET_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' import SelectDataSet from '@/app/components/app/configuration/dataset-config/select-dataset' import { useModalContext } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import Drawer from '@/app/components/base/drawer' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' @@ -110,7 +111,7 @@ const Configuration: FC = () => { const pathname = usePathname() const matched = pathname.match(/\/app\/([^/]+)/) const appId = (matched?.length && matched[1]) ? matched[1] : '' - const [mode, setMode] = useState('') + const [mode, setMode] = useState(AppModeEnum.CHAT) const [publishedConfig, setPublishedConfig] = useState(null) const [conversationId, setConversationId] = useState('') @@ -209,7 +210,7 @@ const Configuration: FC = () => { dataSets: [], agentConfig: DEFAULT_AGENT_SETTING, }) - const isAgent = mode === 'agent-chat' + const isAgent = mode === AppModeEnum.AGENT_CHAT const isOpenAI = modelConfig.provider === 'langgenius/openai/openai' @@ -451,7 +452,7 @@ const Configuration: FC = () => { const appMode = mode if (modeMode === ModelModeType.completion) { - if (appMode !== AppType.completion) { + if (appMode !== AppModeEnum.COMPLETION) { if (!completionPromptConfig.prompt?.text || !completionPromptConfig.conversation_histories_role.assistant_prefix || !completionPromptConfig.conversation_histories_role.user_prefix) await migrateToDefaultPrompt(true, ModelModeType.completion) } @@ -554,7 +555,7 @@ const Configuration: FC = () => { } setCollectionList(collectionList) const res = await fetchAppDetailDirect({ url: '/apps', id: appId }) - setMode(res.mode) + setMode(res.mode as AppModeEnum) const modelConfig = res.model_config as BackendModelConfig const promptMode = modelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple doSetPromptMode(promptMode) @@ -665,10 +666,10 @@ const Configuration: FC = () => { external_data_tools: modelConfig.external_data_tools ?? [], system_parameters: modelConfig.system_parameters, dataSets: datasets || [], - agentConfig: res.mode === 'agent-chat' ? { + agentConfig: res.mode === AppModeEnum.AGENT_CHAT ? { max_iteration: DEFAULT_AGENT_SETTING.max_iteration, ...modelConfig.agent_mode, - // remove dataset + // remove dataset enabled: true, // modelConfig.agent_mode?.enabled is not correct. old app: the value of app with dataset's is always true tools: (modelConfig.agent_mode?.tools ?? []).filter((tool: any) => { return !tool.dataset @@ -705,7 +706,7 @@ const Configuration: FC = () => { provider: currentRerankProvider?.provider, model: currentRerankModel?.model, }) - setDatasetConfigs({ + const datasetConfigsToSet = { ...modelConfig.dataset_configs, ...retrievalConfig, ...(retrievalConfig.reranking_model ? { @@ -714,13 +715,15 @@ const Configuration: FC = () => { reranking_provider_name: correctModelProvider(retrievalConfig.reranking_model.provider), }, } : {}), - } as DatasetConfigs) + } as DatasetConfigs + datasetConfigsToSet.retrieval_model = datasetConfigsToSet.retrieval_model ?? RETRIEVE_TYPE.multiWay + setDatasetConfigs(datasetConfigsToSet) setHasFetchedDetail(true) })() }, [appId]) const promptEmpty = (() => { - if (mode !== AppType.completion) + if (mode !== AppModeEnum.COMPLETION) return false if (isAdvancedMode) { @@ -734,7 +737,7 @@ const Configuration: FC = () => { else { return !modelConfig.configs.prompt_template } })() const cannotPublish = (() => { - if (mode !== AppType.completion) { + if (mode !== AppModeEnum.COMPLETION) { if (!isAdvancedMode) return false @@ -749,7 +752,7 @@ const Configuration: FC = () => { } else { return promptEmpty } })() - const contextVarEmpty = mode === AppType.completion && dataSets.length > 0 && !hasSetContextVar + const contextVarEmpty = mode === AppModeEnum.COMPLETION && dataSets.length > 0 && !hasSetContextVar const onPublish = async (modelAndParameter?: ModelAndParameter, features?: FeaturesData) => { const modelId = modelAndParameter?.model || modelConfig.model_id const promptTemplate = modelConfig.configs.prompt_template @@ -759,7 +762,7 @@ const Configuration: FC = () => { notify({ type: 'error', message: t('appDebug.otherError.promptNoBeEmpty') }) return } - if (isAdvancedMode && mode !== AppType.completion) { + if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) { if (modelModeType === ModelModeType.completion) { if (!hasSetBlockStatus.history) { notify({ type: 'error', message: t('appDebug.otherError.historyNoBeEmpty') }) @@ -981,7 +984,6 @@ const Configuration: FC = () => { <> {
setShowAccountSettingModal({ payload: 'provider' })} + onSetting={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })} inputs={inputs} modelParameterParams={{ setModel: setModel as any, @@ -1040,7 +1042,7 @@ const Configuration: FC = () => { content={t('appDebug.trailUseGPT4Info.description')} isShow={showUseGPT4Confirm} onConfirm={() => { - setShowAccountSettingModal({ payload: 'provider' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) setShowUseGPT4Confirm(false) }} onCancel={() => setShowUseGPT4Confirm(false)} @@ -1072,7 +1074,7 @@ const Configuration: FC = () => { setShowAccountSettingModal({ payload: 'provider' })} + onSetting={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })} inputs={inputs} modelParameterParams={{ setModel: setModel as any, @@ -1089,7 +1091,7 @@ const Configuration: FC = () => { show inWorkflow={false} showFileUpload={false} - isChatMode={mode !== 'completion'} + isChatMode={mode !== AppModeEnum.COMPLETION} disabled={false} onChange={handleFeaturesChange} onClose={() => setShowAppConfigureFeaturesModal(false)} diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index 43c836132f..e8b988767c 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -10,7 +10,7 @@ import { } from '@remixicon/react' import ConfigContext from '@/context/debug-configuration' import type { Inputs } from '@/models/debug' -import { AppType, ModelModeType } from '@/types/app' +import { AppModeEnum, ModelModeType } from '@/types/app' import Select from '@/app/components/base/select' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' @@ -25,7 +25,7 @@ import cn from '@/utils/classnames' import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' export type IPromptValuePanelProps = { - appType: AppType + appType: AppModeEnum onSend?: () => void inputs: Inputs visionConfig: VisionSettings @@ -55,7 +55,7 @@ const PromptValuePanel: FC = ({ }, [promptVariables]) const canNotRun = useMemo(() => { - if (mode !== AppType.completion) + if (mode !== AppModeEnum.COMPLETION) return true if (isAdvancedMode) { @@ -215,7 +215,7 @@ const PromptValuePanel: FC = ({
diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 0b0b325d9a..8b19f43034 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -25,7 +25,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { getRedirection } from '@/utils/app-redirection' import Input from '@/app/components/base/input' -import type { AppMode } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { DSLImportMode } from '@/models/app' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' @@ -61,7 +61,7 @@ const Apps = ({ handleSearch() } - const [currentType, setCurrentType] = useState([]) + const [currentType, setCurrentType] = useState([]) const [currCategory, setCurrCategory] = useTabSearchParams({ defaultTab: allCategoriesEn, disableSearchParams: true, @@ -93,15 +93,15 @@ const Apps = ({ if (currentType.length === 0) return filteredByCategory return filteredByCategory.filter((item) => { - if (currentType.includes('chat') && item.app.mode === 'chat') + if (currentType.includes(AppModeEnum.CHAT) && item.app.mode === AppModeEnum.CHAT) return true - if (currentType.includes('advanced-chat') && item.app.mode === 'advanced-chat') + if (currentType.includes(AppModeEnum.ADVANCED_CHAT) && item.app.mode === AppModeEnum.ADVANCED_CHAT) return true - if (currentType.includes('agent-chat') && item.app.mode === 'agent-chat') + if (currentType.includes(AppModeEnum.AGENT_CHAT) && item.app.mode === AppModeEnum.AGENT_CHAT) return true - if (currentType.includes('completion') && item.app.mode === 'completion') + if (currentType.includes(AppModeEnum.COMPLETION) && item.app.mode === AppModeEnum.COMPLETION) return true - if (currentType.includes('workflow') && item.app.mode === 'workflow') + if (currentType.includes(AppModeEnum.WORKFLOW) && item.app.mode === AppModeEnum.WORKFLOW) return true return false }) diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 3a07e6e0a1..10fc099f9f 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -18,7 +18,7 @@ import { basePath } from '@/utils/var' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { ToastContext } from '@/app/components/base/toast' -import type { AppMode } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { createApp } from '@/service/apps' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' @@ -35,7 +35,7 @@ type CreateAppProps = { onSuccess: () => void onClose: () => void onCreateFromTemplate?: () => void - defaultAppMode?: AppMode + defaultAppMode?: AppModeEnum } function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppProps) { @@ -43,7 +43,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const { push } = useRouter() const { notify } = useContext(ToastContext) - const [appMode, setAppMode] = useState(defaultAppMode || 'advanced-chat') + const [appMode, setAppMode] = useState(defaultAppMode || AppModeEnum.ADVANCED_CHAT) const [appIcon, setAppIcon] = useState({ type: 'emoji', icon: '🤖', background: '#FFEAD5' }) const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [name, setName] = useState('') @@ -57,7 +57,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const isCreatingRef = useRef(false) useEffect(() => { - if (appMode === 'chat' || appMode === 'agent-chat' || appMode === 'completion') + if (appMode === AppModeEnum.CHAT || appMode === AppModeEnum.AGENT_CHAT || appMode === AppModeEnum.COMPLETION) setIsAppTypeExpanded(true) }, [appMode]) @@ -118,24 +118,24 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
} onClick={() => { - setAppMode('workflow') + setAppMode(AppModeEnum.WORKFLOW) }} />
} onClick={() => { - setAppMode('advanced-chat') + setAppMode(AppModeEnum.ADVANCED_CHAT) }} />
@@ -152,34 +152,34 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: {isAppTypeExpanded && (
} onClick={() => { - setAppMode('chat') + setAppMode(AppModeEnum.CHAT) }} /> } onClick={() => { - setAppMode('agent-chat') + setAppMode(AppModeEnum.AGENT_CHAT) }} /> } onClick={() => { - setAppMode('completion') + setAppMode(AppModeEnum.COMPLETION) }} /> )} @@ -255,11 +255,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
- - - - - + + + + +
@@ -309,16 +309,16 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP } -function AppPreview({ mode }: { mode: AppMode }) { +function AppPreview({ mode }: { mode: AppModeEnum }) { const { t } = useTranslation() const docLink = useDocLink() const modeToPreviewInfoMap = { - 'chat': { + [AppModeEnum.CHAT]: { title: t('app.types.chatbot'), description: t('app.newApp.chatbotUserDescription'), link: docLink('/guides/application-orchestrate/chatbot-application'), }, - 'advanced-chat': { + [AppModeEnum.ADVANCED_CHAT]: { title: t('app.types.advanced'), description: t('app.newApp.advancedUserDescription'), link: docLink('/guides/workflow/README', { @@ -326,12 +326,12 @@ function AppPreview({ mode }: { mode: AppMode }) { 'ja-JP': '/guides/workflow/concepts', }), }, - 'agent-chat': { + [AppModeEnum.AGENT_CHAT]: { title: t('app.types.agent'), description: t('app.newApp.agentUserDescription'), link: docLink('/guides/application-orchestrate/agent'), }, - 'completion': { + [AppModeEnum.COMPLETION]: { title: t('app.newApp.completeApp'), description: t('app.newApp.completionUserDescription'), link: docLink('/guides/application-orchestrate/text-generator', { @@ -339,7 +339,7 @@ function AppPreview({ mode }: { mode: AppMode }) { 'ja-JP': '/guides/application-orchestrate/README', }), }, - 'workflow': { + [AppModeEnum.WORKFLOW]: { title: t('app.types.workflow'), description: t('app.newApp.workflowUserDescription'), link: docLink('/guides/workflow/README', { @@ -358,14 +358,14 @@ function AppPreview({ mode }: { mode: AppMode }) { } -function AppScreenShot({ mode, show }: { mode: AppMode; show: boolean }) { +function AppScreenShot({ mode, show }: { mode: AppModeEnum; show: boolean }) { const { theme } = useTheme() const modeToImageMap = { - 'chat': 'Chatbot', - 'advanced-chat': 'Chatflow', - 'agent-chat': 'Agent', - 'completion': 'TextGenerator', - 'workflow': 'Workflow', + [AppModeEnum.CHAT]: 'Chatbot', + [AppModeEnum.ADVANCED_CHAT]: 'Chatflow', + [AppModeEnum.AGENT_CHAT]: 'Agent', + [AppModeEnum.COMPLETION]: 'TextGenerator', + [AppModeEnum.WORKFLOW]: 'Workflow', } return diff --git a/web/app/components/app/log-annotation/index.tsx b/web/app/components/app/log-annotation/index.tsx index 12a611eea8..c0b0854b29 100644 --- a/web/app/components/app/log-annotation/index.tsx +++ b/web/app/components/app/log-annotation/index.tsx @@ -11,6 +11,7 @@ import Loading from '@/app/components/base/loading' import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type' import TabSlider from '@/app/components/base/tab-slider-plain' import { useStore as useAppStore } from '@/app/components/app/store' +import { AppModeEnum } from '@/types/app' type Props = { pageType: PageType @@ -24,7 +25,7 @@ const LogAnnotation: FC = ({ const appDetail = useAppStore(state => state.appDetail) const options = useMemo(() => { - if (appDetail?.mode === 'completion') + if (appDetail?.mode === AppModeEnum.COMPLETION) return [{ value: PageType.log, text: t('appLog.title') }] return [ { value: PageType.log, text: t('appLog.title') }, @@ -42,7 +43,7 @@ const LogAnnotation: FC = ({ return (
- {appDetail.mode !== 'workflow' && ( + {appDetail.mode !== AppModeEnum.WORKFLOW && ( = ({ options={options} /> )} -
- {pageType === PageType.log && appDetail.mode !== 'workflow' && ()} +
+ {pageType === PageType.log && appDetail.mode !== AppModeEnum.WORKFLOW && ()} {pageType === PageType.annotation && ()} - {pageType === PageType.log && appDetail.mode === 'workflow' && ()} + {pageType === PageType.log && appDetail.mode === AppModeEnum.WORKFLOW && ()}
) diff --git a/web/app/components/app/log/empty-element.tsx b/web/app/components/app/log/empty-element.tsx index 78f32bf922..ddddacd873 100644 --- a/web/app/components/app/log/empty-element.tsx +++ b/web/app/components/app/log/empty-element.tsx @@ -5,7 +5,8 @@ import Link from 'next/link' import { Trans, useTranslation } from 'react-i18next' import { basePath } from '@/utils/var' import { getRedirectionPath } from '@/utils/app-redirection' -import type { App, AppMode } from '@/types/app' +import type { App } from '@/types/app' +import { AppModeEnum } from '@/types/app' const ThreeDotsIcon = ({ className }: SVGProps) => { return @@ -16,9 +17,9 @@ const ThreeDotsIcon = ({ className }: SVGProps) => { const EmptyElement: FC<{ appDetail: App }> = ({ appDetail }) => { const { t } = useTranslation() - const getWebAppType = (appType: AppMode) => { - if (appType !== 'completion' && appType !== 'workflow') - return 'chat' + const getWebAppType = (appType: AppModeEnum) => { + if (appType !== AppModeEnum.COMPLETION && appType !== AppModeEnum.WORKFLOW) + return AppModeEnum.CHAT return appType } diff --git a/web/app/components/app/log/index.tsx b/web/app/components/app/log/index.tsx index e556748494..55a3f7d12d 100644 --- a/web/app/components/app/log/index.tsx +++ b/web/app/components/app/log/index.tsx @@ -14,6 +14,7 @@ import Loading from '@/app/components/base/loading' import { fetchChatConversations, fetchCompletionConversations } from '@/service/log' import { APP_PAGE_LIMIT } from '@/config' import type { App } from '@/types/app' +import { AppModeEnum } from '@/types/app' export type ILogsProps = { appDetail: App } @@ -37,7 +38,7 @@ const Logs: FC = ({ appDetail }) => { const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) // Get the app type first - const isChatMode = appDetail.mode !== 'completion' + const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION const query = { page: currPage + 1, diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index d295784083..5de86be7b9 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -20,7 +20,7 @@ import Indicator from '../../header/indicator' import VarPanel from './var-panel' import type { FeedbackFunc, FeedbackType, IChatItem, SubmitAnnotationFunc } from '@/app/components/base/chat/chat/type' import type { Annotation, ChatConversationGeneralDetail, ChatConversationsResponse, ChatMessage, ChatMessagesRequest, CompletionConversationGeneralDetail, CompletionConversationsResponse, LogAnnotation } from '@/models/log' -import type { App } from '@/types/app' +import { type App, AppModeEnum } from '@/types/app' import ActionButton from '@/app/components/base/action-button' import Loading from '@/app/components/base/loading' import Drawer from '@/app/components/base/drawer' @@ -374,7 +374,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { // Only load initial messages, don't auto-load more useEffect(() => { - if (appDetail?.id && detail.id && appDetail?.mode !== 'completion' && !fetchInitiated.current) { + if (appDetail?.id && detail.id && appDetail?.mode !== AppModeEnum.COMPLETION && !fetchInitiated.current) { // Mark as initialized, but don't auto-load more messages fetchInitiated.current = true // Still call fetchData to get initial messages @@ -583,8 +583,8 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { } }, [hasMore, isLoading, loadMoreMessages]) - const isChatMode = appDetail?.mode !== 'completion' - const isAdvanced = appDetail?.mode === 'advanced-chat' + const isChatMode = appDetail?.mode !== AppModeEnum.COMPLETION + const isAdvanced = appDetail?.mode === AppModeEnum.ADVANCED_CHAT const varList = (detail.model_config as any).user_input_form?.map((item: any) => { const itemContent = item[Object.keys(item)[0]] @@ -911,8 +911,8 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) const closingConversationIdRef = useRef(null) const pendingConversationIdRef = useRef(null) const pendingConversationCacheRef = useRef(undefined) - const isChatMode = appDetail.mode !== 'completion' // Whether the app is a chat app - const isChatflow = appDetail.mode === 'advanced-chat' // Whether the app is a chatflow app + const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION // Whether the app is a chat app + const isChatflow = appDetail.mode === AppModeEnum.ADVANCED_CHAT // Whether the app is a chatflow app const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore(useShallow((state: AppStoreState) => ({ setShowPromptLogModal: state.setShowPromptLogModal, setShowAgentLogModal: state.setShowAgentLogModal, diff --git a/web/app/components/app/overview/__tests__/toggle-logic.test.ts b/web/app/components/app/overview/__tests__/toggle-logic.test.ts new file mode 100644 index 0000000000..0c1e1ea0d3 --- /dev/null +++ b/web/app/components/app/overview/__tests__/toggle-logic.test.ts @@ -0,0 +1,228 @@ +import { getWorkflowEntryNode } from '@/app/components/workflow/utils/workflow-entry' + +// Mock the getWorkflowEntryNode function +jest.mock('@/app/components/workflow/utils/workflow-entry', () => ({ + getWorkflowEntryNode: jest.fn(), +})) + +const mockGetWorkflowEntryNode = getWorkflowEntryNode as jest.MockedFunction + +describe('App Card Toggle Logic', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // Helper function that mirrors the actual logic from app-card.tsx + const calculateToggleState = ( + appMode: string, + currentWorkflow: any, + isCurrentWorkspaceEditor: boolean, + isCurrentWorkspaceManager: boolean, + cardType: 'webapp' | 'api', + ) => { + const isWorkflowApp = appMode === 'workflow' + const appUnpublished = isWorkflowApp && !currentWorkflow?.graph + const hasEntryNode = mockGetWorkflowEntryNode(currentWorkflow?.graph?.nodes || []) + const missingEntryNode = isWorkflowApp && !hasEntryNode + const hasInsufficientPermissions = cardType === 'webapp' ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager + const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingEntryNode + const isMinimalState = appUnpublished || missingEntryNode + + return { + toggleDisabled, + isMinimalState, + appUnpublished, + missingEntryNode, + hasInsufficientPermissions, + } + } + + describe('Entry Node Detection Logic', () => { + it('should disable toggle when workflow missing entry node', () => { + mockGetWorkflowEntryNode.mockReturnValue(false) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + true, + true, + 'webapp', + ) + + expect(result.toggleDisabled).toBe(true) + expect(result.missingEntryNode).toBe(true) + expect(result.isMinimalState).toBe(true) + }) + + it('should enable toggle when workflow has entry node', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [{ data: { type: 'start' } }] } }, + true, + true, + 'webapp', + ) + + expect(result.toggleDisabled).toBe(false) + expect(result.missingEntryNode).toBe(false) + expect(result.isMinimalState).toBe(false) + }) + }) + + describe('Published State Logic', () => { + it('should disable toggle when workflow unpublished (no graph)', () => { + const result = calculateToggleState( + 'workflow', + null, // No workflow data = unpublished + true, + true, + 'webapp', + ) + + expect(result.toggleDisabled).toBe(true) + expect(result.appUnpublished).toBe(true) + expect(result.isMinimalState).toBe(true) + }) + + it('should disable toggle when workflow unpublished (empty graph)', () => { + const result = calculateToggleState( + 'workflow', + {}, // No graph property = unpublished + true, + true, + 'webapp', + ) + + expect(result.toggleDisabled).toBe(true) + expect(result.appUnpublished).toBe(true) + expect(result.isMinimalState).toBe(true) + }) + + it('should consider published state when workflow has graph', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + true, + true, + 'webapp', + ) + + expect(result.appUnpublished).toBe(false) + }) + }) + + describe('Permissions Logic', () => { + it('should disable webapp toggle when user lacks editor permissions', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + false, // No editor permission + true, + 'webapp', + ) + + expect(result.toggleDisabled).toBe(true) + expect(result.hasInsufficientPermissions).toBe(true) + }) + + it('should disable api toggle when user lacks manager permissions', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + true, + false, // No manager permission + 'api', + ) + + expect(result.toggleDisabled).toBe(true) + expect(result.hasInsufficientPermissions).toBe(true) + }) + + it('should enable toggle when user has proper permissions', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const webappResult = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + true, // Has editor permission + false, + 'webapp', + ) + + const apiResult = calculateToggleState( + 'workflow', + { graph: { nodes: [] } }, + false, + true, // Has manager permission + 'api', + ) + + expect(webappResult.toggleDisabled).toBe(false) + expect(apiResult.toggleDisabled).toBe(false) + }) + }) + + describe('Combined Conditions Logic', () => { + it('should handle multiple disable conditions correctly', () => { + mockGetWorkflowEntryNode.mockReturnValue(false) + + const result = calculateToggleState( + 'workflow', + null, // Unpublished + false, // No permissions + false, + 'webapp', + ) + + // All three conditions should be true + expect(result.appUnpublished).toBe(true) + expect(result.missingEntryNode).toBe(true) + expect(result.hasInsufficientPermissions).toBe(true) + expect(result.toggleDisabled).toBe(true) + expect(result.isMinimalState).toBe(true) + }) + + it('should enable when all conditions are satisfied', () => { + mockGetWorkflowEntryNode.mockReturnValue(true) + + const result = calculateToggleState( + 'workflow', + { graph: { nodes: [{ data: { type: 'start' } }] } }, // Published + true, // Has permissions + true, + 'webapp', + ) + + expect(result.appUnpublished).toBe(false) + expect(result.missingEntryNode).toBe(false) + expect(result.hasInsufficientPermissions).toBe(false) + expect(result.toggleDisabled).toBe(false) + expect(result.isMinimalState).toBe(false) + }) + }) + + describe('Non-Workflow Apps', () => { + it('should not check workflow-specific conditions for non-workflow apps', () => { + const result = calculateToggleState( + 'chat', // Non-workflow mode + null, + true, + true, + 'webapp', + ) + + expect(result.appUnpublished).toBe(false) // isWorkflowApp is false + expect(result.missingEntryNode).toBe(false) // isWorkflowApp is false + expect(result.toggleDisabled).toBe(false) + expect(result.isMinimalState).toBe(false) + }) + }) +}) diff --git a/web/app/components/app/overview/apikey-info-panel/index.tsx b/web/app/components/app/overview/apikey-info-panel/index.tsx index 7654d49e99..b50b0077cb 100644 --- a/web/app/components/app/overview/apikey-info-panel/index.tsx +++ b/web/app/components/app/overview/apikey-info-panel/index.tsx @@ -9,6 +9,7 @@ import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/gene import { IS_CE_EDITION } from '@/config' import { useProviderContext } from '@/context/provider-context' import { useModalContext } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const APIKeyInfoPanel: FC = () => { const isCloud = !IS_CE_EDITION @@ -47,7 +48,7 @@ const APIKeyInfoPanel: FC = () => {
{t('appOverview.apiKeyInfo.setAPIBtn')}
diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index c6df0ebfd9..dcb6ae6b4d 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -39,7 +39,11 @@ import { fetchAppDetailDirect } from '@/service/apps' import { AccessMode } from '@/models/access-control' import AccessControl from '../app-access-control' import { useAppWhiteListSubjects } from '@/service/access-control' +import { useAppWorkflow } from '@/service/use-workflow' import { useGlobalPublicStore } from '@/context/global-public-context' +import { BlockEnum } from '@/app/components/workflow/types' +import { useDocLink } from '@/context/i18n' +import { AppModeEnum } from '@/types/app' export type IAppCardProps = { className?: string @@ -65,6 +69,8 @@ function AppCard({ const router = useRouter() const pathname = usePathname() const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() + const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '') + const docLink = useDocLink() const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) const [showSettingsModal, setShowSettingsModal] = useState(false) @@ -85,7 +91,7 @@ function AppCard({ api: [{ opName: t('appOverview.overview.apiInfo.doc'), opIcon: RiBookOpenLine }], app: [], } - if (appInfo.mode !== 'completion' && appInfo.mode !== 'workflow') + if (appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW) operationsMap.webapp.push({ opName: t('appOverview.overview.appInfo.embedded.entry'), opIcon: RiWindowLine }) operationsMap.webapp.push({ opName: t('appOverview.overview.appInfo.customize.entry'), opIcon: RiPaintBrushLine }) @@ -98,12 +104,18 @@ function AppCard({ const isApp = cardType === 'webapp' const basicName = isApp - ? appInfo?.site?.title + ? t('appOverview.overview.appInfo.title') : t('appOverview.overview.apiInfo.title') - const toggleDisabled = isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager - const runningStatus = isApp ? appInfo.enable_site : appInfo.enable_api + const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW + const appUnpublished = isWorkflowApp && !currentWorkflow?.graph + const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start) + const missingStartNode = isWorkflowApp && !hasStartNode + const hasInsufficientPermissions = isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager + const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode + const runningStatus = (appUnpublished || missingStartNode) ? false : (isApp ? appInfo.enable_site : appInfo.enable_api) + const isMinimalState = appUnpublished || missingStartNode const { app_base_url, access_token } = appInfo.site ?? {} - const appMode = (appInfo.mode !== 'completion' && appInfo.mode !== 'workflow') ? 'chat' : appInfo.mode + const appMode = (appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appInfo.mode const appUrl = `${app_base_url}${basePath}/${appMode}/${access_token}` const apiUrl = appInfo?.api_base_url @@ -175,10 +187,10 @@ function AppCard({ return (
-
+
- -
-
-
- {isApp - ? t('appOverview.overview.appInfo.accessibleAddress') - : t('appOverview.overview.apiInfo.accessibleAddress')} -
-
-
-
- {isApp ? appUrl : apiUrl} -
+ +
+ {t('appOverview.overview.appInfo.enableTooltip.description')} +
+
window.open(docLink('/guides/workflow/node/user-input'), '_blank')} + > + {t('appOverview.overview.appInfo.enableTooltip.learnMore')} +
+ + ) : '' + } + position="right" + popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg" + offset={24} + > +
+
- - {isApp && } - {isApp && } - {/* button copy link/ button regenerate */} - {showConfirmDelete && ( - { - onGenCode() - setShowConfirmDelete(false) - }} - onCancel={() => setShowConfirmDelete(false)} +
+
+ {!isMinimalState && ( +
+
+ {isApp + ? t('appOverview.overview.appInfo.accessibleAddress') + : t('appOverview.overview.apiInfo.accessibleAddress')} +
+
+
+
+ {isApp ? appUrl : apiUrl} +
+
+ - )} - {isApp && isCurrentWorkspaceManager && ( - -
setShowConfirmDelete(true)} + {isApp && } + {isApp && } + {/* button copy link/ button regenerate */} + {showConfirmDelete && ( + { + onGenCode() + setShowConfirmDelete(false) + }} + onCancel={() => setShowConfirmDelete(false)} + /> + )} + {isApp && isCurrentWorkspaceManager && ( +
-
-
- )} + className="h-6 w-6 cursor-pointer rounded-md hover:bg-state-base-hover" + onClick={() => setShowConfirmDelete(true)} + > +
+
+ + )} +
-
- {isApp && systemFeatures.webapp_auth.enabled && appDetail &&
+ )} + {!isMinimalState && isApp && systemFeatures.webapp_auth.enabled && appDetail &&
{t('app.publishApp.title')}
@@ -287,43 +324,45 @@ function AppCard({
}
-
- {!isApp && } - {OPERATIONS_MAP[cardType].map((op) => { - const disabled - = op.opName === t('appOverview.overview.appInfo.settings.entry') - ? false - : !runningStatus - return ( - - ) - })} -
+ +
+ +
{op.opName}
+
+
+ + ) + })} +
+ )}
{isApp ? ( <> setShowSettingsModal(false)} diff --git a/web/app/components/app/overview/customize/index.tsx b/web/app/components/app/overview/customize/index.tsx index 11d29bb0c8..e440a8cf26 100644 --- a/web/app/components/app/overview/customize/index.tsx +++ b/web/app/components/app/overview/customize/index.tsx @@ -4,7 +4,7 @@ import React from 'react' import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' -import type { AppMode } from '@/types/app' +import { AppModeEnum } from '@/types/app' import Button from '@/app/components/base/button' import Modal from '@/app/components/base/modal' import Tag from '@/app/components/base/tag' @@ -15,7 +15,7 @@ type IShareLinkProps = { linkUrl: string api_base_url: string appId: string - mode: AppMode + mode: AppModeEnum } const StepNum: FC<{ children: React.ReactNode }> = ({ children }) => @@ -42,7 +42,7 @@ const CustomizeModal: FC = ({ }) => { const { t } = useTranslation() const docLink = useDocLink() - const isChatApp = mode === 'chat' || mode === 'advanced-chat' + const isChatApp = mode === AppModeEnum.CHAT || mode === AppModeEnum.ADVANCED_CHAT return = ({ if (isFreePlan) setShowPricingModal() else - setShowAccountSettingModal({ payload: 'billing' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) }, [isFreePlan, setShowAccountSettingModal, setShowPricingModal]) useEffect(() => { @@ -328,7 +329,7 @@ const SettingsModal: FC = ({
{t(`${prefixSettings}.workflow.subTitle`)}
setInputInfo({ ...inputInfo, show_workflow_steps: v })} /> diff --git a/web/app/components/app/overview/trigger-card.tsx b/web/app/components/app/overview/trigger-card.tsx new file mode 100644 index 0000000000..5a0e387ba2 --- /dev/null +++ b/web/app/components/app/overview/trigger-card.tsx @@ -0,0 +1,224 @@ +'use client' +import React from 'react' +import { useTranslation } from 'react-i18next' +import Link from 'next/link' +import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow' +import Switch from '@/app/components/base/switch' +import type { AppDetailResponse } from '@/models/app' +import type { AppSSO } from '@/types/app' +import { useAppContext } from '@/context/app-context' +import { + type AppTrigger, + useAppTriggers, + useInvalidateAppTriggers, + useUpdateTriggerStatus, +} from '@/service/use-tools' +import { useAllTriggerPlugins } from '@/service/use-triggers' +import { canFindTool } from '@/utils' +import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' +import BlockIcon from '@/app/components/workflow/block-icon' +import { BlockEnum } from '@/app/components/workflow/types' +import { useDocLink } from '@/context/i18n' + +export type ITriggerCardProps = { + appInfo: AppDetailResponse & Partial + onToggleResult?: (err: Error | null, message?: string) => void +} + +const getTriggerIcon = (trigger: AppTrigger, triggerPlugins: any[]) => { + const { trigger_type, status, provider_name } = trigger + + // Status dot styling based on trigger status + const getStatusDot = () => { + if (status === 'enabled') { + return ( +
+ ) + } + else { + return ( +
+ ) + } + } + + // Get BlockEnum type from trigger_type + let blockType: BlockEnum + switch (trigger_type) { + case 'trigger-webhook': + blockType = BlockEnum.TriggerWebhook + break + case 'trigger-schedule': + blockType = BlockEnum.TriggerSchedule + break + case 'trigger-plugin': + blockType = BlockEnum.TriggerPlugin + break + default: + blockType = BlockEnum.TriggerWebhook + } + + let triggerIcon: string | undefined + if (trigger_type === 'trigger-plugin' && provider_name) { + const targetTriggers = triggerPlugins || [] + const foundTrigger = targetTriggers.find(triggerWithProvider => + canFindTool(triggerWithProvider.id, provider_name) + || triggerWithProvider.id.includes(provider_name) + || triggerWithProvider.name === provider_name, + ) + triggerIcon = foundTrigger?.icon + } + + return ( +
+ + {getStatusDot()} +
+ ) +} + +function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) { + const { t } = useTranslation() + const docLink = useDocLink() + const appId = appInfo.id + const { isCurrentWorkspaceEditor } = useAppContext() + const { data: triggersResponse, isLoading } = useAppTriggers(appId) + const { mutateAsync: updateTriggerStatus } = useUpdateTriggerStatus() + const invalidateAppTriggers = useInvalidateAppTriggers() + const { data: triggerPlugins } = useAllTriggerPlugins() + + // Zustand store for trigger status sync + const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore() + + const triggers = triggersResponse?.data || [] + const triggerCount = triggers.length + + // Sync trigger statuses to Zustand store when data loads initially or after API calls + React.useEffect(() => { + if (triggers.length > 0) { + const statusMap = triggers.reduce((acc, trigger) => { + // Map API status to EntryNodeStatus: only 'enabled' shows green, others show gray + acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' + return acc + }, {} as Record) + + // Only update if there are actual changes to prevent overriding optimistic updates + setTriggerStatuses(statusMap) + } + }, [triggers, setTriggerStatuses]) + + const onToggleTrigger = async (trigger: AppTrigger, enabled: boolean) => { + try { + // Immediately update Zustand store for real-time UI sync + const newStatus = enabled ? 'enabled' : 'disabled' + setTriggerStatus(trigger.node_id, newStatus) + + await updateTriggerStatus({ + appId, + triggerId: trigger.id, + enableTrigger: enabled, + }) + invalidateAppTriggers(appId) + + // Success toast notification + onToggleResult?.(null) + } + catch (error) { + // Rollback Zustand store state on error + const rollbackStatus = enabled ? 'disabled' : 'enabled' + setTriggerStatus(trigger.node_id, rollbackStatus) + + // Error toast notification + onToggleResult?.(error as Error) + } + } + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ) + } + + return ( +
+
+
+
+
+
+ +
+
+
+ {triggerCount > 0 + ? t('appOverview.overview.triggerInfo.triggersAdded', { count: triggerCount }) + : t('appOverview.overview.triggerInfo.noTriggerAdded') + } +
+
+
+
+
+ + {triggerCount > 0 && ( +
+ {triggers.map(trigger => ( +
+
+
+ {getTriggerIcon(trigger, triggerPlugins || [])} +
+
+ {trigger.title} +
+
+
+
+ {trigger.status === 'enabled' + ? t('appOverview.overview.status.running') + : t('appOverview.overview.status.disable')} +
+
+
+ onToggleTrigger(trigger, enabled)} + disabled={!isCurrentWorkspaceEditor} + /> +
+
+ ))} +
+ )} + + {triggerCount === 0 && ( +
+
+ {t('appOverview.overview.triggerInfo.triggerStatusDescription')}{' '} + + {t('appOverview.overview.triggerInfo.learnAboutTriggers')} + +
+
+ )} +
+
+ ) +} + +export default TriggerCard diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index f1654eb65e..a7e1cea429 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -24,6 +24,7 @@ import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/aler import AppIcon from '@/app/components/base/app-icon' import { useStore as useAppStore } from '@/app/components/app/store' import { noop } from 'lodash-es' +import { AppModeEnum } from '@/types/app' type SwitchAppModalProps = { show: boolean @@ -77,7 +78,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo isCurrentWorkspaceEditor, { id: newAppID, - mode: appDetail.mode === 'completion' ? 'workflow' : 'advanced-chat', + mode: appDetail.mode === AppModeEnum.COMPLETION ? AppModeEnum.WORKFLOW : AppModeEnum.ADVANCED_CHAT, }, removeOriginal ? replace : push, ) diff --git a/web/app/components/app/type-selector/index.tsx b/web/app/components/app/type-selector/index.tsx index f8432ceab6..0f6f050953 100644 --- a/web/app/components/app/type-selector/index.tsx +++ b/web/app/components/app/type-selector/index.tsx @@ -9,13 +9,14 @@ import { PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication' -import type { AppMode } from '@/types/app' +import { AppModeEnum } from '@/types/app' + export type AppSelectorProps = { - value: Array + value: Array onChange: (value: AppSelectorProps['value']) => void } -const allTypes: AppMode[] = ['workflow', 'advanced-chat', 'chat', 'agent-chat', 'completion'] +const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT, AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION] const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => { const [open, setOpen] = useState(false) @@ -66,7 +67,7 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => { export default AppTypeSelector type AppTypeIconProps = { - type: AppMode + type: AppModeEnum style?: React.CSSProperties className?: string wrapperClassName?: string @@ -75,27 +76,27 @@ type AppTypeIconProps = { export const AppTypeIcon = React.memo(({ type, className, wrapperClassName, style }: AppTypeIconProps) => { const wrapperClassNames = cn('inline-flex h-5 w-5 items-center justify-center rounded-md border border-divider-regular', wrapperClassName) const iconClassNames = cn('h-3.5 w-3.5 text-components-avatar-shape-fill-stop-100', className) - if (type === 'chat') { + if (type === AppModeEnum.CHAT) { return
} - if (type === 'agent-chat') { + if (type === AppModeEnum.AGENT_CHAT) { return
} - if (type === 'advanced-chat') { + if (type === AppModeEnum.ADVANCED_CHAT) { return
} - if (type === 'workflow') { + if (type === AppModeEnum.WORKFLOW) { return
} - if (type === 'completion') { + if (type === AppModeEnum.COMPLETION) { return
@@ -133,7 +134,7 @@ function AppTypeSelectTrigger({ values }: { readonly values: AppSelectorProps['v type AppTypeSelectorItemProps = { checked: boolean - type: AppMode + type: AppModeEnum onClick: () => void } function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProps) { @@ -147,21 +148,21 @@ function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProp } type AppTypeLabelProps = { - type: AppMode + type: AppModeEnum className?: string } export function AppTypeLabel({ type, className }: AppTypeLabelProps) { const { t } = useTranslation() let label = '' - if (type === 'chat') + if (type === AppModeEnum.CHAT) label = t('app.typeSelector.chatbot') - if (type === 'agent-chat') + if (type === AppModeEnum.AGENT_CHAT) label = t('app.typeSelector.agent') - if (type === 'completion') + if (type === AppModeEnum.COMPLETION) label = t('app.typeSelector.completion') - if (type === 'advanced-chat') + if (type === AppModeEnum.ADVANCED_CHAT) label = t('app.typeSelector.advanced') - if (type === 'workflow') + if (type === AppModeEnum.WORKFLOW) label = t('app.typeSelector.workflow') return {label} diff --git a/web/app/components/app/workflow-log/detail.tsx b/web/app/components/app/workflow-log/detail.tsx index 7ce701dd68..1c1ed75e80 100644 --- a/web/app/components/app/workflow-log/detail.tsx +++ b/web/app/components/app/workflow-log/detail.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import { useTranslation } from 'react-i18next' import { RiCloseLine, RiPlayLargeLine } from '@remixicon/react' import Run from '@/app/components/workflow/run' +import { WorkflowContextProvider } from '@/app/components/workflow/context' import { useStore } from '@/app/components/app/store' import TooltipPlus from '@/app/components/base/tooltip' import { useRouter } from 'next/navigation' @@ -10,9 +11,10 @@ import { useRouter } from 'next/navigation' type ILogDetail = { runID: string onClose: () => void + canReplay?: boolean } -const DetailPanel: FC = ({ runID, onClose }) => { +const DetailPanel: FC = ({ runID, onClose, canReplay = false }) => { const { t } = useTranslation() const appDetail = useStore(state => state.appDetail) const router = useRouter() @@ -29,24 +31,28 @@ const DetailPanel: FC = ({ runID, onClose }) => {

{t('appLog.runDetail.workflowTitle')}

- - - + + + )}
- + + +
) } diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index c6f9d985ae..30a1974347 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -41,6 +41,7 @@ const Logs: FC = ({ appDetail }) => { const query = { page: currPage + 1, + detail: true, limit, ...(debouncedQueryParams.status !== 'all' ? { status: debouncedQueryParams.status } : {}), ...(debouncedQueryParams.keyword ? { keyword: debouncedQueryParams.keyword } : {}), diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index 395df5da2b..0e9b5dd67f 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -1,16 +1,19 @@ 'use client' import type { FC } from 'react' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { ArrowDownIcon } from '@heroicons/react/24/outline' import DetailPanel from './detail' +import TriggerByDisplay from './trigger-by-display' import type { WorkflowAppLogDetail, WorkflowLogsResponse } from '@/models/log' -import type { App } from '@/types/app' +import { type App, AppModeEnum } from '@/types/app' import Loading from '@/app/components/base/loading' import Drawer from '@/app/components/base/drawer' import Indicator from '@/app/components/header/indicator' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' import cn from '@/utils/classnames' +import type { WorkflowRunTriggeredFrom } from '@/models/log' type ILogs = { logs?: WorkflowLogsResponse @@ -29,6 +32,28 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { const [showDrawer, setShowDrawer] = useState(false) const [currentLog, setCurrentLog] = useState() + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') + const [localLogs, setLocalLogs] = useState(logs?.data || []) + + useEffect(() => { + if (!logs?.data) { + setLocalLogs([]) + return + } + + const sortedLogs = [...logs.data].sort((a, b) => { + const result = a.created_at - b.created_at + return sortOrder === 'asc' ? result : -result + }) + + setLocalLogs(sortedLogs) + }, [logs?.data, sortOrder]) + + const handleSort = () => { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc') + } + + const isWorkflow = appDetail?.mode === AppModeEnum.WORKFLOW const statusTdRender = (status: string) => { if (status === 'succeeded') { @@ -43,7 +68,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { return (
- Fail + Failure
) } @@ -88,15 +113,26 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { - {t('appLog.table.header.startTime')} + +
+ {t('appLog.table.header.startTime')} + +
+ {t('appLog.table.header.status')} {t('appLog.table.header.runtime')} {t('appLog.table.header.tokens')} - {t('appLog.table.header.user')} + {t('appLog.table.header.user')} + {isWorkflow && {t('appLog.table.header.triggered_from')}} - {logs.data.map((log: WorkflowAppLogDetail) => { + {localLogs.map((log: WorkflowAppLogDetail) => { const endUser = log.created_by_end_user ? log.created_by_end_user.session_id : log.created_by_account ? log.created_by_account.name : defaultValue return = ({ logs, appDetail, onRefresh }) => { {endUser}
+ {isWorkflow && ( + + + + )} })} @@ -136,7 +177,11 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { footer={null} panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[600px] rounded-xl border border-components-panel-border' > - +
) diff --git a/web/app/components/app/workflow-log/trigger-by-display.tsx b/web/app/components/app/workflow-log/trigger-by-display.tsx new file mode 100644 index 0000000000..1411503cc2 --- /dev/null +++ b/web/app/components/app/workflow-log/trigger-by-display.tsx @@ -0,0 +1,134 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { + Code, + KnowledgeRetrieval, + Schedule, + WebhookLine, + WindowCursor, +} from '@/app/components/base/icons/src/vender/workflow' +import BlockIcon from '@/app/components/workflow/block-icon' +import { BlockEnum } from '@/app/components/workflow/types' +import useTheme from '@/hooks/use-theme' +import type { TriggerMetadata } from '@/models/log' +import { WorkflowRunTriggeredFrom } from '@/models/log' +import { Theme } from '@/types/app' + +type TriggerByDisplayProps = { + triggeredFrom: WorkflowRunTriggeredFrom + className?: string + showText?: boolean + triggerMetadata?: TriggerMetadata +} + +const getTriggerDisplayName = (triggeredFrom: WorkflowRunTriggeredFrom, t: any, metadata?: TriggerMetadata) => { + if (triggeredFrom === WorkflowRunTriggeredFrom.PLUGIN && metadata?.event_name) + return metadata.event_name + + const nameMap: Record = { + 'debugging': t('appLog.triggerBy.debugging'), + 'app-run': t('appLog.triggerBy.appRun'), + 'webhook': t('appLog.triggerBy.webhook'), + 'schedule': t('appLog.triggerBy.schedule'), + 'plugin': t('appLog.triggerBy.plugin'), + 'rag-pipeline-run': t('appLog.triggerBy.ragPipelineRun'), + 'rag-pipeline-debugging': t('appLog.triggerBy.ragPipelineDebugging'), + } + + return nameMap[triggeredFrom] || triggeredFrom +} + +const getPluginIcon = (metadata: TriggerMetadata | undefined, theme: Theme) => { + if (!metadata) + return null + + const icon = theme === Theme.dark + ? metadata.icon_dark || metadata.icon + : metadata.icon || metadata.icon_dark + + if (!icon) + return null + + return ( + + ) +} + +const getTriggerIcon = (triggeredFrom: WorkflowRunTriggeredFrom, metadata: TriggerMetadata | undefined, theme: Theme) => { + switch (triggeredFrom) { + case 'webhook': + return ( +
+ +
+ ) + case 'schedule': + return ( +
+ +
+ ) + case 'plugin': + return getPluginIcon(metadata, theme) || ( + + ) + case 'debugging': + return ( +
+ +
+ ) + case 'rag-pipeline-run': + case 'rag-pipeline-debugging': + return ( +
+ +
+ ) + case 'app-run': + default: + // For user input types (app-run, etc.), use webapp icon + return ( +
+ +
+ ) + } +} + +const TriggerByDisplay: FC = ({ + triggeredFrom, + className = '', + showText = true, + triggerMetadata, +}) => { + const { t } = useTranslation() + const { theme } = useTheme() + + const displayName = getTriggerDisplayName(triggeredFrom, t, triggerMetadata) + const icon = getTriggerIcon(triggeredFrom, triggerMetadata, theme) + + return ( +
+
+ {icon} +
+ {showText && ( + + {displayName} + + )} +
+ ) +} + +export default TriggerByDisplay diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index cd3495e3c6..564eb493e5 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation' import { useTranslation } from 'react-i18next' import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' import cn from '@/utils/classnames' -import type { App } from '@/types/app' +import { type App, AppModeEnum } from '@/types/app' import Toast, { ToastContext } from '@/app/components/base/toast' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' @@ -171,7 +171,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { } const exportCheck = async () => { - if (app.mode !== 'workflow' && app.mode !== 'advanced-chat') { + if (app.mode !== AppModeEnum.WORKFLOW && app.mode !== AppModeEnum.ADVANCED_CHAT) { onExport() return } @@ -269,7 +269,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { - {(app.mode === 'completion' || app.mode === 'chat') && ( + {(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && ( <> +
: t('common.noData')} +
+ ) : ( + filteredOptions.map((option) => { + const selected = value.includes(option.value) + + return ( +
{ + if (!option.disabled && !disabled) + handleToggleOption(option.value) + }} + > + { + if (!option.disabled && !disabled) + handleToggleOption(option.value) + }} + disabled={option.disabled || disabled} + /> +
+ {option.label} +
+
+ ) + }) + )} + + + + ) +} + +export default CheckboxList diff --git a/web/app/components/base/checkbox/index.tsx b/web/app/components/base/checkbox/index.tsx index 2411d98966..9495292ea6 100644 --- a/web/app/components/base/checkbox/index.tsx +++ b/web/app/components/base/checkbox/index.tsx @@ -30,7 +30,7 @@ const Checkbox = ({
(null) + const titleRef = useRef(null) const [isVisible, setIsVisible] = useState(isShow) + const [isTitleTruncated, setIsTitleTruncated] = useState(false) const confirmTxt = confirmText || `${t('common.operation.confirm')}` const cancelTxt = cancelText || `${t('common.operation.cancel')}` @@ -80,6 +83,13 @@ function Confirm({ } }, [isShow]) + useEffect(() => { + if (titleRef.current) { + const isOverflowing = titleRef.current.scrollWidth > titleRef.current.clientWidth + setIsTitleTruncated(isOverflowing) + } + }, [title, isVisible]) + if (!isVisible) return null @@ -92,8 +102,18 @@ function Confirm({
-
{title}
-
{content}
+ +
+ {title} +
+
+
{content}
{showCancel && } diff --git a/web/app/components/base/date-and-time-picker/time-picker/footer.tsx b/web/app/components/base/date-and-time-picker/time-picker/footer.tsx index 47dd8b127c..dc35830250 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/footer.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/footer.tsx @@ -10,26 +10,25 @@ const Footer: FC = ({ const { t } = useTranslation() return ( -
-
- {/* Now */} - - {/* Confirm Button */} - -
+
+ {/* Now Button */} + + {/* Confirm Button */} +
) } diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx index bd4468e82d..24c7fff52f 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx @@ -29,6 +29,15 @@ jest.mock('@/app/components/base/portal-to-follow-elem', () => ({ jest.mock('./options', () => () =>
) jest.mock('./header', () => () =>
) +jest.mock('@/app/components/base/timezone-label', () => { + return function MockTimezoneLabel({ timezone, inline, className }: { timezone: string, inline?: boolean, className?: string }) { + return ( + + UTC+8 + + ) + } +}) describe('TimePicker', () => { const baseProps: Pick = { @@ -94,4 +103,86 @@ describe('TimePicker', () => { expect(isDayjsObject(emitted)).toBe(true) expect(emitted?.utcOffset()).toBe(dayjs().tz('America/New_York').utcOffset()) }) + + describe('Timezone Label Integration', () => { + test('should not display timezone label by default', () => { + render( + , + ) + + expect(screen.queryByTestId('timezone-label')).not.toBeInTheDocument() + }) + + test('should not display timezone label when showTimezone is false', () => { + render( + , + ) + + expect(screen.queryByTestId('timezone-label')).not.toBeInTheDocument() + }) + + test('should display timezone label when showTimezone is true', () => { + render( + , + ) + + const timezoneLabel = screen.getByTestId('timezone-label') + expect(timezoneLabel).toBeInTheDocument() + expect(timezoneLabel).toHaveAttribute('data-timezone', 'Asia/Shanghai') + }) + + test('should pass inline prop to timezone label', () => { + render( + , + ) + + const timezoneLabel = screen.getByTestId('timezone-label') + expect(timezoneLabel).toHaveAttribute('data-inline', 'true') + }) + + test('should not display timezone label when showTimezone is true but timezone is not provided', () => { + render( + , + ) + + expect(screen.queryByTestId('timezone-label')).not.toBeInTheDocument() + }) + + test('should apply shrink-0 and text-xs classes to timezone label', () => { + render( + , + ) + + const timezoneLabel = screen.getByTestId('timezone-label') + expect(timezoneLabel).toHaveClass('shrink-0', 'text-xs') + }) + }) }) diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx index f23fcf8f4e..9577a107e5 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx @@ -19,6 +19,7 @@ import Header from './header' import { useTranslation } from 'react-i18next' import { RiCloseCircleFill, RiTimeLine } from '@remixicon/react' import cn from '@/utils/classnames' +import TimezoneLabel from '@/app/components/base/timezone-label' const to24Hour = (hour12: string, period: Period) => { const normalized = Number.parseInt(hour12, 10) % 12 @@ -35,6 +36,10 @@ const TimePicker = ({ title, minuteFilter, popupClassName, + notClearable = false, + triggerFullWidth = false, + showTimezone = false, + placement = 'bottom-start', }: TimePickerProps) => { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) @@ -189,7 +194,7 @@ const TimePicker = ({ const inputElem = ( - + {renderTrigger ? (renderTrigger({ inputElem, onClick: handleClickTrigger, isOpen, })) : (
{inputElem} + {showTimezone && timezone && ( + + )} string[] popupClassName?: string + notClearable?: boolean + triggerFullWidth?: boolean + showTimezone?: boolean + placement?: Placement } export type TimePickerFooterProps = { diff --git a/web/app/components/base/date-and-time-picker/utils/dayjs.spec.ts b/web/app/components/base/date-and-time-picker/utils/dayjs.spec.ts index 549ab01029..5c891126b5 100644 --- a/web/app/components/base/date-and-time-picker/utils/dayjs.spec.ts +++ b/web/app/components/base/date-and-time-picker/utils/dayjs.spec.ts @@ -1,5 +1,6 @@ import dayjs from './dayjs' import { + convertTimezoneToOffsetStr, getDateWithTimezone, isDayjsObject, toDayjs, @@ -65,3 +66,50 @@ describe('dayjs utilities', () => { expect(result?.minute()).toBe(0) }) }) + +describe('convertTimezoneToOffsetStr', () => { + test('should return default UTC+0 for undefined timezone', () => { + expect(convertTimezoneToOffsetStr(undefined)).toBe('UTC+0') + }) + + test('should return default UTC+0 for invalid timezone', () => { + expect(convertTimezoneToOffsetStr('Invalid/Timezone')).toBe('UTC+0') + }) + + test('should handle whole hour positive offsets without leading zeros', () => { + expect(convertTimezoneToOffsetStr('Asia/Shanghai')).toBe('UTC+8') + expect(convertTimezoneToOffsetStr('Pacific/Auckland')).toBe('UTC+12') + expect(convertTimezoneToOffsetStr('Pacific/Apia')).toBe('UTC+13') + }) + + test('should handle whole hour negative offsets without leading zeros', () => { + expect(convertTimezoneToOffsetStr('Pacific/Niue')).toBe('UTC-11') + expect(convertTimezoneToOffsetStr('Pacific/Honolulu')).toBe('UTC-10') + expect(convertTimezoneToOffsetStr('America/New_York')).toBe('UTC-5') + }) + + test('should handle zero offset', () => { + expect(convertTimezoneToOffsetStr('Europe/London')).toBe('UTC+0') + expect(convertTimezoneToOffsetStr('UTC')).toBe('UTC+0') + }) + + test('should handle half-hour offsets (30 minutes)', () => { + // India Standard Time: UTC+5:30 + expect(convertTimezoneToOffsetStr('Asia/Kolkata')).toBe('UTC+5:30') + // Australian Central Time: UTC+9:30 + expect(convertTimezoneToOffsetStr('Australia/Adelaide')).toBe('UTC+9:30') + expect(convertTimezoneToOffsetStr('Australia/Darwin')).toBe('UTC+9:30') + }) + + test('should handle 45-minute offsets', () => { + // Chatham Time: UTC+12:45 + expect(convertTimezoneToOffsetStr('Pacific/Chatham')).toBe('UTC+12:45') + }) + + test('should preserve leading zeros in minute part for non-zero minutes', () => { + // Ensure +05:30 is displayed as "UTC+5:30", not "UTC+5:3" + const result = convertTimezoneToOffsetStr('Asia/Kolkata') + expect(result).toMatch(/UTC[+-]\d+:30/) + expect(result).not.toMatch(/UTC[+-]\d+:3[^0]/) + }) +}) diff --git a/web/app/components/base/date-and-time-picker/utils/dayjs.ts b/web/app/components/base/date-and-time-picker/utils/dayjs.ts index 4f53c766ea..b05e725985 100644 --- a/web/app/components/base/date-and-time-picker/utils/dayjs.ts +++ b/web/app/components/base/date-and-time-picker/utils/dayjs.ts @@ -107,7 +107,18 @@ export const convertTimezoneToOffsetStr = (timezone?: string) => { const tzItem = tz.find(item => item.value === timezone) if (!tzItem) return DEFAULT_OFFSET_STR - return `UTC${tzItem.name.charAt(0)}${tzItem.name.charAt(2)}` + // Extract offset from name format like "-11:00 Niue Time" or "+05:30 India Time" + // Name format is always "{offset}:{minutes} {timezone name}" + const offsetMatch = tzItem.name.match(/^([+-]?\d{1,2}):(\d{2})/) + if (!offsetMatch) + return DEFAULT_OFFSET_STR + // Parse hours and minutes separately + const hours = Number.parseInt(offsetMatch[1], 10) + const minutes = Number.parseInt(offsetMatch[2], 10) + const sign = hours >= 0 ? '+' : '' + // If minutes are non-zero, include them in the output (e.g., "UTC+5:30") + // Otherwise, only show hours (e.g., "UTC+8") + return minutes !== 0 ? `UTC${sign}${hours}:${offsetMatch[2]}` : `UTC${sign}${hours}` } export const isDayjsObject = (value: unknown): value is Dayjs => dayjs.isDayjs(value) diff --git a/web/app/components/base/divider/index.tsx b/web/app/components/base/divider/index.tsx index 6fe16b95a2..387f24a5e9 100644 --- a/web/app/components/base/divider/index.tsx +++ b/web/app/components/base/divider/index.tsx @@ -29,7 +29,7 @@ export type DividerProps = { const Divider: FC = ({ type, bgStyle, className = '', style }) => { return ( -
+
) } diff --git a/web/app/components/base/drawer/index.tsx b/web/app/components/base/drawer/index.tsx index c35acbeac7..101ac22b6c 100644 --- a/web/app/components/base/drawer/index.tsx +++ b/web/app/components/base/drawer/index.tsx @@ -10,6 +10,7 @@ export type IDrawerProps = { description?: string dialogClassName?: string dialogBackdropClassName?: string + containerClassName?: string panelClassName?: string children: React.ReactNode footer?: React.ReactNode @@ -22,6 +23,7 @@ export type IDrawerProps = { onCancel?: () => void onOk?: () => void unmount?: boolean + noOverlay?: boolean } export default function Drawer({ @@ -29,6 +31,7 @@ export default function Drawer({ description = '', dialogClassName = '', dialogBackdropClassName = '', + containerClassName = '', panelClassName = '', children, footer, @@ -41,6 +44,7 @@ export default function Drawer({ onCancel, onOk, unmount = false, + noOverlay = false, }: IDrawerProps) { const { t } = useTranslation() return ( @@ -53,15 +57,15 @@ export default function Drawer({ }} className={cn('fixed inset-0 z-[30] overflow-y-auto', dialogClassName)} > -
+
{/* mask */} - { if (!clickOutsideNotOpen) onClose() }} - /> + />}
<>
diff --git a/web/app/components/base/encrypted-bottom/index.tsx b/web/app/components/base/encrypted-bottom/index.tsx new file mode 100644 index 0000000000..8416217517 --- /dev/null +++ b/web/app/components/base/encrypted-bottom/index.tsx @@ -0,0 +1,30 @@ +import cn from '@/utils/classnames' +import { RiLock2Fill } from '@remixicon/react' +import Link from 'next/link' +import { useTranslation } from 'react-i18next' + +type Props = { + className?: string + frontTextKey?: string + backTextKey?: string +} + +export const EncryptedBottom = (props: Props) => { + const { t } = useTranslation() + const { frontTextKey, backTextKey, className } = props + + return ( +
+ + {t(frontTextKey || 'common.provider.encrypted.front')} + + PKCS1_OAEP + + {t(backTextKey || 'common.provider.encrypted.back')} +
+ ) +} diff --git a/web/app/components/base/error-boundary/index.tsx b/web/app/components/base/error-boundary/index.tsx new file mode 100644 index 0000000000..e3df2c2ca8 --- /dev/null +++ b/web/app/components/base/error-boundary/index.tsx @@ -0,0 +1,273 @@ +'use client' +import type { ErrorInfo, ReactNode } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { RiAlertLine, RiBugLine } from '@remixicon/react' +import Button from '@/app/components/base/button' +import cn from '@/utils/classnames' + +type ErrorBoundaryState = { + hasError: boolean + error: Error | null + errorInfo: ErrorInfo | null + errorCount: number +} + +type ErrorBoundaryProps = { + children: ReactNode + fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode) + onError?: (error: Error, errorInfo: ErrorInfo) => void + onReset?: () => void + showDetails?: boolean + className?: string + resetKeys?: Array + resetOnPropsChange?: boolean + isolate?: boolean + enableRecovery?: boolean + customTitle?: string + customMessage?: string +} + +// Internal class component for error catching +class ErrorBoundaryInner extends React.Component< + ErrorBoundaryProps & { + resetErrorBoundary: () => void + onResetKeysChange: (prevResetKeys?: Array) => void + }, + ErrorBoundaryState +> { + constructor(props: any) { + super(props) + this.state = { + hasError: false, + error: null, + errorInfo: null, + errorCount: 0, + } + } + + static getDerivedStateFromError(error: Error): Partial { + return { + hasError: true, + error, + } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + if (process.env.NODE_ENV === 'development') { + console.error('ErrorBoundary caught an error:', error) + console.error('Error Info:', errorInfo) + } + + this.setState(prevState => ({ + errorInfo, + errorCount: prevState.errorCount + 1, + })) + + if (this.props.onError) + this.props.onError(error, errorInfo) + } + + componentDidUpdate(prevProps: any) { + const { resetKeys, resetOnPropsChange } = this.props + const { hasError } = this.state + + if (hasError && prevProps.resetKeys !== resetKeys) { + if (resetKeys?.some((key, idx) => key !== prevProps.resetKeys?.[idx])) + this.props.resetErrorBoundary() + } + + if (hasError && resetOnPropsChange && prevProps.children !== this.props.children) + this.props.resetErrorBoundary() + + if (prevProps.resetKeys !== resetKeys) + this.props.onResetKeysChange(prevProps.resetKeys) + } + + render() { + const { hasError, error, errorInfo, errorCount } = this.state + const { + fallback, + children, + showDetails = false, + className, + isolate = true, + enableRecovery = true, + customTitle, + customMessage, + resetErrorBoundary, + } = this.props + + if (hasError && error) { + if (fallback) { + if (typeof fallback === 'function') + return fallback(error, resetErrorBoundary) + + return fallback + } + + return ( +
+
+ +

+ {customTitle || 'Something went wrong'} +

+
+ +

+ {customMessage || 'An unexpected error occurred while rendering this component.'} +

+ + {showDetails && errorInfo && ( +
+ + + + Error Details (Development Only) + + +
+
+ Error: +
+                    {error.toString()}
+                  
+
+ {errorInfo && ( +
+ Component Stack: +
+                      {errorInfo.componentStack}
+                    
+
+ )} + {errorCount > 1 && ( +
+ This error has occurred {errorCount} times +
+ )} +
+
+ )} + + {enableRecovery && ( +
+ + +
+ )} +
+ ) + } + + return children + } +} + +// Main functional component wrapper +const ErrorBoundary: React.FC = (props) => { + const [errorBoundaryKey, setErrorBoundaryKey] = useState(0) + const resetKeysRef = useRef(props.resetKeys) + const prevResetKeysRef = useRef | undefined>(undefined) + + const resetErrorBoundary = useCallback(() => { + setErrorBoundaryKey(prev => prev + 1) + props.onReset?.() + }, [props]) + + const onResetKeysChange = useCallback((prevResetKeys?: Array) => { + prevResetKeysRef.current = prevResetKeys + }, []) + + useEffect(() => { + if (prevResetKeysRef.current !== props.resetKeys) + resetKeysRef.current = props.resetKeys + }, [props.resetKeys]) + + return ( + + ) +} + +// Hook for imperative error handling +export function useErrorHandler() { + const [error, setError] = useState(null) + + useEffect(() => { + if (error) + throw error + }, [error]) + + return setError +} + +// Hook for catching async errors +export function useAsyncError() { + const [, setError] = useState() + + return useCallback( + (error: Error) => { + setError(() => { + throw error + }) + }, + [setError], + ) +} + +// HOC for wrapping components with error boundary +export function withErrorBoundary

( + Component: React.ComponentType

, + errorBoundaryProps?: Omit, +): React.ComponentType

{ + const WrappedComponent = (props: P) => ( + + + + ) + + WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name || 'Component'})` + + return WrappedComponent +} + +// Simple error fallback component +export const ErrorFallback: React.FC<{ + error: Error + resetErrorBoundary: () => void +}> = ({ error, resetErrorBoundary }) => { + return ( +

+

Oops! Something went wrong

+

{error.message}

+ +
+ ) +} + +export default ErrorBoundary diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx index 095137203b..ff45a7ea4c 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx @@ -26,6 +26,7 @@ import { CustomConfigurationStatusEnum } from '@/app/components/header/account-s import cn from '@/utils/classnames' import { noop } from 'lodash-es' import { useDocLink } from '@/context/i18n' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const systemTypes = ['openai_moderation', 'keywords', 'api'] @@ -55,7 +56,7 @@ const ModerationSettingModal: FC = ({ const { setShowAccountSettingModal } = useModalContext() const handleOpenSettingsModal = () => { setShowAccountSettingModal({ - payload: 'provider', + payload: ACCOUNT_SETTING_TAB.PROVIDER, onCancelCallback: () => { mutate() }, diff --git a/web/app/components/base/form/components/base/base-field.tsx b/web/app/components/base/form/components/base/base-field.tsx index bf415e08a8..db57059b82 100644 --- a/web/app/components/base/form/components/base/base-field.tsx +++ b/web/app/components/base/form/components/base/base-field.tsx @@ -1,20 +1,71 @@ +import CheckboxList from '@/app/components/base/checkbox-list' +import type { FieldState, FormSchema, TypeWithI18N } from '@/app/components/base/form/types' +import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types' +import Input from '@/app/components/base/input' +import Radio from '@/app/components/base/radio' +import RadioE from '@/app/components/base/radio/ui' +import PureSelect from '@/app/components/base/select/pure' +import Tooltip from '@/app/components/base/tooltip' +import { useRenderI18nObject } from '@/hooks/use-i18n' +import { useTriggerPluginDynamicOptions } from '@/service/use-triggers' +import cn from '@/utils/classnames' +import { RiExternalLinkLine } from '@remixicon/react' +import type { AnyFieldApi } from '@tanstack/react-form' +import { useStore } from '@tanstack/react-form' import { isValidElement, memo, useCallback, useMemo, } from 'react' -import { RiExternalLinkLine } from '@remixicon/react' -import type { AnyFieldApi } from '@tanstack/react-form' -import { useStore } from '@tanstack/react-form' -import cn from '@/utils/classnames' -import Input from '@/app/components/base/input' -import PureSelect from '@/app/components/base/select/pure' -import type { FormSchema } from '@/app/components/base/form/types' -import { FormTypeEnum } from '@/app/components/base/form/types' -import { useRenderI18nObject } from '@/hooks/use-i18n' -import Radio from '@/app/components/base/radio' -import RadioE from '@/app/components/base/radio/ui' +import { useTranslation } from 'react-i18next' + +const getExtraProps = (type: FormTypeEnum) => { + switch (type) { + case FormTypeEnum.secretInput: + return { type: 'password', autoComplete: 'new-password' } + case FormTypeEnum.textNumber: + return { type: 'number' } + default: + return { type: 'text' } + } +} + +const getTranslatedContent = ({ content, render }: { + content: React.ReactNode | string | null | undefined | TypeWithI18N | Record + render: (content: TypeWithI18N | Record) => string +}): string => { + if (isValidElement(content) || typeof content === 'string') + return content as string + + if (typeof content === 'object' && content !== null) + return render(content as TypeWithI18N) + + return '' +} + +const VALIDATE_STATUS_STYLE_MAP: Record = { + [FormItemValidateStatusEnum.Error]: { + componentClassName: 'border-components-input-border-destructive focus:border-components-input-border-destructive', + textClassName: 'text-text-destructive', + infoFieldName: 'errors', + }, + [FormItemValidateStatusEnum.Warning]: { + componentClassName: 'border-components-input-border-warning focus:border-components-input-border-warning', + textClassName: 'text-text-warning', + infoFieldName: 'warnings', + }, + [FormItemValidateStatusEnum.Success]: { + componentClassName: '', + textClassName: '', + infoFieldName: '', + }, + [FormItemValidateStatusEnum.Validating]: { + componentClassName: '', + textClassName: '', + infoFieldName: '', + }, +} export type BaseFieldProps = { fieldClassName?: string @@ -25,7 +76,9 @@ export type BaseFieldProps = { field: AnyFieldApi disabled?: boolean onChange?: (field: string, value: any) => void + fieldState?: FieldState } + const BaseField = ({ fieldClassName, labelClassName, @@ -35,204 +88,259 @@ const BaseField = ({ field, disabled: propsDisabled, onChange, + fieldState, }: BaseFieldProps) => { const renderI18nObject = useRenderI18nObject() + const { t } = useTranslation() const { + name, label, required, placeholder, options, labelClassName: formLabelClassName, disabled: formSchemaDisabled, + type: formItemType, + dynamicSelectParams, + multiple = false, + tooltip, + showCopy, + description, + url, + help, } = formSchema const disabled = propsDisabled || formSchemaDisabled - const memorizedLabel = useMemo(() => { - if (isValidElement(label)) - return label + const [translatedLabel, translatedPlaceholder, translatedTooltip, translatedDescription, translatedHelp] = useMemo(() => { + const results = [ + label, + placeholder, + tooltip, + description, + help, + ].map(v => getTranslatedContent({ content: v, render: renderI18nObject })) + if (!results[1]) results[1] = t('common.placeholder.input') + return results + }, [label, placeholder, tooltip, description, help, renderI18nObject]) - if (typeof label === 'string') - return label + const watchedVariables = useMemo(() => { + const variables = new Set() - if (typeof label === 'object' && label !== null) - return renderI18nObject(label as Record) - }, [label, renderI18nObject]) - const memorizedPlaceholder = useMemo(() => { - if (typeof placeholder === 'string') - return placeholder + for (const option of options || []) { + for (const condition of option.show_on || []) + variables.add(condition.variable) + } - if (typeof placeholder === 'object' && placeholder !== null) - return renderI18nObject(placeholder as Record) - }, [placeholder, renderI18nObject]) - const optionValues = useStore(field.form.store, (s) => { + return Array.from(variables) + }, [options]) + + const watchedValues = useStore(field.form.store, (s) => { const result: Record = {} - options?.forEach((option) => { - if (option.show_on?.length) { - option.show_on.forEach((condition) => { - result[condition.variable] = s.values[condition.variable] - }) - } - }) + for (const variable of watchedVariables) + result[variable] = s.values[variable] + return result }) + const memorizedOptions = useMemo(() => { return options?.filter((option) => { - if (!option.show_on || option.show_on.length === 0) + if (!option.show_on?.length) return true return option.show_on.every((condition) => { - const conditionValue = optionValues[condition.variable] - return conditionValue === condition.value + return watchedValues[condition.variable] === condition.value }) }).map((option) => { return { - label: typeof option.label === 'string' ? option.label : renderI18nObject(option.label), + label: getTranslatedContent({ content: option.label, render: renderI18nObject }), value: option.value, } }) || [] - }, [options, renderI18nObject, optionValues]) + }, [options, renderI18nObject, watchedValues]) + const value = useStore(field.form.store, s => s.values[field.name]) + const { data: dynamicOptionsData, isLoading: isDynamicOptionsLoading, error: dynamicOptionsError } = useTriggerPluginDynamicOptions( + dynamicSelectParams || { + plugin_id: '', + provider: '', + action: '', + parameter: '', + credential_id: '', + }, + formItemType === FormTypeEnum.dynamicSelect, + ) + + const dynamicOptions = useMemo(() => { + if (!dynamicOptionsData?.options) + return [] + return dynamicOptionsData.options.map(option => ({ + label: getTranslatedContent({ content: option.label, render: renderI18nObject }), + value: option.value, + })) + }, [dynamicOptionsData, renderI18nObject]) + const handleChange = useCallback((value: any) => { field.handleChange(value) onChange?.(field.name, value) }, [field, onChange]) return ( -
-
- {memorizedLabel} - { - required && !isValidElement(label) && ( - * - ) - } -
-
- { - formSchema.type === FormTypeEnum.textInput && ( - { - handleChange(e.target.value) - }} - onBlur={field.handleBlur} - disabled={disabled} - placeholder={memorizedPlaceholder} + <> +
+
+ {translatedLabel} + { + required && !isValidElement(label) && ( + * + ) + } + {tooltip && ( + {translatedTooltip}
} + triggerClassName='ml-0.5 w-4 h-4' /> - ) - } - { - formSchema.type === FormTypeEnum.secretInput && ( - handleChange(e.target.value)} - onBlur={field.handleBlur} - disabled={disabled} - placeholder={memorizedPlaceholder} - autoComplete={'new-password'} - /> - ) - } - { - formSchema.type === FormTypeEnum.textNumber && ( - handleChange(e.target.value)} - onBlur={field.handleBlur} - disabled={disabled} - placeholder={memorizedPlaceholder} - /> - ) - } - { - formSchema.type === FormTypeEnum.select && ( - handleChange(v)} - disabled={disabled} - placeholder={memorizedPlaceholder} - options={memorizedOptions} - triggerPopupSameWidth - popupProps={{ - className: 'max-h-[320px] overflow-y-auto', - }} - /> - ) - } - { - formSchema.type === FormTypeEnum.radio && ( + )} +
+
+ { + [FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes(formItemType) && ( + { + handleChange(e.target.value) + }} + onBlur={field.handleBlur} + disabled={disabled} + placeholder={translatedPlaceholder} + {...getExtraProps(formItemType)} + showCopyIcon={showCopy} + /> + ) + } + { + formItemType === FormTypeEnum.select && !multiple && ( + handleChange(v)} + disabled={disabled} + placeholder={translatedPlaceholder} + options={memorizedOptions} + triggerPopupSameWidth + popupProps={{ + className: 'max-h-[320px] overflow-y-auto', + }} + /> + ) + } + { + formItemType === FormTypeEnum.checkbox /* && multiple */ && ( + field.handleChange(v)} + options={memorizedOptions} + maxHeight='200px' + /> + ) + } + { + formItemType === FormTypeEnum.dynamicSelect && ( + + ) + } + { + formItemType === FormTypeEnum.radio && ( +
+ { + memorizedOptions.map(option => ( +
!disabled && handleChange(option.value)} + > + { + formSchema.showRadioUI && ( + + ) + } + {option.label} +
+ )) + } +
+ ) + } + { + formItemType === FormTypeEnum.boolean && ( + field.handleChange(v)} + > + True + False + + ) + } + {fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && (
- { - memorizedOptions.map(option => ( -
!disabled && handleChange(option.value)} - > - { - formSchema.showRadioUI && ( - - ) - } - {option.label} -
- )) - } + {fieldState?.[VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].infoFieldName as keyof FieldState]}
- ) - } - { - formSchema.type === FormTypeEnum.boolean && ( - field.handleChange(v)} - > - True - False - - ) - } - { - formSchema.url && ( - - - {renderI18nObject(formSchema?.help as any)} - - { - - } - - ) - } + )} +
-
+ {description && ( +
+ {translatedDescription} +
+ )} + { + url && ( + + + {translatedHelp} + + + + ) + } + + ) } diff --git a/web/app/components/base/form/components/base/base-form.tsx b/web/app/components/base/form/components/base/base-form.tsx index 6b7e992510..0d35380523 100644 --- a/web/app/components/base/form/components/base/base-form.tsx +++ b/web/app/components/base/form/components/base/base-form.tsx @@ -3,6 +3,7 @@ import { useCallback, useImperativeHandle, useMemo, + useState, } from 'react' import type { AnyFieldApi, @@ -12,9 +13,12 @@ import { useForm, useStore, } from '@tanstack/react-form' -import type { - FormRef, - FormSchema, +import { + type FieldState, + FormItemValidateStatusEnum, + type FormRef, + type FormSchema, + type SetFieldsParam, } from '@/app/components/base/form/types' import { BaseField, @@ -36,6 +40,8 @@ export type BaseFormProps = { disabled?: boolean formFromProps?: AnyFormApi onChange?: (field: string, value: any) => void + onSubmit?: (e: React.FormEvent) => void + preventDefaultSubmit?: boolean } & Pick const BaseForm = ({ @@ -50,6 +56,8 @@ const BaseForm = ({ disabled, formFromProps, onChange, + onSubmit, + preventDefaultSubmit = false, }: BaseFormProps) => { const initialDefaultValues = useMemo(() => { if (defaultValues) @@ -68,6 +76,8 @@ const BaseForm = ({ const { getFormValues } = useGetFormValues(form, formSchemas) const { getValidators } = useGetValidators() + const [fieldStates, setFieldStates] = useState>({}) + const showOnValues = useStore(form.store, (s: any) => { const result: Record = {} formSchemas.forEach((schema) => { @@ -81,6 +91,34 @@ const BaseForm = ({ return result }) + const setFields = useCallback((fields: SetFieldsParam[]) => { + const newFieldStates: Record = { ...fieldStates } + + for (const field of fields) { + const { name, value, errors, warnings, validateStatus, help } = field + + if (value !== undefined) + form.setFieldValue(name, value) + + let finalValidateStatus = validateStatus + if (!finalValidateStatus) { + if (errors && errors.length > 0) + finalValidateStatus = FormItemValidateStatusEnum.Error + else if (warnings && warnings.length > 0) + finalValidateStatus = FormItemValidateStatusEnum.Warning + } + + newFieldStates[name] = { + validateStatus: finalValidateStatus, + help, + errors, + warnings, + } + } + + setFieldStates(newFieldStates) + }, [form, fieldStates]) + useImperativeHandle(ref, () => { return { getForm() { @@ -89,8 +127,9 @@ const BaseForm = ({ getFormValues: (option) => { return getFormValues(option) }, + setFields, } - }, [form, getFormValues]) + }, [form, getFormValues, setFields]) const renderField = useCallback((field: AnyFieldApi) => { const formSchema = formSchemas?.find(schema => schema.name === field.name) @@ -100,18 +139,19 @@ const BaseForm = ({ ) } return null - }, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange]) + }, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange, fieldStates]) const renderFieldWrapper = useCallback((formSchema: FormSchema) => { const validators = getValidators(formSchema) @@ -142,9 +182,18 @@ const BaseForm = ({ if (!formSchemas?.length) return null + const handleSubmit = (e: React.FormEvent) => { + if (preventDefaultSubmit) { + e.preventDefault() + e.stopPropagation() + } + onSubmit?.(e) + } + return (
{formSchemas.map(renderFieldWrapper)}
diff --git a/web/app/components/base/form/components/field/select.tsx b/web/app/components/base/form/components/field/select.tsx index dee047e2eb..8a36a49510 100644 --- a/web/app/components/base/form/components/field/select.tsx +++ b/web/app/components/base/form/components/field/select.tsx @@ -11,7 +11,9 @@ type SelectFieldProps = { options: Option[] onChange?: (value: string) => void className?: string -} & Omit +} & Omit & { + multiple?: false +} const SelectField = ({ label, diff --git a/web/app/components/base/form/components/field/variable-or-constant-input.tsx b/web/app/components/base/form/components/field/variable-or-constant-input.tsx index a07e356fa2..b8a96c5401 100644 --- a/web/app/components/base/form/components/field/variable-or-constant-input.tsx +++ b/web/app/components/base/form/components/field/variable-or-constant-input.tsx @@ -1,5 +1,5 @@ import type { ChangeEvent } from 'react' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { RiEditLine } from '@remixicon/react' import cn from '@/utils/classnames' import SegmentedControl from '@/app/components/base/segmented-control' @@ -33,9 +33,9 @@ const VariableOrConstantInputField = ({ }, ] - const handleVariableOrConstantChange = (value: string) => { + const handleVariableOrConstantChange = useCallback((value: string) => { setVariableType(value) - } + }, [setVariableType]) const handleVariableValueChange = () => { console.log('Variable value changed') diff --git a/web/app/components/base/form/hooks/use-get-form-values.ts b/web/app/components/base/form/hooks/use-get-form-values.ts index 36100a724a..b7d08cc005 100644 --- a/web/app/components/base/form/hooks/use-get-form-values.ts +++ b/web/app/components/base/form/hooks/use-get-form-values.ts @@ -12,7 +12,7 @@ export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => const getFormValues = useCallback(( { - needCheckValidatedValues, + needCheckValidatedValues = true, needTransformWhenSecretFieldIsPristine, }: GetValuesOptions, ) => { @@ -20,7 +20,7 @@ export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => if (!needCheckValidatedValues) { return { values, - isCheckValidated: false, + isCheckValidated: true, } } diff --git a/web/app/components/base/form/index.stories.tsx b/web/app/components/base/form/index.stories.tsx index c1b9e894e0..f170cb4771 100644 --- a/web/app/components/base/form/index.stories.tsx +++ b/web/app/components/base/form/index.stories.tsx @@ -102,14 +102,14 @@ const FormPlayground = () => { options={{ ...demoFormOpts, validators: { - onSubmit: ({ value }) => { - const result = UserSchema.safeParse(value as typeof demoFormOpts.defaultValues) + onSubmit: ({ value: formValue }) => { + const result = UserSchema.safeParse(formValue as typeof demoFormOpts.defaultValues) if (!result.success) return result.error.issues[0].message return undefined }, }, - onSubmit: ({ value }) => { + onSubmit: () => { setStatus('Successfully saved profile.') }, }} diff --git a/web/app/components/base/form/types.ts b/web/app/components/base/form/types.ts index ce3b5ec965..268f9db89a 100644 --- a/web/app/components/base/form/types.ts +++ b/web/app/components/base/form/types.ts @@ -6,6 +6,7 @@ import type { AnyFormApi, FieldValidators, } from '@tanstack/react-form' +import type { Locale } from '@/i18n-config' export type TypeWithI18N = { en_US: T @@ -36,7 +37,7 @@ export enum FormTypeEnum { } export type FormOption = { - label: TypeWithI18N | string + label: string | TypeWithI18N | Record value: string show_on?: FormShowOnObject[] icon?: string @@ -44,23 +45,41 @@ export type FormOption = { export type AnyValidators = FieldValidators +export enum FormItemValidateStatusEnum { + Success = 'success', + Warning = 'warning', + Error = 'error', + Validating = 'validating', +} + export type FormSchema = { type: FormTypeEnum name: string - label: string | ReactNode | TypeWithI18N + label: string | ReactNode | TypeWithI18N | Record required: boolean + multiple?: boolean default?: any - tooltip?: string | TypeWithI18N + description?: string | TypeWithI18N | Record + tooltip?: string | TypeWithI18N | Record show_on?: FormShowOnObject[] url?: string scope?: string - help?: string | TypeWithI18N - placeholder?: string | TypeWithI18N + help?: string | TypeWithI18N | Record + placeholder?: string | TypeWithI18N | Record options?: FormOption[] labelClassName?: string + fieldClassName?: string validators?: AnyValidators showRadioUI?: boolean disabled?: boolean + showCopy?: boolean + dynamicSelectParams?: { + plugin_id: string + provider: string + action: string + parameter: string + credential_id: string + } } export type FormValues = Record @@ -69,11 +88,25 @@ export type GetValuesOptions = { needTransformWhenSecretFieldIsPristine?: boolean needCheckValidatedValues?: boolean } + +export type FieldState = { + validateStatus?: FormItemValidateStatusEnum + help?: string | ReactNode + errors?: string[] + warnings?: string[] +} + +export type SetFieldsParam = { + name: string + value?: any +} & FieldState + export type FormRefObject = { getForm: () => AnyFormApi getFormValues: (obj: GetValuesOptions) => { values: Record isCheckValidated: boolean } + setFields: (fields: SetFieldsParam[]) => void } export type FormRef = ForwardedRef diff --git a/web/app/components/base/icons/assets/vender/line/alertsAndFeedback/warning.svg b/web/app/components/base/icons/assets/vender/line/alertsAndFeedback/warning.svg new file mode 100644 index 0000000000..8174878acb --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/alertsAndFeedback/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/line/arrows/IconR.svg b/web/app/components/base/icons/assets/vender/line/arrows/IconR.svg new file mode 100644 index 0000000000..7ff1df98e2 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/arrows/IconR.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/plugin/trigger.svg b/web/app/components/base/icons/assets/vender/plugin/trigger.svg new file mode 100644 index 0000000000..261fcd02b7 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/plugin/trigger.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-double-line.svg b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-double-line.svg new file mode 100644 index 0000000000..56caa01c59 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-double-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-round-fill.svg b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-round-fill.svg new file mode 100644 index 0000000000..48e70bcb51 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-down-round-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/solid/arrows/arrow-up-double-line.svg b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-up-double-line.svg new file mode 100644 index 0000000000..1f0b9858e1 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/solid/arrows/arrow-up-double-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/api-aggregate.svg b/web/app/components/base/icons/assets/vender/workflow/api-aggregate.svg new file mode 100644 index 0000000000..aaf2206d21 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/api-aggregate.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/asterisk.svg b/web/app/components/base/icons/assets/vender/workflow/asterisk.svg new file mode 100644 index 0000000000..d273c7e3d5 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/asterisk.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/calendar-check-line.svg b/web/app/components/base/icons/assets/vender/workflow/calendar-check-line.svg new file mode 100644 index 0000000000..2c7f148c71 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/calendar-check-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/schedule.svg b/web/app/components/base/icons/assets/vender/workflow/schedule.svg new file mode 100644 index 0000000000..69977c4c7f --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/schedule.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/trigger-all.svg b/web/app/components/base/icons/assets/vender/workflow/trigger-all.svg new file mode 100644 index 0000000000..dedcc0ad3c --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/trigger-all.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/app/components/base/icons/assets/vender/workflow/webhook-line.svg b/web/app/components/base/icons/assets/vender/workflow/webhook-line.svg new file mode 100644 index 0000000000..16fd30a961 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/workflow/webhook-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/src/image/llm/BaichuanTextCn.module.css b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.module.css new file mode 100644 index 0000000000..97ab9b22f9 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/baichuan-text-cn.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/BaichuanTextCn.tsx b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.tsx new file mode 100644 index 0000000000..be9a407eb2 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './BaichuanTextCn.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'BaichuanTextCn' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/Minimax.module.css b/web/app/components/base/icons/src/image/llm/Minimax.module.css new file mode 100644 index 0000000000..551ecc3c62 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Minimax.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/minimax.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/Minimax.tsx b/web/app/components/base/icons/src/image/llm/Minimax.tsx new file mode 100644 index 0000000000..7df7e3fcbc --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Minimax.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './Minimax.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'Minimax' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/MinimaxText.module.css b/web/app/components/base/icons/src/image/llm/MinimaxText.module.css new file mode 100644 index 0000000000..a63be49e8b --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/MinimaxText.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/minimax-text.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/MinimaxText.tsx b/web/app/components/base/icons/src/image/llm/MinimaxText.tsx new file mode 100644 index 0000000000..840e8cb439 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/MinimaxText.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './MinimaxText.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'MinimaxText' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/Tongyi.module.css b/web/app/components/base/icons/src/image/llm/Tongyi.module.css new file mode 100644 index 0000000000..3ca440768c --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Tongyi.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/tongyi.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/Tongyi.tsx b/web/app/components/base/icons/src/image/llm/Tongyi.tsx new file mode 100644 index 0000000000..2f62f1a355 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Tongyi.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './Tongyi.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'Tongyi' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/TongyiText.module.css b/web/app/components/base/icons/src/image/llm/TongyiText.module.css new file mode 100644 index 0000000000..f713671808 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/TongyiText.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/tongyi-text.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/TongyiText.tsx b/web/app/components/base/icons/src/image/llm/TongyiText.tsx new file mode 100644 index 0000000000..a52f63c248 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/TongyiText.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './TongyiText.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'TongyiText' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/TongyiTextCn.module.css b/web/app/components/base/icons/src/image/llm/TongyiTextCn.module.css new file mode 100644 index 0000000000..d07e6e8bc4 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/TongyiTextCn.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/tongyi-text-cn.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/TongyiTextCn.tsx b/web/app/components/base/icons/src/image/llm/TongyiTextCn.tsx new file mode 100644 index 0000000000..c982c73aed --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/TongyiTextCn.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './TongyiTextCn.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'TongyiTextCn' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/Wxyy.module.css b/web/app/components/base/icons/src/image/llm/Wxyy.module.css new file mode 100644 index 0000000000..44344a495f --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Wxyy.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/wxyy.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/Wxyy.tsx b/web/app/components/base/icons/src/image/llm/Wxyy.tsx new file mode 100644 index 0000000000..a3c494811e --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/Wxyy.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './Wxyy.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'Wxyy' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/WxyyText.module.css b/web/app/components/base/icons/src/image/llm/WxyyText.module.css new file mode 100644 index 0000000000..58a0c62047 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/WxyyText.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/wxyy-text.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/WxyyText.tsx b/web/app/components/base/icons/src/image/llm/WxyyText.tsx new file mode 100644 index 0000000000..e5dd6e8803 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/WxyyText.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './WxyyText.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'WxyyText' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/WxyyTextCn.module.css b/web/app/components/base/icons/src/image/llm/WxyyTextCn.module.css new file mode 100644 index 0000000000..fb5839ab07 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/WxyyTextCn.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: inline-flex; + background: url(~@/app/components/base/icons/assets/image/llm/wxyy-text-cn.png) center center no-repeat; + background-size: contain; +} diff --git a/web/app/components/base/icons/src/image/llm/WxyyTextCn.tsx b/web/app/components/base/icons/src/image/llm/WxyyTextCn.tsx new file mode 100644 index 0000000000..32108adab4 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/WxyyTextCn.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import cn from '@/utils/classnames' +import s from './WxyyTextCn.module.css' + +const Icon = ( + { + ref, + className, + ...restProps + }: React.DetailedHTMLProps, HTMLSpanElement> & { + ref?: React.RefObject; + }, +) => + +Icon.displayName = 'WxyyTextCn' + +export default Icon diff --git a/web/app/components/base/icons/src/image/llm/index.ts b/web/app/components/base/icons/src/image/llm/index.ts new file mode 100644 index 0000000000..3a4e64ac18 --- /dev/null +++ b/web/app/components/base/icons/src/image/llm/index.ts @@ -0,0 +1,9 @@ +export { default as BaichuanTextCn } from './BaichuanTextCn' +export { default as MinimaxText } from './MinimaxText' +export { default as Minimax } from './Minimax' +export { default as TongyiTextCn } from './TongyiTextCn' +export { default as TongyiText } from './TongyiText' +export { default as Tongyi } from './Tongyi' +export { default as WxyyTextCn } from './WxyyTextCn' +export { default as WxyyText } from './WxyyText' +export { default as Wxyy } from './Wxyy' diff --git a/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx b/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx index 5aa2d6c430..7096a4d2eb 100644 --- a/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx +++ b/web/app/components/base/icons/src/public/billing/AwsMarketplaceDark.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx index 85697f9dae..8d3e6a8a8a 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlue.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx index bf4264f1bd..f44856be61 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectBlueLight.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx index bd6cda4470..fe76f5917f 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectOrange.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx index b70808ef8c..f5c5e7ba3a 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectPurple.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx index ddd04a1911..0d2a07e405 100644 --- a/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx +++ b/web/app/components/base/icons/src/public/knowledge/OptionCardEffectTeal.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx index ea6ce30704..06bb8086bc 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/ExternalKnowledgeBase.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx index 6508ed57c6..6665039002 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/General.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/Graph.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/Graph.tsx index c1360c52ca..127367f873 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/Graph.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/Graph.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx index 7c6c3baa7b..922cb2c825 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/ParentChild.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx b/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx index 34ef88141e..ac41a8b153 100644 --- a/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx +++ b/web/app/components/base/icons/src/public/knowledge/dataset-card/Qa.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx index 9fd923458e..cfd9570081 100644 --- a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx +++ b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsBlue.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx index a646251629..2e40a70367 100644 --- a/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx +++ b/web/app/components/base/icons/src/public/knowledge/online-drive/BucketsGray.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx b/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx index e7a3fdf167..c5c3ea5b72 100644 --- a/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx +++ b/web/app/components/base/icons/src/public/knowledge/online-drive/Folder.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/public/model/Checked.tsx b/web/app/components/base/icons/src/public/model/Checked.tsx new file mode 100644 index 0000000000..7854479cd2 --- /dev/null +++ b/web/app/components/base/icons/src/public/model/Checked.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Checked.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Checked' + +export default Icon diff --git a/web/app/components/base/icons/src/public/model/index.ts b/web/app/components/base/icons/src/public/model/index.ts new file mode 100644 index 0000000000..719a6f0309 --- /dev/null +++ b/web/app/components/base/icons/src/public/model/index.ts @@ -0,0 +1 @@ +export { default as Checked } from './Checked' diff --git a/web/app/components/base/icons/src/public/plugins/Google.tsx b/web/app/components/base/icons/src/public/plugins/Google.tsx new file mode 100644 index 0000000000..3e19ecd2f8 --- /dev/null +++ b/web/app/components/base/icons/src/public/plugins/Google.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Google.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Google' + +export default Icon diff --git a/web/app/components/base/icons/src/public/plugins/WebReader.tsx b/web/app/components/base/icons/src/public/plugins/WebReader.tsx new file mode 100644 index 0000000000..5606e32f88 --- /dev/null +++ b/web/app/components/base/icons/src/public/plugins/WebReader.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './WebReader.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'WebReader' + +export default Icon diff --git a/web/app/components/base/icons/src/public/plugins/Wikipedia.tsx b/web/app/components/base/icons/src/public/plugins/Wikipedia.tsx new file mode 100644 index 0000000000..c2fde5c1f8 --- /dev/null +++ b/web/app/components/base/icons/src/public/plugins/Wikipedia.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Wikipedia.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Wikipedia' + +export default Icon diff --git a/web/app/components/base/icons/src/public/plugins/index.ts b/web/app/components/base/icons/src/public/plugins/index.ts new file mode 100644 index 0000000000..87dc37167c --- /dev/null +++ b/web/app/components/base/icons/src/public/plugins/index.ts @@ -0,0 +1,7 @@ +export { default as Google } from './Google' +export { default as PartnerDark } from './PartnerDark' +export { default as PartnerLight } from './PartnerLight' +export { default as VerifiedDark } from './VerifiedDark' +export { default as VerifiedLight } from './VerifiedLight' +export { default as WebReader } from './WebReader' +export { default as Wikipedia } from './Wikipedia' diff --git a/web/app/components/base/icons/src/public/thought/DataSet.tsx b/web/app/components/base/icons/src/public/thought/DataSet.tsx index e279c77ec7..f35ff4efbc 100644 --- a/web/app/components/base/icons/src/public/thought/DataSet.tsx +++ b/web/app/components/base/icons/src/public/thought/DataSet.tsx @@ -18,4 +18,3 @@ const Icon = ( Icon.displayName = 'DataSet' export default Icon - diff --git a/web/app/components/base/icons/src/public/thought/Loading.tsx b/web/app/components/base/icons/src/public/thought/Loading.tsx new file mode 100644 index 0000000000..af959fba40 --- /dev/null +++ b/web/app/components/base/icons/src/public/thought/Loading.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Loading.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Loading' + +export default Icon diff --git a/web/app/components/base/icons/src/public/thought/Search.tsx b/web/app/components/base/icons/src/public/thought/Search.tsx new file mode 100644 index 0000000000..ecd98048d5 --- /dev/null +++ b/web/app/components/base/icons/src/public/thought/Search.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Search.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Search' + +export default Icon diff --git a/web/app/components/base/icons/src/public/thought/ThoughtList.tsx b/web/app/components/base/icons/src/public/thought/ThoughtList.tsx new file mode 100644 index 0000000000..e7f0e312ef --- /dev/null +++ b/web/app/components/base/icons/src/public/thought/ThoughtList.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ThoughtList.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ThoughtList' + +export default Icon diff --git a/web/app/components/base/icons/src/public/thought/WebReader.tsx b/web/app/components/base/icons/src/public/thought/WebReader.tsx new file mode 100644 index 0000000000..5606e32f88 --- /dev/null +++ b/web/app/components/base/icons/src/public/thought/WebReader.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './WebReader.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'WebReader' + +export default Icon diff --git a/web/app/components/base/icons/src/public/thought/index.ts b/web/app/components/base/icons/src/public/thought/index.ts index 35adcb50bb..8a45489dbf 100644 --- a/web/app/components/base/icons/src/public/thought/index.ts +++ b/web/app/components/base/icons/src/public/thought/index.ts @@ -1,2 +1,5 @@ export { default as DataSet } from './DataSet' - +export { default as Loading } from './Loading' +export { default as Search } from './Search' +export { default as ThoughtList } from './ThoughtList' +export { default as WebReader } from './WebReader' diff --git a/web/app/components/base/icons/src/public/tracing/index.ts b/web/app/components/base/icons/src/public/tracing/index.ts index 9eaf42b7e0..8911798b56 100644 --- a/web/app/components/base/icons/src/public/tracing/index.ts +++ b/web/app/components/base/icons/src/public/tracing/index.ts @@ -8,10 +8,10 @@ export { default as LangsmithIconBig } from './LangsmithIconBig' export { default as LangsmithIcon } from './LangsmithIcon' export { default as OpikIconBig } from './OpikIconBig' export { default as OpikIcon } from './OpikIcon' -export { default as PhoenixIconBig } from './PhoenixIconBig' -export { default as PhoenixIcon } from './PhoenixIcon' export { default as TencentIconBig } from './TencentIconBig' export { default as TencentIcon } from './TencentIcon' +export { default as PhoenixIconBig } from './PhoenixIconBig' +export { default as PhoenixIcon } from './PhoenixIcon' export { default as TracingIcon } from './TracingIcon' export { default as WeaveIconBig } from './WeaveIconBig' export { default as WeaveIcon } from './WeaveIcon' diff --git a/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx b/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx index fc1270ae66..8068f7113c 100644 --- a/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/AddChunks.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx b/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx index 72ae12c7dd..b93cd2a325 100644 --- a/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/ArrowShape.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/Divider.tsx b/web/app/components/base/icons/src/vender/knowledge/Divider.tsx index 56606448be..8f7537b0db 100644 --- a/web/app/components/base/icons/src/vender/knowledge/Divider.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/Divider.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/Economic.tsx b/web/app/components/base/icons/src/vender/knowledge/Economic.tsx index c69560689e..52e2262fc1 100644 --- a/web/app/components/base/icons/src/vender/knowledge/Economic.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/Economic.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx b/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx index 0e36656343..714e63ecc0 100644 --- a/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/FullTextSearch.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx b/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx index 6e75ed920a..e269f3ad91 100644 --- a/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/GeneralChunk.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx b/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx index 880e63a003..964e4f1a2b 100644 --- a/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/HighQuality.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx b/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx index 45d76c2fd1..b9a83245ee 100644 --- a/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/HybridSearch.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx b/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx index 949cd508de..87664b706a 100644 --- a/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/ParentChildChunk.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx b/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx index 6ebc279a15..2492e63710 100644 --- a/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/QuestionAndAnswer.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx b/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx index 4826abb20f..497f24a984 100644 --- a/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/SearchMenu.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx b/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx index 2346033f89..fa22a54587 100644 --- a/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx +++ b/web/app/components/base/icons/src/vender/knowledge/VectorSearch.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.json b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.json new file mode 100644 index 0000000000..e131493a55 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "12", + "height": "12", + "viewBox": "0 0 12 12", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M6.43295 1.50009L11.1961 9.7501C11.3342 9.98925 11.2523 10.295 11.0131 10.4331C10.9371 10.477 10.8509 10.5001 10.7631 10.5001H1.23682C0.960676 10.5001 0.736816 10.2762 0.736816 10.0001C0.736816 9.9123 0.759921 9.8261 0.803806 9.7501L5.56695 1.50009C5.705 1.26094 6.0108 1.179 6.24995 1.31707C6.32595 1.36096 6.3891 1.42408 6.43295 1.50009ZM5.49995 8.0001V9.0001H6.49995V8.0001H5.49995ZM5.49995 4.50008V7.0001H6.49995V4.50008H5.49995Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "Warning" +} diff --git a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.tsx b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.tsx new file mode 100644 index 0000000000..b73363b2c2 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Warning.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Warning' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts index f0a0faf74d..4e721d70eb 100644 --- a/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts +++ b/web/app/components/base/icons/src/vender/line/alertsAndFeedback/index.ts @@ -1,3 +1,4 @@ export { default as AlertTriangle } from './AlertTriangle' export { default as ThumbsDown } from './ThumbsDown' export { default as ThumbsUp } from './ThumbsUp' +export { default as Warning } from './Warning' diff --git a/web/app/components/base/icons/src/vender/line/arrows/IconR.json b/web/app/components/base/icons/src/vender/line/arrows/IconR.json new file mode 100644 index 0000000000..31624cf04f --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/arrows/IconR.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 14 14", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M9.43341 6.41661L6.30441 3.2876L7.12936 2.46265L11.6666 6.99994L7.12936 11.5372L6.30441 10.7122L9.43341 7.58327H2.33331V6.41661H9.43341Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "IconR" +} diff --git a/web/app/components/base/icons/src/vender/line/arrows/IconR.tsx b/web/app/components/base/icons/src/vender/line/arrows/IconR.tsx new file mode 100644 index 0000000000..0546223e95 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/arrows/IconR.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './IconR.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'IconR' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/arrows/index.ts b/web/app/components/base/icons/src/vender/line/arrows/index.ts index c329b3636e..78554c86f1 100644 --- a/web/app/components/base/icons/src/vender/line/arrows/index.ts +++ b/web/app/components/base/icons/src/vender/line/arrows/index.ts @@ -1,3 +1,4 @@ +export { default as IconR } from './IconR' export { default as ArrowNarrowLeft } from './ArrowNarrowLeft' export { default as ArrowUpRight } from './ArrowUpRight' export { default as ChevronDownDouble } from './ChevronDownDouble' diff --git a/web/app/components/base/icons/src/vender/line/communication/AiText.json b/web/app/components/base/icons/src/vender/line/communication/AiText.json new file mode 100644 index 0000000000..2473c64c22 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/communication/AiText.json @@ -0,0 +1,39 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 14 14", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "id": "ai-text" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "id": "Vector", + "d": "M2.33301 10.5H4.08301M2.33301 7H5.24967M2.33301 3.5H11.6663M9.91634 5.83333L10.7913 7.875L12.833 8.75L10.7913 9.625L9.91634 11.6667L9.04134 9.625L6.99967 8.75L9.04134 7.875L9.91634 5.83333Z", + "stroke": "currentColor", + "stroke-width": "1.25", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + } + ] + } + ] + }, + "name": "AiText" +} diff --git a/web/app/components/base/icons/src/vender/line/communication/AiText.tsx b/web/app/components/base/icons/src/vender/line/communication/AiText.tsx new file mode 100644 index 0000000000..7d5a860038 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/communication/AiText.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AiText.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'AiText' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/communication/index.ts b/web/app/components/base/icons/src/vender/line/communication/index.ts index 27118f1dde..3ab20e8bb4 100644 --- a/web/app/components/base/icons/src/vender/line/communication/index.ts +++ b/web/app/components/base/icons/src/vender/line/communication/index.ts @@ -1,3 +1,4 @@ +export { default as AiText } from './AiText' export { default as ChatBotSlim } from './ChatBotSlim' export { default as ChatBot } from './ChatBot' export { default as CuteRobot } from './CuteRobot' diff --git a/web/app/components/base/icons/src/vender/line/layout/AlignLeft01.tsx b/web/app/components/base/icons/src/vender/line/layout/AlignLeft01.tsx new file mode 100644 index 0000000000..0761e89f56 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/layout/AlignLeft01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AlignLeft01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'AlignLeft01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/layout/AlignRight01.tsx b/web/app/components/base/icons/src/vender/line/layout/AlignRight01.tsx new file mode 100644 index 0000000000..ffe1889ff8 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/layout/AlignRight01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AlignRight01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'AlignRight01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/layout/Grid01.tsx b/web/app/components/base/icons/src/vender/line/layout/Grid01.tsx new file mode 100644 index 0000000000..bc9b6115be --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/layout/Grid01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Grid01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Grid01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/layout/index.ts b/web/app/components/base/icons/src/vender/line/layout/index.ts index a6aa205faa..7c12b1f58f 100644 --- a/web/app/components/base/icons/src/vender/line/layout/index.ts +++ b/web/app/components/base/icons/src/vender/line/layout/index.ts @@ -1 +1,4 @@ +export { default as AlignLeft01 } from './AlignLeft01' +export { default as AlignRight01 } from './AlignRight01' +export { default as Grid01 } from './Grid01' export { default as LayoutGrid02 } from './LayoutGrid02' diff --git a/web/app/components/base/icons/src/vender/line/users/User01.tsx b/web/app/components/base/icons/src/vender/line/users/User01.tsx new file mode 100644 index 0000000000..42f2144b97 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/users/User01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './User01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'User01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/users/Users01.tsx b/web/app/components/base/icons/src/vender/line/users/Users01.tsx new file mode 100644 index 0000000000..b63daf7242 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/users/Users01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Users01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Users01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/users/index.ts b/web/app/components/base/icons/src/vender/line/users/index.ts new file mode 100644 index 0000000000..9f8a35152f --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/users/index.ts @@ -0,0 +1,2 @@ +export { default as User01 } from './User01' +export { default as Users01 } from './Users01' diff --git a/web/app/components/base/icons/src/vender/line/weather/Stars02.tsx b/web/app/components/base/icons/src/vender/line/weather/Stars02.tsx new file mode 100644 index 0000000000..8a42448c70 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/weather/Stars02.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Stars02.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Stars02' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/weather/index.ts b/web/app/components/base/icons/src/vender/line/weather/index.ts new file mode 100644 index 0000000000..1a68bce765 --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/weather/index.ts @@ -0,0 +1 @@ +export { default as Stars02 } from './Stars02' diff --git a/web/app/components/base/icons/src/vender/pipeline/InputField.tsx b/web/app/components/base/icons/src/vender/pipeline/InputField.tsx index 4c224844d0..981b2d38d2 100644 --- a/web/app/components/base/icons/src/vender/pipeline/InputField.tsx +++ b/web/app/components/base/icons/src/vender/pipeline/InputField.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx b/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx index e0c2cc5386..2a31601cb3 100644 --- a/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx +++ b/web/app/components/base/icons/src/vender/pipeline/PipelineFill.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx b/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx index e18df7af48..5f37828ed5 100644 --- a/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx +++ b/web/app/components/base/icons/src/vender/pipeline/PipelineLine.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject>; + ref?: React.RefObject>; }, ) => diff --git a/web/app/components/base/icons/src/vender/plugin/Trigger.json b/web/app/components/base/icons/src/vender/plugin/Trigger.json new file mode 100644 index 0000000000..409ef0e478 --- /dev/null +++ b/web/app/components/base/icons/src/vender/plugin/Trigger.json @@ -0,0 +1,73 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "fill-rule": "evenodd", + "clip-rule": "evenodd", + "d": "M7.1499 6.35213L7.25146 6.38208L14.2248 9.03898L14.3172 9.08195C14.7224 9.30788 14.778 9.87906 14.424 10.179L14.342 10.2389L11.8172 11.817L10.2391 14.3417C9.96271 14.7839 9.32424 14.751 9.08219 14.317L9.03923 14.2245L6.38232 7.25122C6.18829 6.74188 6.64437 6.24196 7.1499 6.35213ZM9.81201 12.5084L10.7671 10.981L10.8114 10.9185C10.8589 10.8589 10.9163 10.8075 10.9813 10.7668L12.5086 9.81177L8.15251 8.15226L9.81201 12.5084Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.2124 10.3977L3.56266 12.0474L2.61995 11.1047L4.26969 9.455L5.2124 10.3977Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M3.66683 7.99992H1.3335V6.66659H3.66683V7.99992Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.2124 4.2688L4.26969 5.21151L2.61995 3.56177L3.56266 2.61906L5.2124 4.2688Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M12.0477 3.56177L10.3979 5.21151L9.45524 4.2688L11.105 2.61906L12.0477 3.56177Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M8.00016 3.66659H6.66683V1.33325H8.00016V3.66659Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "Trigger" +} diff --git a/web/app/components/base/icons/src/vender/plugin/Trigger.tsx b/web/app/components/base/icons/src/vender/plugin/Trigger.tsx new file mode 100644 index 0000000000..b8f6a56ca7 --- /dev/null +++ b/web/app/components/base/icons/src/vender/plugin/Trigger.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Trigger.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Trigger' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/plugin/index.ts b/web/app/components/base/icons/src/vender/plugin/index.ts index 943c764116..b345526eb7 100644 --- a/web/app/components/base/icons/src/vender/plugin/index.ts +++ b/web/app/components/base/icons/src/vender/plugin/index.ts @@ -1,2 +1,3 @@ export { default as BoxSparkleFill } from './BoxSparkleFill' export { default as LeftCorner } from './LeftCorner' +export { default as Trigger } from './Trigger' diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.json b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.json new file mode 100644 index 0000000000..17bc271b9e --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M8.00001 12.7761L12.1381 8.63804L11.1953 7.69524L8.00001 10.8905L4.80475 7.69524L3.86194 8.63804L8.00001 12.7761ZM8.00001 9.00951L12.1381 4.87146L11.1953 3.92865L8.00001 7.12391L4.80475 3.92865L3.86194 4.87146L8.00001 9.00951Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "ArrowDownDoubleLine" +} diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.tsx b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.tsx new file mode 100644 index 0000000000..166a5b624b --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownDoubleLine.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ArrowDownDoubleLine.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ArrowDownDoubleLine' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.json b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.json new file mode 100644 index 0000000000..b150caf879 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.json @@ -0,0 +1,27 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M6.02888 6.23572C5.08558 6.23572 4.56458 7.33027 5.15943 8.06239L7.13069 10.4885C7.57898 11.0403 8.42124 11.0403 8.86962 10.4885L10.8408 8.06239C11.4357 7.33027 10.9147 6.23572 9.97134 6.23572H6.02888Z", + "fill": "currentColor", + "fill-opacity": "0.3" + }, + "children": [] + } + ] + }, + "name": "ArrowDownRoundFill" +} diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx new file mode 100644 index 0000000000..24a1ea53fd --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowDownRoundFill.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ArrowDownRoundFill.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ArrowDownRoundFill' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.json b/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.json new file mode 100644 index 0000000000..b76fc3e80c --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M8 3.22388L3.86194 7.36193L4.80475 8.30473L8 5.10949L11.1953 8.30473L12.1381 7.36193L8 3.22388ZM8 6.99046L3.86194 11.1285L4.80475 12.0713L8 8.87606L11.1953 12.0713L12.1381 11.1285L8 6.99046Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "ArrowUpDoubleLine" +} diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.tsx b/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.tsx new file mode 100644 index 0000000000..06ba38ec70 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ArrowUpDoubleLine.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ArrowUpDoubleLine.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ArrowUpDoubleLine' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/arrows/ChevronDown.tsx b/web/app/components/base/icons/src/vender/solid/arrows/ChevronDown.tsx new file mode 100644 index 0000000000..643ddfbf79 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/ChevronDown.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ChevronDown.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ChevronDown' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/arrows/HighPriority.tsx b/web/app/components/base/icons/src/vender/solid/arrows/HighPriority.tsx new file mode 100644 index 0000000000..af6fa05e5c --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/HighPriority.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './HighPriority.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'HighPriority' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/arrows/index.ts b/web/app/components/base/icons/src/vender/solid/arrows/index.ts new file mode 100644 index 0000000000..58ce9aa8ac --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/arrows/index.ts @@ -0,0 +1,5 @@ +export { default as ArrowDownDoubleLine } from './ArrowDownDoubleLine' +export { default as ArrowDownRoundFill } from './ArrowDownRoundFill' +export { default as ArrowUpDoubleLine } from './ArrowUpDoubleLine' +export { default as ChevronDown } from './ChevronDown' +export { default as HighPriority } from './HighPriority' diff --git a/web/app/components/base/icons/src/vender/solid/communication/AiText.json b/web/app/components/base/icons/src/vender/solid/communication/AiText.json new file mode 100644 index 0000000000..65860e58b9 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/communication/AiText.json @@ -0,0 +1,53 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "24", + "height": "24", + "viewBox": "0 0 24 24", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4 5C3.44772 5 3 5.44772 3 6C3 6.55228 3.44772 7 4 7H20C20.5523 7 21 6.55228 21 6C21 5.44772 20.5523 5 20 5H4Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M17.9191 9.60608C17.7616 9.2384 17.4 9 17 9C16.6 9 16.2384 9.2384 16.0809 9.60608L14.7384 12.7384L11.6061 14.0809C11.2384 14.2384 11 14.6 11 15C11 15.4 11.2384 15.7616 11.6061 15.9191L14.7384 17.2616L16.0809 20.3939C16.2384 20.7616 16.6 21 17 21C17.4 21 17.7616 20.7616 17.9191 20.3939L19.2616 17.2616L22.3939 15.9191C22.7616 15.7616 23 15.4 23 15C23 14.6 22.7616 14.2384 22.3939 14.0809L19.2616 12.7384L17.9191 9.60608Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4 11C3.44772 11 3 11.4477 3 12C3 12.5523 3.44772 13 4 13H9C9.55228 13 10 12.5523 10 12C10 11.4477 9.55228 11 9 11H4Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4 17C3.44772 17 3 17.4477 3 18C3 18.5523 3.44772 19 4 19H7C7.55228 19 8 18.5523 8 18C8 17.4477 7.55228 17 7 17H4Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "AiText" +} diff --git a/web/app/components/base/icons/src/vender/solid/communication/AiText.tsx b/web/app/components/base/icons/src/vender/solid/communication/AiText.tsx new file mode 100644 index 0000000000..7d5a860038 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/communication/AiText.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AiText.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'AiText' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/communication/index.ts b/web/app/components/base/icons/src/vender/solid/communication/index.ts index a1659b7b18..7d2a3a5a95 100644 --- a/web/app/components/base/icons/src/vender/solid/communication/index.ts +++ b/web/app/components/base/icons/src/vender/solid/communication/index.ts @@ -1,3 +1,4 @@ +export { default as AiText } from './AiText' export { default as BubbleTextMod } from './BubbleTextMod' export { default as ChatBot } from './ChatBot' export { default as CuteRobot } from './CuteRobot' diff --git a/web/app/components/base/icons/src/vender/solid/layout/Grid01.tsx b/web/app/components/base/icons/src/vender/solid/layout/Grid01.tsx new file mode 100644 index 0000000000..bc9b6115be --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/layout/Grid01.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Grid01.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Grid01' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/layout/index.ts b/web/app/components/base/icons/src/vender/solid/layout/index.ts new file mode 100644 index 0000000000..73a2513d51 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/layout/index.ts @@ -0,0 +1 @@ +export { default as Grid01 } from './Grid01' diff --git a/web/app/components/base/icons/src/vender/workflow/ApiAggregate.json b/web/app/components/base/icons/src/vender/workflow/ApiAggregate.json new file mode 100644 index 0000000000..1057842352 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/ApiAggregate.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.92578 11.0094C5.92578 10.0174 5.12163 9.21256 4.12956 9.21256C3.13752 9.2126 2.33333 10.0174 2.33333 11.0094C2.33349 12.0014 3.13762 12.8056 4.12956 12.8057C5.12153 12.8057 5.92562 12.0014 5.92578 11.0094ZM13.6667 11.0094C13.6667 10.0174 12.8625 9.2126 11.8704 9.21256C10.8784 9.21256 10.0742 10.0174 10.0742 11.0094C10.0744 12.0014 10.8785 12.8057 11.8704 12.8057C12.8624 12.8056 13.6665 12.0014 13.6667 11.0094ZM9.79622 4.32389C9.79619 3.33186 8.99205 2.52767 8 2.52767C7.00796 2.52767 6.20382 3.33186 6.20378 4.32389C6.20378 5.31596 7.00793 6.12012 8 6.12012C8.99207 6.12012 9.79622 5.31596 9.79622 4.32389ZM11.1296 4.32389C11.1296 5.82351 10.0748 7.07628 8.66667 7.38184V7.9196L9.74284 8.71387C10.3012 8.19607 11.0489 7.87923 11.8704 7.87923C13.5989 7.87927 15 9.28101 15 11.0094C14.9998 12.7377 13.5988 14.139 11.8704 14.139C10.1421 14.139 8.74104 12.7378 8.74089 11.0094C8.74089 10.5837 8.82585 10.1776 8.97982 9.80762L8 9.08366L7.01953 9.80762C7.17356 10.1777 7.25911 10.5836 7.25911 11.0094C7.25896 12.7378 5.85791 14.139 4.12956 14.139C2.40124 14.139 1.00016 12.7377 1 11.0094C1 9.28101 2.40114 7.87927 4.12956 7.87923C4.95094 7.87923 5.69819 8.19627 6.25651 8.71387L7.33333 7.9196V7.38184C5.92523 7.07628 4.87044 5.82351 4.87044 4.32389C4.87048 2.59548 6.27158 1.19434 8 1.19434C9.72843 1.19434 11.1295 2.59548 11.1296 4.32389Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "ApiAggregate" +} diff --git a/web/app/components/base/icons/src/vender/workflow/ApiAggregate.tsx b/web/app/components/base/icons/src/vender/workflow/ApiAggregate.tsx new file mode 100644 index 0000000000..64193e900b --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/ApiAggregate.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './ApiAggregate.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'ApiAggregate' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/Asterisk.json b/web/app/components/base/icons/src/vender/workflow/Asterisk.json new file mode 100644 index 0000000000..d7fa156d99 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Asterisk.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 14 14", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M7.58325 1.75L7.58314 5.98908L11.2549 3.86982L11.8382 4.88018L8.16705 6.99942L11.8382 9.11983L11.2549 10.1302L7.58314 8.01033L7.58325 12.25H6.41659L6.41647 8.01033L2.74495 10.1302L2.16162 9.11983L5.83254 7L2.16162 4.88018L2.74495 3.86982L6.41647 5.98908L6.41659 1.75H7.58325Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "Asterisk" +} diff --git a/web/app/components/base/icons/src/vender/workflow/Asterisk.tsx b/web/app/components/base/icons/src/vender/workflow/Asterisk.tsx new file mode 100644 index 0000000000..916b90429c --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Asterisk.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Asterisk.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Asterisk' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.json b/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.json new file mode 100644 index 0000000000..8f77528653 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 14 14", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.24984 0.583252V1.74992H8.74984V0.583252H9.9165V1.74992H12.2498C12.572 1.74992 12.8332 2.01109 12.8332 2.33325V11.6666C12.8332 11.9888 12.572 12.2499 12.2498 12.2499H1.74984C1.42767 12.2499 1.1665 11.9888 1.1665 11.6666V2.33325C1.1665 2.01109 1.42767 1.74992 1.74984 1.74992H4.08317V0.583252H5.24984ZM11.6665 5.83325H2.33317V11.0833H11.6665V5.83325ZM8.77055 6.49592L9.5955 7.32093L6.70817 10.2083L4.64578 8.14588L5.47073 7.32093L6.70817 8.55835L8.77055 6.49592ZM4.08317 2.91659H2.33317V4.66659H11.6665V2.91659H9.9165V3.49992H8.74984V2.91659H5.24984V3.49992H4.08317V2.91659Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "CalendarCheckLine" +} diff --git a/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.tsx b/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.tsx new file mode 100644 index 0000000000..e480da2f04 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/CalendarCheckLine.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './CalendarCheckLine.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'CalendarCheckLine' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/Schedule.json b/web/app/components/base/icons/src/vender/workflow/Schedule.json new file mode 100644 index 0000000000..1c2d181dc4 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Schedule.json @@ -0,0 +1,46 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M11.3333 9.33337C11.7015 9.33337 11.9999 9.63193 12 10V11.0573L12.8047 11.862L12.8503 11.9128C13.0638 12.1746 13.0487 12.5607 12.8047 12.8047C12.5606 13.0488 12.1746 13.0639 11.9128 12.8503L11.862 12.8047L10.862 11.8047C10.7371 11.6798 10.6667 11.5101 10.6667 11.3334V10C10.6668 9.63193 10.9652 9.33337 11.3333 9.33337Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "fill-rule": "evenodd", + "clip-rule": "evenodd", + "d": "M11.3333 7.33337C13.5425 7.33337 15.3333 9.12424 15.3333 11.3334C15.3333 13.5425 13.5425 15.3334 11.3333 15.3334C9.12419 15.3334 7.33333 13.5425 7.33333 11.3334C7.33333 9.12424 9.12419 7.33337 11.3333 7.33337ZM11.3333 8.66671C9.86057 8.66671 8.66667 9.86061 8.66667 11.3334C8.66667 12.8061 9.86057 14 11.3333 14C12.8061 14 14 12.8061 14 11.3334C14 9.86061 12.8061 8.66671 11.3333 8.66671Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M10.6667 1.33337C11.0349 1.33337 11.3333 1.63185 11.3333 2.00004V2.66671H12.6667C13.4031 2.66671 14 3.26367 14 4.00004V5.66671C14 6.0349 13.7015 6.33337 13.3333 6.33337C12.9651 6.33337 12.6667 6.0349 12.6667 5.66671V4.00004H3.33333V12.6667H5.66667C6.03486 12.6667 6.33333 12.9652 6.33333 13.3334C6.33333 13.7016 6.03486 14 5.66667 14H3.33333C2.59697 14 2 13.4031 2 12.6667V4.00004C2 3.26366 2.59696 2.66671 3.33333 2.66671H4.66667V2.00004C4.66667 1.63185 4.96514 1.33337 5.33333 1.33337C5.70152 1.33337 6 1.63185 6 2.00004V2.66671H10V2.00004C10 1.63185 10.2985 1.33337 10.6667 1.33337Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "Schedule" +} diff --git a/web/app/components/base/icons/src/vender/workflow/Schedule.tsx b/web/app/components/base/icons/src/vender/workflow/Schedule.tsx new file mode 100644 index 0000000000..71205efd0b --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/Schedule.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './Schedule.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'Schedule' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/TriggerAll.json b/web/app/components/base/icons/src/vender/workflow/TriggerAll.json new file mode 100644 index 0000000000..c324e8be04 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/TriggerAll.json @@ -0,0 +1,73 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "14", + "height": "14", + "viewBox": "0 0 14 14", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "fill-rule": "evenodd", + "clip-rule": "evenodd", + "d": "M5.34698 6.42505C5.10275 5.79268 5.67045 5.17005 6.29816 5.30916L6.42446 5.34758L13.0846 7.92049L13.1999 7.97518C13.7051 8.26089 13.7647 8.9802 13.3118 9.34432L13.207 9.41659L10.8196 10.8202L9.416 13.2076C9.08465 13.7711 8.28069 13.742 7.97459 13.2004L7.9199 13.0852L5.34698 6.42505ZM8.791 11.6392L9.73631 10.0325L9.73696 10.0318L9.7962 9.94458C9.86055 9.86164 9.94031 9.79125 10.0312 9.73755L10.0319 9.7369L11.6387 8.79159L6.99738 6.99797L8.791 11.6392Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M2.79751 8.9257C3.05781 8.66539 3.47985 8.66547 3.74021 8.9257C4.00057 9.18604 4.00056 9.60805 3.74021 9.86841L3.03318 10.5754C2.77283 10.8356 2.35078 10.8357 2.09047 10.5754C1.83032 10.3151 1.83033 9.89305 2.09047 9.63273L2.79751 8.9257Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M1.99998 5.66659C2.36817 5.66659 2.66665 5.96506 2.66665 6.33325C2.66665 6.70144 2.36817 6.99992 1.99998 6.99992H0.99998C0.63179 6.99992 0.333313 6.70144 0.333313 6.33325C0.333313 5.96506 0.63179 5.66659 0.99998 5.66659H1.99998Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M9.63279 2.09106C9.8931 1.83077 10.3151 1.83086 10.5755 2.09106C10.8358 2.35142 10.8359 2.77343 10.5755 3.03377L9.86847 3.7408C9.6081 4.00098 9.18605 4.0011 8.92576 3.7408C8.66559 3.4805 8.66562 3.05841 8.92576 2.7981L9.63279 2.09106Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M2.09113 2.09041C2.33521 1.84649 2.72126 1.83132 2.98305 2.04484L3.03383 2.09041L3.74087 2.79744L3.78644 2.84823C3.9999 3.11002 3.98476 3.49609 3.74087 3.74015C3.49682 3.9842 3.11079 3.9992 2.84894 3.78573L2.79816 3.74015L2.09113 3.03312L2.04555 2.98234C1.83199 2.72049 1.84705 2.33449 2.09113 2.09041Z", + "fill": "currentColor" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M6.33331 0.333252C6.7015 0.333252 6.99998 0.631729 6.99998 0.999919V1.99992C6.99998 2.36811 6.7015 2.66659 6.33331 2.66659C5.96512 2.66659 5.66665 2.36811 5.66665 1.99992V0.999919C5.66665 0.631729 5.96512 0.333252 6.33331 0.333252Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "TriggerAll" +} diff --git a/web/app/components/base/icons/src/vender/workflow/TriggerAll.tsx b/web/app/components/base/icons/src/vender/workflow/TriggerAll.tsx new file mode 100644 index 0000000000..71f2dbdb36 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/TriggerAll.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './TriggerAll.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'TriggerAll' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/WebhookLine.json b/web/app/components/base/icons/src/vender/workflow/WebhookLine.json new file mode 100644 index 0000000000..8319fd25f3 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/WebhookLine.json @@ -0,0 +1,26 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M5.91246 9.42618C5.77036 9.66084 5.70006 9.85191 5.81358 10.1502C6.12696 10.9742 5.68488 11.776 4.85394 11.9937C4.07033 12.199 3.30686 11.684 3.15138 10.8451C3.01362 10.1025 3.58988 9.37451 4.40859 9.25851C4.45305 9.25211 4.49808 9.24938 4.55563 9.24591C4.58692 9.24404 4.62192 9.24191 4.66252 9.23884L5.90792 7.15051C5.12463 6.37166 4.65841 5.46114 4.7616 4.33295C4.83455 3.53543 5.14813 2.84626 5.72135 2.28138C6.81916 1.19968 8.49403 1.02449 9.78663 1.85479C11.0282 2.65232 11.5967 4.20582 11.112 5.53545L9.97403 5.22671C10.1263 4.48748 10.0137 3.82362 9.5151 3.25494C9.1857 2.87947 8.76303 2.68267 8.28236 2.61015C7.31883 2.46458 6.37278 3.08364 6.09207 4.02937C5.77342 5.10275 6.25566 5.97954 7.5735 6.64023C7.0207 7.56944 6.47235 8.50124 5.91246 9.42618ZM9.18916 5.51562C9.5877 6.2187 9.99236 6.93244 10.3934 7.63958C12.4206 7.01244 13.9491 8.13458 14.4974 9.33604C15.1597 10.7873 14.707 12.5062 13.4062 13.4016C12.0711 14.3207 10.3827 14.1636 9.19976 12.983L10.1279 12.2063C11.2962 12.963 12.3181 12.9274 13.0767 12.0314C13.7236 11.2669 13.7096 10.1271 13.0439 9.37871C12.2757 8.51511 11.2467 8.48878 10.0029 9.31784C9.48696 8.40251 8.96196 7.49424 8.46236 6.57234C8.2939 6.2616 8.10783 6.08135 7.72816 6.01558C7.09403 5.90564 6.68463 5.36109 6.66007 4.75099C6.63593 4.14763 6.99136 3.60224 7.54696 3.38974C8.0973 3.17924 8.74316 3.34916 9.11336 3.81707C9.4159 4.19938 9.51203 4.62966 9.35283 5.10116C9.32283 5.19018 9.28689 5.27727 9.2475 5.37261C9.22869 5.418 9.20916 5.46538 9.18916 5.51562ZM7.7013 11.2634H10.1417C10.1757 11.3087 10.2075 11.3536 10.2386 11.3973C10.3034 11.4887 10.3649 11.5755 10.4367 11.6526C10.9536 12.2052 11.8263 12.2326 12.3788 11.7197C12.9514 11.1881 12.9773 10.2951 12.4362 9.74011C11.9068 9.19704 11.0019 9.14518 10.5103 9.72018C10.2117 10.0696 9.9057 10.1107 9.50936 10.1045C8.49423 10.0888 7.47843 10.0994 6.46346 10.0994C6.52934 11.5273 5.98953 12.417 4.9189 12.6283C3.87051 12.8352 2.90496 12.3003 2.56502 11.3243C2.17891 10.2153 2.65641 9.32838 4.0361 8.62444C3.93228 8.24838 3.8274 7.86778 3.72357 7.49071C2.21981 7.81844 1.09162 9.27738 1.20809 10.9187C1.31097 12.3676 2.47975 13.6544 3.90909 13.8849C4.68542 14.0102 5.41485 13.88 6.09157 13.4962C6.96216 13.0022 7.46736 12.2254 7.7013 11.2634Z", + "fill": "currentColor" + }, + "children": [] + } + ] + }, + "name": "WebhookLine" +} diff --git a/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx b/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx new file mode 100644 index 0000000000..0379692808 --- /dev/null +++ b/web/app/components/base/icons/src/vender/workflow/WebhookLine.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './WebhookLine.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconData } from '@/app/components/base/icons/IconBase' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject>; + }, +) => + +Icon.displayName = 'WebhookLine' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/workflow/index.ts b/web/app/components/base/icons/src/vender/workflow/index.ts index 37b7306f7b..26ee3e4126 100644 --- a/web/app/components/base/icons/src/vender/workflow/index.ts +++ b/web/app/components/base/icons/src/vender/workflow/index.ts @@ -1,6 +1,9 @@ export { default as Agent } from './Agent' export { default as Answer } from './Answer' +export { default as ApiAggregate } from './ApiAggregate' export { default as Assigner } from './Assigner' +export { default as Asterisk } from './Asterisk' +export { default as CalendarCheckLine } from './CalendarCheckLine' export { default as Code } from './Code' export { default as Datasource } from './Datasource' export { default as DocsExtractor } from './DocsExtractor' @@ -19,6 +22,9 @@ export { default as LoopEnd } from './LoopEnd' export { default as Loop } from './Loop' export { default as ParameterExtractor } from './ParameterExtractor' export { default as QuestionClassifier } from './QuestionClassifier' +export { default as Schedule } from './Schedule' export { default as TemplatingTransform } from './TemplatingTransform' +export { default as TriggerAll } from './TriggerAll' export { default as VariableX } from './VariableX' +export { default as WebhookLine } from './WebhookLine' export { default as WindowCursor } from './WindowCursor' diff --git a/web/app/components/base/image-gallery/index.tsx b/web/app/components/base/image-gallery/index.tsx index 0f9061fdb6..fdb9711292 100644 --- a/web/app/components/base/image-gallery/index.tsx +++ b/web/app/components/base/image-gallery/index.tsx @@ -1,9 +1,9 @@ 'use client' +import ImagePreview from '@/app/components/base/image-uploader/image-preview' +import cn from '@/utils/classnames' import type { FC } from 'react' import React, { useState } from 'react' import s from './style.module.css' -import cn from '@/utils/classnames' -import ImagePreview from '@/app/components/base/image-uploader/image-preview' type Props = { srcs: string[] @@ -36,10 +36,8 @@ const ImageGallery: FC = ({ const imgStyle = getWidthStyle(imgNum) return (
- {/* TODO: support preview */} {srcs.map((src, index) => ( - - = ({ imagePreviewUrl && ( setImagePreviewUrl('')} title={''} /> + onCancel={() => setImagePreviewUrl('')} + title={''} + /> ) }
diff --git a/web/app/components/base/input-with-copy/index.spec.tsx b/web/app/components/base/input-with-copy/index.spec.tsx new file mode 100644 index 0000000000..f302f1715a --- /dev/null +++ b/web/app/components/base/input-with-copy/index.spec.tsx @@ -0,0 +1,150 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' +import InputWithCopy from './index' + +// Mock the copy-to-clipboard library +jest.mock('copy-to-clipboard', () => jest.fn(() => true)) + +// Mock the i18n hook +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'common.operation.copy': 'Copy', + 'common.operation.copied': 'Copied', + 'appOverview.overview.appInfo.embedded.copy': 'Copy', + 'appOverview.overview.appInfo.embedded.copied': 'Copied', + } + return translations[key] || key + }, + }), +})) + +// Mock lodash-es debounce +jest.mock('lodash-es', () => ({ + debounce: (fn: any) => fn, +})) + +describe('InputWithCopy component', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders correctly with default props', () => { + const mockOnChange = jest.fn() + render() + const input = screen.getByDisplayValue('test value') + const copyButton = screen.getByRole('button') + expect(input).toBeInTheDocument() + expect(copyButton).toBeInTheDocument() + }) + + it('hides copy button when showCopyButton is false', () => { + const mockOnChange = jest.fn() + render() + const input = screen.getByDisplayValue('test value') + const copyButton = screen.queryByRole('button') + expect(input).toBeInTheDocument() + expect(copyButton).not.toBeInTheDocument() + }) + + it('copies input value when copy button is clicked', async () => { + const copyToClipboard = require('copy-to-clipboard') + const mockOnChange = jest.fn() + render() + + const copyButton = screen.getByRole('button') + fireEvent.click(copyButton) + + expect(copyToClipboard).toHaveBeenCalledWith('test value') + }) + + it('copies custom value when copyValue prop is provided', async () => { + const copyToClipboard = require('copy-to-clipboard') + const mockOnChange = jest.fn() + render() + + const copyButton = screen.getByRole('button') + fireEvent.click(copyButton) + + expect(copyToClipboard).toHaveBeenCalledWith('custom copy value') + }) + + it('calls onCopy callback when copy button is clicked', async () => { + const onCopyMock = jest.fn() + const mockOnChange = jest.fn() + render() + + const copyButton = screen.getByRole('button') + fireEvent.click(copyButton) + + expect(onCopyMock).toHaveBeenCalledWith('test value') + }) + + it('shows copied state after successful copy', async () => { + const mockOnChange = jest.fn() + render() + + const copyButton = screen.getByRole('button') + fireEvent.click(copyButton) + + // Hover over the button to trigger tooltip + fireEvent.mouseEnter(copyButton) + + // Check if the tooltip shows "Copied" state + await waitFor(() => { + expect(screen.getByText('Copied')).toBeInTheDocument() + }, { timeout: 2000 }) + }) + + it('passes through all input props correctly', () => { + const mockOnChange = jest.fn() + render( + , + ) + + const input = screen.getByDisplayValue('test value') + expect(input).toHaveAttribute('placeholder', 'Custom placeholder') + expect(input).toBeDisabled() + expect(input).toHaveAttribute('readonly') + expect(input).toHaveClass('custom-class') + }) + + it('handles empty value correctly', () => { + const copyToClipboard = require('copy-to-clipboard') + const mockOnChange = jest.fn() + render() + const input = screen.getByRole('textbox') + const copyButton = screen.getByRole('button') + + expect(input).toBeInTheDocument() + expect(copyButton).toBeInTheDocument() + + fireEvent.click(copyButton) + expect(copyToClipboard).toHaveBeenCalledWith('') + }) + + it('maintains focus on input after copy', async () => { + const mockOnChange = jest.fn() + render() + + const input = screen.getByDisplayValue('test value') + const copyButton = screen.getByRole('button') + + input.focus() + expect(input).toHaveFocus() + + fireEvent.click(copyButton) + + // Input should maintain focus after copy + expect(input).toHaveFocus() + }) +}) diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx new file mode 100644 index 0000000000..87b7de5005 --- /dev/null +++ b/web/app/components/base/input-with-copy/index.tsx @@ -0,0 +1,104 @@ +'use client' +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RiClipboardFill, RiClipboardLine } from '@remixicon/react' +import { debounce } from 'lodash-es' +import copy from 'copy-to-clipboard' +import type { InputProps } from '../input' +import Tooltip from '../tooltip' +import ActionButton from '../action-button' +import cn from '@/utils/classnames' + +export type InputWithCopyProps = { + showCopyButton?: boolean + copyValue?: string // Value to copy, defaults to input value + onCopy?: (value: string) => void // Callback when copy is triggered +} & Omit // Remove conflicting props + +const prefixEmbedded = 'appOverview.overview.appInfo.embedded' + +const InputWithCopy = React.forwardRef(( + { + showCopyButton = true, + copyValue, + onCopy, + value, + wrapperClassName, + ...inputProps + }, + ref, +) => { + const { t } = useTranslation() + const [isCopied, setIsCopied] = useState(false) + // Determine what value to copy + const valueToString = typeof value === 'string' ? value : String(value || '') + const finalCopyValue = copyValue || valueToString + + const onClickCopy = debounce(() => { + copy(finalCopyValue) + setIsCopied(true) + onCopy?.(finalCopyValue) + }, 100) + + const onMouseLeave = debounce(() => { + setIsCopied(false) + }, 100) + + useEffect(() => { + if (isCopied) { + const timeout = setTimeout(() => { + setIsCopied(false) + }, 2000) + return () => { + clearTimeout(timeout) + } + } + }, [isCopied]) + + return ( +
+ rest)(inputProps)} + /> + {showCopyButton && ( +
+ + + {isCopied ? ( + + ) : ( + + )} + + +
+ )} +
+ ) +}) + +InputWithCopy.displayName = 'InputWithCopy' + +export default InputWithCopy diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx index 881aa1d610..688e1dd880 100644 --- a/web/app/components/base/input/index.tsx +++ b/web/app/components/base/input/index.tsx @@ -1,10 +1,11 @@ +import cn from '@/utils/classnames' +import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react' +import { type VariantProps, cva } from 'class-variance-authority' +import { noop } from 'lodash-es' import type { CSSProperties, ChangeEventHandler, FocusEventHandler } from 'react' import React from 'react' import { useTranslation } from 'react-i18next' -import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react' -import { type VariantProps, cva } from 'class-variance-authority' -import cn from '@/utils/classnames' -import { noop } from 'lodash-es' +import { CopyFeedbackNew } from '../copy-feedback' export const inputVariants = cva( '', @@ -24,6 +25,7 @@ export const inputVariants = cva( export type InputProps = { showLeftIcon?: boolean showClearIcon?: boolean + showCopyIcon?: boolean onClear?: () => void disabled?: boolean destructive?: boolean @@ -41,6 +43,7 @@ const Input = ({ destructive, showLeftIcon, showClearIcon, + showCopyIcon, onClear, wrapperClassName, className, @@ -92,8 +95,8 @@ const Input = ({ showLeftIcon && size === 'large' && 'pl-7', showClearIcon && value && 'pr-[26px]', showClearIcon && value && size === 'large' && 'pr-7', - destructive && 'pr-[26px]', - destructive && size === 'large' && 'pr-7', + (destructive || showCopyIcon) && 'pr-[26px]', + (destructive || showCopyIcon) && size === 'large' && 'pr-7', disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled', destructive && 'border-components-input-border-destructive bg-components-input-bg-destructive text-components-input-text-filled hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive', className, @@ -115,6 +118,14 @@ const Input = ({ {destructive && ( )} + {showCopyIcon && ( +
+ +
+ )} { unit && (
diff --git a/web/app/components/base/linked-apps-panel/index.stories.tsx b/web/app/components/base/linked-apps-panel/index.stories.tsx index 786d1bdf56..da8abb0677 100644 --- a/web/app/components/base/linked-apps-panel/index.stories.tsx +++ b/web/app/components/base/linked-apps-panel/index.stories.tsx @@ -1,12 +1,13 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import LinkedAppsPanel from '.' import type { RelatedApp } from '@/models/datasets' +import { AppModeEnum } from '@/types/app' const mockRelatedApps: RelatedApp[] = [ { id: 'app-cx', name: 'Customer Support Assistant', - mode: 'chat', + mode: AppModeEnum.CHAT, icon_type: 'emoji', icon: '\u{1F4AC}', icon_background: '#EEF2FF', @@ -15,7 +16,7 @@ const mockRelatedApps: RelatedApp[] = [ { id: 'app-ops', name: 'Ops Workflow Orchestrator', - mode: 'workflow', + mode: AppModeEnum.WORKFLOW, icon_type: 'emoji', icon: '\u{1F6E0}\u{FE0F}', icon_background: '#ECFDF3', @@ -24,7 +25,7 @@ const mockRelatedApps: RelatedApp[] = [ { id: 'app-research', name: 'Research Synthesizer', - mode: 'advanced-chat', + mode: AppModeEnum.ADVANCED_CHAT, icon_type: 'emoji', icon: '\u{1F9E0}', icon_background: '#FDF2FA', diff --git a/web/app/components/base/linked-apps-panel/index.tsx b/web/app/components/base/linked-apps-panel/index.tsx index c3c3f5b46c..561bd49c2a 100644 --- a/web/app/components/base/linked-apps-panel/index.tsx +++ b/web/app/components/base/linked-apps-panel/index.tsx @@ -6,6 +6,7 @@ import { RiArrowRightUpLine } from '@remixicon/react' import cn from '@/utils/classnames' import AppIcon from '@/app/components/base/app-icon' import type { RelatedApp } from '@/models/datasets' +import { AppModeEnum } from '@/types/app' type ILikedItemProps = { appStatus?: boolean @@ -14,11 +15,11 @@ type ILikedItemProps = { } const appTypeMap = { - 'chat': 'Chatbot', - 'completion': 'Completion', - 'agent-chat': 'Agent', - 'advanced-chat': 'Chatflow', - 'workflow': 'Workflow', + [AppModeEnum.CHAT]: 'Chatbot', + [AppModeEnum.COMPLETION]: 'Completion', + [AppModeEnum.AGENT_CHAT]: 'Agent', + [AppModeEnum.ADVANCED_CHAT]: 'Chatflow', + [AppModeEnum.WORKFLOW]: 'Workflow', } const LikedItem = ({ diff --git a/web/app/components/base/markdown-blocks/index.ts b/web/app/components/base/markdown-blocks/index.ts index ba68b4e8b1..ab6be2e9e7 100644 --- a/web/app/components/base/markdown-blocks/index.ts +++ b/web/app/components/base/markdown-blocks/index.ts @@ -5,9 +5,11 @@ export { default as AudioBlock } from './audio-block' export { default as CodeBlock } from './code-block' +export * from './plugin-img' +export * from './plugin-paragraph' export { default as Img } from './img' -export { default as Link } from './link' export { default as Paragraph } from './paragraph' +export { default as Link } from './link' export { default as PreCode } from './pre-code' export { default as ScriptBlock } from './script-block' export { default as VideoBlock } from './video-block' diff --git a/web/app/components/base/markdown-blocks/plugin-img.tsx b/web/app/components/base/markdown-blocks/plugin-img.tsx new file mode 100644 index 0000000000..ed1ee8fa0b --- /dev/null +++ b/web/app/components/base/markdown-blocks/plugin-img.tsx @@ -0,0 +1,48 @@ +/** + * @fileoverview Img component for rendering tags in Markdown. + * Extracted from the main markdown renderer for modularity. + * Uses the ImageGallery component to display images. + */ +import React, { useEffect, useMemo, useState } from 'react' +import ImageGallery from '@/app/components/base/image-gallery' +import { getMarkdownImageURL } from './utils' +import { usePluginReadmeAsset } from '@/service/use-plugins' +import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper' + +type ImgProps = { + src: string + pluginInfo?: SimplePluginInfo +} + +export const PluginImg: React.FC = ({ src, pluginInfo }) => { + const { pluginUniqueIdentifier, pluginId } = pluginInfo || {} + const { data: assetData } = usePluginReadmeAsset({ plugin_unique_identifier: pluginUniqueIdentifier, file_name: src }) + const [blobUrl, setBlobUrl] = useState() + + useEffect(() => { + if (!assetData) { + setBlobUrl(undefined) + return + } + + const objectUrl = URL.createObjectURL(assetData) + setBlobUrl(objectUrl) + + return () => { + URL.revokeObjectURL(objectUrl) + } + }, [assetData]) + + const imageUrl = useMemo(() => { + if (blobUrl) + return blobUrl + + return getMarkdownImageURL(src, pluginId) + }, [blobUrl, pluginId, src]) + + return ( +
+ +
+ ) +} diff --git a/web/app/components/base/markdown-blocks/plugin-paragraph.tsx b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx new file mode 100644 index 0000000000..ae1e2d7101 --- /dev/null +++ b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx @@ -0,0 +1,69 @@ +/** + * @fileoverview Paragraph component for rendering

tags in Markdown. + * Extracted from the main markdown renderer for modularity. + * Handles special rendering for paragraphs that directly contain an image. + */ +import ImageGallery from '@/app/components/base/image-gallery' +import { usePluginReadmeAsset } from '@/service/use-plugins' +import React, { useEffect, useMemo, useState } from 'react' +import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper' +import { getMarkdownImageURL } from './utils' + +type PluginParagraphProps = { + pluginInfo?: SimplePluginInfo + node?: any + children?: React.ReactNode +} + +export const PluginParagraph: React.FC = ({ pluginInfo, node, children }) => { + const { pluginUniqueIdentifier, pluginId } = pluginInfo || {} + const childrenNode = node?.children as Array | undefined + const firstChild = childrenNode?.[0] + const isImageParagraph = firstChild?.tagName === 'img' + const imageSrc = isImageParagraph ? firstChild?.properties?.src : undefined + + const { data: assetData } = usePluginReadmeAsset({ + plugin_unique_identifier: pluginUniqueIdentifier, + file_name: isImageParagraph && imageSrc ? imageSrc : '', + }) + + const [blobUrl, setBlobUrl] = useState() + + useEffect(() => { + if (!assetData) { + setBlobUrl(undefined) + return + } + + const objectUrl = URL.createObjectURL(assetData) + setBlobUrl(objectUrl) + + return () => { + URL.revokeObjectURL(objectUrl) + } + }, [assetData]) + + const imageUrl = useMemo(() => { + if (blobUrl) + return blobUrl + + if (isImageParagraph && imageSrc) + return getMarkdownImageURL(imageSrc, pluginId) + + return '' + }, [blobUrl, imageSrc, isImageParagraph, pluginId]) + + if (isImageParagraph) { + const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined + + return ( +

+ + {remainingChildren && ( +
{remainingChildren}
+ )} +
+ ) + } + return

{children}

+} diff --git a/web/app/components/base/markdown-blocks/utils.ts b/web/app/components/base/markdown-blocks/utils.ts index d8df76aefc..f7dbe9b7ed 100644 --- a/web/app/components/base/markdown-blocks/utils.ts +++ b/web/app/components/base/markdown-blocks/utils.ts @@ -1,7 +1,14 @@ -import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config' +import { ALLOW_UNSAFE_DATA_SCHEME, MARKETPLACE_API_PREFIX } from '@/config' export const isValidUrl = (url: string): boolean => { const validPrefixes = ['http:', 'https:', '//', 'mailto:'] if (ALLOW_UNSAFE_DATA_SCHEME) validPrefixes.push('data:') return validPrefixes.some(prefix => url.startsWith(prefix)) } + +export const getMarkdownImageURL = (url: string, pathname?: string) => { + const regex = /(^\.\/_assets|^_assets)/ + if (regex.test(url)) + return `${MARKETPLACE_API_PREFIX}${MARKETPLACE_API_PREFIX.endsWith('/') ? '' : '/'}plugins/${pathname ?? ''}${url.replace(regex, '/_assets')}` + return url +} diff --git a/web/app/components/base/markdown/index.tsx b/web/app/components/base/markdown/index.tsx index 19f39d8aaa..bb49fe1b14 100644 --- a/web/app/components/base/markdown/index.tsx +++ b/web/app/components/base/markdown/index.tsx @@ -3,7 +3,7 @@ import 'katex/dist/katex.min.css' import { flow } from 'lodash-es' import cn from '@/utils/classnames' import { preprocessLaTeX, preprocessThinkTag } from './markdown-utils' -import type { ReactMarkdownWrapperProps } from './react-markdown-wrapper' +import type { ReactMarkdownWrapperProps, SimplePluginInfo } from './react-markdown-wrapper' const ReactMarkdown = dynamic(() => import('./react-markdown-wrapper').then(mod => mod.ReactMarkdownWrapper), { ssr: false }) @@ -17,10 +17,11 @@ const ReactMarkdown = dynamic(() => import('./react-markdown-wrapper').then(mod export type MarkdownProps = { content: string className?: string + pluginInfo?: SimplePluginInfo } & Pick export const Markdown = (props: MarkdownProps) => { - const { customComponents = {} } = props + const { customComponents = {}, pluginInfo } = props const latexContent = flow([ preprocessThinkTag, preprocessLaTeX, @@ -28,7 +29,7 @@ export const Markdown = (props: MarkdownProps) => { return (
- +
) } diff --git a/web/app/components/base/markdown/react-markdown-wrapper.tsx b/web/app/components/base/markdown/react-markdown-wrapper.tsx index afe3d8a737..22964ec04f 100644 --- a/web/app/components/base/markdown/react-markdown-wrapper.tsx +++ b/web/app/components/base/markdown/react-markdown-wrapper.tsx @@ -1,35 +1,31 @@ -import ReactMarkdown from 'react-markdown' -import RemarkMath from 'remark-math' -import RemarkBreaks from 'remark-breaks' -import RehypeKatex from 'rehype-katex' -import RemarkGfm from 'remark-gfm' -import RehypeRaw from 'rehype-raw' +import { AudioBlock, Img, Link, MarkdownButton, MarkdownForm, Paragraph, PluginImg, PluginParagraph, ScriptBlock, ThinkBlock, VideoBlock } from '@/app/components/base/markdown-blocks' import { ENABLE_SINGLE_DOLLAR_LATEX } from '@/config' -import AudioBlock from '@/app/components/base/markdown-blocks/audio-block' -import Img from '@/app/components/base/markdown-blocks/img' -import Link from '@/app/components/base/markdown-blocks/link' -import MarkdownButton from '@/app/components/base/markdown-blocks/button' -import MarkdownForm from '@/app/components/base/markdown-blocks/form' -import Paragraph from '@/app/components/base/markdown-blocks/paragraph' -import ScriptBlock from '@/app/components/base/markdown-blocks/script-block' -import ThinkBlock from '@/app/components/base/markdown-blocks/think-block' -import VideoBlock from '@/app/components/base/markdown-blocks/video-block' +import dynamic from 'next/dynamic' +import type { FC } from 'react' +import ReactMarkdown from 'react-markdown' +import RehypeKatex from 'rehype-katex' +import RehypeRaw from 'rehype-raw' +import RemarkBreaks from 'remark-breaks' +import RemarkGfm from 'remark-gfm' +import RemarkMath from 'remark-math' import { customUrlTransform } from './markdown-utils' -import type { FC } from 'react' - -import dynamic from 'next/dynamic' - const CodeBlock = dynamic(() => import('@/app/components/base/markdown-blocks/code-block'), { ssr: false }) +export type SimplePluginInfo = { + pluginUniqueIdentifier: string + pluginId: string +} + export type ReactMarkdownWrapperProps = { latexContent: any customDisallowedElements?: string[] customComponents?: Record> + pluginInfo?: SimplePluginInfo } export const ReactMarkdownWrapper: FC = (props) => { - const { customComponents, latexContent } = props + const { customComponents, latexContent, pluginInfo } = props return ( = (props) => { rehypePlugins={[ RehypeKatex, RehypeRaw as any, - // The Rehype plug-in is used to remove the ref attribute of an element + // The Rehype plug-in is used to remove the ref attribute of an element () => { return (tree: any) => { const iterate = (node: any) => { @@ -64,11 +60,11 @@ export const ReactMarkdownWrapper: FC = (props) => { disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]} components={{ code: CodeBlock, - img: Img, + img: (props: any) => pluginInfo ? : , video: VideoBlock, audio: AudioBlock, a: Link, - p: Paragraph, + p: (props: any) => pluginInfo ? : , button: MarkdownButton, form: MarkdownForm, script: ScriptBlock as any, diff --git a/web/app/components/base/modal/index.tsx b/web/app/components/base/modal/index.tsx index 426953261e..f091717191 100644 --- a/web/app/components/base/modal/index.tsx +++ b/web/app/components/base/modal/index.tsx @@ -8,6 +8,7 @@ import { noop } from 'lodash-es' type IModal = { className?: string wrapperClassName?: string + containerClassName?: string isShow: boolean onClose?: () => void title?: React.ReactNode @@ -16,11 +17,14 @@ type IModal = { closable?: boolean overflowVisible?: boolean highPriority?: boolean // For modals that need to appear above dropdowns + overlayOpacity?: boolean // For semi-transparent overlay instead of default + clickOutsideNotClose?: boolean // Prevent closing when clicking outside modal } export default function Modal({ className, wrapperClassName, + containerClassName, isShow, onClose = noop, title, @@ -29,19 +33,21 @@ export default function Modal({ closable = false, overflowVisible = false, highPriority = false, + overlayOpacity = false, + clickOutsideNotClose = false, }: IModal) { return ( - +
-
{ @@ -49,7 +55,7 @@ export default function Modal({ e.stopPropagation() }} > -
+
void @@ -26,6 +27,9 @@ type ModalProps = { footerSlot?: React.ReactNode bottomSlot?: React.ReactNode disabled?: boolean + containerClassName?: string + wrapperClassName?: string + clickOutsideNotClose?: boolean } const Modal = ({ onClose, @@ -44,24 +48,28 @@ const Modal = ({ footerSlot, bottomSlot, disabled, + containerClassName, + wrapperClassName, + clickOutsideNotClose = false, }: ModalProps) => { const { t } = useTranslation() return (
e.stopPropagation()} > -
+
{title} { subTitle && ( @@ -79,10 +87,10 @@ const Modal = ({
{ children && ( -
{children}
+
{children}
) } -
+
{footerSlot}
@@ -117,7 +125,11 @@ const Modal = ({
- {bottomSlot} + {bottomSlot && ( +
+ {bottomSlot} +
+ )}
diff --git a/web/app/components/base/node-status/index.tsx b/web/app/components/base/node-status/index.tsx new file mode 100644 index 0000000000..a09737809d --- /dev/null +++ b/web/app/components/base/node-status/index.tsx @@ -0,0 +1,74 @@ +'use client' +import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle' +import classNames from '@/utils/classnames' +import { RiErrorWarningFill } from '@remixicon/react' +import { type VariantProps, cva } from 'class-variance-authority' +import type { CSSProperties } from 'react' +import React from 'react' + +export enum NodeStatusEnum { + warning = 'warning', + error = 'error', +} + +const nodeStatusVariants = cva( + 'flex items-center gap-1 rounded-md px-2 py-1 system-xs-medium', + { + variants: { + status: { + [NodeStatusEnum.warning]: 'bg-state-warning-hover text-text-warning', + [NodeStatusEnum.error]: 'bg-state-destructive-hover text-text-destructive', + }, + }, + defaultVariants: { + status: NodeStatusEnum.warning, + }, + }, +) + +const StatusIconMap: Record = { + [NodeStatusEnum.warning]: { IconComponent: AlertTriangle, message: 'Warning' }, + [NodeStatusEnum.error]: { IconComponent: RiErrorWarningFill, message: 'Error' }, +} + +export type NodeStatusProps = { + message?: string + styleCss?: CSSProperties + iconClassName?: string +} & React.HTMLAttributes & VariantProps + +const NodeStatus = ({ + className, + status, + message, + styleCss, + iconClassName, + children, + ...props +}: NodeStatusProps) => { + const Icon = StatusIconMap[status ?? NodeStatusEnum.warning].IconComponent + const defaultMessage = StatusIconMap[status ?? NodeStatusEnum.warning].message + + return ( +
+ + {message ?? defaultMessage} + {children} +
+ ) +} + +NodeStatus.displayName = 'NodeStatus' + +export default React.memo(NodeStatus) diff --git a/web/app/components/base/notion-page-selector/base.tsx b/web/app/components/base/notion-page-selector/base.tsx index adf044c406..1f9ddeaebd 100644 --- a/web/app/components/base/notion-page-selector/base.tsx +++ b/web/app/components/base/notion-page-selector/base.tsx @@ -10,6 +10,7 @@ import { useInvalidPreImportNotionPages, usePreImportNotionPages } from '@/servi import Header from '../../datasets/create/website/base/header' import type { DataSourceCredential } from '../../header/account-setting/data-source-page-new/types' import Loading from '../loading' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type NotionPageSelectorProps = { value?: string[] @@ -124,7 +125,7 @@ const NotionPageSelector = ({ }, [pagesMapAndSelectedPagesId, onPreview]) const handleConfigureNotion = useCallback(() => { - setShowAccountSettingModal({ payload: 'data-source' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) }, [setShowAccountSettingModal]) if (isFetchingNotionPagesError) { diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx index e165b93a66..2bd67d0ced 100644 --- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/component.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, + useMemo, useState, } from 'react' import { useTranslation } from 'react-i18next' @@ -18,7 +19,7 @@ import { DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND, UPDATE_WORKFLOW_NODES_MAP, } from './index' -import { isConversationVar, isENV, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import Tooltip from '@/app/components/base/tooltip' import { isExceptionVariable } from '@/app/components/workflow/utils' import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel' @@ -65,25 +66,33 @@ const WorkflowVariableBlockComponent = ({ )() const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState(workflowNodesMap) const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]] - const isEnv = isENV(variables) - const isChatVar = isConversationVar(variables) + const isException = isExceptionVariable(varName, node?.type) - let variableValid = true - if (isEnv) { - if (environmentVariables) - variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) - } - else if (isChatVar) { - if (conversationVariables) - variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) - } - else if (isRagVar) { - if (ragVariables) - variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`) - } - else { - variableValid = !!node - } + const variableValid = useMemo(() => { + let variableValid = true + const isEnv = isENV(variables) + const isChatVar = isConversationVar(variables) + const isGlobal = isGlobalVar(variables) + if (isGlobal) + return true + + if (isEnv) { + if (environmentVariables) + variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) + } + else if (isChatVar) { + if (conversationVariables) + variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`) + } + else if (isRagVar) { + if (ragVariables) + variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`) + } + else { + variableValid = !!node + } + return variableValid + }, [variables, node, environmentVariables, conversationVariables, isRagVar, ragVariables]) const reactflow = useReactFlow() const store = useStoreApi() diff --git a/web/app/components/base/search-input/index.tsx b/web/app/components/base/search-input/index.tsx index 3330b55330..abf1817e88 100644 --- a/web/app/components/base/search-input/index.tsx +++ b/web/app/components/base/search-input/index.tsx @@ -22,7 +22,7 @@ const SearchInput: FC = ({ const { t } = useTranslation() const [focus, setFocus] = useState(false) const isComposing = useRef(false) - const [internalValue, setInternalValue] = useState(value) + const [compositionValue, setCompositionValue] = useState('') return (
= ({ white && '!bg-white placeholder:!text-gray-400 hover:!bg-white group-hover:!bg-white', )} placeholder={placeholder || t('common.operation.search')!} - value={internalValue} + value={isComposing.current ? compositionValue : value} onChange={(e) => { - setInternalValue(e.target.value) - if (!isComposing.current) - onChange(e.target.value) + const newValue = e.target.value + if (isComposing.current) + setCompositionValue(newValue) + else + onChange(newValue) }} onCompositionStart={() => { isComposing.current = true + setCompositionValue(value) }} onCompositionEnd={(e) => { isComposing.current = false + setCompositionValue('') onChange(e.currentTarget.value) }} onFocus={() => setFocus(true)} @@ -64,7 +68,6 @@ const SearchInput: FC = ({ className='group/clear flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center' onClick={() => { onChange('') - setInternalValue('') }} > diff --git a/web/app/components/base/select/custom.tsx b/web/app/components/base/select/custom.tsx index 444c975f7e..f9032658c3 100644 --- a/web/app/components/base/select/custom.tsx +++ b/web/app/components/base/select/custom.tsx @@ -58,6 +58,7 @@ const CustomSelect = ({ onOpenChange, placement, offset, + triggerPopupSameWidth = true, } = containerProps || {} const { className: triggerClassName, @@ -85,6 +86,7 @@ const CustomSelect = ({ offset={offset || 4} open={mergedOpen} onOpenChange={handleOpenChange} + triggerPopupSameWidth={triggerPopupSameWidth} > handleOpenChange(!mergedOpen)} diff --git a/web/app/components/base/select/index.tsx b/web/app/components/base/select/index.tsx index f2ca32d660..1a096d7f93 100644 --- a/web/app/components/base/select/index.tsx +++ b/web/app/components/base/select/index.tsx @@ -26,6 +26,9 @@ const defaultItems = [ export type Item = { value: number | string name: string + isGroup?: boolean + disabled?: boolean + extra?: React.ReactNode } & Record export type ISelectProps = { @@ -70,14 +73,13 @@ const Select: FC = ({ const [open, setOpen] = useState(false) const [selectedItem, setSelectedItem] = useState(null) + // Ensure selectedItem is properly set when defaultValue or items change useEffect(() => { let defaultSelect = null - const existed = items.find((item: Item) => item.value === defaultValue) - if (existed) - defaultSelect = existed - + // Handle cases where defaultValue might be undefined, null, or empty string + defaultSelect = (defaultValue && items.find((item: Item) => item.value === defaultValue)) || null setSelectedItem(defaultSelect) - }, [defaultValue]) + }, [defaultValue, items]) const filteredItems: Item[] = query === '' @@ -193,14 +195,18 @@ const SimpleSelect: FC = ({ const [selectedItem, setSelectedItem] = useState(null) + // Enhanced: Preserve user selection, only reset when necessary useEffect(() => { - let defaultSelect = null - const existed = items.find((item: Item) => item.value === defaultValue) - if (existed) - defaultSelect = existed + // Only reset if no current selection or current selection is invalid + const isCurrentSelectionValid = selectedItem && items.some(item => item.value === selectedItem.value) - setSelectedItem(defaultSelect) - }, [defaultValue]) + if (!isCurrentSelectionValid) { + let defaultSelect = null + // Handle cases where defaultValue might be undefined, null, or empty string + defaultSelect = items.find((item: Item) => item.value === defaultValue) ?? null + setSelectedItem(defaultSelect) + } + }, [defaultValue, items, selectedItem]) const listboxRef = useRef(null) @@ -255,38 +261,47 @@ const SimpleSelect: FC = ({ {(!disabled) && ( - {items.map((item: Item) => ( - - {({ /* active, */ selected }) => ( - <> - {renderOption - ? renderOption({ item, selected }) - : (<> - {item.name} - {selected && !hideChecked && ( - - - )} - )} - - )} - - ))} + {items.map((item: Item) => + item.isGroup ? ( +
+ {item.name} +
+ ) : ( + + {({ /* active, */ selected }) => ( + <> + {renderOption + ? renderOption({ item, selected }) + : (<> + {item.name} + {selected && !hideChecked && ( + + + )} + )} + + )} + + ), + )}
)}
@@ -334,6 +349,7 @@ const PortalSelect: FC = ({ onOpenChange={setOpen} placement='bottom-start' offset={4} + triggerPopupSameWidth={true} > !readonly && setOpen(v => !v)} className='w-full'> {renderTrigger @@ -361,7 +377,7 @@ const PortalSelect: FC = ({
{items.map((item: Item) => (
= ({ {!hideChecked && item.value === value && ( )} + {item.extra}
))}
diff --git a/web/app/components/base/select/pure.tsx b/web/app/components/base/select/pure.tsx index cede31d2ba..3de8245025 100644 --- a/web/app/components/base/select/pure.tsx +++ b/web/app/components/base/select/pure.tsx @@ -1,5 +1,6 @@ import { useCallback, + useMemo, useState, } from 'react' import { useTranslation } from 'react-i18next' @@ -22,10 +23,8 @@ export type Option = { value: string } -export type PureSelectProps = { +type SharedPureSelectProps = { options: Option[] - value?: string - onChange?: (value: string) => void containerProps?: PortalToFollowElemOptions & { open?: boolean onOpenChange?: (open: boolean) => void @@ -38,22 +37,39 @@ export type PureSelectProps = { className?: string itemClassName?: string title?: string + titleClassName?: string }, placeholder?: string disabled?: boolean triggerPopupSameWidth?: boolean } -const PureSelect = ({ - options, - value, - onChange, - containerProps, - triggerProps, - popupProps, - placeholder, - disabled, - triggerPopupSameWidth, -}: PureSelectProps) => { + +type SingleSelectProps = { + multiple?: false + value?: string + onChange?: (value: string) => void +} + +type MultiSelectProps = { + multiple: true + value?: string[] + onChange?: (value: string[]) => void +} + +export type PureSelectProps = SharedPureSelectProps & (SingleSelectProps | MultiSelectProps) +const PureSelect = (props: PureSelectProps) => { + const { + options, + containerProps, + triggerProps, + popupProps, + placeholder, + disabled, + triggerPopupSameWidth, + multiple, + value, + onChange, + } = props const { t } = useTranslation() const { open, @@ -69,6 +85,7 @@ const PureSelect = ({ className: popupClassName, itemClassName: popupItemClassName, title: popupTitle, + titleClassName: popupTitleClassName, } = popupProps || {} const [localOpen, setLocalOpen] = useState(false) @@ -79,8 +96,13 @@ const PureSelect = ({ setLocalOpen(openValue) }, [onOpenChange]) - const selectedOption = options.find(option => option.value === value) - const triggerText = selectedOption?.label || placeholder || t('common.placeholder.select') + const triggerText = useMemo(() => { + const placeholderText = placeholder || t('common.placeholder.select') + if (multiple) + return value?.length ? t('common.dynamicSelect.selected', { count: value.length }) : placeholderText + + return options.find(option => option.value === value)?.label || placeholderText + }, [multiple, value, options, placeholder]) return (
{ popupTitle && ( -
+
{popupTitle}
) @@ -144,6 +169,14 @@ const PureSelect = ({ title={option.label} onClick={() => { if (disabled) return + if (multiple) { + const currentValues = value ?? [] + const nextValues = currentValues.includes(option.value) + ? currentValues.filter(valueItem => valueItem !== option.value) + : [...currentValues, option.value] + onChange?.(nextValues) + return + } onChange?.(option.value) handleOpenChange(false) }} @@ -152,7 +185,11 @@ const PureSelect = ({ {option.label}
{ - value === option.value && + ( + multiple + ? (value ?? []).includes(option.value) + : value === option.value + ) && }
)) diff --git a/web/app/components/base/textarea/index.tsx b/web/app/components/base/textarea/index.tsx index 7813eb7209..609f1ad51d 100644 --- a/web/app/components/base/textarea/index.tsx +++ b/web/app/components/base/textarea/index.tsx @@ -20,7 +20,7 @@ const textareaVariants = cva( ) export type TextareaProps = { - value: string + value: string | number disabled?: boolean destructive?: boolean styleCss?: CSSProperties diff --git a/web/app/components/base/timezone-label/__tests__/index.test.tsx b/web/app/components/base/timezone-label/__tests__/index.test.tsx new file mode 100644 index 0000000000..1c36ac929a --- /dev/null +++ b/web/app/components/base/timezone-label/__tests__/index.test.tsx @@ -0,0 +1,145 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import TimezoneLabel from '../index' + +// Mock the convertTimezoneToOffsetStr function +jest.mock('@/app/components/base/date-and-time-picker/utils/dayjs', () => ({ + convertTimezoneToOffsetStr: (timezone?: string) => { + if (!timezone) return 'UTC+0' + + // Mock implementation matching the actual timezone conversions + const timezoneOffsets: Record = { + 'Asia/Shanghai': 'UTC+8', + 'America/New_York': 'UTC-5', + 'Europe/London': 'UTC+0', + 'Pacific/Auckland': 'UTC+13', + 'Pacific/Niue': 'UTC-11', + 'UTC': 'UTC+0', + } + + return timezoneOffsets[timezone] || 'UTC+0' + }, +})) + +describe('TimezoneLabel', () => { + describe('Basic Rendering', () => { + it('should render timezone offset correctly', () => { + render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + }) + + it('should display UTC+0 for invalid timezone', () => { + render() + expect(screen.getByText('UTC+0')).toBeInTheDocument() + }) + + it('should handle UTC timezone', () => { + render() + expect(screen.getByText('UTC+0')).toBeInTheDocument() + }) + }) + + describe('Styling', () => { + it('should apply default tertiary text color', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveClass('text-text-tertiary') + expect(span).not.toHaveClass('text-text-quaternary') + }) + + it('should apply quaternary text color in inline mode', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveClass('text-text-quaternary') + }) + + it('should apply custom className', () => { + const { container } = render( + , + ) + const span = container.querySelector('span') + expect(span).toHaveClass('custom-class') + }) + + it('should maintain default classes with custom className', () => { + const { container } = render( + , + ) + const span = container.querySelector('span') + expect(span).toHaveClass('system-sm-regular') + expect(span).toHaveClass('text-text-tertiary') + expect(span).toHaveClass('custom-class') + }) + }) + + describe('Tooltip', () => { + it('should include timezone information in title attribute', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveAttribute('title', 'Timezone: Asia/Shanghai (UTC+8)') + }) + + it('should update tooltip for different timezones', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveAttribute('title', 'Timezone: America/New_York (UTC-5)') + }) + }) + + describe('Edge Cases', () => { + it('should handle positive offset timezones', () => { + render() + expect(screen.getByText('UTC+13')).toBeInTheDocument() + }) + + it('should handle negative offset timezones', () => { + render() + expect(screen.getByText('UTC-11')).toBeInTheDocument() + }) + + it('should handle zero offset timezone', () => { + render() + expect(screen.getByText('UTC+0')).toBeInTheDocument() + }) + }) + + describe('Props Variations', () => { + it('should render with only required timezone prop', () => { + render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + }) + + it('should render with all props', () => { + const { container } = render( + , + ) + const span = container.querySelector('span') + expect(screen.getByText('UTC-5')).toBeInTheDocument() + expect(span).toHaveClass('text-xs') + expect(span).toHaveClass('text-text-quaternary') + }) + }) + + describe('Memoization', () => { + it('should memoize offset calculation', () => { + const { rerender } = render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + + // Rerender with same props should not trigger recalculation + rerender() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + }) + + it('should recalculate when timezone changes', () => { + const { rerender } = render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + + rerender() + expect(screen.getByText('UTC-5')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/timezone-label/index.tsx b/web/app/components/base/timezone-label/index.tsx new file mode 100644 index 0000000000..b151ceb9b8 --- /dev/null +++ b/web/app/components/base/timezone-label/index.tsx @@ -0,0 +1,56 @@ +import React, { useMemo } from 'react' +import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs' +import cn from '@/utils/classnames' + +export type TimezoneLabelProps = { + /** IANA timezone identifier (e.g., 'Asia/Shanghai', 'America/New_York') */ + timezone: string + /** Additional CSS classes to apply */ + className?: string + /** Use inline mode with lighter text color for secondary display */ + inline?: boolean +} + +/** + * TimezoneLabel component displays timezone information in UTC offset format. + * + * @example + * // Standard display + * + * // Output: UTC+8 + * + * @example + * // Inline mode with lighter color + * + * // Output: UTC-5 + * + * @example + * // Custom styling + * + */ +const TimezoneLabel: React.FC = ({ + timezone, + className, + inline = false, +}) => { + // Memoize offset calculation to avoid redundant computations + const offsetStr = useMemo( + () => convertTimezoneToOffsetStr(timezone), + [timezone], + ) + + return ( + + {offsetStr} + + ) +} + +export default React.memo(TimezoneLabel) diff --git a/web/app/components/base/tooltip/index.tsx b/web/app/components/base/tooltip/index.tsx index eb7ca56cb0..46680c8f5b 100644 --- a/web/app/components/base/tooltip/index.tsx +++ b/web/app/components/base/tooltip/index.tsx @@ -17,6 +17,7 @@ export type TooltipProps = { popupContent?: React.ReactNode children?: React.ReactNode popupClassName?: string + portalContentClassName?: string noDecoration?: boolean offset?: OffsetOptions needsDelay?: boolean @@ -32,6 +33,7 @@ const Tooltip: FC = ({ popupContent, children, popupClassName, + portalContentClassName, noDecoration, offset, asChild = true, @@ -104,7 +106,7 @@ const Tooltip: FC = ({ {children ||
} {popupContent && (
{
{/* Waveform visualization placeholder */}
- {new Array(40).fill(0).map((_, i) => ( + {Array.from({ length: 40 }).map((_, i) => (
= { apiRateLimit: 5000, documentProcessingPriority: Priority.standard, messageRequest: 200, + triggerEvents: 3000, annotatedResponse: 10, logHistory: 30, }, @@ -43,6 +44,7 @@ export const ALL_PLANS: Record = { apiRateLimit: NUM_INFINITE, documentProcessingPriority: Priority.priority, messageRequest: 5000, + triggerEvents: 20000, annotatedResponse: 2000, logHistory: NUM_INFINITE, }, @@ -60,6 +62,7 @@ export const ALL_PLANS: Record = { apiRateLimit: NUM_INFINITE, documentProcessingPriority: Priority.topPriority, messageRequest: 10000, + triggerEvents: NUM_INFINITE, annotatedResponse: 5000, logHistory: NUM_INFINITE, }, @@ -74,6 +77,8 @@ export const defaultPlan = { teamMembers: 1, annotatedResponse: 1, documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, }, total: { documents: 50, @@ -82,5 +87,7 @@ export const defaultPlan = { teamMembers: 1, annotatedResponse: 10, documentsUploadQuota: 0, + apiRateLimit: ALL_PLANS.sandbox.apiRateLimit, + triggerEvents: ALL_PLANS.sandbox.triggerEvents, }, } diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index dd3908635b..4b68fcfb15 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/web/app/components/billing/plan/index.tsx @@ -6,8 +6,10 @@ import { useRouter } from 'next/navigation' import { RiBook2Line, RiFileEditLine, + RiFlashlightLine, RiGraduationCapLine, RiGroupLine, + RiSpeedLine, } from '@remixicon/react' import { Plan, SelfHostedPlan } from '../type' import VectorSpaceInfo from '../usage-info/vector-space-info' @@ -43,6 +45,8 @@ const PlanComp: FC = ({ usage, total, } = plan + const perMonthUnit = ` ${t('billing.usagePage.perMonth')}` + const triggerEventUnit = plan.type === Plan.sandbox ? undefined : perMonthUnit const [showModal, setShowModal] = React.useState(false) const { mutateAsync } = useEducationVerify() @@ -119,6 +123,20 @@ const PlanComp: FC = ({ usage={usage.annotatedResponse} total={total.annotatedResponse} /> + +
+ + + & { vectorSpace: number } +export type UsagePlanInfo = Pick & { vectorSpace: number } export enum DocumentProcessingPriority { standard = 'standard', @@ -87,6 +88,14 @@ export type CurrentPlanInfoBackend = { size: number limit: number // total. 0 means unlimited } + api_rate_limit?: { + size: number + limit: number // total. 0 means unlimited + } + trigger_events?: { + size: number + limit: number // total. 0 means unlimited + } docs_processing: DocumentProcessingPriority can_replace_logo: boolean model_load_balancing_enabled: boolean diff --git a/web/app/components/billing/usage-info/index.tsx b/web/app/components/billing/usage-info/index.tsx index 30b4bca776..0ed8775772 100644 --- a/web/app/components/billing/usage-info/index.tsx +++ b/web/app/components/billing/usage-info/index.tsx @@ -15,6 +15,7 @@ type Props = { usage: number total: number unit?: string + unitPosition?: 'inline' | 'suffix' } const LOW = 50 @@ -27,7 +28,8 @@ const UsageInfo: FC = ({ tooltip, usage, total, - unit = '', + unit, + unitPosition = 'suffix', }) => { const { t } = useTranslation() @@ -41,6 +43,12 @@ const UsageInfo: FC = ({ return 'bg-components-progress-error-progress' })() + const isUnlimited = total === NUM_INFINITE + let totalDisplay: string | number = isUnlimited ? t('billing.plansCommon.unlimited') : total + if (!isUnlimited && unit && unitPosition === 'inline') + totalDisplay = `${total}${unit}` + const showUnit = !!unit && !isUnlimited && unitPosition === 'suffix' + return (
@@ -56,10 +64,17 @@ const UsageInfo: FC = ({ /> )}
-
- {usage} -
/
-
{total === NUM_INFINITE ? t('billing.plansCommon.unlimited') : `${total}${unit}`}
+
+
+ {usage} +
/
+
{totalDisplay}
+
+ {showUnit && ( +
+ {unit} +
+ )}
= ({ usage={usage.vectorSpace} total={total.vectorSpace} unit='MB' + unitPosition='inline' /> ) } diff --git a/web/app/components/billing/utils/index.ts b/web/app/components/billing/utils/index.ts index 111f02e3cf..00ab7913b5 100644 --- a/web/app/components/billing/utils/index.ts +++ b/web/app/components/billing/utils/index.ts @@ -1,5 +1,5 @@ import type { CurrentPlanInfoBackend } from '../type' -import { NUM_INFINITE } from '@/app/components/billing/config' +import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config' const parseLimit = (limit: number) => { if (limit === 0) @@ -9,14 +9,23 @@ const parseLimit = (limit: number) => { } export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => { + const planType = data.billing.subscription.plan + const planPreset = ALL_PLANS[planType] + const resolveLimit = (limit?: number, fallback?: number) => { + const value = limit ?? fallback ?? 0 + return parseLimit(value) + } + return { - type: data.billing.subscription.plan, + type: planType, usage: { vectorSpace: data.vector_space.size, buildApps: data.apps?.size || 0, teamMembers: data.members.size, annotatedResponse: data.annotation_quota_limit.size, documentsUploadQuota: data.documents_upload_quota.size, + apiRateLimit: data.api_rate_limit?.size ?? 0, + triggerEvents: data.trigger_events?.size ?? 0, }, total: { vectorSpace: parseLimit(data.vector_space.limit), @@ -24,6 +33,8 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => { teamMembers: parseLimit(data.members.limit), annotatedResponse: parseLimit(data.annotation_quota_limit.limit), documentsUploadQuota: parseLimit(data.documents_upload_quota.limit), + apiRateLimit: resolveLimit(data.api_rate_limit?.limit, planPreset?.apiRateLimit ?? NUM_INFINITE), + triggerEvents: resolveLimit(data.trigger_events?.limit, planPreset?.triggerEvents), }, } } diff --git a/web/app/components/datasets/create/file-uploader/index.tsx b/web/app/components/datasets/create/file-uploader/index.tsx index aee2192b6c..4aec0d4082 100644 --- a/web/app/components/datasets/create/file-uploader/index.tsx +++ b/web/app/components/datasets/create/file-uploader/index.tsx @@ -106,8 +106,6 @@ const FileUploader = ({ return isValidType && isValidSize }, [fileUploadConfig, notify, t, ACCEPTS]) - type UploadResult = Awaited> - const fileUpload = useCallback(async (fileItem: FileItem): Promise => { const formData = new FormData() formData.append('file', fileItem.file) diff --git a/web/app/components/datasets/create/index.tsx b/web/app/components/datasets/create/index.tsx index 11def1a8bc..b04bd85530 100644 --- a/web/app/components/datasets/create/index.tsx +++ b/web/app/components/datasets/create/index.tsx @@ -16,6 +16,7 @@ import { useGetDefaultDataSourceListAuth } from '@/service/use-datasource' import { produce } from 'immer' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import Loading from '@/app/components/base/loading' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type DatasetUpdateFormProps = { datasetId?: string @@ -117,7 +118,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { {step === 1 && ( setShowAccountSettingModal({ payload: 'data-source' })} + onSetting={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })} datasetId={datasetId} dataSourceType={dataSourceType} dataSourceTypeDisable={!!datasetDetail?.data_source_type} @@ -141,7 +142,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => { {(step === 2 && (!datasetId || (datasetId && !!datasetDetail))) && ( setShowAccountSettingModal({ payload: 'provider' })} + onSetting={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })} indexingType={datasetDetail?.indexing_technique} datasetId={datasetId} dataSourceType={dataSourceType} diff --git a/web/app/components/datasets/create/website/firecrawl/index.tsx b/web/app/components/datasets/create/website/firecrawl/index.tsx index 8d207a0386..51c2c7d505 100644 --- a/web/app/components/datasets/create/website/firecrawl/index.tsx +++ b/web/app/components/datasets/create/website/firecrawl/index.tsx @@ -14,6 +14,7 @@ import Toast from '@/app/components/base/toast' import { checkFirecrawlTaskStatus, createFirecrawlTask } from '@/service/datasets' import { sleep } from '@/utils' import Header from '../base/header' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const ERROR_I18N_PREFIX = 'common.errorMsg' const I18N_PREFIX = 'datasetCreation.stepOne.website' @@ -51,7 +52,7 @@ const FireCrawl: FC = ({ const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/create/website/index.tsx b/web/app/components/datasets/create/website/index.tsx index 7190ca3228..ee7ace6815 100644 --- a/web/app/components/datasets/create/website/index.tsx +++ b/web/app/components/datasets/create/website/index.tsx @@ -13,6 +13,7 @@ import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' import { DataSourceProvider } from '@/models/common' import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config' import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type Props = { onPreview: (payload: CrawlResultItem) => void @@ -48,7 +49,7 @@ const Website: FC = ({ const handleOnConfig = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/create/website/jina-reader/index.tsx b/web/app/components/datasets/create/website/jina-reader/index.tsx index 460c169fb4..b6e6177af2 100644 --- a/web/app/components/datasets/create/website/jina-reader/index.tsx +++ b/web/app/components/datasets/create/website/jina-reader/index.tsx @@ -14,6 +14,7 @@ import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datas import { sleep } from '@/utils' import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' import Header from '../base/header' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const ERROR_I18N_PREFIX = 'common.errorMsg' const I18N_PREFIX = 'datasetCreation.stepOne.website' @@ -51,7 +52,7 @@ const JinaReader: FC = ({ const { setShowAccountSettingModal } = useModalContext() const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/create/website/watercrawl/index.tsx b/web/app/components/datasets/create/website/watercrawl/index.tsx index 640b1c2063..67a3e53feb 100644 --- a/web/app/components/datasets/create/website/watercrawl/index.tsx +++ b/web/app/components/datasets/create/website/watercrawl/index.tsx @@ -14,6 +14,7 @@ import Toast from '@/app/components/base/toast' import { checkWatercrawlTaskStatus, createWatercrawlTask } from '@/service/datasets' import { sleep } from '@/utils' import Header from '../base/header' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const ERROR_I18N_PREFIX = 'common.errorMsg' const I18N_PREFIX = 'datasetCreation.stepOne.website' @@ -51,7 +52,7 @@ const WaterCrawl: FC = ({ const { setShowAccountSettingModal } = useModalContext() const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx index f5cbac909d..97d6721e00 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx @@ -16,6 +16,7 @@ import Title from './title' import { useGetDataSourceAuth } from '@/service/use-datasource' import Loading from '@/app/components/base/loading' import { useDocLink } from '@/context/i18n' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type OnlineDocumentsProps = { isInPipeline?: boolean @@ -120,7 +121,7 @@ const OnlineDocuments = ({ const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx index ed2820675c..da8fd5dcc0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx @@ -15,6 +15,7 @@ import { useShallow } from 'zustand/react/shallow' import { useModalContextSelector } from '@/context/modal-context' import { useGetDataSourceAuth } from '@/service/use-datasource' import { useDocLink } from '@/context/i18n' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type OnlineDriveProps = { nodeId: string @@ -180,7 +181,7 @@ const OnlineDrive = ({ const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx index c46cbdf0f1..648f6a5d93 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx @@ -26,6 +26,7 @@ import { useShallow } from 'zustand/react/shallow' import { useModalContextSelector } from '@/context/modal-context' import { useGetDataSourceAuth } from '@/service/use-datasource' import { useDocLink } from '@/context/i18n' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const I18N_PREFIX = 'datasetCreation.stepOne.website' @@ -139,7 +140,7 @@ const WebsiteCrawl = ({ const handleSetting = useCallback(() => { setShowAccountSettingModal({ - payload: 'data-source', + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }, [setShowAccountSettingModal]) diff --git a/web/app/components/develop/doc.tsx b/web/app/components/develop/doc.tsx index 82b6b00e44..28a3219535 100644 --- a/web/app/components/develop/doc.tsx +++ b/web/app/components/develop/doc.tsx @@ -18,7 +18,7 @@ import TemplateChatJa from './template/template_chat.ja.mdx' import I18n from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' import useTheme from '@/hooks/use-theme' -import { Theme } from '@/types/app' +import { AppModeEnum, Theme } from '@/types/app' import cn from '@/utils/classnames' type IDocProps = { @@ -115,7 +115,7 @@ const Doc = ({ appDetail }: IDocProps) => { } const Template = useMemo(() => { - if (appDetail?.mode === 'chat' || appDetail?.mode === 'agent-chat') { + if (appDetail?.mode === AppModeEnum.CHAT || appDetail?.mode === AppModeEnum.AGENT_CHAT) { switch (locale) { case LanguagesSupported[1]: return @@ -125,7 +125,7 @@ const Doc = ({ appDetail }: IDocProps) => { return } } - if (appDetail?.mode === 'advanced-chat') { + if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { switch (locale) { case LanguagesSupported[1]: return @@ -135,7 +135,7 @@ const Doc = ({ appDetail }: IDocProps) => { return } } - if (appDetail?.mode === 'workflow') { + if (appDetail?.mode === AppModeEnum.WORKFLOW) { switch (locale) { case LanguagesSupported[1]: return @@ -145,7 +145,7 @@ const Doc = ({ appDetail }: IDocProps) => { return } } - if (appDetail?.mode === 'completion') { + if (appDetail?.mode === AppModeEnum.COMPLETION) { switch (locale) { case LanguagesSupported[1]: return diff --git a/web/app/components/explore/app-card/index.tsx b/web/app/components/explore/app-card/index.tsx index 0d6a9b4ad4..daf863b84d 100644 --- a/web/app/components/explore/app-card/index.tsx +++ b/web/app/components/explore/app-card/index.tsx @@ -6,6 +6,7 @@ import cn from '@/utils/classnames' import type { App } from '@/models/explore' import AppIcon from '@/app/components/base/app-icon' import { AppTypeIcon } from '../../app/type-selector' +import { AppModeEnum } from '@/types/app' export type AppCardProps = { app: App canCreate: boolean @@ -40,11 +41,11 @@ const AppCard = ({
{appBasicInfo.name}
- {appBasicInfo.mode === 'advanced-chat' &&
{t('app.types.advanced').toUpperCase()}
} - {appBasicInfo.mode === 'chat' &&
{t('app.types.chatbot').toUpperCase()}
} - {appBasicInfo.mode === 'agent-chat' &&
{t('app.types.agent').toUpperCase()}
} - {appBasicInfo.mode === 'workflow' &&
{t('app.types.workflow').toUpperCase()}
} - {appBasicInfo.mode === 'completion' &&
{t('app.types.completion').toUpperCase()}
} + {appBasicInfo.mode === AppModeEnum.ADVANCED_CHAT &&
{t('app.types.advanced').toUpperCase()}
} + {appBasicInfo.mode === AppModeEnum.CHAT &&
{t('app.types.chatbot').toUpperCase()}
} + {appBasicInfo.mode === AppModeEnum.AGENT_CHAT &&
{t('app.types.agent').toUpperCase()}
} + {appBasicInfo.mode === AppModeEnum.WORKFLOW &&
{t('app.types.workflow').toUpperCase()}
} + {appBasicInfo.mode === AppModeEnum.COMPLETION &&
{t('app.types.completion').toUpperCase()}
}
diff --git a/web/app/components/explore/create-app-modal/index.tsx b/web/app/components/explore/create-app-modal/index.tsx index e94999db04..84621858be 100644 --- a/web/app/components/explore/create-app-modal/index.tsx +++ b/web/app/components/explore/create-app-modal/index.tsx @@ -13,7 +13,7 @@ import Toast from '@/app/components/base/toast' import AppIcon from '@/app/components/base/app-icon' import { useProviderContext } from '@/context/provider-context' import AppsFull from '@/app/components/billing/apps-full-in-dialog' -import type { AppIconType } from '@/types/app' +import { type AppIconType, AppModeEnum } from '@/types/app' import { noop } from 'lodash-es' export type CreateAppModalProps = { @@ -158,7 +158,7 @@ const CreateAppModal = ({ />
{/* answer icon */} - {isEditModal && (appMode === 'chat' || appMode === 'advanced-chat' || appMode === 'agent-chat') && ( + {isEditModal && (appMode === AppModeEnum.CHAT || appMode === AppModeEnum.ADVANCED_CHAT || appMode === AppModeEnum.AGENT_CHAT) && (
{t('app.answerIcon.title')}
diff --git a/web/app/components/explore/installed-app/index.tsx b/web/app/components/explore/installed-app/index.tsx index 8032e173c6..18aab337d2 100644 --- a/web/app/components/explore/installed-app/index.tsx +++ b/web/app/components/explore/installed-app/index.tsx @@ -12,6 +12,7 @@ import AppUnavailable from '../../base/app-unavailable' import { useGetUserCanAccessApp } from '@/service/access-control' import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore' import type { AppData } from '@/models/share' +import { AppModeEnum } from '@/types/app' export type IInstalledAppProps = { id: string @@ -102,13 +103,13 @@ const InstalledApp: FC = ({ } return (
- {installedApp?.app.mode !== 'completion' && installedApp?.app.mode !== 'workflow' && ( + {installedApp?.app.mode !== AppModeEnum.COMPLETION && installedApp?.app.mode !== AppModeEnum.WORKFLOW && ( )} - {installedApp?.app.mode === 'completion' && ( + {installedApp?.app.mode === AppModeEnum.COMPLETION && ( )} - {installedApp?.app.mode === 'workflow' && ( + {installedApp?.app.mode === AppModeEnum.WORKFLOW && ( )}
diff --git a/web/app/components/goto-anything/context.tsx b/web/app/components/goto-anything/context.tsx index fee4b72c91..25fe2ddf96 100644 --- a/web/app/components/goto-anything/context.tsx +++ b/web/app/components/goto-anything/context.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import React, { createContext, useContext, useEffect, useState } from 'react' import { usePathname } from 'next/navigation' +import { isInWorkflowPage } from '../workflow/constants' /** * Interface for the GotoAnything context @@ -50,7 +51,7 @@ export const GotoAnythingProvider: React.FC = ({ chil } // Workflow pages: /app/[appId]/workflow or /workflow/[token] (shared) - const isWorkflow = /^\/app\/[^/]+\/workflow$/.test(pathname) || /^\/workflow\/[^/]+$/.test(pathname) + const isWorkflow = isInWorkflowPage() // RAG Pipeline pages: /datasets/[datasetId]/pipeline const isRagPipeline = /^\/datasets\/[^/]+\/pipeline$/.test(pathname) diff --git a/web/app/components/header/account-dropdown/compliance.tsx b/web/app/components/header/account-dropdown/compliance.tsx index b5849682e9..8dc4aeec32 100644 --- a/web/app/components/header/account-dropdown/compliance.tsx +++ b/web/app/components/header/account-dropdown/compliance.tsx @@ -16,6 +16,7 @@ import cn from '@/utils/classnames' import { useProviderContext } from '@/context/provider-context' import { Plan } from '@/app/components/billing/type' import { useModalContext } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { getDocDownloadUrl } from '@/service/common' enum DocName { @@ -38,7 +39,7 @@ const UpgradeOrDownload: FC = ({ doc_name }) => { if (isFreePlan) setShowPricingModal() else - setShowAccountSettingModal({ payload: 'billing' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) }, [isFreePlan, setShowAccountSettingModal, setShowPricingModal]) const { isPending, mutate: downloadCompliance } = useMutation({ diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 30b2bfdf6f..d00cddc693 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -33,6 +33,7 @@ import cn from '@/utils/classnames' import { useGlobalPublicStore } from '@/context/global-public-context' import { useDocLink } from '@/context/i18n' import { useLogout } from '@/service/use-common' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' export default function AppSelector() { const itemClassName = ` @@ -122,7 +123,7 @@ export default function AppSelector() {
setShowAccountSettingModal({ payload: 'members' })}> + )} onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}>
{t('common.userProfile.settings')}
diff --git a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx index ce218540ee..549b5e7910 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx @@ -16,6 +16,7 @@ import { } from '@/app/components/base/icons/src/vender/line/arrows' import { useModalContext } from '@/context/modal-context' import { fetchApiBasedExtensionList } from '@/service/common' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' type ApiBasedExtensionSelectorProps = { value: string @@ -83,7 +84,7 @@ const ApiBasedExtensionSelector: FC = ({ className='flex cursor-pointer items-center text-xs text-text-accent' onClick={() => { setOpen(false) - setShowAccountSettingModal({ payload: 'api-based-extension' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION }) }} > {t('common.apiBasedExtension.selector.manage')} diff --git a/web/app/components/header/account-setting/constants.ts b/web/app/components/header/account-setting/constants.ts new file mode 100644 index 0000000000..2bf2f2eff5 --- /dev/null +++ b/web/app/components/header/account-setting/constants.ts @@ -0,0 +1,21 @@ +export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings' + +export const ACCOUNT_SETTING_TAB = { + PROVIDER: 'provider', + MEMBERS: 'members', + BILLING: 'billing', + DATA_SOURCE: 'data-source', + API_BASED_EXTENSION: 'api-based-extension', + CUSTOM: 'custom', + LANGUAGE: 'language', +} as const + +export type AccountSettingTab = typeof ACCOUNT_SETTING_TAB[keyof typeof ACCOUNT_SETTING_TAB] + +export const DEFAULT_ACCOUNT_SETTING_TAB = ACCOUNT_SETTING_TAB.MEMBERS + +export const isValidAccountSettingTab = (tab: string | null): tab is AccountSettingTab => { + if (!tab) + return false + return Object.values(ACCOUNT_SETTING_TAB).includes(tab as AccountSettingTab) +} diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts index e4d9ba8950..01790d7002 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts @@ -8,7 +8,7 @@ import { useMarketplacePlugins, } from '@/app/components/plugins/marketplace/hooks' import type { Plugin } from '@/app/components/plugins/types' -import { PluginType } from '@/app/components/plugins/types' +import { PluginCategoryEnum } from '@/app/components/plugins/types' import { getMarketplacePluginsByCollectionId } from '@/app/components/plugins/marketplace/utils' export const useMarketplaceAllPlugins = (providers: any[], searchText: string) => { @@ -38,7 +38,7 @@ export const useMarketplaceAllPlugins = (providers: any[], searchText: string) = if (searchText) { queryPluginsWithDebounced({ query: searchText, - category: PluginType.datasource, + category: PluginCategoryEnum.datasource, exclude, type: 'plugin', sortBy: 'install_count', @@ -48,7 +48,7 @@ export const useMarketplaceAllPlugins = (providers: any[], searchText: string) = else { queryPlugins({ query: '', - category: PluginType.datasource, + category: PluginCategoryEnum.datasource, type: 'plugin', pageSize: 1000, exclude, diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 8e71597e9c..49f6f62a08 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -31,6 +31,10 @@ import { useProviderContext } from '@/context/provider-context' import { useAppContext } from '@/context/app-context' import MenuDialog from '@/app/components/header/account-setting/menu-dialog' import Input from '@/app/components/base/input' +import { + ACCOUNT_SETTING_TAB, + type AccountSettingTab, +} from '@/app/components/header/account-setting/constants' const iconClassName = ` w-5 h-5 mr-2 @@ -38,11 +42,12 @@ const iconClassName = ` type IAccountSettingProps = { onCancel: () => void - activeTab?: string + activeTab?: AccountSettingTab + onTabChange?: (tab: AccountSettingTab) => void } type GroupItem = { - key: string + key: AccountSettingTab name: string description?: string icon: React.JSX.Element @@ -51,56 +56,71 @@ type GroupItem = { export default function AccountSetting({ onCancel, - activeTab = 'members', + activeTab = ACCOUNT_SETTING_TAB.MEMBERS, + onTabChange, }: IAccountSettingProps) { - const [activeMenu, setActiveMenu] = useState(activeTab) + const [activeMenu, setActiveMenu] = useState(activeTab) + useEffect(() => { + setActiveMenu(activeTab) + }, [activeTab]) const { t } = useTranslation() const { enableBilling, enableReplaceWebAppLogo } = useProviderContext() const { isCurrentWorkspaceDatasetOperator } = useAppContext() - const workplaceGroupItems = (() => { + const workplaceGroupItems: GroupItem[] = (() => { if (isCurrentWorkspaceDatasetOperator) return [] - return [ + + const items: GroupItem[] = [ { - key: 'provider', + key: ACCOUNT_SETTING_TAB.PROVIDER, name: t('common.settings.provider'), icon: , activeIcon: , }, { - key: 'members', + key: ACCOUNT_SETTING_TAB.MEMBERS, name: t('common.settings.members'), icon: , activeIcon: , }, - { - // Use key false to hide this item - key: enableBilling ? 'billing' : false, + ] + + if (enableBilling) { + items.push({ + key: ACCOUNT_SETTING_TAB.BILLING, name: t('common.settings.billing'), description: t('billing.plansCommon.receiptInfo'), icon: , activeIcon: , - }, + }) + } + + items.push( { - key: 'data-source', + key: ACCOUNT_SETTING_TAB.DATA_SOURCE, name: t('common.settings.dataSource'), icon: , activeIcon: , }, { - key: 'api-based-extension', + key: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION, name: t('common.settings.apiBasedExtension'), icon: , activeIcon: , }, - { - key: (enableReplaceWebAppLogo || enableBilling) ? 'custom' : false, + ) + + if (enableReplaceWebAppLogo || enableBilling) { + items.push({ + key: ACCOUNT_SETTING_TAB.CUSTOM, name: t('custom.custom'), icon: , activeIcon: , - }, - ].filter(item => !!item.key) as GroupItem[] + }) + } + + return items })() const media = useBreakpoints() @@ -117,7 +137,7 @@ export default function AccountSetting({ name: t('common.settings.generalGroup'), items: [ { - key: 'language', + key: ACCOUNT_SETTING_TAB.LANGUAGE, name: t('common.settings.language'), icon: , activeIcon: , @@ -167,7 +187,10 @@ export default function AccountSetting({ 'mb-0.5 flex h-[37px] cursor-pointer items-center rounded-lg p-1 pl-3 text-sm', activeMenu === item.key ? 'system-sm-semibold bg-state-base-active text-components-menu-item-text-active' : 'system-sm-medium text-components-menu-item-text')} title={item.name} - onClick={() => setActiveMenu(item.key)} + onClick={() => { + setActiveMenu(item.key) + onTabChange?.(item.key) + }} > {activeMenu === item.key ? item.activeIcon : item.icon} {!isMobile &&
{item.name}
} diff --git a/web/app/components/header/account-setting/model-provider-page/declarations.ts b/web/app/components/header/account-setting/model-provider-page/declarations.ts index 62cb1a96e9..134df7b3e8 100644 --- a/web/app/components/header/account-setting/model-provider-page/declarations.ts +++ b/web/app/components/header/account-setting/model-provider-page/declarations.ts @@ -14,7 +14,8 @@ export enum FormTypeEnum { secretInput = 'secret-input', select = 'select', radio = 'radio', - boolean = 'checkbox', + checkbox = 'checkbox', + boolean = 'boolean', files = 'files', file = 'file', modelSelector = 'model-selector', diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index 48dc609795..8cfd144681 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -35,7 +35,7 @@ import { useMarketplacePlugins, } from '@/app/components/plugins/marketplace/hooks' import type { Plugin } from '@/app/components/plugins/types' -import { PluginType } from '@/app/components/plugins/types' +import { PluginCategoryEnum } from '@/app/components/plugins/types' import { getMarketplacePluginsByCollectionId } from '@/app/components/plugins/marketplace/utils' import { useModalContextSelector } from '@/context/modal-context' import { useEventEmitterContextContext } from '@/context/event-emitter' @@ -278,7 +278,7 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText: if (searchText) { queryPluginsWithDebounced({ query: searchText, - category: PluginType.model, + category: PluginCategoryEnum.model, exclude, type: 'plugin', sortBy: 'install_count', @@ -288,7 +288,7 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText: else { queryPlugins({ query: '', - category: PluginType.model, + category: PluginCategoryEnum.model, type: 'plugin', pageSize: 1000, exclude, diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx index 164aeb5bc3..3c51762c52 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx @@ -264,7 +264,7 @@ function Form< ) } - if (formSchema.type === FormTypeEnum.boolean) { + if (formSchema.type === FormTypeEnum.checkbox) { const { variable, label, show_on, required, } = formSchema as CredentialFormSchemaRadio diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx index 82ba072b94..e56def4113 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx @@ -36,7 +36,6 @@ export type ModelParameterModalProps = { popupClassName?: string portalToFollowElemContentClassName?: string isAdvancedMode: boolean - mode: string modelId: string provider: string setModel: (model: { modelId: string; provider: string; mode?: string; features?: string[] }) => void diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx index b43fcd6301..ae7d863d91 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx @@ -15,6 +15,7 @@ import { useLanguage } from '../hooks' import PopupItem from './popup-item' import { XCircle } from '@/app/components/base/icons/src/vender/solid/general' import { useModalContext } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { supportFunctionCall } from '@/utils/tool-call' import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager' @@ -129,7 +130,7 @@ const Popup: FC = ({
{ onHide() - setShowAccountSettingModal({ payload: 'provider' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) }}> {t('common.model.settingsLink')} diff --git a/web/app/components/header/app-nav/index.tsx b/web/app/components/header/app-nav/index.tsx index 97e08a1166..740e790630 100644 --- a/web/app/components/header/app-nav/index.tsx +++ b/web/app/components/header/app-nav/index.tsx @@ -19,6 +19,7 @@ import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal' import type { AppListResponse } from '@/models/app' import { useAppContext } from '@/context/app-context' import { useStore as useAppStore } from '@/app/components/app/store' +import { AppModeEnum } from '@/types/app' const getKey = ( pageIndex: number, @@ -79,7 +80,7 @@ const AppNav = () => { return `/app/${app.id}/overview` } else { - if (app.mode === 'workflow' || app.mode === 'advanced-chat') + if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) return `/app/${app.id}/workflow` else return `/app/${app.id}/configuration` diff --git a/web/app/components/header/index.tsx b/web/app/components/header/index.tsx index fc511d2954..ef24d471e0 100644 --- a/web/app/components/header/index.tsx +++ b/web/app/components/header/index.tsx @@ -19,6 +19,7 @@ import PlanBadge from './plan-badge' import LicenseNav from './license-env' import { Plan } from '../billing/type' import { useGlobalPublicStore } from '@/context/global-public-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const navClassName = ` flex items-center relative px-3 h-8 rounded-xl @@ -38,7 +39,7 @@ const Header = () => { if (isFreePlan) setShowPricingModal() else - setShowAccountSettingModal({ payload: 'billing' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) }, [isFreePlan, setShowAccountSettingModal, setShowPricingModal]) if (isMobile) { diff --git a/web/app/components/header/nav/nav-selector/index.tsx b/web/app/components/header/nav/nav-selector/index.tsx index 6c9db287e7..4a13bc8a3c 100644 --- a/web/app/components/header/nav/nav-selector/index.tsx +++ b/web/app/components/header/nav/nav-selector/index.tsx @@ -15,7 +15,7 @@ import { AppTypeIcon } from '@/app/components/app/type-selector' import { useAppContext } from '@/context/app-context' import { useStore as useAppStore } from '@/app/components/app/store' import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' -import type { AppIconType, AppMode } from '@/types/app' +import type { AppIconType, AppModeEnum } from '@/types/app' export type NavItem = { id: string @@ -25,7 +25,7 @@ export type NavItem = { icon: string icon_background: string | null icon_url: string | null - mode?: AppMode + mode?: AppModeEnum } export type INavSelectorProps = { navigationItems: NavItem[] diff --git a/web/app/components/plugins/card/index.tsx b/web/app/components/plugins/card/index.tsx index b3c09b5bfd..e20aef6220 100644 --- a/web/app/components/plugins/card/index.tsx +++ b/web/app/components/plugins/card/index.tsx @@ -1,21 +1,21 @@ 'use client' -import React from 'react' -import type { Plugin } from '../types' -import Icon from '../card/base/card-icon' -import CornerMark from './base/corner-mark' -import Title from './base/title' -import OrgInfo from './base/org-info' -import Description from './base/description' -import Placeholder from './base/placeholder' -import cn from '@/utils/classnames' -import { useGetLanguage } from '@/context/i18n' -import { getLanguage } from '@/i18n-config/language' -import { useSingleCategories } from '../hooks' -import { renderI18nObject } from '@/i18n-config' import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks' +import { useGetLanguage } from '@/context/i18n' +import { renderI18nObject } from '@/i18n-config' +import { getLanguage } from '@/i18n-config/language' +import cn from '@/utils/classnames' +import { RiAlertFill } from '@remixicon/react' +import React from 'react' import Partner from '../base/badges/partner' import Verified from '../base/badges/verified' -import { RiAlertFill } from '@remixicon/react' +import Icon from '../card/base/card-icon' +import { useCategories } from '../hooks' +import type { Plugin } from '../types' +import CornerMark from './base/corner-mark' +import Description from './base/description' +import OrgInfo from './base/org-info' +import Placeholder from './base/placeholder' +import Title from './base/title' export type Props = { className?: string @@ -49,10 +49,8 @@ const Card = ({ const defaultLocale = useGetLanguage() const locale = localeFromProps ? getLanguage(localeFromProps) : defaultLocale const { t } = useMixedTranslation(localeFromProps) - const { categoriesMap } = useSingleCategories(t) + const { categoriesMap } = useCategories(t, true) const { category, type, name, org, label, brief, icon, verified, badges = [] } = payload - const isBundle = !['plugin', 'model', 'tool', 'datasource', 'extension', 'agent-strategy'].includes(type) - const cornerMark = isBundle ? categoriesMap.bundle?.label : categoriesMap[category]?.label const getLocalizedText = (obj: Record | undefined) => obj ? renderI18nObject(obj, locale) : '' const isPartner = badges.includes('partner') @@ -70,7 +68,7 @@ const Card = ({ return (
- {!hideCornerMark && } + {!hideCornerMark && } {/* Header */}
diff --git a/web/app/components/plugins/constants.ts b/web/app/components/plugins/constants.ts index 7436611c79..d9203fd4ea 100644 --- a/web/app/components/plugins/constants.ts +++ b/web/app/components/plugins/constants.ts @@ -1,3 +1,5 @@ +import { PluginCategoryEnum } from './types' + export const tagKeys = [ 'agent', 'rag', @@ -20,10 +22,11 @@ export const tagKeys = [ ] export const categoryKeys = [ - 'model', - 'tool', - 'datasource', - 'agent-strategy', - 'extension', + PluginCategoryEnum.model, + PluginCategoryEnum.tool, + PluginCategoryEnum.datasource, + PluginCategoryEnum.agent, + PluginCategoryEnum.extension, 'bundle', + PluginCategoryEnum.trigger, ] diff --git a/web/app/components/plugins/hooks.ts b/web/app/components/plugins/hooks.ts index f22b2c4d69..8303a4cc46 100644 --- a/web/app/components/plugins/hooks.ts +++ b/web/app/components/plugins/hooks.ts @@ -1,10 +1,11 @@ +import type { TFunction } from 'i18next' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import type { TFunction } from 'i18next' import { categoryKeys, tagKeys, } from './constants' +import { PluginCategoryEnum } from './types' export type Tag = { name: string @@ -51,56 +52,24 @@ type Category = { label: string } -export const useCategories = (translateFromOut?: TFunction) => { +export const useCategories = (translateFromOut?: TFunction, isSingle?: boolean) => { const { t: translation } = useTranslation() const t = translateFromOut || translation const categories = useMemo(() => { return categoryKeys.map((category) => { - if (category === 'agent-strategy') { + if (category === PluginCategoryEnum.agent) { return { - name: 'agent-strategy', - label: t('plugin.category.agents'), + name: PluginCategoryEnum.agent, + label: isSingle ? t('plugin.categorySingle.agent') : t('plugin.category.agents'), } } return { name: category, - label: t(`plugin.category.${category}s`), + label: isSingle ? t(`plugin.categorySingle.${category}`) : t(`plugin.category.${category}s`), } }) - }, [t]) - - const categoriesMap = useMemo(() => { - return categories.reduce((acc, category) => { - acc[category.name] = category - return acc - }, {} as Record) - }, [categories]) - - return { - categories, - categoriesMap, - } -} - -export const useSingleCategories = (translateFromOut?: TFunction) => { - const { t: translation } = useTranslation() - const t = translateFromOut || translation - - const categories = useMemo(() => { - return categoryKeys.map((category) => { - if (category === 'agent-strategy') { - return { - name: 'agent-strategy', - label: t('plugin.categorySingle.agent'), - } - } - return { - name: category, - label: t(`plugin.categorySingle.${category}`), - } - }) - }, [t]) + }, [t, isSingle]) const categoriesMap = useMemo(() => { return categories.reduce((acc, category) => { diff --git a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx index 7c3ab29c49..264c4782cd 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx @@ -5,9 +5,10 @@ import { useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useInvalidateAllBuiltInTools, useInvalidateAllToolProviders, useInvalidateRAGRecommendedPlugins } from '@/service/use-tools' import { useInvalidateStrategyProviders } from '@/service/use-strategy' import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types' -import { PluginType } from '../../types' +import { PluginCategoryEnum } from '../../types' import { useInvalidDataSourceList } from '@/service/use-pipeline' import { useInvalidDataSourceListAuth } from '@/service/use-datasource' +import { useInvalidateAllTriggerPlugins } from '@/service/use-triggers' const useRefreshPluginList = () => { const invalidateInstalledPluginList = useInvalidateInstalledPluginList() @@ -24,6 +25,8 @@ const useRefreshPluginList = () => { const invalidateStrategyProviders = useInvalidateStrategyProviders() + const invalidateAllTriggerPlugins = useInvalidateAllTriggerPlugins() + const invalidateRAGRecommendedPlugins = useInvalidateRAGRecommendedPlugins() return { refreshPluginList: (manifest?: PluginManifestInMarket | Plugin | PluginDeclaration | null, refreshAllType?: boolean) => { @@ -31,20 +34,23 @@ const useRefreshPluginList = () => { invalidateInstalledPluginList() // tool page, tool select - if ((manifest && PluginType.tool.includes(manifest.category)) || refreshAllType) { + if ((manifest && PluginCategoryEnum.tool.includes(manifest.category)) || refreshAllType) { invalidateAllToolProviders() invalidateAllBuiltInTools() invalidateRAGRecommendedPlugins() // TODO: update suggested tools. It's a function in hook useMarketplacePlugins,handleUpdatePlugins } - if ((manifest && PluginType.datasource.includes(manifest.category)) || refreshAllType) { + if ((manifest && PluginCategoryEnum.trigger.includes(manifest.category)) || refreshAllType) + invalidateAllTriggerPlugins() + + if ((manifest && PluginCategoryEnum.datasource.includes(manifest.category)) || refreshAllType) { invalidateAllDataSources() invalidateDataSourceListAuth() } // model select - if ((manifest && PluginType.model.includes(manifest.category)) || refreshAllType) { + if ((manifest && PluginCategoryEnum.model.includes(manifest.category)) || refreshAllType) { refreshModelProviders() refetchLLMModelList() refetchEmbeddingModelList() @@ -52,7 +58,7 @@ const useRefreshPluginList = () => { } // agent select - if ((manifest && PluginType.agent.includes(manifest.category)) || refreshAllType) + if ((manifest && PluginCategoryEnum.agent.includes(manifest.category)) || refreshAllType) invalidateStrategyProviders() }, } diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 10aead17c4..5bc9263aaa 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -65,10 +65,12 @@ export const useMarketplacePlugins = () => { } = useMutationPluginsFromMarketplace() const [prevPlugins, setPrevPlugins] = useState() + const resetPlugins = useCallback(() => { reset() setPrevPlugins(undefined) }, [reset]) + const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => { mutateAsync(pluginsSearchParams).then((res) => { const currentPage = pluginsSearchParams.page || 1 @@ -85,9 +87,6 @@ export const useMarketplacePlugins = () => { } }) }, [mutateAsync]) - const queryPlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => { - handleUpdatePlugins(pluginsSearchParams) - }, [handleUpdatePlugins]) const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => { handleUpdatePlugins(pluginsSearchParams) @@ -99,7 +98,7 @@ export const useMarketplacePlugins = () => { plugins: prevPlugins, total: data?.data?.total, resetPlugins, - queryPlugins, + queryPlugins: handleUpdatePlugins, queryPluginsWithDebounced, cancelQueryPluginsWithDebounced, isLoading: isPending, diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 4a40eb0e06..249be1ef83 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -1,4 +1,6 @@ 'use client' +import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' +import cn from '@/utils/classnames' import { RiArchive2Line, RiBrain2Line, @@ -7,22 +9,22 @@ import { RiPuzzle2Line, RiSpeakAiLine, } from '@remixicon/react' -import { PluginType } from '../types' +import { useCallback, useEffect } from 'react' +import { PluginCategoryEnum } from '../types' import { useMarketplaceContext } from './context' import { useMixedTranslation, useSearchBoxAutoAnimate, } from './hooks' -import cn from '@/utils/classnames' -import { useCallback, useEffect } from 'react' export const PLUGIN_TYPE_SEARCH_MAP = { all: 'all', - model: PluginType.model, - tool: PluginType.tool, - agent: PluginType.agent, - extension: PluginType.extension, - datasource: PluginType.datasource, + model: PluginCategoryEnum.model, + tool: PluginCategoryEnum.tool, + agent: PluginCategoryEnum.agent, + extension: PluginCategoryEnum.extension, + datasource: PluginCategoryEnum.datasource, + trigger: PluginCategoryEnum.trigger, bundle: 'bundle', } type PluginTypeSwitchProps = { @@ -63,6 +65,11 @@ const PluginTypeSwitch = ({ text: t('plugin.category.datasources'), icon: , }, + { + value: PLUGIN_TYPE_SEARCH_MAP.trigger, + text: t('plugin.category.triggers'), + icon: , + }, { value: PLUGIN_TYPE_SEARCH_MAP.agent, text: t('plugin.category.agents'), diff --git a/web/app/components/plugins/marketplace/search-box/index.tsx b/web/app/components/plugins/marketplace/search-box/index.tsx index 0bc214ae1a..c398964b4e 100644 --- a/web/app/components/plugins/marketplace/search-box/index.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.tsx @@ -19,6 +19,7 @@ type SearchBoxProps = { usedInMarketplace?: boolean onShowAddCustomCollectionModal?: () => void onAddedCustomTool?: () => void + autoFocus?: boolean } const SearchBox = ({ search, @@ -32,6 +33,7 @@ const SearchBox = ({ usedInMarketplace = false, supportAddCustomTool, onShowAddCustomCollectionModal, + autoFocus = false, }: SearchBoxProps) => { return (
@@ -82,11 +84,12 @@ const SearchBox = ({ { !usedInMarketplace && ( <> -
+
{ - if (pluginType === PluginType.tool) - return 'category=tool' + if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum)) + return `category=${pluginType}` - if (pluginType === PluginType.agent) - return 'category=agent-strategy' - - if (pluginType === PluginType.model) - return 'category=model' - - if (pluginType === PluginType.extension) + if (pluginType === PluginCategoryEnum.extension) return 'category=endpoint' - if (pluginType === PluginType.datasource) - return 'category=datasource' - if (pluginType === 'bundle') return 'type=bundle' diff --git a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx index 95676c656e..cb90b075b0 100644 --- a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx @@ -6,7 +6,6 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' import Modal from '@/app/components/base/modal/modal' import { CredentialTypeEnum } from '../types' import AuthForm from '@/app/components/base/form/form-scenarios/auth' @@ -23,6 +22,9 @@ import { useGetPluginCredentialSchemaHook, useUpdatePluginCredentialHook, } from '../hooks/use-credential' +import { ReadmeEntrance } from '../../readme-panel/entrance' +import { ReadmeShowType } from '../../readme-panel/store' +import { EncryptedBottom } from '@/app/components/base/encrypted-bottom' export type ApiKeyModalProps = { pluginPayload: PluginPayload @@ -134,25 +136,17 @@ const ApiKeyModal = ({ footerSlot={ (
) } - bottomSlot={ -
- - {t('common.modelProvider.encrypted.front')} - - PKCS1_OAEP - - {t('common.modelProvider.encrypted.back')} -
- } + bottomSlot={} onConfirm={handleConfirm} showExtraButton={!!editValues} onExtraButtonClick={onRemove} disabled={disabled || isLoading || doingAction} + clickOutsideNotClose={true} + wrapperClassName='!z-[101]' > + {pluginPayload.detail && ( + + )} { isLoading && (
diff --git a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx index c10b06166b..256f6d0f4b 100644 --- a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx @@ -23,6 +23,8 @@ import type { } from '@/app/components/base/form/types' import { useToastContext } from '@/app/components/base/toast' import Button from '@/app/components/base/button' +import { ReadmeEntrance } from '../../readme-panel/entrance' +import { ReadmeShowType } from '../../readme-panel/store' type OAuthClientSettingsProps = { pluginPayload: PluginPayload @@ -154,16 +156,20 @@ const OAuthClientSettings = ({
) } + containerClassName='pt-0' + wrapperClassName='!z-[101]' + clickOutsideNotClose={true} > - <> - - + {pluginPayload.detail && ( + + )} + ) } diff --git a/web/app/components/plugins/plugin-auth/types.ts b/web/app/components/plugins/plugin-auth/types.ts index fb23269b4b..9974586302 100644 --- a/web/app/components/plugins/plugin-auth/types.ts +++ b/web/app/components/plugins/plugin-auth/types.ts @@ -1,4 +1,5 @@ import type { CollectionType } from '../../tools/types' +import type { PluginDetail } from '../types' export type { AddApiKeyButtonProps } from './authorize/add-api-key-button' export type { AddOAuthButtonProps } from './authorize/add-oauth-button' @@ -7,12 +8,14 @@ export enum AuthCategory { tool = 'tool', datasource = 'datasource', model = 'model', + trigger = 'trigger', } export type PluginPayload = { category: AuthCategory provider: string - providerType: CollectionType | string + providerType?: CollectionType | string + detail?: PluginDetail } export enum CredentialTypeEnum { diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx index 12cd74e10a..edf15a4419 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx @@ -6,7 +6,7 @@ import AppInputsForm from '@/app/components/plugins/plugin-detail-panel/app-sele import { useAppDetail } from '@/service/use-apps' import { useAppWorkflow } from '@/service/use-workflow' import { useFileUploadConfig } from '@/service/use-common' -import { Resolution } from '@/types/app' +import { AppModeEnum, Resolution } from '@/types/app' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import type { App } from '@/types/app' import type { FileUpload } from '@/app/components/base/features/types' @@ -30,7 +30,7 @@ const AppInputsPanel = ({ }: Props) => { const { t } = useTranslation() const inputsRef = useRef(value?.inputs || {}) - const isBasicApp = appDetail.mode !== 'advanced-chat' && appDetail.mode !== 'workflow' + const isBasicApp = appDetail.mode !== AppModeEnum.ADVANCED_CHAT && appDetail.mode !== AppModeEnum.WORKFLOW const { data: fileUploadConfig } = useFileUploadConfig() const { data: currentApp, isFetching: isAppLoading } = useAppDetail(appDetail.id) const { data: currentWorkflow, isFetching: isWorkflowLoading } = useAppWorkflow(isBasicApp ? '' : appDetail.id) @@ -77,7 +77,7 @@ const AppInputsPanel = ({ required: false, } } - if(item.checkbox) { + if (item.checkbox) { return { ...item.checkbox, type: 'checkbox', @@ -148,7 +148,7 @@ const AppInputsPanel = ({ } }) || [] } - if ((currentApp.mode === 'completion' || currentApp.mode === 'workflow') && basicAppFileConfig.enabled) { + if ((currentApp.mode === AppModeEnum.COMPLETION || currentApp.mode === AppModeEnum.WORKFLOW) && basicAppFileConfig.enabled) { inputFormSchema.push({ label: 'Image Upload', variable: '#image#', diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx index 10c28507f7..43fb4b30e0 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx @@ -12,7 +12,7 @@ import type { } from '@floating-ui/react' import Input from '@/app/components/base/input' import AppIcon from '@/app/components/base/app-icon' -import type { App } from '@/types/app' +import { type App, AppModeEnum } from '@/types/app' import { useTranslation } from 'react-i18next' type Props = { @@ -118,15 +118,15 @@ const AppPicker: FC = ({ const getAppType = (app: App) => { switch (app.mode) { - case 'advanced-chat': + case AppModeEnum.ADVANCED_CHAT: return 'chatflow' - case 'agent-chat': + case AppModeEnum.AGENT_CHAT: return 'agent' - case 'chat': + case AppModeEnum.CHAT: return 'chat' - case 'completion': + case AppModeEnum.COMPLETION: return 'completion' - case 'workflow': + case AppModeEnum.WORKFLOW: return 'workflow' } } diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx index 9f326fa198..44ddb8360e 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx @@ -1,7 +1,26 @@ -import React, { useCallback, useMemo, useState } from 'react' -import { useTheme } from 'next-themes' -import { useTranslation } from 'react-i18next' -import { useBoolean } from 'ahooks' +import ActionButton from '@/app/components/base/action-button' +import Badge from '@/app/components/base/badge' +import Button from '@/app/components/base/button' +import Confirm from '@/app/components/base/confirm' +import { Github } from '@/app/components/base/icons/src/public/common' +import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' +import Toast from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' +import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth' +import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' +import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info' +import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place' +import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker' +import { API_PREFIX } from '@/config' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useGetLanguage, useI18N } from '@/context/i18n' +import { useModalContext } from '@/context/modal-context' +import { useProviderContext } from '@/context/provider-context' +import { uninstallPlugin } from '@/service/plugins' +import { useAllToolProviders, useInvalidateAllToolProviders } from '@/service/use-tools' +import cn from '@/utils/classnames' +import { getMarketplaceUrl } from '@/utils/var' import { RiArrowLeftRightLine, RiBugLine, @@ -9,54 +28,35 @@ import { RiHardDrive3Line, RiVerifiedBadgeLine, } from '@remixicon/react' -import type { PluginDetail } from '../types' -import { PluginSource, PluginType } from '../types' -import Description from '../card/base/description' -import Icon from '../card/base/card-icon' -import Title from '../card/base/title' -import OrgInfo from '../card/base/org-info' -import { useGitHubReleases } from '../install-plugin/hooks' -import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker' -import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place' -import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' -import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info' -import ActionButton from '@/app/components/base/action-button' -import Button from '@/app/components/base/button' -import Badge from '@/app/components/base/badge' -import Confirm from '@/app/components/base/confirm' -import Tooltip from '@/app/components/base/tooltip' -import Toast from '@/app/components/base/toast' -import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' -import { Github } from '@/app/components/base/icons/src/public/common' -import { uninstallPlugin } from '@/service/plugins' -import { useGetLanguage, useI18N } from '@/context/i18n' -import { useModalContext } from '@/context/modal-context' -import { useProviderContext } from '@/context/provider-context' -import { useInvalidateAllToolProviders } from '@/service/use-tools' -import { API_PREFIX } from '@/config' -import cn from '@/utils/classnames' -import { getMarketplaceUrl } from '@/utils/var' -import { PluginAuth } from '@/app/components/plugins/plugin-auth' -import { AuthCategory } from '@/app/components/plugins/plugin-auth' -import { useAllToolProviders } from '@/service/use-tools' -import DeprecationNotice from '../base/deprecation-notice' +import { useBoolean } from 'ahooks' +import { useTheme } from 'next-themes' +import React, { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { AutoUpdateLine } from '../../base/icons/src/vender/system' -import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils' +import DeprecationNotice from '../base/deprecation-notice' +import Icon from '../card/base/card-icon' +import Description from '../card/base/description' +import OrgInfo from '../card/base/org-info' +import Title from '../card/base/title' +import { useGitHubReleases } from '../install-plugin/hooks' import useReferenceSetting from '../plugin-page/use-reference-setting' import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types' -import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils' +import type { PluginDetail } from '../types' +import { PluginCategoryEnum, PluginSource } from '../types' const i18nPrefix = 'plugin.action' type Props = { detail: PluginDetail - onHide: () => void - onUpdate: (isDelete?: boolean) => void + isReadmeView?: boolean + onHide?: () => void + onUpdate?: (isDelete?: boolean) => void } const DetailHeader = ({ detail, + isReadmeView = false, onHide, onUpdate, }: Props) => { @@ -85,8 +85,9 @@ const DetailHeader = ({ deprecated_reason, alternative_plugin_id, } = detail - const { author, category, name, label, description, icon, verified, tool } = detail.declaration - const isTool = category === PluginType.tool + + const { author, category, name, label, description, icon, verified, tool } = detail.declaration || detail + const isTool = category === PluginCategoryEnum.tool const providerBriefInfo = tool?.identity const providerKey = `${plugin_id}/${providerBriefInfo?.name}` const { data: collectionList = [] } = useAllToolProviders(isTool) @@ -128,13 +129,13 @@ const DetailHeader = ({ return false if (!autoUpgradeInfo || !isFromMarketplace) return false - if(autoUpgradeInfo.strategy_setting === 'disabled') + if (autoUpgradeInfo.strategy_setting === 'disabled') return false - if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all) + if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all) return true - if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id)) + if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id)) return true - if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id)) + if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id)) return true return false }, [autoUpgradeInfo, plugin_id, isFromMarketplace]) @@ -156,7 +157,7 @@ const DetailHeader = ({ if (needUpdate) { setShowUpdatePluginModal({ onSaveCallback: () => { - onUpdate() + onUpdate?.() }, payload: { type: PluginSource.github, @@ -176,7 +177,7 @@ const DetailHeader = ({ } const handleUpdatedFromMarketplace = () => { - onUpdate() + onUpdate?.() hideUpdateModal() } @@ -201,26 +202,26 @@ const DetailHeader = ({ hideDeleting() if (res.success) { hideDeleteConfirm() - onUpdate(true) - if (PluginType.model.includes(category)) + onUpdate?.(true) + if (PluginCategoryEnum.model.includes(category)) refreshModelProviders() - if (PluginType.tool.includes(category)) + if (PluginCategoryEnum.tool.includes(category)) invalidateAllToolProviders() } }, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders]) return ( -
+
-
- +
+
- {verified && <RiVerifiedBadgeLine className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" />} - <PluginVersionPicker - disabled={!isFromMarketplace} + {verified && !isReadmeView && <RiVerifiedBadgeLine className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" />} + {version && <PluginVersionPicker + disabled={!isFromMarketplace || isReadmeView} isShow={isShow} onShowChange={setIsShow} pluginID={plugin_id} @@ -240,15 +241,15 @@ const DetailHeader = ({ text={ <> <div>{isFromGitHub ? meta!.version : version}</div> - {isFromMarketplace && <RiArrowLeftRightLine className='ml-1 h-3 w-3 text-text-tertiary' />} + {isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className='ml-1 h-3 w-3 text-text-tertiary' />} </> } hasRedCornerMark={hasNewVersion} /> } - /> + />} {/* Auto update info */} - {isAutoUpgradeEnabled && ( + {isAutoUpgradeEnabled && !isReadmeView && ( <Tooltip popupContent={t('plugin.autoUpdate.nextUpdateTime', { time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}> {/* add a a div to fix tooltip hover not show problem */} <div> @@ -276,44 +277,47 @@ const DetailHeader = ({ <OrgInfo packageNameClassName='w-auto' orgName={author} - packageName={name} + packageName={name?.includes('/') ? (name.split('/').pop() || '') : name} /> - <div className='system-xs-regular ml-1 mr-0.5 text-text-quaternary'>·</div> - {detail.source === PluginSource.marketplace && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.marketplace')} > - <div><BoxSparkleFill className='h-3.5 w-3.5 text-text-tertiary hover:text-text-accent' /></div> - </Tooltip> - )} - {detail.source === PluginSource.github && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.github')} > - <div><Github className='h-3.5 w-3.5 text-text-secondary hover:text-text-primary' /></div> - </Tooltip> - )} - {detail.source === PluginSource.local && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.local')} > - <div><RiHardDrive3Line className='h-3.5 w-3.5 text-text-tertiary' /></div> - </Tooltip> - )} - {detail.source === PluginSource.debugging && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.debugging')} > - <div><RiBugLine className='h-3.5 w-3.5 text-text-tertiary hover:text-text-warning' /></div> - </Tooltip> - )} + {source && <> + <div className='system-xs-regular ml-1 mr-0.5 text-text-quaternary'>·</div> + {source === PluginSource.marketplace && ( + <Tooltip popupContent={t('plugin.detailPanel.categoryTip.marketplace')} > + <div><BoxSparkleFill className='h-3.5 w-3.5 text-text-tertiary hover:text-text-accent' /></div> + </Tooltip> + )} + {source === PluginSource.github && ( + <Tooltip popupContent={t('plugin.detailPanel.categoryTip.github')} > + <div><Github className='h-3.5 w-3.5 text-text-secondary hover:text-text-primary' /></div> + </Tooltip> + )} + {source === PluginSource.local && ( + <Tooltip popupContent={t('plugin.detailPanel.categoryTip.local')} > + <div><RiHardDrive3Line className='h-3.5 w-3.5 text-text-tertiary' /></div> + </Tooltip> + )} + {source === PluginSource.debugging && ( + <Tooltip popupContent={t('plugin.detailPanel.categoryTip.debugging')} > + <div><RiBugLine className='h-3.5 w-3.5 text-text-tertiary hover:text-text-warning' /></div> + </Tooltip> + )} + </>} </div> </div> </div> - <div className='flex gap-1'> - <OperationDropdown - source={detail.source} - onInfo={showPluginInfo} - onCheckVersion={handleUpdate} - onRemove={showDeleteConfirm} - detailUrl={detailUrl} - /> - <ActionButton onClick={onHide}> - <RiCloseLine className='h-4 w-4' /> - </ActionButton> - </div> + {!isReadmeView && ( + <div className='flex gap-1'> + <OperationDropdown + source={source} + onInfo={showPluginInfo} + onCheckVersion={handleUpdate} + onRemove={showDeleteConfirm} + detailUrl={detailUrl} + /> + <ActionButton onClick={onHide}> + <RiCloseLine className='h-4 w-4' /> + </ActionButton> + </div>)} </div> {isFromMarketplace && ( <DeprecationNotice @@ -324,14 +328,15 @@ const DetailHeader = ({ className='mt-3' /> )} - <Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description> + {!isReadmeView && <Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description>} { - category === PluginType.tool && ( + category === PluginCategoryEnum.tool && !isReadmeView && ( <PluginAuth pluginPayload={{ provider: provider?.name || '', category: AuthCategory.tool, providerType: provider?.type || '', + detail, }} /> ) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx index 00cd1b88ae..9c3765def3 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' import { RiClipboardLine, RiDeleteBinLine, RiEditLine, RiLoginCircleLine } from '@remixicon/react' -import type { EndpointListItem } from '../types' +import type { EndpointListItem, PluginDetail } from '../types' import EndpointModal from './endpoint-modal' import { NAME_FIELD } from './utils' import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema' @@ -22,11 +22,13 @@ import { } from '@/service/use-endpoints' type Props = { + pluginDetail: PluginDetail data: EndpointListItem handleChange: () => void } const EndpointCard = ({ + pluginDetail, data, handleChange, }: Props) => { @@ -206,10 +208,11 @@ const EndpointCard = ({ )} {isShowEndpointModal && ( <EndpointModal - formSchemas={formSchemas} + formSchemas={formSchemas as any} defaultValues={formValue} onCancel={hideEndpointModalConfirm} onSaved={handleUpdate} + pluginDetail={pluginDetail} /> )} </div> diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx index 5735022c5d..fff6775495 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx @@ -102,14 +102,16 @@ const EndpointList = ({ detail }: Props) => { key={index} data={item} handleChange={() => invalidateEndpointList(detail.plugin_id)} + pluginDetail={detail} /> ))} </div> {isShowEndpointModal && ( <EndpointModal - formSchemas={formSchemas} + formSchemas={formSchemas as any} onCancel={hideEndpointModal} onSaved={handleCreate} + pluginDetail={detail} /> )} </div> diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx index d4c0bc2d92..48aeecf1b2 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx @@ -10,12 +10,16 @@ import Form from '@/app/components/header/account-setting/model-provider-page/mo import Toast from '@/app/components/base/toast' import { useRenderI18nObject } from '@/hooks/use-i18n' import cn from '@/utils/classnames' +import { ReadmeEntrance } from '../readme-panel/entrance' +import type { PluginDetail } from '../types' +import type { FormSchema } from '../../base/form/types' type Props = { - formSchemas: any + formSchemas: FormSchema[] defaultValues?: any onCancel: () => void onSaved: (value: Record<string, any>) => void + pluginDetail: PluginDetail } const extractDefaultValues = (schemas: any[]) => { @@ -32,6 +36,7 @@ const EndpointModal: FC<Props> = ({ defaultValues = {}, onCancel, onSaved, + pluginDetail, }) => { const getValueFromI18nObject = useRenderI18nObject() const { t } = useTranslation() @@ -43,7 +48,7 @@ const EndpointModal: FC<Props> = ({ const handleSave = () => { for (const field of formSchemas) { if (field.required && !tempCredential[field.name]) { - Toast.notify({ type: 'error', message: t('common.errorMsg.fieldRequired', { field: getValueFromI18nObject(field.label) }) }) + Toast.notify({ type: 'error', message: t('common.errorMsg.fieldRequired', { field: typeof field.label === 'string' ? field.label : getValueFromI18nObject(field.label as Record<string, string>) }) }) return } } @@ -84,6 +89,7 @@ const EndpointModal: FC<Props> = ({ </ActionButton> </div> <div className='system-xs-regular mt-0.5 text-text-tertiary'>{t('plugin.detailPanel.endpointModalDesc')}</div> + <ReadmeEntrance pluginDetail={pluginDetail} className='px-0 pt-3' /> </div> <div className='grow overflow-y-auto'> <div className='px-4 py-2'> @@ -92,7 +98,7 @@ const EndpointModal: FC<Props> = ({ onChange={(v) => { setTempCredential(v) }} - formSchemas={formSchemas} + formSchemas={formSchemas as any} isEditMode={true} showOnVariableMap={{}} validating={false} diff --git a/web/app/components/plugins/plugin-detail-panel/index.tsx b/web/app/components/plugins/plugin-detail-panel/index.tsx index de248390f4..380d2329f6 100644 --- a/web/app/components/plugins/plugin-detail-panel/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/index.tsx @@ -1,15 +1,19 @@ 'use client' -import React from 'react' +import Drawer from '@/app/components/base/drawer' +import { PluginCategoryEnum, type PluginDetail } from '@/app/components/plugins/types' +import cn from '@/utils/classnames' import type { FC } from 'react' +import { useCallback, useEffect } from 'react' +import ActionList from './action-list' +import AgentStrategyList from './agent-strategy-list' +import DatasourceActionList from './datasource-action-list' import DetailHeader from './detail-header' import EndpointList from './endpoint-list' -import ActionList from './action-list' -import DatasourceActionList from './datasource-action-list' import ModelList from './model-list' -import AgentStrategyList from './agent-strategy-list' -import Drawer from '@/app/components/base/drawer' -import type { PluginDetail } from '@/app/components/plugins/types' -import cn from '@/utils/classnames' +import { SubscriptionList } from './subscription-list' +import { usePluginStore } from './store' +import { TriggerEventsList } from './trigger/event-list' +import { ReadmeEntrance } from '../readme-panel/entrance' type Props = { detail?: PluginDetail @@ -22,11 +26,24 @@ const PluginDetailPanel: FC<Props> = ({ onUpdate, onHide, }) => { - const handleUpdate = (isDelete = false) => { + const handleUpdate = useCallback((isDelete = false) => { if (isDelete) onHide() onUpdate() - } + }, [onHide, onUpdate]) + + const { setDetail } = usePluginStore() + + useEffect(() => { + setDetail(!detail ? undefined : { + plugin_id: detail.plugin_id, + provider: `${detail.plugin_id}/${detail.declaration.name}`, + plugin_unique_identifier: detail.plugin_unique_identifier || '', + declaration: detail.declaration, + name: detail.name, + id: detail.id, + }) + }, [detail]) if (!detail) return null @@ -43,17 +60,24 @@ const PluginDetailPanel: FC<Props> = ({ > {detail && ( <> - <DetailHeader - detail={detail} - onHide={onHide} - onUpdate={handleUpdate} - /> + <DetailHeader detail={detail} onUpdate={handleUpdate} onHide={onHide} /> <div className='grow overflow-y-auto'> - {!!detail.declaration.tool && <ActionList detail={detail} />} - {!!detail.declaration.agent_strategy && <AgentStrategyList detail={detail} />} - {!!detail.declaration.endpoint && <EndpointList detail={detail} />} - {!!detail.declaration.model && <ModelList detail={detail} />} - {!!detail.declaration.datasource && <DatasourceActionList detail={detail} />} + <div className='flex min-h-full flex-col'> + <div className='flex-1'> + {detail.declaration.category === PluginCategoryEnum.trigger && ( + <> + <SubscriptionList /> + <TriggerEventsList /> + </> + )} + {!!detail.declaration.tool && <ActionList detail={detail} />} + {!!detail.declaration.agent_strategy && <AgentStrategyList detail={detail} />} + {!!detail.declaration.endpoint && <EndpointList detail={detail} />} + {!!detail.declaration.model && <ModelList detail={detail} />} + {!!detail.declaration.datasource && <DatasourceActionList detail={detail} />} + </div> + <ReadmeEntrance pluginDetail={detail} className='mt-auto' /> + </div> </div> </> )} diff --git a/web/app/components/plugins/plugin-detail-panel/store.ts b/web/app/components/plugins/plugin-detail-panel/store.ts new file mode 100644 index 0000000000..931b08215d --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/store.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand' +import type { + ParametersSchema, + PluginDeclaration, + PluginDetail, + PluginTriggerSubscriptionConstructor, +} from '../types' + +type TriggerDeclarationSummary = { + subscription_schema?: ParametersSchema[] + subscription_constructor?: PluginTriggerSubscriptionConstructor | null +} + +export type SimpleDetail = Pick<PluginDetail, 'plugin_id' | 'name' | 'plugin_unique_identifier' | 'id'> & { + provider: string + declaration: Partial<Omit<PluginDeclaration, 'trigger'>> & { + trigger?: TriggerDeclarationSummary + } +} + +type Shape = { + detail: SimpleDetail | undefined + setDetail: (detail?: SimpleDetail) => void +} + +export const usePluginStore = create<Shape>(set => ({ + detail: undefined, + setDetail: (detail?: SimpleDetail) => set({ detail }), +})) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx new file mode 100644 index 0000000000..17a46febdf --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx @@ -0,0 +1,449 @@ +'use client' +// import { CopyFeedbackNew } from '@/app/components/base/copy-feedback' +import { EncryptedBottom } from '@/app/components/base/encrypted-bottom' +import { BaseForm } from '@/app/components/base/form/components/base' +import type { FormRefObject } from '@/app/components/base/form/types' +import { FormTypeEnum } from '@/app/components/base/form/types' +import Modal from '@/app/components/base/modal/modal' +import Toast from '@/app/components/base/toast' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers' +import { + useBuildTriggerSubscription, + useCreateTriggerSubscriptionBuilder, + useTriggerSubscriptionBuilderLogs, + useUpdateTriggerSubscriptionBuilder, + useVerifyTriggerSubscriptionBuilder, +} from '@/service/use-triggers' +import { parsePluginErrorMessage } from '@/utils/error-parser' +import { isPrivateOrLocalAddress } from '@/utils/urlValidation' +import { RiLoader2Line } from '@remixicon/react' +import { debounce } from 'lodash-es' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import LogViewer from '../log-viewer' +import { usePluginSubscriptionStore } from '../store' +import { usePluginStore } from '../../store' + +type Props = { + onClose: () => void + createType: SupportedCreationMethods + builder?: TriggerSubscriptionBuilder +} + +const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = { + [SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey, + [SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2, + [SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized, +} + +enum ApiKeyStep { + Verify = 'verify', + Configuration = 'configuration', +} + +const defaultFormValues = { values: {}, isCheckValidated: false } + +const normalizeFormType = (type: FormTypeEnum | string): FormTypeEnum => { + if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) + return type as FormTypeEnum + + switch (type) { + case 'string': + case 'text': + return FormTypeEnum.textInput + case 'password': + case 'secret': + return FormTypeEnum.secretInput + case 'number': + case 'integer': + return FormTypeEnum.textNumber + case 'boolean': + return FormTypeEnum.boolean + default: + return FormTypeEnum.textInput + } +} + +const StatusStep = ({ isActive, text }: { isActive: boolean, text: string }) => { + return <div className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive + ? 'text-state-accent-solid' + : 'text-text-tertiary'}`}> + {/* Active indicator dot */} + {isActive && ( + <div className='h-1 w-1 rounded-full bg-state-accent-solid'></div> + )} + {text} + </div> +} + +const MultiSteps = ({ currentStep }: { currentStep: ApiKeyStep }) => { + const { t } = useTranslation() + return <div className='mb-6 flex w-1/3 items-center gap-2'> + <StatusStep isActive={currentStep === ApiKeyStep.Verify} text={t('pluginTrigger.modal.steps.verify')} /> + <div className='h-px w-3 shrink-0 bg-divider-deep'></div> + <StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t('pluginTrigger.modal.steps.configuration')} /> + </div> +} + +export const CommonCreateModal = ({ onClose, createType, builder }: Props) => { + const { t } = useTranslation() + const detail = usePluginStore(state => state.detail) + const { refresh } = usePluginSubscriptionStore() + + const [currentStep, setCurrentStep] = useState<ApiKeyStep>(createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration) + + const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder) + const isInitializedRef = useRef(false) + + const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyTriggerSubscriptionBuilder() + const { mutateAsync: createBuilder /* isPending: isCreatingBuilder */ } = useCreateTriggerSubscriptionBuilder() + const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription() + const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder() + + const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || [] // manual + const manualPropertiesFormRef = React.useRef<FormRefObject>(null) + + const subscriptionFormRef = React.useRef<FormRefObject>(null) + + const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] // apikey and oauth + const autoCommonParametersFormRef = React.useRef<FormRefObject>(null) + + const rawApiKeyCredentialsSchema = detail?.declaration.trigger?.subscription_constructor?.credentials_schema || [] + const apiKeyCredentialsSchema = useMemo(() => { + return rawApiKeyCredentialsSchema.map(schema => ({ + ...schema, + tooltip: schema.help, + })) + }, [rawApiKeyCredentialsSchema]) + const apiKeyCredentialsFormRef = React.useRef<FormRefObject>(null) + + const { data: logData } = useTriggerSubscriptionBuilderLogs( + detail?.provider || '', + subscriptionBuilder?.id || '', + { + enabled: createType === SupportedCreationMethods.MANUAL, + refetchInterval: 3000, + }, + ) + + useEffect(() => { + const initializeBuilder = async () => { + isInitializedRef.current = true + try { + const response = await createBuilder({ + provider: detail?.provider || '', + credential_type: CREDENTIAL_TYPE_MAP[createType], + }) + setSubscriptionBuilder(response.subscription_builder) + } + catch (error) { + console.error('createBuilder error:', error) + Toast.notify({ + type: 'error', + message: t('pluginTrigger.modal.errors.createFailed'), + }) + } + } + if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider) + initializeBuilder() + }, [subscriptionBuilder, detail?.provider, createType, createBuilder, t]) + + useEffect(() => { + if (subscriptionBuilder?.endpoint && subscriptionFormRef.current && currentStep === ApiKeyStep.Configuration) { + const form = subscriptionFormRef.current.getForm() + if (form) + form.setFieldValue('callback_url', subscriptionBuilder.endpoint) + if (isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) { + console.log('isPrivateOrLocalAddress', isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) + subscriptionFormRef.current?.setFields([{ + name: 'callback_url', + warnings: [t('pluginTrigger.modal.form.callbackUrl.privateAddressWarning')], + }]) + } + else { + subscriptionFormRef.current?.setFields([{ + name: 'callback_url', + warnings: [], + }]) + } + } + }, [subscriptionBuilder?.endpoint, currentStep, t]) + + const debouncedUpdate = useMemo( + () => debounce((provider: string, builderId: string, properties: Record<string, any>) => { + updateBuilder( + { + provider, + subscriptionBuilderId: builderId, + properties, + }, + { + onError: (error: any) => { + console.error('Failed to update subscription builder:', error) + Toast.notify({ + type: 'error', + message: error?.message || t('pluginTrigger.modal.errors.updateFailed'), + }) + }, + }, + ) + }, 500), + [updateBuilder, t], + ) + + const handleManualPropertiesChange = useCallback(() => { + if (!subscriptionBuilder || !detail?.provider) + return + + const formValues = manualPropertiesFormRef.current?.getFormValues({ needCheckValidatedValues: false }) || { values: {}, isCheckValidated: true } + + debouncedUpdate(detail.provider, subscriptionBuilder.id, formValues.values) + }, [subscriptionBuilder, detail?.provider, debouncedUpdate]) + + useEffect(() => { + return () => { + debouncedUpdate.cancel() + } + }, [debouncedUpdate]) + + const handleVerify = () => { + const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || defaultFormValues + const credentials = apiKeyCredentialsFormValues.values + + if (!Object.keys(credentials).length) { + Toast.notify({ + type: 'error', + message: 'Please fill in all required credentials', + }) + return + } + + apiKeyCredentialsFormRef.current?.setFields([{ + name: Object.keys(credentials)[0], + errors: [], + }]) + + verifyCredentials( + { + provider: detail?.provider || '', + subscriptionBuilderId: subscriptionBuilder?.id || '', + credentials, + }, + { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.modal.apiKey.verify.success'), + }) + setCurrentStep(ApiKeyStep.Configuration) + }, + onError: async (error: any) => { + const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.modal.apiKey.verify.error') + apiKeyCredentialsFormRef.current?.setFields([{ + name: Object.keys(credentials)[0], + errors: [errorMessage], + }]) + }, + }, + ) + } + + const handleCreate = () => { + if (!subscriptionBuilder) { + Toast.notify({ + type: 'error', + message: 'Subscription builder not found', + }) + return + } + + const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({}) + if (!subscriptionFormValues?.isCheckValidated) + return + + const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string + + const params: BuildTriggerSubscriptionPayload = { + provider: detail?.provider || '', + subscriptionBuilderId: subscriptionBuilder.id, + name: subscriptionNameValue, + } + + if (createType !== SupportedCreationMethods.MANUAL) { + if (autoCommonParametersSchema.length > 0) { + const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || defaultFormValues + if (!autoCommonParametersFormValues?.isCheckValidated) + return + params.parameters = autoCommonParametersFormValues.values + } + } + else if (manualPropertiesSchema.length > 0) { + const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || defaultFormValues + if (!manualFormValues?.isCheckValidated) + return + } + + buildSubscription( + params, + { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.subscription.createSuccess'), + }) + onClose() + refresh?.() + }, + onError: async (error: any) => { + const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.subscription.createFailed') + Toast.notify({ + type: 'error', + message: errorMessage, + }) + }, + }, + ) + } + + const handleConfirm = () => { + if (currentStep === ApiKeyStep.Verify) + handleVerify() + else + handleCreate() + } + + const handleApiKeyCredentialsChange = () => { + apiKeyCredentialsFormRef.current?.setFields([{ + name: apiKeyCredentialsSchema[0].name, + errors: [], + }]) + } + + return ( + <Modal + title={t(`pluginTrigger.modal.${createType === SupportedCreationMethods.APIKEY ? 'apiKey' : createType.toLowerCase()}.title`)} + confirmButtonText={ + currentStep === ApiKeyStep.Verify + ? isVerifyingCredentials ? t('pluginTrigger.modal.common.verifying') : t('pluginTrigger.modal.common.verify') + : isBuilding ? t('pluginTrigger.modal.common.creating') : t('pluginTrigger.modal.common.create') + } + onClose={onClose} + onCancel={onClose} + onConfirm={handleConfirm} + disabled={isVerifyingCredentials || isBuilding} + bottomSlot={currentStep === ApiKeyStep.Verify ? <EncryptedBottom /> : null} + size={createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'} + containerClassName='min-h-[360px]' + clickOutsideNotClose + > + {createType === SupportedCreationMethods.APIKEY && <MultiSteps currentStep={currentStep} />} + {currentStep === ApiKeyStep.Verify && ( + <> + {apiKeyCredentialsSchema.length > 0 && ( + <div className='mb-4'> + <BaseForm + formSchemas={apiKeyCredentialsSchema} + ref={apiKeyCredentialsFormRef} + labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary' + preventDefaultSubmit={true} + formClassName='space-y-4' + onChange={handleApiKeyCredentialsChange} + /> + </div> + )} + </> + )} + {currentStep === ApiKeyStep.Configuration && <div className='max-h-[70vh]'> + <BaseForm + formSchemas={[ + { + name: 'subscription_name', + label: t('pluginTrigger.modal.form.subscriptionName.label'), + placeholder: t('pluginTrigger.modal.form.subscriptionName.placeholder'), + type: FormTypeEnum.textInput, + required: true, + }, + { + name: 'callback_url', + label: t('pluginTrigger.modal.form.callbackUrl.label'), + placeholder: t('pluginTrigger.modal.form.callbackUrl.placeholder'), + type: FormTypeEnum.textInput, + required: false, + default: subscriptionBuilder?.endpoint || '', + disabled: true, + tooltip: t('pluginTrigger.modal.form.callbackUrl.tooltip'), + showCopy: true, + }, + ]} + ref={subscriptionFormRef} + labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary' + formClassName='space-y-4 mb-4' + /> + {/* <div className='system-xs-regular mb-6 mt-[-1rem] text-text-tertiary'> + {t('pluginTrigger.modal.form.callbackUrl.description')} + </div> */} + {createType !== SupportedCreationMethods.MANUAL && autoCommonParametersSchema.length > 0 && ( + <BaseForm + formSchemas={autoCommonParametersSchema.map((schema) => { + const normalizedType = normalizeFormType(schema.type as FormTypeEnum | string) + return { + ...schema, + tooltip: schema.description, + type: normalizedType, + dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect ? { + plugin_id: detail?.plugin_id || '', + provider: detail?.provider || '', + action: 'provider', + parameter: schema.name, + credential_id: subscriptionBuilder?.id || '', + } : undefined, + fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined, + labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined, + } + })} + ref={autoCommonParametersFormRef} + labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary' + formClassName='space-y-4' + /> + )} + {createType === SupportedCreationMethods.MANUAL && <> + {manualPropertiesSchema.length > 0 && ( + <div className='mb-6'> + <BaseForm + formSchemas={manualPropertiesSchema.map(schema => ({ + ...schema, + tooltip: schema.description, + }))} + ref={manualPropertiesFormRef} + labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary' + formClassName='space-y-4' + onChange={handleManualPropertiesChange} + /> + </div> + )} + <div className='mb-6'> + <div className='mb-3 flex items-center gap-2'> + <div className='system-xs-medium-uppercase text-text-tertiary'> + {t('pluginTrigger.modal.manual.logs.title')} + </div> + <div className='h-px flex-1 bg-gradient-to-r from-divider-regular to-transparent' /> + </div> + + <div className='mb-1 flex items-center justify-center gap-1 rounded-lg bg-background-section p-3'> + <div className='h-3.5 w-3.5'> + <RiLoader2Line className='h-full w-full animate-spin' /> + </div> + <div className='system-xs-regular text-text-tertiary'> + {t('pluginTrigger.modal.manual.logs.loading', { pluginName: detail?.name || '' })} + </div> + </div> + <LogViewer logs={logData?.logs || []} /> + </div> + </>} + </div>} + </Modal> + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx new file mode 100644 index 0000000000..7515ba4b4a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx @@ -0,0 +1,242 @@ +import { ActionButton, ActionButtonState } from '@/app/components/base/action-button' +import Badge from '@/app/components/base/badge' +import { Button } from '@/app/components/base/button' +import type { Option } from '@/app/components/base/select/custom' +import CustomSelect from '@/app/components/base/select/custom' +import Toast from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { openOAuthPopup } from '@/hooks/use-oauth' +import { useInitiateTriggerOAuth, useTriggerOAuthConfig, useTriggerProviderInfo } from '@/service/use-triggers' +import cn from '@/utils/classnames' +import { RiAddLine, RiEqualizer2Line } from '@remixicon/react' +import { useBoolean } from 'ahooks' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { SupportedCreationMethods } from '../../../types' +import { usePluginStore } from '../../store' +import { useSubscriptionList } from '../use-subscription-list' +import { CommonCreateModal } from './common-modal' +import { OAuthClientSettingsModal } from './oauth-client' + +export enum CreateButtonType { + FULL_BUTTON = 'full-button', + ICON_BUTTON = 'icon-button', +} + +type Props = { + className?: string + buttonType?: CreateButtonType + shape?: 'square' | 'circle' +} + +const MAX_COUNT = 10 + +export const DEFAULT_METHOD = 'default' + +export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON, shape = 'square' }: Props) => { + const { t } = useTranslation() + const { subscriptions } = useSubscriptionList() + const subscriptionCount = subscriptions?.length || 0 + const [selectedCreateInfo, setSelectedCreateInfo] = useState<{ type: SupportedCreationMethods, builder?: TriggerSubscriptionBuilder } | null>(null) + + const detail = usePluginStore(state => state.detail) + + const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '') + const supportedMethods = providerInfo?.supported_creation_methods || [] + const { data: oauthConfig, refetch: refetchOAuthConfig } = useTriggerOAuthConfig(detail?.provider || '', supportedMethods.includes(SupportedCreationMethods.OAUTH)) + const { mutate: initiateOAuth } = useInitiateTriggerOAuth() + + const methodType = supportedMethods.length === 1 ? supportedMethods[0] : DEFAULT_METHOD + + const [isShowClientSettingsModal, { + setTrue: showClientSettingsModal, + setFalse: hideClientSettingsModal, + }] = useBoolean(false) + + const buttonTextMap = useMemo(() => { + return { + [SupportedCreationMethods.OAUTH]: t('pluginTrigger.subscription.createButton.oauth'), + [SupportedCreationMethods.APIKEY]: t('pluginTrigger.subscription.createButton.apiKey'), + [SupportedCreationMethods.MANUAL]: t('pluginTrigger.subscription.createButton.manual'), + [DEFAULT_METHOD]: t('pluginTrigger.subscription.empty.button'), + } + }, [t]) + + const onClickClientSettings = (e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => { + e.stopPropagation() + e.preventDefault() + showClientSettingsModal() + } + + const allOptions = useMemo(() => { + const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured + + return [ + { + value: SupportedCreationMethods.OAUTH, + label: t('pluginTrigger.subscription.addType.options.oauth.title'), + tag: !showCustomBadge ? null : <Badge className='ml-1 mr-0.5'> + {t('plugin.auth.custom')} + </Badge>, + extra: <Tooltip popupContent={t('pluginTrigger.subscription.addType.options.oauth.clientSettings')}> + <ActionButton onClick={onClickClientSettings}> + <RiEqualizer2Line className='h-4 w-4 text-text-tertiary' /> + </ActionButton> + </Tooltip>, + show: supportedMethods.includes(SupportedCreationMethods.OAUTH), + }, + { + value: SupportedCreationMethods.APIKEY, + label: t('pluginTrigger.subscription.addType.options.apikey.title'), + show: supportedMethods.includes(SupportedCreationMethods.APIKEY), + }, + { + value: SupportedCreationMethods.MANUAL, + label: t('pluginTrigger.subscription.addType.options.manual.description'), + extra: <Tooltip popupContent={t('pluginTrigger.subscription.addType.options.manual.tip')} />, + show: supportedMethods.includes(SupportedCreationMethods.MANUAL), + }, + ] + }, [t, oauthConfig, supportedMethods, methodType]) + + const onChooseCreateType = async (type: SupportedCreationMethods) => { + if (type === SupportedCreationMethods.OAUTH) { + if (oauthConfig?.configured) { + initiateOAuth(detail?.provider || '', { + onSuccess: (response) => { + openOAuthPopup(response.authorization_url, (callbackData) => { + if (callbackData) { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.modal.oauth.authorization.authSuccess'), + }) + setSelectedCreateInfo({ type: SupportedCreationMethods.OAUTH, builder: response.subscription_builder }) + } + }) + }, + onError: () => { + Toast.notify({ + type: 'error', + message: t('pluginTrigger.modal.oauth.authorization.authFailed'), + }) + }, + }) + } + else { + showClientSettingsModal() + } + } + else { + setSelectedCreateInfo({ type }) + } + } + + const onClickCreate = (e: React.MouseEvent<HTMLButtonElement>) => { + if (subscriptionCount >= MAX_COUNT) { + e.stopPropagation() + return + } + + if (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) + return + + e.stopPropagation() + e.preventDefault() + onChooseCreateType(methodType) + } + + if (!supportedMethods.length) + return null + + return <> + <CustomSelect<Option & { show: boolean; extra?: React.ReactNode; tag?: React.ReactNode }> + options={allOptions.filter(option => option.show)} + value={methodType} + onChange={value => onChooseCreateType(value as any)} + containerProps={{ + open: (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) ? undefined : false, + placement: 'bottom-start', + offset: 4, + triggerPopupSameWidth: buttonType === CreateButtonType.FULL_BUTTON, + }} + triggerProps={{ + className: cn('h-8 bg-transparent px-0 hover:bg-transparent', methodType !== DEFAULT_METHOD && supportedMethods.length > 1 && 'pointer-events-none', buttonType === CreateButtonType.FULL_BUTTON && 'grow'), + }} + popupProps={{ + wrapperClassName: 'z-[1000]', + }} + CustomTrigger={() => { + return buttonType === CreateButtonType.FULL_BUTTON ? ( + <Button + variant='primary' + size='medium' + className='flex w-full items-center justify-between px-0' + onClick={onClickCreate} + > + <div className='flex flex-1 items-center justify-center'> + <RiAddLine className='mr-2 size-4' /> + {buttonTextMap[methodType]} + {methodType === SupportedCreationMethods.OAUTH && oauthConfig?.custom_enabled && oauthConfig?.custom_configured && <Badge + className='ml-1 mr-0.5 border-text-primary-on-surface bg-components-badge-bg-dimm text-text-primary-on-surface' + > + {t('plugin.auth.custom')} + </Badge>} + </div> + {methodType === SupportedCreationMethods.OAUTH + && <div className='ml-auto flex items-center'> + <div className="h-4 w-px bg-text-primary-on-surface opacity-15" /> + <Tooltip popupContent={t('pluginTrigger.subscription.addType.options.oauth.clientSettings')}> + <div onClick={onClickClientSettings} className='p-2'> + <RiEqualizer2Line className='size-4 text-components-button-primary-text' /> + </div> + </Tooltip> + </div> + } + </Button> + ) : ( + <Tooltip + popupContent={subscriptionCount >= MAX_COUNT ? t('pluginTrigger.subscription.maxCount', { num: MAX_COUNT }) : t(`pluginTrigger.subscription.addType.options.${methodType.toLowerCase()}.description`)} + disabled={!(supportedMethods?.length === 1 || subscriptionCount >= MAX_COUNT)}> + <ActionButton + onClick={onClickCreate} + className={cn( + 'float-right', + shape === 'circle' && '!rounded-full border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg-hover text-components-button-secondary-accent-text shadow-xs hover:border-components-button-secondary-border-disabled hover:bg-components-button-secondary-bg-disabled hover:text-components-button-secondary-accent-text-disabled', + )} + state={subscriptionCount >= MAX_COUNT ? ActionButtonState.Disabled : ActionButtonState.Default} + > + <RiAddLine className='size-4' /> + </ActionButton> + </Tooltip> + ) + }} + CustomOption={option => ( + <> + <div className='mr-8 flex grow items-center gap-1 truncate px-1'> + {option.label} + {option.tag} + </div> + {option.extra} + </> + )} + /> + {selectedCreateInfo && ( + <CommonCreateModal + createType={selectedCreateInfo.type} + builder={selectedCreateInfo.builder} + onClose={() => setSelectedCreateInfo(null)} + /> + )} + {isShowClientSettingsModal && ( + <OAuthClientSettingsModal + oauthConfig={oauthConfig} + onClose={() => { + hideClientSettingsModal() + refetchOAuthConfig() + }} + showOAuthCreateModal={builder => setSelectedCreateInfo({ type: SupportedCreationMethods.OAUTH, builder })} + /> + )} + </> +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx new file mode 100644 index 0000000000..ef182a70aa --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx @@ -0,0 +1,257 @@ +'use client' +import Button from '@/app/components/base/button' +import { BaseForm } from '@/app/components/base/form/components/base' +import type { FormRefObject } from '@/app/components/base/form/types' +import Modal from '@/app/components/base/modal/modal' +import Toast from '@/app/components/base/toast' +import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' +import { openOAuthPopup } from '@/hooks/use-oauth' +import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers' +import { + useConfigureTriggerOAuth, + useDeleteTriggerOAuth, + useInitiateTriggerOAuth, + useVerifyTriggerSubscriptionBuilder, +} from '@/service/use-triggers' +import { + RiClipboardLine, + RiInformation2Fill, +} from '@remixicon/react' +import React, { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePluginStore } from '../../store' + +type Props = { + oauthConfig?: TriggerOAuthConfig + onClose: () => void + showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void +} + +enum AuthorizationStatusEnum { + Pending = 'pending', + Success = 'success', + Failed = 'failed', +} + +enum ClientTypeEnum { + Default = 'default', + Custom = 'custom', +} + +export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreateModal }: Props) => { + const { t } = useTranslation() + const detail = usePluginStore(state => state.detail) + const { system_configured, params, oauth_client_schema } = oauthConfig || {} + const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>() + const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>() + + const [clientType, setClientType] = useState<ClientTypeEnum>(system_configured ? ClientTypeEnum.Default : ClientTypeEnum.Custom) + + const clientFormRef = React.useRef<FormRefObject>(null) + + const oauthClientSchema = useMemo(() => { + if (oauth_client_schema && oauth_client_schema.length > 0 && params) { + const oauthConfigPramaKeys = Object.keys(params || {}) + for (const schema of oauth_client_schema) { + if (oauthConfigPramaKeys.includes(schema.name)) + schema.default = params?.[schema.name] + } + return oauth_client_schema + } + return [] + }, [oauth_client_schema, params]) + + const providerName = detail?.provider || '' + const { mutate: initiateOAuth } = useInitiateTriggerOAuth() + const { mutate: verifyBuilder } = useVerifyTriggerSubscriptionBuilder() + const { mutate: configureOAuth } = useConfigureTriggerOAuth() + const { mutate: deleteOAuth } = useDeleteTriggerOAuth() + + const handleAuthorization = () => { + setAuthorizationStatus(AuthorizationStatusEnum.Pending) + initiateOAuth(providerName, { + onSuccess: (response) => { + setSubscriptionBuilder(response.subscription_builder) + openOAuthPopup(response.authorization_url, (callbackData) => { + if (callbackData) { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.modal.oauth.authorization.authSuccess'), + }) + onClose() + showOAuthCreateModal(response.subscription_builder) + } + }) + }, + onError: () => { + setAuthorizationStatus(AuthorizationStatusEnum.Failed) + Toast.notify({ + type: 'error', + message: t('pluginTrigger.modal.oauth.authorization.authFailed'), + }) + }, + }) + } + + useEffect(() => { + if (providerName && subscriptionBuilder && authorizationStatus === AuthorizationStatusEnum.Pending) { + const pollInterval = setInterval(() => { + verifyBuilder( + { + provider: providerName, + subscriptionBuilderId: subscriptionBuilder.id, + }, + { + onSuccess: (response) => { + if (response.verified) { + setAuthorizationStatus(AuthorizationStatusEnum.Success) + clearInterval(pollInterval) + } + }, + onError: () => { + // Continue polling - auth might still be in progress + }, + }, + ) + }, 3000) + + return () => clearInterval(pollInterval) + } + }, [subscriptionBuilder, authorizationStatus, verifyBuilder, providerName, t]) + + const handleRemove = () => { + deleteOAuth(providerName, { + onSuccess: () => { + onClose() + Toast.notify({ + type: 'success', + message: t('pluginTrigger.modal.oauth.remove.success'), + }) + }, + onError: (error: any) => { + Toast.notify({ + type: 'error', + message: error?.message || t('pluginTrigger.modal.oauth.remove.failed'), + }) + }, + }) + } + + const handleSave = (needAuth: boolean) => { + const isCustom = clientType === ClientTypeEnum.Custom + const params: ConfigureTriggerOAuthPayload = { + provider: providerName, + enabled: isCustom, + } + + if (isCustom) { + const clientFormValues = clientFormRef.current?.getFormValues({}) as { values: TriggerOAuthClientParams, isCheckValidated: boolean } + if (!clientFormValues.isCheckValidated) + return + const clientParams = clientFormValues.values + if (clientParams.client_id === oauthConfig?.params.client_id) + clientParams.client_id = '[__HIDDEN__]' + + if (clientParams.client_secret === oauthConfig?.params.client_secret) + clientParams.client_secret = '[__HIDDEN__]' + + params.client_params = clientParams + } + + configureOAuth(params, { + onSuccess: () => { + if (needAuth) { + handleAuthorization() + } + else { + onClose() + Toast.notify({ + type: 'success', + message: t('pluginTrigger.modal.oauth.save.success'), + }) + } + }, + }) + } + + return ( + <Modal + title={t('pluginTrigger.modal.oauth.title')} + confirmButtonText={authorizationStatus === AuthorizationStatusEnum.Pending ? t('pluginTrigger.modal.common.authorizing') + : authorizationStatus === AuthorizationStatusEnum.Success ? t('pluginTrigger.modal.oauth.authorization.waitingJump') : t('plugin.auth.saveAndAuth')} + cancelButtonText={t('plugin.auth.saveOnly')} + extraButtonText={t('common.operation.cancel')} + showExtraButton + clickOutsideNotClose + extraButtonVariant='secondary' + onExtraButtonClick={onClose} + onClose={onClose} + onCancel={() => handleSave(false)} + onConfirm={() => handleSave(true)} + footerSlot={ + oauthConfig?.custom_enabled && oauthConfig?.params && clientType === ClientTypeEnum.Custom && ( + <div className='grow'> + <Button + variant='secondary' + className='text-components-button-destructive-secondary-text' + // disabled={disabled || doingAction || !editValues} + onClick={handleRemove} + > + {t('common.operation.remove')} + </Button> + </div> + ) + } + > + <div className='system-sm-medium mb-2 text-text-secondary'>{t('pluginTrigger.subscription.addType.options.oauth.clientTitle')}</div> + {oauthConfig?.system_configured && <div className='mb-4 flex w-full items-start justify-between gap-2'> + {[ClientTypeEnum.Default, ClientTypeEnum.Custom].map(option => ( + <OptionCard + key={option} + title={t(`pluginTrigger.subscription.addType.options.oauth.${option}`)} + onSelect={() => setClientType(option)} + selected={clientType === option} + className="flex-1" + /> + ))} + </div>} + {clientType === ClientTypeEnum.Custom && oauthConfig?.redirect_uri && ( + <div className='mb-4 flex items-start gap-3 rounded-xl bg-background-section-burn p-4'> + <div className='rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg p-2 shadow-xs shadow-shadow-shadow-3'> + <RiInformation2Fill className='h-5 w-5 shrink-0 text-text-accent' /> + </div> + <div className='flex-1 text-text-secondary'> + <div className='system-sm-regular whitespace-pre-wrap leading-4'> + {t('pluginTrigger.modal.oauthRedirectInfo')} + </div> + <div className='system-sm-medium my-1.5 break-all leading-4'> + {oauthConfig.redirect_uri} + </div> + <Button + variant='secondary' + size='small' + onClick={() => { + navigator.clipboard.writeText(oauthConfig.redirect_uri) + Toast.notify({ + type: 'success', + message: t('common.actionMsg.copySuccessfully'), + }) + }}> + <RiClipboardLine className='mr-1 h-[14px] w-[14px]' /> + {t('common.operation.copy')} + </Button> + </div> + </div> + )} + {clientType === ClientTypeEnum.Custom && oauthClientSchema.length > 0 && ( + <BaseForm + formSchemas={oauthClientSchema} + ref={clientFormRef} + labelClassName='system-sm-medium mb-2 block text-text-secondary' + formClassName='space-y-4' + /> + )} + </Modal > + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx new file mode 100644 index 0000000000..178983c6b1 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx @@ -0,0 +1,75 @@ +import Confirm from '@/app/components/base/confirm' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { useDeleteTriggerSubscription } from '@/service/use-triggers' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePluginSubscriptionStore } from './store' + +type Props = { + onClose: (deleted: boolean) => void + isShow: boolean + currentId: string + currentName: string + workflowsInUse: number +} + +const tPrefix = 'pluginTrigger.subscription.list.item.actions.deleteConfirm' + +export const DeleteConfirm = (props: Props) => { + const { onClose, isShow, currentId, currentName, workflowsInUse } = props + const { refresh } = usePluginSubscriptionStore() + const { mutate: deleteSubscription, isPending: isDeleting } = useDeleteTriggerSubscription() + const { t } = useTranslation() + const [inputName, setInputName] = useState('') + + const onConfirm = () => { + if (workflowsInUse > 0 && inputName !== currentName) { + Toast.notify({ + type: 'error', + message: t(`${tPrefix}.confirmInputWarning`), + // temporarily + className: 'z-[10000001]', + }) + return + } + deleteSubscription(currentId, { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t(`${tPrefix}.success`, { name: currentName }), + className: 'z-[10000001]', + }) + refresh?.() + onClose(true) + }, + onError: (error: any) => { + Toast.notify({ + type: 'error', + message: error?.message || t(`${tPrefix}.error`, { name: currentName }), + className: 'z-[10000001]', + }) + }, + }) + } + return <Confirm + title={t(`${tPrefix}.title`, { name: currentName })} + confirmText={t(`${tPrefix}.confirm`)} + content={workflowsInUse > 0 ? <> + {t(`${tPrefix}.contentWithApps`, { count: workflowsInUse })} + <div className='system-sm-medium mb-2 mt-6 text-text-secondary'>{t(`${tPrefix}.confirmInputTip`, { name: currentName })}</div> + <Input + value={inputName} + onChange={e => setInputName(e.target.value)} + placeholder={t(`${tPrefix}.confirmInputPlaceholder`, { name: currentName })} + /> + </> + : t(`${tPrefix}.content`)} + isShow={isShow} + isLoading={isDeleting} + isDisabled={isDeleting} + onConfirm={onConfirm} + onCancel={() => onClose(false)} + maskClosable={false} + /> +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx new file mode 100644 index 0000000000..8acb8f40df --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx @@ -0,0 +1,51 @@ +import { withErrorBoundary } from '@/app/components/base/error-boundary' +import Loading from '@/app/components/base/loading' +import { SubscriptionListView } from './list-view' +import { SubscriptionSelectorView } from './selector-view' +import { useSubscriptionList } from './use-subscription-list' + +export enum SubscriptionListMode { + PANEL = 'panel', + SELECTOR = 'selector', +} + +export type SimpleSubscription = { + id: string, + name: string +} + +type SubscriptionListProps = { + mode?: SubscriptionListMode + selectedId?: string + onSelect?: (v: SimpleSubscription, callback?: () => void) => void +} + +export { SubscriptionSelectorEntry } from './selector-entry' + +export const SubscriptionList = withErrorBoundary(({ + mode = SubscriptionListMode.PANEL, + selectedId, + onSelect, +}: SubscriptionListProps) => { + const { isLoading, refetch } = useSubscriptionList() + if (isLoading) { + return ( + <div className='flex items-center justify-center py-4'> + <Loading /> + </div> + ) + } + + if (mode === SubscriptionListMode.SELECTOR) { + return ( + <SubscriptionSelectorView + selectedId={selectedId} + onSelect={(v) => { + onSelect?.(v, refetch) + }} + /> + ) + } + + return <SubscriptionListView /> +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx new file mode 100644 index 0000000000..a64d2f4070 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx @@ -0,0 +1,50 @@ +'use client' +import Tooltip from '@/app/components/base/tooltip' +import cn from '@/utils/classnames' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { CreateButtonType, CreateSubscriptionButton } from './create' +import SubscriptionCard from './subscription-card' +import { useSubscriptionList } from './use-subscription-list' + +type SubscriptionListViewProps = { + showTopBorder?: boolean +} + +export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({ + showTopBorder = false, +}) => { + const { t } = useTranslation() + const { subscriptions } = useSubscriptionList() + + const subscriptionCount = subscriptions?.length || 0 + + return ( + <div className={cn('border-divider-subtle px-4 py-2', showTopBorder && 'border-t')}> + <div className='relative flex items-center justify-between'> + {subscriptionCount > 0 && ( + <div className='flex h-8 shrink-0 items-center gap-1'> + <span className='system-sm-semibold-uppercase text-text-secondary'> + {t('pluginTrigger.subscription.listNum', { num: subscriptionCount })} + </span> + <Tooltip popupContent={t('pluginTrigger.subscription.list.tip')} /> + </div> + )} + <CreateSubscriptionButton + buttonType={subscriptionCount > 0 ? CreateButtonType.ICON_BUTTON : CreateButtonType.FULL_BUTTON} + /> + </div> + + {subscriptionCount > 0 && ( + <div className='flex flex-col gap-1'> + {subscriptions?.map(subscription => ( + <SubscriptionCard + key={subscription.id} + data={subscription} + /> + ))} + </div> + )} + </div> + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx new file mode 100644 index 0000000000..8b16d2c60a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.tsx @@ -0,0 +1,193 @@ +'use client' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiArrowDownSLine, + RiArrowRightSLine, + RiCheckboxCircleFill, + RiErrorWarningFill, + RiFileCopyLine, +} from '@remixicon/react' +import cn from '@/utils/classnames' +import Toast from '@/app/components/base/toast' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import type { TriggerLogEntity } from '@/app/components/workflow/block-selector/types' +import dayjs from 'dayjs' + +type Props = { + logs: TriggerLogEntity[] + className?: string +} + +enum LogTypeEnum { + REQUEST = 'request', + RESPONSE = 'response', +} + +const LogViewer = ({ logs, className }: Props) => { + const { t } = useTranslation() + const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set()) + + const toggleLogExpansion = (logId: string) => { + const newExpanded = new Set(expandedLogs) + if (newExpanded.has(logId)) + newExpanded.delete(logId) + else + newExpanded.add(logId) + + setExpandedLogs(newExpanded) + } + + const parseRequestData = (data: any) => { + if (typeof data === 'string' && data.startsWith('payload=')) { + try { + const urlDecoded = decodeURIComponent(data.substring(8)) // Remove 'payload=' + return JSON.parse(urlDecoded) + } + catch { + return data + } + } + + if (typeof data === 'object') + return data + + try { + return JSON.parse(data) + } + catch { + return data + } + } + + const renderJsonContent = (originalData: any, title: LogTypeEnum) => { + const parsedData = title === LogTypeEnum.REQUEST ? { headers: originalData.headers, data: parseRequestData(originalData.data) } : originalData + const isJsonObject = typeof parsedData === 'object' + + if (isJsonObject) { + return ( + <CodeEditor + readOnly + title={<div className="system-xs-semibold-uppercase text-text-secondary">{title}</div>} + language={CodeLanguage.json} + value={parsedData} + isJSONStringifyBeauty + nodeId="" + /> + ) + } + + return ( + <div className='rounded-md bg-components-input-bg-normal'> + <div className='flex items-center justify-between px-2 py-1'> + <div className='system-xs-semibold-uppercase text-text-secondary'> + {title} + </div> + <button + onClick={(e) => { + e.stopPropagation() + navigator.clipboard.writeText(String(parsedData)) + Toast.notify({ + type: 'success', + message: t('common.actionMsg.copySuccessfully'), + }) + }} + className='rounded-md p-0.5 hover:bg-components-panel-border' + > + <RiFileCopyLine className='h-4 w-4 text-text-tertiary' /> + </button> + </div> + <div className='px-2 pb-2 pt-1'> + <pre className='code-xs-regular whitespace-pre-wrap break-all text-text-secondary'> + {String(parsedData)} + </pre> + </div> + </div> + ) + } + + if (!logs || logs.length === 0) + return null + + return ( + <div className={cn('flex flex-col gap-1', className)}> + {logs.map((log, index) => { + const logId = log.id || index.toString() + const isExpanded = expandedLogs.has(logId) + const isSuccess = log.response.status_code === 200 + const isError = log.response.status_code >= 400 + + return ( + <div + key={logId} + className={cn( + 'relative overflow-hidden rounded-lg border bg-components-panel-on-panel-item-bg shadow-sm hover:bg-components-panel-on-panel-item-bg-hover', + isError && 'border-state-destructive-border', + !isError && isExpanded && 'border-components-panel-border', + !isError && !isExpanded && 'border-components-panel-border-subtle', + )} + > + {isError && ( + <div className='pointer-events-none absolute left-0 top-0 h-7 w-[179px]'> + <svg xmlns="http://www.w3.org/2000/svg" width="179" height="28" viewBox="0 0 179 28" fill="none" className='h-full w-full'> + <g filter="url(#filter0_f_error_glow)"> + <circle cx="27" cy="14" r="32" fill="#F04438" fillOpacity="0.25" /> + </g> + <defs> + <filter id="filter0_f_error_glow" x="-125" y="-138" width="304" height="304" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> + <feFlood floodOpacity="0" result="BackgroundImageFix" /> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> + <feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur" /> + </filter> + </defs> + </svg> + </div> + )} + + <button + onClick={() => toggleLogExpansion(logId)} + className={cn( + 'flex w-full items-center justify-between px-2 py-1.5 text-left', + isExpanded ? 'pb-1 pt-2' : 'min-h-7', + )} + > + <div className='flex items-center gap-0'> + {isExpanded ? ( + <RiArrowDownSLine className='h-4 w-4 text-text-tertiary' /> + ) : ( + <RiArrowRightSLine className='h-4 w-4 text-text-tertiary' /> + )} + <div className='system-xs-semibold-uppercase text-text-secondary'> + {t(`pluginTrigger.modal.manual.logs.${LogTypeEnum.REQUEST}`)} #{index + 1} + </div> + </div> + + <div className='flex items-center gap-1'> + <div className='system-xs-regular text-text-tertiary'> + {dayjs(log.created_at).format('HH:mm:ss')} + </div> + <div className='h-3.5 w-3.5'> + {isSuccess ? ( + <RiCheckboxCircleFill className='h-full w-full text-text-success' /> + ) : ( + <RiErrorWarningFill className='h-full w-full text-text-destructive' /> + )} + </div> + </div> + </button> + + {isExpanded && ( + <div className='flex flex-col gap-1 px-1 pb-1'> + {renderJsonContent(log.request, LogTypeEnum.REQUEST)} + {renderJsonContent(log.response, LogTypeEnum.RESPONSE)} + </div> + )} + </div> + ) + })} + </div> + ) +} + +export default LogViewer diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx new file mode 100644 index 0000000000..c23e022ac5 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx @@ -0,0 +1,126 @@ +'use client' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list' +import { SubscriptionList, SubscriptionListMode } from '@/app/components/plugins/plugin-detail-panel/subscription-list' +import cn from '@/utils/classnames' +import { RiArrowDownSLine, RiWebhookLine } from '@remixicon/react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useSubscriptionList } from './use-subscription-list' + +type SubscriptionTriggerButtonProps = { + selectedId?: string + onClick?: () => void + isOpen?: boolean + className?: string +} + +const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({ + selectedId, + onClick, + isOpen = false, + className, +}) => { + const { t } = useTranslation() + const { subscriptions } = useSubscriptionList() + + const statusConfig = useMemo(() => { + if (!selectedId) { + if (isOpen) { + return { + label: t('pluginTrigger.subscription.selectPlaceholder'), + color: 'yellow' as const, + } + } + return { + label: t('pluginTrigger.subscription.noSubscriptionSelected'), + color: 'red' as const, + } + } + + if (subscriptions && subscriptions.length > 0) { + const selectedSubscription = subscriptions?.find(sub => sub.id === selectedId) + + if (!selectedSubscription) { + return { + label: t('pluginTrigger.subscription.subscriptionRemoved'), + color: 'red' as const, + } + } + + return { + label: selectedSubscription.name, + color: 'green' as const, + } + } + + return { + label: t('pluginTrigger.subscription.noSubscriptionSelected'), + color: 'red' as const, + } + }, [selectedId, subscriptions, t, isOpen]) + + return ( + <button + className={cn( + 'flex h-8 items-center gap-1 rounded-lg px-2 transition-colors', + 'hover:bg-state-base-hover-alt', + isOpen && 'bg-state-base-hover-alt', + className, + )} + onClick={onClick} + > + <RiWebhookLine className={cn('h-3.5 w-3.5 shrink-0 text-text-secondary', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')} /> + <span className={cn('system-xs-medium truncate text-components-button-ghost-text', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')}> + {statusConfig.label} + </span> + <RiArrowDownSLine + className={cn( + 'ml-auto h-4 w-4 shrink-0 text-text-quaternary transition-transform', + isOpen && 'rotate-180', + statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text', + )} + /> + </button> + ) +} + +export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: { + selectedId?: string, + onSelect: (v: SimpleSubscription, callback?: () => void) => void +}) => { + const [isOpen, setIsOpen] = useState(false) + + return <PortalToFollowElem + placement='bottom-start' + offset={4} + open={isOpen} + onOpenChange={setIsOpen} + > + <PortalToFollowElemTrigger asChild> + <div> + <SubscriptionTriggerButton + selectedId={selectedId} + onClick={() => setIsOpen(!isOpen)} + isOpen={isOpen} + /> + </div> + </PortalToFollowElemTrigger> + <PortalToFollowElemContent className='z-[11]'> + <div className='rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'> + <SubscriptionList + mode={SubscriptionListMode.SELECTOR} + selectedId={selectedId} + onSelect={(...args) => { + onSelect(...args) + setIsOpen(false) + }} + /> + </div> + </PortalToFollowElemContent> + </PortalToFollowElem> +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx new file mode 100644 index 0000000000..04b078e347 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx @@ -0,0 +1,90 @@ +'use client' +import ActionButton from '@/app/components/base/action-button' +import Tooltip from '@/app/components/base/tooltip' +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import cn from '@/utils/classnames' +import { RiCheckLine, RiDeleteBinLine, RiWebhookLine } from '@remixicon/react' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { CreateButtonType, CreateSubscriptionButton } from './create' +import { DeleteConfirm } from './delete-confirm' +import { useSubscriptionList } from './use-subscription-list' + +type SubscriptionSelectorProps = { + selectedId?: string + onSelect?: ({ id, name }: { id: string, name: string }) => void +} + +export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({ + selectedId, + onSelect, +}) => { + const { t } = useTranslation() + const { subscriptions } = useSubscriptionList() + const [deletedSubscription, setDeletedSubscription] = useState<TriggerSubscription | null>(null) + const subscriptionCount = subscriptions?.length || 0 + + return ( + <div className='w-[320px] p-1'> + {subscriptionCount > 0 && <div className='ml-7 mr-1.5 flex h-8 items-center justify-between'> + <div className='flex shrink-0 items-center gap-1'> + <span className='system-sm-semibold-uppercase text-text-secondary'> + {t('pluginTrigger.subscription.listNum', { num: subscriptionCount })} + </span> + <Tooltip popupContent={t('pluginTrigger.subscription.list.tip')} /> + </div> + <CreateSubscriptionButton + buttonType={CreateButtonType.ICON_BUTTON} + shape='circle' + /> + </div>} + <div className='max-h-[320px] overflow-y-auto'> + {subscriptions?.map(subscription => ( + <div + key={subscription.id} + className={cn( + 'group flex w-full items-center justify-between rounded-lg p-1 text-left transition-colors', + 'hover:bg-state-base-hover has-[.subscription-delete-btn:hover]:!bg-state-destructive-hover', + selectedId === subscription.id && 'bg-state-base-hover', + )} + > + <button + type='button' + className='flex flex-1 items-center text-left' + onClick={() => onSelect?.(subscription)} + > + <div className='flex items-center'> + {selectedId === subscription.id && ( + <RiCheckLine className='mr-2 h-4 w-4 shrink-0 text-text-accent' /> + )} + <RiWebhookLine className={cn('mr-1.5 h-3.5 w-3.5 text-text-secondary', selectedId !== subscription.id && 'ml-6')} /> + <span className='system-md-regular leading-6 text-text-secondary'> + {subscription.name} + </span> + </div> + </button> + <ActionButton onClick={(e) => { + e.stopPropagation() + setDeletedSubscription(subscription) + }} className='subscription-delete-btn hidden shrink-0 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive group-hover:flex'> + <RiDeleteBinLine className='size-4' /> + </ActionButton> + </div> + ))} + </div> + {deletedSubscription && ( + <DeleteConfirm + onClose={(deleted) => { + if (deleted) + onSelect?.({ id: '', name: '' }) + setDeletedSubscription(null) + }} + isShow={!!deletedSubscription} + currentId={deletedSubscription.id} + currentName={deletedSubscription.name} + workflowsInUse={deletedSubscription.workflows_in_use} + /> + )} + </div> + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/store.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/store.ts new file mode 100644 index 0000000000..24840e9971 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/store.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand' + +type ShapeSubscription = { + refresh?: () => void + setRefresh: (refresh: () => void) => void +} + +export const usePluginSubscriptionStore = create<ShapeSubscription>(set => ({ + refresh: undefined, + setRefresh: (refresh: () => void) => set({ refresh }), +})) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx new file mode 100644 index 0000000000..b2a86b5c76 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx @@ -0,0 +1,85 @@ +'use client' +import ActionButton from '@/app/components/base/action-button' +import Tooltip from '@/app/components/base/tooltip' +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import cn from '@/utils/classnames' +import { + RiDeleteBinLine, + RiWebhookLine, +} from '@remixicon/react' +import { useBoolean } from 'ahooks' +import { useTranslation } from 'react-i18next' +import { DeleteConfirm } from './delete-confirm' + +type Props = { + data: TriggerSubscription +} + +const SubscriptionCard = ({ data }: Props) => { + const { t } = useTranslation() + const [isShowDeleteModal, { + setTrue: showDeleteModal, + setFalse: hideDeleteModal, + }] = useBoolean(false) + + return ( + <> + <div + className={cn( + 'group relative cursor-pointer rounded-lg border-[0.5px] px-4 py-3 shadow-xs transition-all', + 'border-components-panel-border-subtle bg-components-panel-on-panel-item-bg', + 'hover:bg-components-panel-on-panel-item-bg-hover', + 'has-[.subscription-delete-btn:hover]:!border-state-destructive-border has-[.subscription-delete-btn:hover]:!bg-state-destructive-hover', + )} + > + <div className='flex items-center justify-between'> + <div className='flex h-6 items-center gap-1'> + <RiWebhookLine className='h-4 w-4 text-text-secondary' /> + <span className='system-md-semibold text-text-secondary'> + {data.name} + </span> + </div> + + <ActionButton + onClick={showDeleteModal} + className='subscription-delete-btn hidden transition-colors hover:bg-state-destructive-hover hover:text-text-destructive group-hover:block' + > + <RiDeleteBinLine className='h-4 w-4' /> + </ActionButton> + </div> + + <div className='mt-1 flex items-center justify-between'> + <Tooltip + disabled={!data.endpoint} + popupContent={data.endpoint && ( + <div className='max-w-[320px] break-all'> + {data.endpoint} + </div> + )} + position='left' + > + <div className='system-xs-regular flex-1 truncate text-text-tertiary'> + {data.endpoint} + </div> + </Tooltip> + <div className="mx-2 text-xs text-text-tertiary opacity-30">·</div> + <div className='system-xs-regular shrink-0 text-text-tertiary'> + {data.workflows_in_use > 0 ? t('pluginTrigger.subscription.list.item.usedByNum', { num: data.workflows_in_use }) : t('pluginTrigger.subscription.list.item.noUsed')} + </div> + </div> + </div> + + {isShowDeleteModal && ( + <DeleteConfirm + onClose={hideDeleteModal} + isShow={isShowDeleteModal} + currentId={data.id} + currentName={data.name} + workflowsInUse={data.workflows_in_use} + /> + )} + </> + ) +} + +export default SubscriptionCard diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts new file mode 100644 index 0000000000..ff3e903a31 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react' +import { useTriggerSubscriptions } from '@/service/use-triggers' +import { usePluginStore } from '../store' +import { usePluginSubscriptionStore } from './store' + +export const useSubscriptionList = () => { + const detail = usePluginStore(state => state.detail) + const { setRefresh } = usePluginSubscriptionStore() + + const { data: subscriptions, isLoading, refetch } = useTriggerSubscriptions(detail?.provider || '') + + useEffect(() => { + if (refetch) + setRefresh(refetch) + }, [refetch, setRefresh]) + + return { + detail, + subscriptions, + isLoading, + refetch, + } +} diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx index d56d48d6d5..ea7892be32 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx @@ -40,6 +40,7 @@ import { AuthCategory, PluginAuthInAgent, } from '@/app/components/plugins/plugin-auth' +import { ReadmeEntrance } from '../../readme-panel/entrance' type Props = { disabled?: boolean @@ -272,7 +273,10 @@ const ToolSelector: FC<Props> = ({ {/* base form */} <div className='flex flex-col gap-3 px-4 py-2'> <div className='flex flex-col gap-1'> - <div className='system-sm-semibold flex h-6 items-center text-text-secondary'>{t('plugin.detailPanel.toolSelector.toolLabel')}</div> + <div className='system-sm-semibold flex h-6 items-center justify-between text-text-secondary'> + {t('plugin.detailPanel.toolSelector.toolLabel')} + <ReadmeEntrance pluginDetail={currentProvider as any} showShortTip className='pb-0' /> + </div> <ToolPicker placement='bottom' offset={offset} @@ -315,6 +319,7 @@ const ToolSelector: FC<Props> = ({ provider: currentProvider.name, category: AuthCategory.tool, providerType: currentProvider.type, + detail: currentProvider as any, }} credentialId={value?.credential_id} onAuthorizationItemClick={handleAuthorizationItemClick} diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx index b79ee78664..88bf7f0dfd 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx @@ -54,7 +54,7 @@ const ReasoningConfigForm: React.FC<Props> = ({ const getVarKindType = (type: FormTypeEnum) => { if (type === FormTypeEnum.file || type === FormTypeEnum.files) return VarKindType.variable - if (type === FormTypeEnum.select || type === FormTypeEnum.boolean || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object) + if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object) return VarKindType.constant if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) return VarKindType.mixed @@ -164,7 +164,7 @@ const ReasoningConfigForm: React.FC<Props> = ({ const isArray = type === FormTypeEnum.array const isShowJSONEditor = isObject || isArray const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files - const isBoolean = type === FormTypeEnum.boolean + const isBoolean = type === FormTypeEnum.checkbox const isSelect = type === FormTypeEnum.select const isAppSelector = type === FormTypeEnum.appSelector const isModelSelector = type === FormTypeEnum.modelSelector diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx new file mode 100644 index 0000000000..2083f34263 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx @@ -0,0 +1,157 @@ +'use client' +import ActionButton from '@/app/components/base/action-button' +import Divider from '@/app/components/base/divider' +import Drawer from '@/app/components/base/drawer' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import Icon from '@/app/components/plugins/card/base/card-icon' +import Description from '@/app/components/plugins/card/base/description' +import OrgInfo from '@/app/components/plugins/card/base/org-info' +import { triggerEventParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' +import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types' +import Field from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field' +import cn from '@/utils/classnames' +import { + RiArrowLeftLine, + RiCloseLine, +} from '@remixicon/react' +import type { TFunction } from 'i18next' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' +import type { TriggerEvent } from '@/app/components/plugins/types' + +type EventDetailDrawerProps = { + eventInfo: TriggerEvent + providerInfo: TriggerProviderApiEntity + onClose: () => void +} + +const getType = (type: string, t: TFunction) => { + if (type === 'number-input') + return t('tools.setBuiltInTools.number') + if (type === 'text-input') + return t('tools.setBuiltInTools.string') + if (type === 'checkbox') + return 'boolean' + if (type === 'file') + return t('tools.setBuiltInTools.file') + return type +} + +// Convert JSON Schema to StructuredOutput format +const convertSchemaToField = (schema: any): any => { + const field: any = { + type: Array.isArray(schema.type) ? schema.type[0] : schema.type || 'string', + } + + if (schema.description) + field.description = schema.description + + if (schema.properties) { + field.properties = Object.entries(schema.properties).reduce((acc, [key, value]: [string, any]) => ({ + ...acc, + [key]: convertSchemaToField(value), + }), {}) + } + + if (schema.required) + field.required = schema.required + + if (schema.items) + field.items = convertSchemaToField(schema.items) + + if (schema.enum) + field.enum = schema.enum + + return field +} + +export const EventDetailDrawer: FC<EventDetailDrawerProps> = (props) => { + const { eventInfo, providerInfo, onClose } = props + const language = useLanguage() + const { t } = useTranslation() + const parametersSchemas = triggerEventParametersToFormSchemas(eventInfo.parameters) + + // Convert output_schema properties to array for direct rendering + const outputFields = eventInfo.output_schema?.properties + ? Object.entries(eventInfo.output_schema.properties).map(([name, schema]: [string, any]) => ({ + name, + field: convertSchemaToField(schema), + required: eventInfo.output_schema.required?.includes(name) || false, + })) + : [] + + return ( + <Drawer + isOpen + clickOutsideNotOpen={false} + onClose={onClose} + footer={null} + mask={false} + positionCenter={false} + panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')} + > + <div className='relative border-b border-divider-subtle p-4 pb-3'> + <div className='absolute right-3 top-3'> + <ActionButton onClick={onClose}> + <RiCloseLine className='h-4 w-4' /> + </ActionButton> + </div> + <div + className='system-xs-semibold-uppercase mb-2 flex cursor-pointer items-center gap-1 text-text-accent-secondary' + onClick={onClose} + > + <RiArrowLeftLine className='h-4 w-4' /> + {t('plugin.detailPanel.operation.back')} + </div> + <div className='flex items-center gap-1'> + <Icon size='tiny' className='h-6 w-6' src={providerInfo.icon!} /> + <OrgInfo + packageNameClassName='w-auto' + orgName={providerInfo.author} + packageName={providerInfo.name.split('/').pop() || ''} + /> + </div> + <div className='system-md-semibold mt-1 text-text-primary'>{eventInfo?.identity?.label[language]}</div> + <Description className='mb-2 mt-3 h-auto' text={eventInfo.description[language]} descriptionLineRows={2}></Description> + </div> + <div className='flex h-full flex-col gap-2 overflow-y-auto px-4 pb-2 pt-4'> + <div className='system-sm-semibold-uppercase text-text-secondary'>{t('tools.setBuiltInTools.parameters')}</div> + {parametersSchemas.length > 0 ? ( + parametersSchemas.map((item, index) => ( + <div key={index} className='py-1'> + <div className='flex items-center gap-2'> + <div className='code-sm-semibold text-text-secondary'>{item.label[language]}</div> + <div className='system-xs-regular text-text-tertiary'> + {getType(item.type, t)} + </div> + {item.required && ( + <div className='system-xs-medium text-text-warning-secondary'>{t('tools.setBuiltInTools.required')}</div> + )} + </div> + {item.description && ( + <div className='system-xs-regular mt-0.5 text-text-tertiary'> + {item.description?.[language]} + </div> + )} + </div> + )) + ) : <div className='system-xs-regular text-text-tertiary'>{t('pluginTrigger.events.item.noParameters')}</div>} + <Divider className='mb-2 mt-1 h-px' /> + <div className='flex flex-col gap-2'> + <div className='system-sm-semibold-uppercase text-text-secondary'>{t('pluginTrigger.events.output')}</div> + <div className='relative left-[-7px]'> + {outputFields.map(item => ( + <Field + key={item.name} + name={item.name} + payload={item.field} + required={item.required} + rootClassName='code-sm-semibold text-text-secondary' + /> + ))} + </div> + </div> + </div> + </Drawer> + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx new file mode 100644 index 0000000000..93f2fcc9c7 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx @@ -0,0 +1,71 @@ +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import type { TriggerEvent } from '@/app/components/plugins/types' +import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types' +import { useTriggerProviderInfo } from '@/service/use-triggers' +import cn from '@/utils/classnames' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePluginStore } from '../store' +import { EventDetailDrawer } from './event-detail-drawer' + +type TriggerEventCardProps = { + eventInfo: TriggerEvent + providerInfo: TriggerProviderApiEntity +} + +const TriggerEventCard = ({ eventInfo, providerInfo }: TriggerEventCardProps) => { + const { identity, description } = eventInfo + const language = useLanguage() + const [showDetail, setShowDetail] = useState(false) + const title = identity.label?.[language] ?? identity.label?.en_US ?? '' + const descriptionText = description?.[language] ?? description?.en_US ?? '' + return ( + <> + <div + className={cn('bg-components-panel-item-bg cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover')} + onClick={() => setShowDetail(true)} + > + <div className='system-md-semibold pb-0.5 text-text-secondary'>{title}</div> + <div className='system-xs-regular line-clamp-2 text-text-tertiary' title={descriptionText}>{descriptionText}</div> + </div> + {showDetail && ( + <EventDetailDrawer + eventInfo={eventInfo} + providerInfo={providerInfo} + onClose={() => setShowDetail(false)} + /> + )} + </> + ) +} + +export const TriggerEventsList = () => { + const { t } = useTranslation() + const detail = usePluginStore(state => state.detail) + + const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '') + const triggerEvents = providerInfo?.events || [] + + if (!providerInfo || !triggerEvents.length) + return null + + return ( + <div className='px-4 pb-4 pt-2'> + <div className='mb-1 py-1'> + <div className='system-sm-semibold-uppercase mb-1 flex h-6 items-center justify-between text-text-secondary'> + {t('pluginTrigger.events.actionNum', { num: triggerEvents.length, event: t(`pluginTrigger.events.${triggerEvents.length > 1 ? 'events' : 'event'}`) })} + </div> + </div> + <div className='flex flex-col gap-2'> + { + triggerEvents.map((triggerEvent: TriggerEvent) => ( + <TriggerEventCard + key={`${detail?.plugin_id}${triggerEvent.identity?.name || ''}`} + eventInfo={triggerEvent} + providerInfo={providerInfo} + />)) + } + </div> + </div> + ) +} diff --git a/web/app/components/plugins/plugin-item/action.tsx b/web/app/components/plugins/plugin-item/action.tsx index 6d7ff388ea..80dfd78f12 100644 --- a/web/app/components/plugins/plugin-item/action.tsx +++ b/web/app/components/plugins/plugin-item/action.tsx @@ -14,7 +14,7 @@ import { useGitHubReleases } from '../install-plugin/hooks' import Toast from '@/app/components/base/toast' import { useModalContext } from '@/context/modal-context' import { useInvalidateInstalledPluginList } from '@/service/use-plugins' -import type { PluginType } from '@/app/components/plugins/types' +import type { PluginCategoryEnum } from '@/app/components/plugins/types' const i18nPrefix = 'plugin.action' @@ -23,7 +23,7 @@ type Props = { installationId: string pluginUniqueIdentifier: string pluginName: string - category: PluginType + category: PluginCategoryEnum usedInApps: number isShowFetchNewVersion: boolean isShowInfo: boolean @@ -92,11 +92,18 @@ const Action: FC<Props> = ({ const handleDelete = useCallback(async () => { showDeleting() - const res = await uninstallPlugin(installationId) - hideDeleting() - if (res.success) { - hideDeleteConfirm() - onDelete() + try{ + const res = await uninstallPlugin(installationId) + if (res.success) { + hideDeleteConfirm() + onDelete() + } + } + catch (error) { + console.error('uninstallPlugin error', error) + } + finally { + hideDeleting() } }, [installationId, onDelete]) return ( diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index ed7cf47bb7..9352df23c8 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -1,7 +1,12 @@ 'use client' -import type { FC } from 'react' -import React, { useCallback, useMemo } from 'react' -import { useTheme } from 'next-themes' +import Tooltip from '@/app/components/base/tooltip' +import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' +import { API_PREFIX } from '@/config' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useRenderI18nObject } from '@/hooks/use-i18n' +import cn from '@/utils/classnames' +import { getMarketplaceUrl } from '@/utils/var' import { RiArrowRightUpLine, RiBugLine, @@ -10,26 +15,21 @@ import { RiLoginCircleLine, RiVerifiedBadgeLine, } from '@remixicon/react' +import { useTheme } from 'next-themes' +import type { FC } from 'react' +import React, { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { usePluginPageContext } from '../plugin-page/context' -import { Github } from '../../base/icons/src/public/common' +import { gte } from 'semver' import Badge from '../../base/badge' -import { type PluginDetail, PluginSource, PluginType } from '../types' +import { Github } from '../../base/icons/src/public/common' import CornerMark from '../card/base/corner-mark' import Description from '../card/base/description' import OrgInfo from '../card/base/org-info' import Title from '../card/base/title' +import { useCategories } from '../hooks' +import { usePluginPageContext } from '../plugin-page/context' +import { PluginCategoryEnum, type PluginDetail, PluginSource } from '../types' import Action from './action' -import cn from '@/utils/classnames' -import { API_PREFIX } from '@/config' -import { useSingleCategories } from '../hooks' -import { useRenderI18nObject } from '@/hooks/use-i18n' -import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' -import { useAppContext } from '@/context/app-context' -import { gte } from 'semver' -import Tooltip from '@/app/components/base/tooltip' -import { getMarketplaceUrl } from '@/utils/var' -import { useGlobalPublicStore } from '@/context/global-public-context' type Props = { className?: string @@ -42,7 +42,7 @@ const PluginItem: FC<Props> = ({ }) => { const { t } = useTranslation() const { theme } = useTheme() - const { categoriesMap } = useSingleCategories() + const { categoriesMap } = useCategories(t, true) const currentPluginID = usePluginPageContext(v => v.currentPluginID) const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID) const { refreshPluginList } = useRefreshPluginList() @@ -150,7 +150,7 @@ const PluginItem: FC<Props> = ({ packageName={name} packageNameClassName='w-auto max-w-[150px]' /> - {category === PluginType.extension && ( + {category === PluginCategoryEnum.extension && ( <> <div className='system-xs-regular mx-2 text-text-quaternary'>·</div> <div className='system-xs-regular flex items-center gap-x-1 overflow-hidden text-text-tertiary'> diff --git a/web/app/components/plugins/readme-panel/constants.ts b/web/app/components/plugins/readme-panel/constants.ts new file mode 100644 index 0000000000..7d6782e665 --- /dev/null +++ b/web/app/components/plugins/readme-panel/constants.ts @@ -0,0 +1,6 @@ +export const BUILTIN_TOOLS_ARRAY = [ + 'code', + 'audio', + 'time', + 'webscraper', +] diff --git a/web/app/components/plugins/readme-panel/entrance.tsx b/web/app/components/plugins/readme-panel/entrance.tsx new file mode 100644 index 0000000000..f3b4c98412 --- /dev/null +++ b/web/app/components/plugins/readme-panel/entrance.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { RiBookReadLine } from '@remixicon/react' +import cn from '@/utils/classnames' +import { ReadmeShowType, useReadmePanelStore } from './store' +import { BUILTIN_TOOLS_ARRAY } from './constants' +import type { PluginDetail } from '../types' + +export const ReadmeEntrance = ({ + pluginDetail, + showType = ReadmeShowType.drawer, + className, + showShortTip = false, +}: { + pluginDetail: PluginDetail + showType?: ReadmeShowType + className?: string + showShortTip?: boolean +}) => { + const { t } = useTranslation() + const { setCurrentPluginDetail } = useReadmePanelStore() + + const handleReadmeClick = () => { + if (pluginDetail) + setCurrentPluginDetail(pluginDetail, showType) + } + if (!pluginDetail || BUILTIN_TOOLS_ARRAY.includes(pluginDetail.id)) + return null + + return ( + <div className={cn('flex flex-col items-start justify-center gap-2 pb-4 pt-0', showType === ReadmeShowType.drawer && 'px-4', className)}> + {!showShortTip && <div className="relative h-1 w-8 shrink-0"> + <div className="h-px w-full bg-divider-regular"></div> + </div>} + + <button + onClick={handleReadmeClick} + className="flex w-full items-center justify-start gap-1 text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only" + > + <div className="relative flex h-3 w-3 items-center justify-center overflow-hidden"> + <RiBookReadLine className="h-3 w-3" /> + </div> + <span className="text-xs font-normal leading-4"> + {!showShortTip ? t('plugin.readmeInfo.needHelpCheckReadme') : t('plugin.readmeInfo.title')} + </span> + </button> + </div> + ) +} diff --git a/web/app/components/plugins/readme-panel/index.tsx b/web/app/components/plugins/readme-panel/index.tsx new file mode 100644 index 0000000000..70d1e0db2c --- /dev/null +++ b/web/app/components/plugins/readme-panel/index.tsx @@ -0,0 +1,120 @@ +'use client' +import ActionButton from '@/app/components/base/action-button' +import Loading from '@/app/components/base/loading' +import { Markdown } from '@/app/components/base/markdown' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { usePluginReadme } from '@/service/use-plugins' +import cn from '@/utils/classnames' +import { RiBookReadLine, RiCloseLine } from '@remixicon/react' +import type { FC } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import DetailHeader from '../plugin-detail-panel/detail-header' +import { ReadmeShowType, useReadmePanelStore } from './store' + +const ReadmePanel: FC = () => { + const { currentPluginDetail, setCurrentPluginDetail } = useReadmePanelStore() + const { detail, showType } = currentPluginDetail || {} + const { t } = useTranslation() + const language = useLanguage() + + const pluginUniqueIdentifier = detail?.plugin_unique_identifier || '' + + const { data: readmeData, isLoading, error } = usePluginReadme( + { plugin_unique_identifier: pluginUniqueIdentifier, language: language === 'zh-Hans' ? undefined : language }, + ) + + const onClose = () => { + setCurrentPluginDetail() + } + + if (!detail) return null + + const children = ( + <div className="flex h-full w-full flex-col overflow-hidden"> + <div className="rounded-t-xl bg-background-body px-4 py-4"> + <div className="mb-3 flex items-center justify-between"> + <div className="flex items-center gap-1"> + <RiBookReadLine className="h-3 w-3 text-text-tertiary" /> + <span className="text-xs font-medium uppercase text-text-tertiary"> + {t('plugin.readmeInfo.title')} + </span> + </div> + <ActionButton onClick={onClose}> + <RiCloseLine className='h-4 w-4' /> + </ActionButton> + </div> + <DetailHeader detail={detail} isReadmeView={true} /> + </div> + + <div className="flex-1 overflow-y-auto px-4 py-3"> + {(() => { + if (isLoading) { + return ( + <div className="flex h-40 items-center justify-center"> + <Loading type="area" /> + </div> + ) + } + + if (error) { + return ( + <div className="py-8 text-center text-text-tertiary"> + <p>{t('plugin.readmeInfo.failedToFetch')}</p> + </div> + ) + } + + if (readmeData?.readme) { + return ( + <Markdown + content={readmeData.readme} + pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }} + /> + ) + } + + return ( + <div className="py-8 text-center text-text-tertiary"> + <p>{t('plugin.readmeInfo.noReadmeAvailable')}</p> + </div> + ) + })()} + </div> + </div> + ) + + const portalContent = showType === ReadmeShowType.drawer + ? ( + <div className='pointer-events-none fixed inset-0 z-[9997] flex justify-start'> + <div + className={cn( + 'pointer-events-auto mb-2 ml-2 mr-2 mt-16 w-[600px] max-w-[600px] justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl', + )} + > + {children} + </div> + </div> + ) + : ( + <div className='pointer-events-none fixed inset-0 z-[9997] flex items-center justify-center p-2'> + <div + className={cn( + 'pointer-events-auto relative h-[calc(100vh-16px)] w-full max-w-[800px] rounded-2xl bg-components-panel-bg p-0 shadow-xl', + )} + onClick={(event) => { + event.stopPropagation() + }} + > + {children} + </div> + </div> + ) + + return createPortal( + portalContent, + document.body, + ) +} + +export default ReadmePanel diff --git a/web/app/components/plugins/readme-panel/store.ts b/web/app/components/plugins/readme-panel/store.ts new file mode 100644 index 0000000000..29c989db10 --- /dev/null +++ b/web/app/components/plugins/readme-panel/store.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand' +import type { PluginDetail } from '@/app/components/plugins/types' + +export enum ReadmeShowType { + drawer = 'drawer', + modal = 'modal', +} + +type Shape = { + currentPluginDetail?: { + detail: PluginDetail + showType: ReadmeShowType + } + setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => void +} + +export const useReadmePanelStore = create<Shape>(set => ({ + currentPluginDetail: undefined, + setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => set({ + currentPluginDetail: !detail ? undefined : { + detail, + showType: showType ?? ReadmeShowType.drawer, + }, + }), +})) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx index 2d00788142..dfbeaad9cb 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx @@ -15,6 +15,7 @@ import { RiTimeLine } from '@remixicon/react' import cn from '@/utils/classnames' import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs' import { useModalContextSelector } from '@/context/modal-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const i18nPrefix = 'plugin.autoUpdate' @@ -30,7 +31,7 @@ const SettingTimeZone: FC<{ }) => { const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) return ( - <span className='body-xs-regular cursor-pointer text-text-accent' onClick={() => setShowAccountSettingModal({ payload: 'language' })} >{children}</span> + <span className='body-xs-regular cursor-pointer text-text-accent' onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.LANGUAGE })} >{children}</span> ) } const AutoUpdateSetting: FC<Props> = ({ @@ -143,6 +144,7 @@ const AutoUpdateSetting: FC<Props> = ({ title={t(`${i18nPrefix}.updateTime`)} minuteFilter={minuteFilter} renderTrigger={renderTimePickerTrigger} + placement='bottom-end' /> <div className='body-xs-regular mt-1 text-right text-text-tertiary'> <Trans diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index 2e061d7d69..d9659df3ad 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -3,12 +3,16 @@ import type { ToolCredential } from '@/app/components/tools/types' import type { Locale } from '@/i18n-config' import type { AgentFeature } from '@/app/components/workflow/nodes/agent/types' import type { AutoUpdateConfig } from './reference-setting-modal/auto-update-setting/types' -export enum PluginType { +import type { FormTypeEnum } from '../base/form/types' +import type { TypeWithI18N } from '@/app/components/base/form/types' + +export enum PluginCategoryEnum { tool = 'tool', model = 'model', extension = 'extension', agent = 'agent-strategy', datasource = 'datasource', + trigger = 'trigger', } export enum PluginSource { @@ -68,7 +72,7 @@ export type PluginDeclaration = { author: string icon: string name: string - category: PluginType + category: PluginCategoryEnum label: Record<Locale, string> description: Record<Locale, string> created_at: string @@ -82,6 +86,111 @@ export type PluginDeclaration = { tags: string[] agent_strategy: any meta: PluginDeclarationMeta + trigger: PluginTriggerDefinition +} + +export type PluginTriggerSubscriptionConstructor = { + credentials_schema: CredentialsSchema[] + oauth_schema: OauthSchema + parameters: ParametersSchema[] +} + +export type PluginTriggerDefinition = { + events: TriggerEvent[] + identity: Identity + subscription_constructor: PluginTriggerSubscriptionConstructor + subscription_schema: ParametersSchema[] +} + +export type CredentialsSchema = { + name: string + label: Record<Locale, string> + description: Record<Locale, string> + type: FormTypeEnum + scope: any + required: boolean + default: any + options: any + help: Record<Locale, string> + url: string + placeholder: Record<Locale, string> +} + +export type OauthSchema = { + client_schema: CredentialsSchema[] + credentials_schema: CredentialsSchema[] +} + +export type ParametersSchema = { + name: string + label: Record<Locale, string> + type: FormTypeEnum + auto_generate: any + template: any + scope: any + required: boolean + multiple: boolean + default?: string[] + min: any + max: any + precision: any + options?: Array<{ + value: string + label: Record<Locale, string> + icon?: string + }> + description: Record<Locale, string> +} + +export type PropertiesSchema = { + type: FormTypeEnum + name: string + scope: any + required: boolean + default: any + options: Array<{ + value: string + label: Record<Locale, string> + icon?: string + }> + label: Record<Locale, string> + help: Record<Locale, string> + url: any + placeholder: any +} + +export type TriggerEventParameter = { + name: string + label: TypeWithI18N + type: string + auto_generate: any + template: any + scope: any + required: boolean + multiple: boolean + default: any + min: any + max: any + precision: any + options?: Array<{ + value: string + label: TypeWithI18N + icon?: string + }> + description?: TypeWithI18N +} + +export type TriggerEvent = { + name: string + identity: { + author: string + name: string + label: TypeWithI18N + provider?: string + } + description: TypeWithI18N + parameters: TriggerEventParameter[] + output_schema: Record<string, any> } export type PluginManifestInMarket = { @@ -90,7 +199,7 @@ export type PluginManifestInMarket = { org: string icon: string label: Record<Locale, string> - category: PluginType + category: PluginCategoryEnum version: string // combine the other place to it latest_version: string brief: Record<Locale, string> @@ -104,6 +213,12 @@ export type PluginManifestInMarket = { from: Dependency['type'] } +export enum SupportedCreationMethods { + OAUTH = 'OAUTH', + APIKEY = 'APIKEY', + MANUAL = 'MANUAL', +} + export type PluginDetail = { id: string created_at: string @@ -127,7 +242,7 @@ export type PluginDetail = { } export type PluginInfoFromMarketPlace = { - category: PluginType + category: PluginCategoryEnum latest_package_identifier: string latest_version: string } @@ -149,7 +264,7 @@ export type Plugin = { // Repo readme.md content introduction: string repository: string - category: PluginType + category: PluginCategoryEnum install_count: number endpoint: { settings: CredentialFormSchemaBase[] @@ -179,7 +294,7 @@ export type ReferenceSetting = { } export type UpdateFromMarketPlacePayload = { - category: PluginType + category: PluginCategoryEnum originalPackageInfo: { id: string payload: PluginDeclaration @@ -202,7 +317,7 @@ export type UpdateFromGitHubPayload = { export type UpdatePluginPayload = { type: PluginSource - category: PluginType + category: PluginCategoryEnum marketPlace?: UpdateFromMarketPlacePayload github?: UpdateFromGitHubPayload } @@ -469,15 +584,18 @@ export type StrategyDetail = { features: AgentFeature[] } +export type Identity = { + author: string + name: string + label: Record<Locale, string> + description: Record<Locale, string> + icon: string + icon_dark?: string + tags: string[] +} + export type StrategyDeclaration = { - identity: { - author: string - name: string - description: Record<Locale, string> - icon: string - label: Record<Locale, string> - tags: string[] - }, + identity: Identity, plugin_id: string strategies: StrategyDetail[] } diff --git a/web/app/components/rag-pipeline/components/panel/index.tsx b/web/app/components/rag-pipeline/components/panel/index.tsx index e2fd958405..793248e3eb 100644 --- a/web/app/components/rag-pipeline/components/panel/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/index.tsx @@ -22,14 +22,19 @@ const InputFieldEditorPanel = dynamic(() => import('./input-field/editor'), { const PreviewPanel = dynamic(() => import('./input-field/preview'), { ssr: false, }) - +const GlobalVariablePanel = dynamic(() => import('@/app/components/workflow/panel/global-variable-panel'), { + ssr: false, +}) const RagPipelinePanelOnRight = () => { const historyWorkflowData = useStore(s => s.historyWorkflowData) const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel) + const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel) + return ( <> {historyWorkflowData && <Record />} {showDebugAndPreviewPanel && <TestRunPanel />} + {showGlobalVariablePanel && <GlobalVariablePanel />} </> ) } diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts index d9de69716e..b70a2e6a34 100644 --- a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts @@ -7,6 +7,7 @@ import { import { useNodesReadOnly, } from '@/app/components/workflow/hooks/use-workflow' +import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' import { API_PREFIX } from '@/config' import { syncWorkflowDraft } from '@/service/workflow' import { usePipelineRefreshDraft } from '.' @@ -83,7 +84,7 @@ export const useNodesSyncDraft = () => { } }, [getPostParams, getNodesReadOnly]) - const doSyncWorkflowDraft = useCallback(async ( + const performSync = useCallback(async ( notRefreshWhenSyncError?: boolean, callback?: { onSuccess?: () => void @@ -121,6 +122,8 @@ export const useNodesSyncDraft = () => { } }, [getPostParams, getNodesReadOnly, workflowStore, handleRefreshWorkflowDraft]) + const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly) + return { doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose, diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts index c70bce8523..6af72bee05 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts @@ -60,7 +60,10 @@ export const usePipelineInit = () => { if (error && error.json && !error.bodyUsed && datasetId) { error.json().then((err: any) => { if (err.code === 'draft_workflow_not_exist') { - workflowStore.setState({ notInitialWorkflow: true }) + workflowStore.setState({ + notInitialWorkflow: true, + shouldAutoOpenStartNodeSelector: true, + }) syncWorkflowDraft({ url: `/rag/pipelines/${datasetId}/workflows/draft`, params: { diff --git a/web/app/components/tools/add-tool-modal/empty.tsx b/web/app/components/tools/add-tool-modal/empty.tsx index 5759589c8e..4d69dc1076 100644 --- a/web/app/components/tools/add-tool-modal/empty.tsx +++ b/web/app/components/tools/add-tool-modal/empty.tsx @@ -35,7 +35,7 @@ const Empty = ({ const hasTitle = t(`tools.addToolModal.${renderType}.title`) !== `tools.addToolModal.${renderType}.title` return ( - <div className='flex h-[336px] flex-col items-center justify-center'> + <div className='flex flex-col items-center justify-center'> <NoToolPlaceholder className={theme === 'dark' ? 'invert' : ''} /> <div className='mb-1 mt-2 text-[13px] font-medium leading-[18px] text-text-primary'> {hasTitle ? t(`tools.addToolModal.${renderType}.title`) : 'No tools available'} diff --git a/web/app/components/tools/marketplace/hooks.ts b/web/app/components/tools/marketplace/hooks.ts index 0790d52721..e3fad24710 100644 --- a/web/app/components/tools/marketplace/hooks.ts +++ b/web/app/components/tools/marketplace/hooks.ts @@ -9,7 +9,7 @@ import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, } from '@/app/components/plugins/marketplace/hooks' -import { PluginType } from '@/app/components/plugins/types' +import { PluginCategoryEnum } from '@/app/components/plugins/types' import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils' import { useAllToolProviders } from '@/service/use-tools' @@ -49,7 +49,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin if (searchPluginText) { queryPluginsWithDebounced({ - category: PluginType.tool, + category: PluginCategoryEnum.tool, query: searchPluginText, tags: filterPluginTags, exclude, @@ -59,7 +59,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin return } queryPlugins({ - category: PluginType.tool, + category: PluginCategoryEnum.tool, query: searchPluginText, tags: filterPluginTags, exclude, @@ -70,8 +70,8 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin else { if (isSuccess) { queryMarketplaceCollectionsAndPlugins({ - category: PluginType.tool, - condition: getMarketplaceListCondition(PluginType.tool), + category: PluginCategoryEnum.tool, + condition: getMarketplaceListCondition(PluginCategoryEnum.tool), exclude, type: 'plugin', }) @@ -95,7 +95,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin pageRef.current++ queryPlugins({ - category: PluginType.tool, + category: PluginCategoryEnum.tool, query: searchPluginText, tags: filterPluginTags, exclude, diff --git a/web/app/components/tools/mcp/mcp-service-card.tsx b/web/app/components/tools/mcp/mcp-service-card.tsx index 6c86932b32..1f40b1e4b3 100644 --- a/web/app/components/tools/mcp/mcp-service-card.tsx +++ b/web/app/components/tools/mcp/mcp-service-card.tsx @@ -13,7 +13,7 @@ import CopyFeedback from '@/app/components/base/copy-feedback' import Confirm from '@/app/components/base/confirm' import type { AppDetailResponse } from '@/models/app' import { useAppContext } from '@/context/app-context' -import type { AppSSO } from '@/types/app' +import { AppModeEnum, type AppSSO } from '@/types/app' import Indicator from '@/app/components/header/indicator' import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal' import { useAppWorkflow } from '@/service/use-workflow' @@ -26,6 +26,7 @@ import { import { BlockEnum } from '@/app/components/workflow/types' import cn from '@/utils/classnames' import { fetchAppDetail } from '@/service/apps' +import { useDocLink } from '@/context/i18n' export type IAppCardProps = { appInfo: AppDetailResponse & Partial<AppSSO> @@ -35,6 +36,7 @@ function MCPServiceCard({ appInfo, }: IAppCardProps) { const { t } = useTranslation() + const docLink = useDocLink() const appId = appInfo.id const { mutateAsync: updateMCPServer } = useUpdateMCPServer() const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode() @@ -43,7 +45,7 @@ function MCPServiceCard({ const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showMCPServerModal, setShowMCPServerModal] = useState(false) - const isAdvancedApp = appInfo?.mode === 'advanced-chat' || appInfo?.mode === 'workflow' + const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW const isBasicApp = !isAdvancedApp const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '') const [basicAppConfig, setBasicAppConfig] = useState<any>({}) @@ -69,11 +71,16 @@ function MCPServiceCard({ const { data: detail } = useMCPServerDetail(appId) const { id, status, server_code } = detail ?? {} + const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at const serverPublished = !!id const serverActivated = status === 'active' const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********' - const toggleDisabled = !isCurrentWorkspaceEditor || appUnpublished + const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start) + const missingStartNode = isWorkflowApp && !hasStartNode + const hasInsufficientPermissions = !isCurrentWorkspaceEditor + const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode + const isMinimalState = appUnpublished || missingStartNode const [activated, setActivated] = useState(serverActivated) @@ -136,12 +143,12 @@ function MCPServiceCard({ return ( <> - <div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight')}> + <div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}> <div className='rounded-xl bg-background-default'> - <div className='flex w-full flex-col items-start justify-center gap-3 self-stretch border-b-[0.5px] border-divider-subtle p-3'> + <div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}> <div className='flex w-full items-center gap-3 self-stretch'> <div className='flex grow items-center'> - <div className='mr-3 shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-indigo-indigo-500 p-1 shadow-md'> + <div className='mr-2 shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'> <Mcp className='h-4 w-4 text-text-primary-on-surface' /> </div> <div className="group w-full"> @@ -159,61 +166,86 @@ function MCPServiceCard({ </div> </div> <Tooltip - popupContent={appUnpublished ? t('tools.mcp.server.publishTip') : ''} + popupContent={ + toggleDisabled ? ( + appUnpublished ? ( + t('tools.mcp.server.publishTip') + ) : missingStartNode ? ( + <> + <div className="mb-1 text-xs font-normal text-text-secondary"> + {t('appOverview.overview.appInfo.enableTooltip.description')} + </div> + <div + className="cursor-pointer text-xs font-normal text-text-accent hover:underline" + onClick={() => window.open(docLink('/guides/workflow/node/user-input'), '_blank')} + > + {t('appOverview.overview.appInfo.enableTooltip.learnMore')} + </div> + </> + ) : '' + ) : '' + } + position="right" + popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg" + offset={24} > <div> <Switch defaultValue={activated} onChange={onChangeStatus} disabled={toggleDisabled} /> </div> </Tooltip> </div> - <div className='flex flex-col items-start justify-center self-stretch'> - <div className="system-xs-medium pb-1 text-text-tertiary"> - {t('tools.mcp.server.url')} - </div> - <div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2"> - <div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1"> - <div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary"> - {serverURL} - </div> + {!isMinimalState && ( + <div className='flex flex-col items-start justify-center self-stretch'> + <div className="system-xs-medium pb-1 text-text-tertiary"> + {t('tools.mcp.server.url')} </div> - {serverPublished && ( - <> - <CopyFeedback - content={serverURL} - className={'!size-6'} - /> - <Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" /> - {isCurrentWorkspaceManager && ( - <Tooltip - popupContent={t('appOverview.overview.appInfo.regenerate') || ''} - > - <div - className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover" - onClick={() => setShowConfirmDelete(true)} + <div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2"> + <div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1"> + <div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary"> + {serverURL} + </div> + </div> + {serverPublished && ( + <> + <CopyFeedback + content={serverURL} + className={'!size-6'} + /> + <Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" /> + {isCurrentWorkspaceManager && ( + <Tooltip + popupContent={t('appOverview.overview.appInfo.regenerate') || ''} > - <RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')}/> - </div> - </Tooltip> - )} - </> - )} + <div + className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover" + onClick={() => setShowConfirmDelete(true)} + > + <RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')}/> + </div> + </Tooltip> + )} + </> + )} + </div> </div> - </div> + )} </div> - <div className='flex items-center gap-1 self-stretch p-3'> - <Button - disabled={toggleDisabled} - size='small' - variant='ghost' - onClick={() => setShowMCPServerModal(true)} - > + {!isMinimalState && ( + <div className='flex items-center gap-1 self-stretch p-3'> + <Button + disabled={toggleDisabled} + size='small' + variant='ghost' + onClick={() => setShowMCPServerModal(true)} + > - <div className="flex items-center justify-center gap-[1px]"> - <RiEditLine className="h-3.5 w-3.5" /> - <div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('tools.mcp.server.edit') : t('tools.mcp.server.addDescription')}</div> - </div> - </Button> - </div> + <div className="flex items-center justify-center gap-[1px]"> + <RiEditLine className="h-3.5 w-3.5" /> + <div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('tools.mcp.server.edit') : t('tools.mcp.server.addDescription')}</div> + </div> + </Button> + </div> + )} </div> </div> {showMCPServerModal && ( diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index 1bfccc04e5..1b76afc5c7 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -34,6 +34,7 @@ export enum CollectionType { workflow = 'workflow', mcp = 'mcp', datasource = 'datasource', + trigger = 'trigger', } export type Emoji = { @@ -65,6 +66,7 @@ export type Collection = { masked_headers?: Record<string, string> is_authorized?: boolean provider?: string + credential_id?: string is_dynamic_registration?: boolean authentication?: { client_id?: string @@ -84,6 +86,7 @@ export type ToolParameter = { form: string llm_description: string required: boolean + multiple: boolean default: string options?: { label: TypeWithI18N @@ -93,7 +96,33 @@ export type ToolParameter = { max?: number } +export type TriggerParameter = { + name: string + label: TypeWithI18N + human_description: TypeWithI18N + type: string + form: string + llm_description: string + required: boolean + multiple: boolean + default: string + options?: { + label: TypeWithI18N + value: string + }[] +} + // Action +export type Event = { + name: string + author: string + label: TypeWithI18N + description: TypeWithI18N + parameters: TriggerParameter[] + labels: string[] + output_schema: Record<string, any> +} + export type Tool = { name: string author: string diff --git a/web/app/components/tools/utils/to-form-schema.ts b/web/app/components/tools/utils/to-form-schema.ts index 8e85a5f9b0..69f5dd5f2f 100644 --- a/web/app/components/tools/utils/to-form-schema.ts +++ b/web/app/components/tools/utils/to-form-schema.ts @@ -1,6 +1,7 @@ -import type { ToolCredential, ToolParameter } from '../types' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +import type { TriggerEventParameter } from '../../plugins/types' +import type { ToolCredential, ToolParameter } from '../types' export const toType = (type: string) => { switch (type) { @@ -14,6 +15,21 @@ export const toType = (type: string) => { return type } } + +export const triggerEventParametersToFormSchemas = (parameters: TriggerEventParameter[]) => { + if (!parameters?.length) + return [] + + return parameters.map((parameter) => { + return { + ...parameter, + type: toType(parameter.type), + _type: parameter.type, + tooltip: parameter.description, + } + }) +} + export const toolParametersToFormSchemas = (parameters: ToolParameter[]) => { if (!parameters) return [] @@ -165,7 +181,7 @@ export const getConfiguredValue = (value: Record<string, any>, formSchemas: { va const getVarKindType = (type: FormTypeEnum) => { if (type === FormTypeEnum.file || type === FormTypeEnum.files) return VarKindType.variable - if (type === FormTypeEnum.select || type === FormTypeEnum.boolean || type === FormTypeEnum.textNumber) + if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber) return VarKindType.constant if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) return VarKindType.mixed diff --git a/web/app/components/tools/workflow-tool/configure-button.tsx b/web/app/components/tools/workflow-tool/configure-button.tsx index 095ed369b2..bf0d789ff9 100644 --- a/web/app/components/tools/workflow-tool/configure-button.tsx +++ b/web/app/components/tools/workflow-tool/configure-button.tsx @@ -28,6 +28,7 @@ type Props = { inputs?: InputVar[] handlePublish: (params?: PublishWorkflowParams) => Promise<void> onRefreshData?: () => void + disabledReason?: string } const WorkflowToolConfigureButton = ({ @@ -41,6 +42,7 @@ const WorkflowToolConfigureButton = ({ inputs, handlePublish, onRefreshData, + disabledReason, }: Props) => { const { t } = useTranslation() const router = useRouter() @@ -200,7 +202,8 @@ const WorkflowToolConfigureButton = ({ {t('workflow.common.configureRequired')} </span> )} - </div>) + </div> + ) : ( <div className='flex items-center justify-start gap-2 p-2 pl-2.5' @@ -214,6 +217,11 @@ const WorkflowToolConfigureButton = ({ </div> </div> )} + {disabledReason && ( + <div className='mt-1 px-2.5 pb-2 text-xs leading-[18px] text-text-tertiary'> + {disabledReason} + </div> + )} {published && ( <div className='border-t-[0.5px] border-divider-regular px-2.5 py-2'> <div className='flex justify-between gap-x-2'> @@ -221,7 +229,7 @@ const WorkflowToolConfigureButton = ({ size='small' className='w-[140px]' onClick={() => setShowModal(true)} - disabled={!isCurrentWorkspaceManager} + disabled={!isCurrentWorkspaceManager || disabled} > {t('workflow.common.configure')} {outdated && <Indicator className='ml-1' color={'yellow'} />} @@ -230,14 +238,17 @@ const WorkflowToolConfigureButton = ({ size='small' className='w-[140px]' onClick={() => router.push('/tools?category=workflow')} + disabled={disabled} > {t('workflow.common.manageInTools')} <RiArrowRightUpLine className='ml-1 h-4 w-4' /> </Button> </div> - {outdated && <div className='mt-1 text-xs leading-[18px] text-text-warning'> - {t('workflow.common.workflowAsToolTip')} - </div>} + {outdated && ( + <div className='mt-1 text-xs leading-[18px] text-text-warning'> + {t('workflow.common.workflowAsToolTip')} + </div> + )} </div> )} </div> diff --git a/web/app/components/workflow-app/components/workflow-children.tsx b/web/app/components/workflow-app/components/workflow-children.tsx index af61e8a849..1c8ed0cdf9 100644 --- a/web/app/components/workflow-app/components/workflow-children.tsx +++ b/web/app/components/workflow-app/components/workflow-children.tsx @@ -1,19 +1,32 @@ import { memo, + useCallback, useState, } from 'react' import type { EnvironmentVariable } from '@/app/components/workflow/types' import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants' +import { START_INITIAL_POSITION } from '@/app/components/workflow/constants' +import { generateNewNode } from '@/app/components/workflow/utils' import { useStore } from '@/app/components/workflow/store' +import { useStoreApi } from 'reactflow' import PluginDependency from '../../workflow/plugin-dependency' import { + useAutoGenerateWebhookUrl, useDSL, usePanelInteractions, } from '@/app/components/workflow/hooks' +import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' import { useEventEmitterContextContext } from '@/context/event-emitter' import WorkflowHeader from './workflow-header' import WorkflowPanel from './workflow-panel' import dynamic from 'next/dynamic' +import { BlockEnum } from '@/app/components/workflow/types' +import type { + PluginDefaultValue, + TriggerDefaultValue, +} from '@/app/components/workflow/block-selector/types' +import { useAutoOnboarding } from '../hooks/use-auto-onboarding' +import { useAvailableNodesMetaData } from '../hooks' const Features = dynamic(() => import('@/app/components/workflow/features'), { ssr: false, @@ -24,6 +37,34 @@ const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-ds const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), { ssr: false, }) +const WorkflowOnboardingModal = dynamic(() => import('./workflow-onboarding-modal'), { + ssr: false, +}) + +const getTriggerPluginNodeData = ( + triggerConfig: TriggerDefaultValue, + fallbackTitle?: string, + fallbackDesc?: string, +) => { + return { + plugin_id: triggerConfig.plugin_id, + provider_id: triggerConfig.provider_name, + provider_type: triggerConfig.provider_type, + provider_name: triggerConfig.provider_name, + event_name: triggerConfig.event_name, + event_label: triggerConfig.event_label, + event_description: triggerConfig.event_description, + title: triggerConfig.event_label || triggerConfig.title || fallbackTitle, + desc: triggerConfig.event_description || fallbackDesc, + output_schema: { ...triggerConfig.output_schema }, + parameters_schema: triggerConfig.paramSchemas ? [...triggerConfig.paramSchemas] : [], + config: { ...triggerConfig.params }, + subscription_id: triggerConfig.subscription_id, + plugin_unique_identifier: triggerConfig.plugin_unique_identifier, + is_team_authorization: triggerConfig.is_team_authorization, + meta: triggerConfig.meta ? { ...triggerConfig.meta } : undefined, + } +} const WorkflowChildren = () => { const { eventEmitter } = useEventEmitterContextContext() @@ -31,6 +72,14 @@ const WorkflowChildren = () => { const showFeaturesPanel = useStore(s => s.showFeaturesPanel) const showImportDSLModal = useStore(s => s.showImportDSLModal) const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal) + const showOnboarding = useStore(s => s.showOnboarding) + const setShowOnboarding = useStore(s => s.setShowOnboarding) + const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode) + const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector) + const reactFlowStore = useStoreApi() + const availableNodesMetaData = useAvailableNodesMetaData() + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { handleOnboardingClose } = useAutoOnboarding() const { handlePaneContextmenuCancel, } = usePanelInteractions() @@ -44,12 +93,84 @@ const WorkflowChildren = () => { setSecretEnvList(v.payload.data as EnvironmentVariable[]) }) + const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl() + + const handleCloseOnboarding = useCallback(() => { + handleOnboardingClose() + }, [handleOnboardingClose]) + + const handleSelectStartNode = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => { + const nodeDefault = availableNodesMetaData.nodesMap?.[nodeType] + if (!nodeDefault?.defaultValue) + return + + const baseNodeData = { ...nodeDefault.defaultValue } + + const mergedNodeData = (() => { + if (nodeType !== BlockEnum.TriggerPlugin || !toolConfig) { + return { + ...baseNodeData, + ...toolConfig, + } + } + + const triggerNodeData = getTriggerPluginNodeData( + toolConfig as TriggerDefaultValue, + baseNodeData.title, + baseNodeData.desc, + ) + + return { + ...baseNodeData, + ...triggerNodeData, + config: { + ...(baseNodeData as { config?: Record<string, any> }).config, + ...triggerNodeData.config, + }, + } + })() + + const { newNode } = generateNewNode({ + data: { + ...mergedNodeData, + } as any, + position: START_INITIAL_POSITION, + }) + + const { setNodes, setEdges } = reactFlowStore.getState() + setNodes([newNode]) + setEdges([]) + + setShowOnboarding?.(false) + setHasSelectedStartNode?.(true) + setShouldAutoOpenStartNodeSelector?.(true) + + handleSyncWorkflowDraft(true, false, { + onSuccess: () => { + autoGenerateWebhookUrl(newNode.id) + console.log('Node successfully saved to draft') + }, + onError: () => { + console.error('Failed to save node to draft') + }, + }) + }, [availableNodesMetaData, setShowOnboarding, setHasSelectedStartNode, reactFlowStore, handleSyncWorkflowDraft]) + return ( <> <PluginDependency /> { showFeaturesPanel && <Features /> } + { + showOnboarding && ( + <WorkflowOnboardingModal + isShow={showOnboarding} + onClose={handleCloseOnboarding} + onSelectStartNode={handleSelectStartNode} + /> + ) + } { showImportDSLModal && ( <UpdateDSLModal diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx index 05b37c1469..d229006177 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo, } from 'react' -import { useEdges, useNodes, useStore as useReactflowStore } from 'reactflow' +import { useEdges, useNodes } from 'reactflow' import { RiApps2AddLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { @@ -15,6 +15,7 @@ import { useChecklistBeforePublish, useNodesReadOnly, useNodesSyncDraft, + // useWorkflowRunValidation, } from '@/app/components/workflow/hooks' import Button from '@/app/components/base/button' import AppPublisher from '@/app/components/app/app-publisher' @@ -22,36 +23,44 @@ import { useFeatures } from '@/app/components/base/features/hooks' import type { CommonEdgeType, CommonNodeType, + Node, } from '@/app/components/workflow/types' import { BlockEnum, InputVarType, + isTriggerNode, } from '@/app/components/workflow/types' import { useToastContext } from '@/app/components/base/toast' import { useInvalidateAppWorkflow, usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow' +import { useInvalidateAppTriggers } from '@/service/use-tools' import type { PublishWorkflowParams } from '@/types/workflow' import { fetchAppDetail } from '@/service/apps' import { useStore as useAppStore } from '@/app/components/app/store' import useTheme from '@/hooks/use-theme' import cn from '@/utils/classnames' +import { useIsChatMode } from '@/app/components/workflow/hooks' +import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' const FeaturesTrigger = () => { const { t } = useTranslation() const { theme } = useTheme() + const isChatMode = useIsChatMode() const workflowStore = useWorkflowStore() const appDetail = useAppStore(s => s.appDetail) const appID = appDetail?.id const setAppDetail = useAppStore(s => s.setAppDetail) - const { - nodesReadOnly, - getNodesReadOnly, - } = useNodesReadOnly() + const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly() const publishedAt = useStore(s => s.publishedAt) const draftUpdatedAt = useStore(s => s.draftUpdatedAt) const toolPublished = useStore(s => s.toolPublished) - const startVariables = useReactflowStore( - s => s.getNodes().find(node => node.data.type === BlockEnum.Start)?.data.variables, - ) + const lastPublishedHasUserInput = useStore(s => s.lastPublishedHasUserInput) + + const nodes = useNodes<CommonNodeType>() + const hasWorkflowNodes = nodes.length > 0 + const startNode = nodes.find(node => node.data.type === BlockEnum.Start) + const startVariables = (startNode as Node<StartNodeType>)?.data?.variables + const edges = useEdges<CommonEdgeType>() + const fileSettings = useFeatures(s => s.features.file) const variables = useMemo(() => { const data = startVariables || [] @@ -73,6 +82,22 @@ const FeaturesTrigger = () => { const { handleCheckBeforePublish } = useChecklistBeforePublish() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { notify } = useToastContext() + const startNodeIds = useMemo( + () => nodes.filter(node => node.data.type === BlockEnum.Start).map(node => node.id), + [nodes], + ) + const hasUserInputNode = useMemo(() => { + if (!startNodeIds.length) + return false + return edges.some(edge => startNodeIds.includes(edge.source)) + }, [edges, startNodeIds]) + // Track trigger presence so the publisher can adjust UI (e.g. hide missing start section). + const hasTriggerNode = useMemo(() => ( + nodes.some(node => isTriggerNode(node.data.type as BlockEnum)) + ), [nodes]) + + const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() + const invalidateAppTriggers = useInvalidateAppTriggers() const handleShowFeatures = useCallback(() => { const { @@ -85,8 +110,6 @@ const FeaturesTrigger = () => { setShowFeaturesPanel(!showFeaturesPanel) }, [workflowStore, getNodesReadOnly]) - const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() - const updateAppDetail = useCallback(async () => { try { const res = await fetchAppDetail({ url: '/apps', id: appID! }) @@ -96,14 +119,17 @@ const FeaturesTrigger = () => { console.error(error) } }, [appID, setAppDetail]) + const { mutateAsync: publishWorkflow } = usePublishWorkflow() - const nodes = useNodes<CommonNodeType>() - const edges = useEdges<CommonEdgeType>() + // const { validateBeforeRun } = useWorkflowRunValidation() const needWarningNodes = useChecklist(nodes, edges) const updatePublishedWorkflow = useInvalidateAppWorkflow() const onPublish = useCallback(async (params?: PublishWorkflowParams) => { // First check if there are any items in the checklist + // if (!validateBeforeRun()) + // throw new Error('Checklist has unresolved items') + if (needWarningNodes.length > 0) { notify({ type: 'error', message: t('workflow.panel.checklistTip') }) throw new Error('Checklist has unresolved items') @@ -121,14 +147,16 @@ const FeaturesTrigger = () => { notify({ type: 'success', message: t('common.api.actionSuccess') }) updatePublishedWorkflow(appID!) updateAppDetail() + invalidateAppTriggers(appID!) workflowStore.getState().setPublishedAt(res.created_at) + workflowStore.getState().setLastPublishedHasUserInput(hasUserInputNode) resetWorkflowVersionHistory() } } else { throw new Error('Checklist failed') } - }, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, notify, appID, t, updatePublishedWorkflow, updateAppDetail, workflowStore, resetWorkflowVersionHistory]) + }, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, notify, appID, t, updatePublishedWorkflow, updateAppDetail, workflowStore, resetWorkflowVersionHistory, invalidateAppTriggers]) const onPublisherToggle = useCallback((state: boolean) => { if (state) @@ -141,27 +169,34 @@ const FeaturesTrigger = () => { return ( <> - <Button - className={cn( - 'text-components-button-secondary-text', - theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', - )} - onClick={handleShowFeatures} - > - <RiApps2AddLine className='mr-1 h-4 w-4 text-components-button-secondary-text' /> - {t('workflow.common.features')} - </Button> + {/* Feature button is only visible in chatflow mode (advanced-chat) */} + {isChatMode && ( + <Button + className={cn( + 'text-components-button-secondary-text', + theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', + )} + onClick={handleShowFeatures} + > + <RiApps2AddLine className='mr-1 h-4 w-4 text-components-button-secondary-text' /> + {t('workflow.common.features')} + </Button> + )} <AppPublisher {...{ publishedAt, draftUpdatedAt, - disabled: nodesReadOnly, + disabled: nodesReadOnly || !hasWorkflowNodes, toolPublished, inputs: variables, onRefreshData: handleToolConfigureUpdate, onPublish, onToggle: onPublisherToggle, + workflowToolAvailable: lastPublishedHasUserInput, crossAxisOffset: 4, + missingStartNode: !startNode, + hasTriggerNode, + publishDisabled: !hasWorkflowNodes, }} /> </> diff --git a/web/app/components/workflow-app/components/workflow-header/index.tsx b/web/app/components/workflow-app/components/workflow-header/index.tsx index 53a050146e..c0b8a37b87 100644 --- a/web/app/components/workflow-app/components/workflow-header/index.tsx +++ b/web/app/components/workflow-app/components/workflow-header/index.tsx @@ -41,8 +41,8 @@ const WorkflowHeader = () => { return { normal: { components: { - left: <ChatVariableTrigger />, middle: <FeaturesTrigger />, + chatVariableTrigger: <ChatVariableTrigger />, }, runAndHistoryProps: { showRunButton: !isChatMode, diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index f979a12f26..e90b2904c9 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -66,6 +66,10 @@ const WorkflowMain = ({ handleStartWorkflowRun, handleWorkflowStartRunInChatflow, handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, } = useWorkflowStartRun() const availableNodesMetaData = useAvailableNodesMetaData() const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl() @@ -108,6 +112,10 @@ const WorkflowMain = ({ handleStartWorkflowRun, handleWorkflowStartRunInChatflow, handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, availableNodesMetaData, getWorkflowRunAndTraceUrl, exportCheck, @@ -141,6 +149,10 @@ const WorkflowMain = ({ handleStartWorkflowRun, handleWorkflowStartRunInChatflow, handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, availableNodesMetaData, getWorkflowRunAndTraceUrl, exportCheck, diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx new file mode 100644 index 0000000000..747a232ca7 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx @@ -0,0 +1,99 @@ +'use client' +import type { FC } from 'react' +import { + useCallback, + useEffect, +} from 'react' +import { useTranslation } from 'react-i18next' +import { BlockEnum } from '@/app/components/workflow/types' +import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types' +import Modal from '@/app/components/base/modal' +import StartNodeSelectionPanel from './start-node-selection-panel' +import { useDocLink } from '@/context/i18n' + +type WorkflowOnboardingModalProps = { + isShow: boolean + onClose: () => void + onSelectStartNode: (nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => void +} + +const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({ + isShow, + onClose, + onSelectStartNode, +}) => { + const { t } = useTranslation() + const docLink = useDocLink() + + const handleSelectUserInput = useCallback(() => { + onSelectStartNode(BlockEnum.Start) + onClose() // Close modal after selection + }, [onSelectStartNode, onClose]) + + const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => { + onSelectStartNode(nodeType, toolConfig) + onClose() // Close modal after selection + }, [onSelectStartNode, onClose]) + + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isShow) + onClose() + } + document.addEventListener('keydown', handleEsc) + return () => document.removeEventListener('keydown', handleEsc) + }, [isShow, onClose]) + + return ( + <> + <Modal + isShow={isShow} + onClose={onClose} + className="w-[618px] max-w-[618px] rounded-2xl border border-effects-highlight bg-background-default-subtle shadow-lg" + overlayOpacity + closable + clickOutsideNotClose + > + <div className="pb-4"> + {/* Header */} + <div className="mb-6"> + <h3 className="title-2xl-semi-bold mb-2 text-text-primary"> + {t('workflow.onboarding.title')} + </h3> + <div className="body-xs-regular leading-4 text-text-tertiary"> + {t('workflow.onboarding.description')}{' '} + <a + href={docLink('/guides/workflow/node/start')} + target="_blank" + rel="noopener noreferrer" + className="hover:text-text-accent-hover cursor-pointer text-text-accent underline" + > + {t('workflow.onboarding.learnMore')} + </a>{' '} + {t('workflow.onboarding.aboutStartNode')} + </div> + </div> + + {/* Content */} + <StartNodeSelectionPanel + onSelectUserInput={handleSelectUserInput} + onSelectTrigger={handleTriggerSelect} + /> + </div> + </Modal> + + {/* ESC tip below modal */} + {isShow && ( + <div className="body-xs-regular pointer-events-none fixed left-1/2 top-1/2 z-[70] flex -translate-x-1/2 translate-y-[165px] items-center gap-1 text-text-quaternary"> + <span>{t('workflow.onboarding.escTip.press')}</span> + <kbd className="system-kbd inline-flex h-4 min-w-4 items-center justify-center rounded bg-components-kbd-bg-gray px-1 text-text-tertiary"> + {t('workflow.onboarding.escTip.key')} + </kbd> + <span>{t('workflow.onboarding.escTip.toDismiss')}</span> + </div> + )} + </> + ) +} + +export default WorkflowOnboardingModal diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx new file mode 100644 index 0000000000..e28de39fdd --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx @@ -0,0 +1,53 @@ +'use client' +import type { FC, ReactNode } from 'react' +import cn from '@/utils/classnames' + +type StartNodeOptionProps = { + icon: ReactNode + title: string + subtitle?: string + description: string + onClick: () => void +} + +const StartNodeOption: FC<StartNodeOptionProps> = ({ + icon, + title, + subtitle, + description, + onClick, +}) => { + return ( + <div + onClick={onClick} + className={cn( + 'hover:border-components-panel-border-active flex h-40 w-[280px] cursor-pointer flex-col gap-2 rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-4 shadow-sm transition-all hover:shadow-md', + )} + > + {/* Icon */} + <div className="shrink-0"> + {icon} + </div> + + {/* Text content */} + <div className="flex h-[74px] flex-col gap-1 py-0.5"> + <div className="h-5 leading-5"> + <h3 className="system-md-semi-bold text-text-primary"> + {title} + {subtitle && ( + <span className="system-md-regular text-text-quaternary"> {subtitle}</span> + )} + </h3> + </div> + + <div className="h-12 leading-4"> + <p className="system-xs-regular text-text-tertiary"> + {description} + </p> + </div> + </div> + </div> + ) +} + +export default StartNodeOption diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx new file mode 100644 index 0000000000..de934a13b2 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx @@ -0,0 +1,80 @@ +'use client' +import type { FC } from 'react' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import StartNodeOption from './start-node-option' +import NodeSelector from '@/app/components/workflow/block-selector' +import { Home } from '@/app/components/base/icons/src/vender/workflow' +import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow' +import { BlockEnum } from '@/app/components/workflow/types' +import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types' +import { TabsEnum } from '@/app/components/workflow/block-selector/types' + +type StartNodeSelectionPanelProps = { + onSelectUserInput: () => void + onSelectTrigger: (nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => void +} + +const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({ + onSelectUserInput, + onSelectTrigger, +}) => { + const { t } = useTranslation() + const [showTriggerSelector, setShowTriggerSelector] = useState(false) + + const handleTriggerClick = useCallback(() => { + setShowTriggerSelector(true) + }, []) + + const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => { + setShowTriggerSelector(false) + onSelectTrigger(nodeType, toolConfig) + }, [onSelectTrigger]) + + return ( + <div className="grid grid-cols-2 gap-4"> + <StartNodeOption + icon={ + <div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2"> + <Home className="h-5 w-5 text-white" /> + </div> + } + title={t('workflow.onboarding.userInputFull')} + description={t('workflow.onboarding.userInputDescription')} + onClick={onSelectUserInput} + /> + + <NodeSelector + open={showTriggerSelector} + onOpenChange={setShowTriggerSelector} + onSelect={handleTriggerSelect} + placement="right" + offset={-200} + noBlocks={true} + showStartTab={true} + defaultActiveTab={TabsEnum.Start} + forceShowStartContent={true} + availableBlocksTypes={[ + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ]} + trigger={() => ( + <StartNodeOption + icon={ + <div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2"> + <TriggerAll className="h-5 w-5 text-white" /> + </div> + } + title={t('workflow.onboarding.trigger')} + description={t('workflow.onboarding.triggerDescription')} + onClick={handleTriggerClick} + /> + )} + popupClassName="z-[1200]" + /> + </div> + ) +} + +export default StartNodeSelectionPanel diff --git a/web/app/components/workflow-app/hooks/use-auto-onboarding.ts b/web/app/components/workflow-app/hooks/use-auto-onboarding.ts new file mode 100644 index 0000000000..e4f5774adf --- /dev/null +++ b/web/app/components/workflow-app/hooks/use-auto-onboarding.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect } from 'react' +import { useStoreApi } from 'reactflow' +import { useWorkflowStore } from '@/app/components/workflow/store' + +export const useAutoOnboarding = () => { + const store = useStoreApi() + const workflowStore = useWorkflowStore() + + const checkAndShowOnboarding = useCallback(() => { + const { getNodes } = store.getState() + const { + showOnboarding, + hasShownOnboarding, + notInitialWorkflow, + setShowOnboarding, + setHasShownOnboarding, + setShouldAutoOpenStartNodeSelector, + } = workflowStore.getState() + + // Skip if already showing onboarding or it's the initial workflow creation + if (showOnboarding || notInitialWorkflow) + return + + const nodes = getNodes() + + // Check if canvas is completely empty (no nodes at all) + // Only trigger onboarding when canvas is completely blank to avoid data loss + const isCompletelyEmpty = nodes.length === 0 + + // Show onboarding only if canvas is completely empty and we haven't shown it before in this session + if (isCompletelyEmpty && !hasShownOnboarding) { + setShowOnboarding?.(true) + setHasShownOnboarding?.(true) + setShouldAutoOpenStartNodeSelector?.(true) + } + }, [store, workflowStore]) + + const handleOnboardingClose = useCallback(() => { + const { + setShowOnboarding, + setHasShownOnboarding, + setShouldAutoOpenStartNodeSelector, + hasSelectedStartNode, + setHasSelectedStartNode, + } = workflowStore.getState() + setShowOnboarding?.(false) + setHasShownOnboarding?.(true) + if (hasSelectedStartNode) + setHasSelectedStartNode?.(false) + else + setShouldAutoOpenStartNodeSelector?.(false) + }, [workflowStore]) + + // Check on mount and when nodes change + useEffect(() => { + // Small delay to ensure the workflow data is loaded + const timer = setTimeout(() => { + checkAndShowOnboarding() + }, 500) + + return () => clearTimeout(timer) + }, [checkAndShowOnboarding]) + + return { + checkAndShowOnboarding, + handleOnboardingClose, + } +} diff --git a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts index ba51b06401..aefcd33102 100644 --- a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts @@ -1,7 +1,10 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useGetLanguage } from '@/context/i18n' +import { useDocLink } from '@/context/i18n' import StartDefault from '@/app/components/workflow/nodes/start/default' +import TriggerWebhookDefault from '@/app/components/workflow/nodes/trigger-webhook/default' +import TriggerScheduleDefault from '@/app/components/workflow/nodes/trigger-schedule/default' +import TriggerPluginDefault from '@/app/components/workflow/nodes/trigger-plugin/default' import EndDefault from '@/app/components/workflow/nodes/end/default' import AnswerDefault from '@/app/components/workflow/nodes/answer/default' import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node' @@ -12,7 +15,7 @@ import { BlockEnum } from '@/app/components/workflow/types' export const useAvailableNodesMetaData = () => { const { t } = useTranslation() const isChatMode = useIsChatMode() - const language = useGetLanguage() + const docLink = useDocLink() const mergedNodesMetaData = useMemo(() => [ ...WORKFLOW_COMMON_NODES, @@ -20,28 +23,27 @@ export const useAvailableNodesMetaData = () => { ...( isChatMode ? [AnswerDefault] - : [EndDefault] + : [ + EndDefault, + TriggerWebhookDefault, + TriggerScheduleDefault, + TriggerPluginDefault, + ] ), ], [isChatMode]) - const prefixLink = useMemo(() => { - if (language === 'zh_Hans') - return 'https://docs.dify.ai/zh-hans/guides/workflow/node/' - - return 'https://docs.dify.ai/guides/workflow/node/' - }, [language]) - const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => { const { metaData } = node const title = t(`workflow.blocks.${metaData.type}`) const description = t(`workflow.blocksAbout.${metaData.type}`) + const helpLinkPath = `guides/workflow/node/${metaData.helpLinkUri}` return { ...node, metaData: { ...metaData, title, description, - helpLinkUri: `${prefixLink}${metaData.helpLinkUri}`, + helpLinkUri: docLink(helpLinkPath), }, defaultValue: { ...node.defaultValue, @@ -49,7 +51,7 @@ export const useAvailableNodesMetaData = () => { title, }, } - }), [mergedNodesMetaData, t, prefixLink]) + }), [mergedNodesMetaData, t, docLink]) const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => { acc![node.metaData.type] = node diff --git a/web/app/components/workflow-app/hooks/use-is-chat-mode.ts b/web/app/components/workflow-app/hooks/use-is-chat-mode.ts index 3cdfc77b2a..d286c1a540 100644 --- a/web/app/components/workflow-app/hooks/use-is-chat-mode.ts +++ b/web/app/components/workflow-app/hooks/use-is-chat-mode.ts @@ -1,7 +1,8 @@ import { useStore as useAppStore } from '@/app/components/app/store' +import { AppModeEnum } from '@/types/app' export const useIsChatMode = () => { const appDetail = useAppStore(s => s.appDetail) - return appDetail?.mode === 'advanced-chat' + return appDetail?.mode === AppModeEnum.ADVANCED_CHAT } diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index c1f40c9d8c..56d9021feb 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -1,14 +1,9 @@ import { useCallback } from 'react' import { produce } from 'immer' import { useStoreApi } from 'reactflow' -import { useParams } from 'next/navigation' -import { - useWorkflowStore, -} from '@/app/components/workflow/store' -import { BlockEnum } from '@/app/components/workflow/types' -import { - useNodesReadOnly, -} from '@/app/components/workflow/hooks/use-workflow' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow' +import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' import { syncWorkflowDraft } from '@/service/workflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { API_PREFIX } from '@/config' @@ -20,7 +15,6 @@ export const useNodesSyncDraft = () => { const featuresStore = useFeaturesStore() const { getNodesReadOnly } = useNodesReadOnly() const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() - const params = useParams() const getPostParams = useCallback(() => { const { @@ -28,65 +22,60 @@ export const useNodesSyncDraft = () => { edges, transform, } = store.getState() - const nodes = getNodes() + const nodes = getNodes().filter(node => !node.data?._isTempNode) const [x, y, zoom] = transform const { appId, conversationVariables, environmentVariables, syncWorkflowDraftHash, + isWorkflowDataLoaded, } = workflowStore.getState() - if (appId && !!nodes.length) { - const hasStartNode = nodes.find(node => node.data.type === BlockEnum.Start) + if (!appId || !isWorkflowDataLoaded) + return null - if (!hasStartNode) - return - - const features = featuresStore!.getState().features - const producedNodes = produce(nodes, (draft) => { - draft.forEach((node) => { - Object.keys(node.data).forEach((key) => { - if (key.startsWith('_')) - delete node.data[key] - }) + const features = featuresStore!.getState().features + const producedNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + Object.keys(node.data).forEach((key) => { + if (key.startsWith('_')) + delete node.data[key] }) }) - const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp), (draft) => { - draft.forEach((edge) => { - Object.keys(edge.data).forEach((key) => { - if (key.startsWith('_')) - delete edge.data[key] - }) + }) + const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp), (draft) => { + draft.forEach((edge) => { + Object.keys(edge.data).forEach((key) => { + if (key.startsWith('_')) + delete edge.data[key] }) }) - return { - url: `/apps/${appId}/workflows/draft`, - params: { - graph: { - nodes: producedNodes, - edges: producedEdges, - viewport: { - x, - y, - zoom, - }, - }, - features: { - opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '', - suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [], - suggested_questions_after_answer: features.suggested, - text_to_speech: features.text2speech, - speech_to_text: features.speech2text, - retriever_resource: features.citation, - sensitive_word_avoidance: features.moderation, - file_upload: features.file, - }, - environment_variables: environmentVariables, - conversation_variables: conversationVariables, - hash: syncWorkflowDraftHash, + }) + const viewport = { x, y, zoom } + + return { + url: `/apps/${appId}/workflows/draft`, + params: { + graph: { + nodes: producedNodes, + edges: producedEdges, + viewport, }, - } + features: { + opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '', + suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [], + suggested_questions_after_answer: features.suggested, + text_to_speech: features.text2speech, + speech_to_text: features.speech2text, + retriever_resource: features.citation, + sensitive_word_avoidance: features.moderation, + file_upload: features.file, + }, + environment_variables: environmentVariables, + conversation_variables: conversationVariables, + hash: syncWorkflowDraftHash, + }, } }, [store, featuresStore, workflowStore]) @@ -95,15 +84,11 @@ export const useNodesSyncDraft = () => { return const postParams = getPostParams() - if (postParams) { - navigator.sendBeacon( - `${API_PREFIX}/apps/${params.appId}/workflows/draft`, - JSON.stringify(postParams.params), - ) - } - }, [getPostParams, params.appId, getNodesReadOnly]) + if (postParams) + navigator.sendBeacon(`${API_PREFIX}${postParams.url}`, JSON.stringify(postParams.params)) + }, [getPostParams, getNodesReadOnly]) - const doSyncWorkflowDraft = useCallback(async ( + const performSync = useCallback(async ( notRefreshWhenSyncError?: boolean, callback?: { onSuccess?: () => void @@ -141,6 +126,8 @@ export const useNodesSyncDraft = () => { } }, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft]) + const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly) + return { doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose, diff --git a/web/app/components/workflow-app/hooks/use-workflow-init.ts b/web/app/components/workflow-app/hooks/use-workflow-init.ts index fadd2007bc..a0a6cc22a1 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-init.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-init.ts @@ -18,7 +18,20 @@ import { import type { FetchWorkflowDraftResponse } from '@/types/workflow' import { useWorkflowConfig } from '@/service/use-workflow' import type { FileUploadConfigResponse } from '@/models/common' +import type { Edge, Node } from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import { AppModeEnum } from '@/types/app' +const hasConnectedUserInput = (nodes: Node[] = [], edges: Edge[] = []): boolean => { + const startNodeIds = nodes + .filter(node => node?.data?.type === BlockEnum.Start) + .map(node => node.id) + + if (!startNodeIds.length) + return false + + return edges.some(edge => startNodeIds.includes(edge.source)) +} export const useWorkflowInit = () => { const workflowStore = useWorkflowStore() const { @@ -53,6 +66,7 @@ export const useWorkflowInit = () => { }, {} as Record<string, string>), environmentVariables: res.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [], conversationVariables: res.conversation_variables || [], + isWorkflowDataLoaded: true, }) setSyncWorkflowDraftHash(res.hash) setIsLoading(false) @@ -61,13 +75,22 @@ export const useWorkflowInit = () => { if (error && error.json && !error.bodyUsed && appDetail) { error.json().then((err: any) => { if (err.code === 'draft_workflow_not_exist') { - workflowStore.setState({ notInitialWorkflow: true }) + const isAdvancedChat = appDetail.mode === AppModeEnum.ADVANCED_CHAT + workflowStore.setState({ + notInitialWorkflow: true, + showOnboarding: !isAdvancedChat, + shouldAutoOpenStartNodeSelector: !isAdvancedChat, + hasShownOnboarding: false, + }) + const nodesData = isAdvancedChat ? nodesTemplate : [] + const edgesData = isAdvancedChat ? edgesTemplate : [] + syncWorkflowDraft({ url: `/apps/${appDetail.id}/workflows/draft`, params: { graph: { - nodes: nodesTemplate, - edges: edgesTemplate, + nodes: nodesData, + edges: edgesData, }, features: { retriever_resource: { enabled: true }, @@ -101,9 +124,14 @@ export const useWorkflowInit = () => { }, {} as Record<string, any>), }) workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at) + const graph = publishedWorkflow?.graph + workflowStore.getState().setLastPublishedHasUserInput( + hasConnectedUserInput(graph?.nodes, graph?.edges), + ) } catch (e) { console.error(e) + workflowStore.getState().setLastPublishedHasUserInput(false) } }, [workflowStore, appDetail]) diff --git a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts index c944e10c4c..910ddd4a8d 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts @@ -16,18 +16,43 @@ export const useWorkflowRefreshDraft = () => { setEnvironmentVariables, setEnvSecrets, setConversationVariables, + setIsWorkflowDataLoaded, + isWorkflowDataLoaded, + debouncedSyncWorkflowDraft, } = workflowStore.getState() + + if (debouncedSyncWorkflowDraft && typeof (debouncedSyncWorkflowDraft as any).cancel === 'function') + (debouncedSyncWorkflowDraft as any).cancel() + + const wasLoaded = isWorkflowDataLoaded + if (wasLoaded) + setIsWorkflowDataLoaded(false) setIsSyncingWorkflowDraft(true) - fetchWorkflowDraft(`/apps/${appId}/workflows/draft`).then((response) => { - handleUpdateWorkflowCanvas(response.graph as WorkflowDataUpdater) - setSyncWorkflowDraftHash(response.hash) - setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { - acc[env.id] = env.value - return acc - }, {} as Record<string, string>)) - setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || []) - setConversationVariables(response.conversation_variables || []) - }).finally(() => setIsSyncingWorkflowDraft(false)) + fetchWorkflowDraft(`/apps/${appId}/workflows/draft`) + .then((response) => { + // Ensure we have a valid workflow structure with viewport + const workflowData: WorkflowDataUpdater = { + nodes: response.graph?.nodes || [], + edges: response.graph?.edges || [], + viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 }, + } + handleUpdateWorkflowCanvas(workflowData) + setSyncWorkflowDraftHash(response.hash) + setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { + acc[env.id] = env.value + return acc + }, {} as Record<string, string>)) + setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || []) + setConversationVariables(response.conversation_variables || []) + setIsWorkflowDataLoaded(true) + }) + .catch(() => { + if (wasLoaded) + setIsWorkflowDataLoaded(true) + }) + .finally(() => { + setIsSyncingWorkflowDraft(false) + }) }, [handleUpdateWorkflowCanvas, workflowStore]) return { diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index 0cfcd6099b..3ab1c522e7 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { useReactFlow, useStoreApi, @@ -12,7 +12,8 @@ import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow- import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' import { useStore as useAppStore } from '@/app/components/app/store' import type { IOtherOptions } from '@/service/base' -import { ssePost } from '@/service/base' +import Toast from '@/app/components/base/toast' +import { handleStream, ssePost } from '@/service/base' import { stopWorkflowRun } from '@/service/workflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager' @@ -22,6 +23,35 @@ import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useInvalidAllLastRun } from '@/service/use-workflow' import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars' import { useConfigsMap } from './use-configs-map' +import { post } from '@/service/base' +import { ContentType } from '@/service/fetch' +import { TriggerType } from '@/app/components/workflow/header/test-run-menu' +import { AppModeEnum } from '@/types/app' + +type HandleRunMode = TriggerType +type HandleRunOptions = { + mode?: HandleRunMode + scheduleNodeId?: string + webhookNodeId?: string + pluginNodeId?: string + allNodeIds?: string[] +} + +type DebuggableTriggerType = Exclude<TriggerType, TriggerType.UserInput> + +const controllerKeyMap: Record<DebuggableTriggerType, string> = { + [TriggerType.Webhook]: '__webhookDebugAbortController', + [TriggerType.Plugin]: '__pluginDebugAbortController', + [TriggerType.All]: '__allTriggersDebugAbortController', + [TriggerType.Schedule]: '__scheduleDebugAbortController', +} + +const debugLabelMap: Record<DebuggableTriggerType, string> = { + [TriggerType.Webhook]: 'Webhook', + [TriggerType.Plugin]: 'Plugin', + [TriggerType.All]: 'All', + [TriggerType.Schedule]: 'Schedule', +} export const useWorkflowRun = () => { const store = useStoreApi() @@ -39,6 +69,8 @@ export const useWorkflowRun = () => { ...configsMap, }) + const abortControllerRef = useRef<AbortController | null>(null) + const { handleWorkflowStarted, handleWorkflowFinished, @@ -111,7 +143,10 @@ export const useWorkflowRun = () => { const handleRun = useCallback(async ( params: any, callback?: IOtherOptions, + options?: HandleRunOptions, ) => { + const runMode: HandleRunMode = options?.mode ?? TriggerType.UserInput + const resolvedParams = params ?? {} const { getNodes, setNodes, @@ -139,6 +174,7 @@ export const useWorkflowRun = () => { onNodeRetry, onAgentLog, onError, + onCompleted, ...restCallback } = callback || {} workflowStore.setState({ historyWorkflowData: undefined }) @@ -150,175 +186,531 @@ export const useWorkflowRun = () => { clientHeight, } = workflowContainer! - const isInWorkflowDebug = appDetail?.mode === 'workflow' + const isInWorkflowDebug = appDetail?.mode === AppModeEnum.WORKFLOW let url = '' - if (appDetail?.mode === 'advanced-chat') + if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) { + if (!appDetail?.id) { + console.error('handleRun: missing app id for trigger plugin run') + return + } + url = `/apps/${appDetail.id}/workflows/draft/trigger/run` + } + else if (runMode === TriggerType.All) { + if (!appDetail?.id) { + console.error('handleRun: missing app id for trigger run all') + return + } + url = `/apps/${appDetail.id}/workflows/draft/trigger/run-all` + } + else if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { url = `/apps/${appDetail.id}/advanced-chat/workflows/draft/run` - - if (isInWorkflowDebug) + } + else if (isInWorkflowDebug && appDetail?.id) { url = `/apps/${appDetail.id}/workflows/draft/run` + } + + let requestBody = {} + + if (runMode === TriggerType.Schedule) + requestBody = { node_id: options?.scheduleNodeId } + + else if (runMode === TriggerType.Webhook) + requestBody = { node_id: options?.webhookNodeId } + + else if (runMode === TriggerType.Plugin) + requestBody = { node_id: options?.pluginNodeId } + + else if (runMode === TriggerType.All) + requestBody = { node_ids: options?.allNodeIds } + + else + requestBody = resolvedParams + + if (!url) + return + + if (runMode === TriggerType.Schedule && !options?.scheduleNodeId) { + console.error('handleRun: schedule trigger run requires node id') + return + } + + if (runMode === TriggerType.Webhook && !options?.webhookNodeId) { + console.error('handleRun: webhook trigger run requires node id') + return + } + + if (runMode === TriggerType.Plugin && !options?.pluginNodeId) { + console.error('handleRun: plugin trigger run requires node id') + return + } + + if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0) { + console.error('handleRun: all trigger run requires node ids') + return + } + + abortControllerRef.current?.abort() + abortControllerRef.current = null const { setWorkflowRunningData, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + setListeningTriggerNodeId, } = workflowStore.getState() - setWorkflowRunningData({ - result: { - inputs_truncated: false, - process_data_truncated: false, - outputs_truncated: false, - status: WorkflowRunningStatus.Running, - }, - tracing: [], - resultText: '', - }) + + if ( + runMode === TriggerType.Webhook + || runMode === TriggerType.Plugin + || runMode === TriggerType.All + || runMode === TriggerType.Schedule + ) { + setIsListening(true) + setShowVariableInspectPanel(true) + setListeningTriggerIsAll(runMode === TriggerType.All) + if (runMode === TriggerType.All) + setListeningTriggerNodeIds(options?.allNodeIds ?? []) + else if (runMode === TriggerType.Webhook && options?.webhookNodeId) + setListeningTriggerNodeIds([options.webhookNodeId]) + else if (runMode === TriggerType.Schedule && options?.scheduleNodeId) + setListeningTriggerNodeIds([options.scheduleNodeId]) + else if (runMode === TriggerType.Plugin && options?.pluginNodeId) + setListeningTriggerNodeIds([options.pluginNodeId]) + else + setListeningTriggerNodeIds([]) + setWorkflowRunningData({ + result: { + status: WorkflowRunningStatus.Running, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + resultText: '', + }) + } + else { + setIsListening(false) + setListeningTriggerType(null) + setListeningTriggerNodeId(null) + setListeningTriggerNodeIds([]) + setListeningTriggerIsAll(false) + setWorkflowRunningData({ + result: { + status: WorkflowRunningStatus.Running, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + resultText: '', + }) + } let ttsUrl = '' let ttsIsPublic = false - if (params.token) { + if (resolvedParams.token) { ttsUrl = '/text-to-audio' ttsIsPublic = true } - else if (params.appId) { + else if (resolvedParams.appId) { if (pathname.search('explore/installed') > -1) - ttsUrl = `/installed-apps/${params.appId}/text-to-audio` + ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio` else - ttsUrl = `/apps/${params.appId}/text-to-audio` + ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio` } const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', noop) + const clearAbortController = () => { + abortControllerRef.current = null + delete (window as any).__webhookDebugAbortController + delete (window as any).__pluginDebugAbortController + delete (window as any).__scheduleDebugAbortController + delete (window as any).__allTriggersDebugAbortController + } + + const clearListeningState = () => { + const state = workflowStore.getState() + state.setIsListening(false) + state.setListeningTriggerType(null) + state.setListeningTriggerNodeId(null) + state.setListeningTriggerNodeIds([]) + state.setListeningTriggerIsAll(false) + } + + const wrappedOnError = (params: any) => { + clearAbortController() + handleWorkflowFailed() + clearListeningState() + + if (onError) + onError(params) + } + + const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError?: boolean, errorMessage?: string) => { + clearAbortController() + clearListeningState() + if (onCompleted) + onCompleted(hasError, errorMessage) + } + + const baseSseOptions: IOtherOptions = { + ...restCallback, + onWorkflowStarted: (params) => { + const state = workflowStore.getState() + if (state.workflowRunningData) { + state.setWorkflowRunningData(produce(state.workflowRunningData, (draft) => { + draft.resultText = '' + })) + } + handleWorkflowStarted(params) + + if (onWorkflowStarted) + onWorkflowStarted(params) + }, + onWorkflowFinished: (params) => { + clearListeningState() + handleWorkflowFinished(params) + + if (onWorkflowFinished) + onWorkflowFinished(params) + if (isInWorkflowDebug) { + fetchInspectVars({}) + invalidAllLastRun() + } + }, + onNodeStarted: (params) => { + handleWorkflowNodeStarted( + params, + { + clientWidth, + clientHeight, + }, + ) + + if (onNodeStarted) + onNodeStarted(params) + }, + onNodeFinished: (params) => { + handleWorkflowNodeFinished(params) + + if (onNodeFinished) + onNodeFinished(params) + }, + onIterationStart: (params) => { + handleWorkflowNodeIterationStarted( + params, + { + clientWidth, + clientHeight, + }, + ) + + if (onIterationStart) + onIterationStart(params) + }, + onIterationNext: (params) => { + handleWorkflowNodeIterationNext(params) + + if (onIterationNext) + onIterationNext(params) + }, + onIterationFinish: (params) => { + handleWorkflowNodeIterationFinished(params) + + if (onIterationFinish) + onIterationFinish(params) + }, + onLoopStart: (params) => { + handleWorkflowNodeLoopStarted( + params, + { + clientWidth, + clientHeight, + }, + ) + + if (onLoopStart) + onLoopStart(params) + }, + onLoopNext: (params) => { + handleWorkflowNodeLoopNext(params) + + if (onLoopNext) + onLoopNext(params) + }, + onLoopFinish: (params) => { + handleWorkflowNodeLoopFinished(params) + + if (onLoopFinish) + onLoopFinish(params) + }, + onNodeRetry: (params) => { + handleWorkflowNodeRetry(params) + + if (onNodeRetry) + onNodeRetry(params) + }, + onAgentLog: (params) => { + handleWorkflowAgentLog(params) + + if (onAgentLog) + onAgentLog(params) + }, + onTextChunk: (params) => { + handleWorkflowTextChunk(params) + }, + onTextReplace: (params) => { + handleWorkflowTextReplace(params) + }, + onTTSChunk: (messageId: string, audio: string) => { + if (!audio || audio === '') + return + player.playAudioWithAudio(audio, true) + AudioPlayerManager.getInstance().resetMsgId(messageId) + }, + onTTSEnd: (messageId: string, audio: string) => { + player.playAudioWithAudio(audio, false) + }, + onError: wrappedOnError, + onCompleted: wrappedOnCompleted, + } + + const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => { + const timer = window.setTimeout(resolve, delay) + signal.addEventListener('abort', () => { + clearTimeout(timer) + resolve() + }, { once: true }) + }) + + const runTriggerDebug = async (debugType: DebuggableTriggerType) => { + const controller = new AbortController() + abortControllerRef.current = controller + + const controllerKey = controllerKeyMap[debugType] + + ; (window as any)[controllerKey] = controller + + const debugLabel = debugLabelMap[debugType] + + const poll = async (): Promise<void> => { + try { + const response = await post<Response>(url, { + body: requestBody, + signal: controller.signal, + }, { + needAllResponseContent: true, + }) + + if (controller.signal.aborted) + return + + if (!response) { + const message = `${debugLabel} debug request failed` + Toast.notify({ type: 'error', message }) + clearAbortController() + return + } + + const contentType = response.headers.get('content-type') || '' + + if (contentType.includes(ContentType.json)) { + let data: any = null + try { + data = await response.json() + } + catch (jsonError) { + console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError) + Toast.notify({ type: 'error', message: `${debugLabel} debug request failed` }) + clearAbortController() + clearListeningState() + return + } + + if (controller.signal.aborted) + return + + if (data?.status === 'waiting') { + const delay = Number(data.retry_in) || 2000 + await waitWithAbort(controller.signal, delay) + if (controller.signal.aborted) + return + await poll() + return + } + + const errorMessage = data?.message || `${debugLabel} debug failed` + Toast.notify({ type: 'error', message: errorMessage }) + clearAbortController() + setWorkflowRunningData({ + result: { + status: WorkflowRunningStatus.Failed, + error: errorMessage, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + }) + clearListeningState() + return + } + + clearListeningState() + handleStream( + response, + baseSseOptions.onData ?? noop, + baseSseOptions.onCompleted, + baseSseOptions.onThought, + baseSseOptions.onMessageEnd, + baseSseOptions.onMessageReplace, + baseSseOptions.onFile, + baseSseOptions.onWorkflowStarted, + baseSseOptions.onWorkflowFinished, + baseSseOptions.onNodeStarted, + baseSseOptions.onNodeFinished, + baseSseOptions.onIterationStart, + baseSseOptions.onIterationNext, + baseSseOptions.onIterationFinish, + baseSseOptions.onLoopStart, + baseSseOptions.onLoopNext, + baseSseOptions.onLoopFinish, + baseSseOptions.onNodeRetry, + baseSseOptions.onParallelBranchStarted, + baseSseOptions.onParallelBranchFinished, + baseSseOptions.onTextChunk, + baseSseOptions.onTTSChunk, + baseSseOptions.onTTSEnd, + baseSseOptions.onTextReplace, + baseSseOptions.onAgentLog, + baseSseOptions.onDataSourceNodeProcessing, + baseSseOptions.onDataSourceNodeCompleted, + baseSseOptions.onDataSourceNodeError, + ) + } + catch (error) { + if (controller.signal.aborted) + return + if (error instanceof Response) { + const data = await error.clone().json() as Record<string, any> + const { error: respError } = data || {} + Toast.notify({ type: 'error', message: respError }) + clearAbortController() + setWorkflowRunningData({ + result: { + status: WorkflowRunningStatus.Failed, + error: respError, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + }) + } + clearListeningState() + } + } + + await poll() + } + + if (runMode === TriggerType.Schedule) { + await runTriggerDebug(TriggerType.Schedule) + return + } + + if (runMode === TriggerType.Webhook) { + await runTriggerDebug(TriggerType.Webhook) + return + } + + if (runMode === TriggerType.Plugin) { + await runTriggerDebug(TriggerType.Plugin) + return + } + + if (runMode === TriggerType.All) { + await runTriggerDebug(TriggerType.All) + return + } + ssePost( url, { - body: params, + body: requestBody, }, { - onWorkflowStarted: (params) => { - handleWorkflowStarted(params) - - if (onWorkflowStarted) - onWorkflowStarted(params) + ...baseSseOptions, + getAbortController: (controller: AbortController) => { + abortControllerRef.current = controller }, - onWorkflowFinished: (params) => { - handleWorkflowFinished(params) - - if (onWorkflowFinished) - onWorkflowFinished(params) - if (isInWorkflowDebug) { - fetchInspectVars({}) - invalidAllLastRun() - } - }, - onError: (params) => { - handleWorkflowFailed() - - if (onError) - onError(params) - }, - onNodeStarted: (params) => { - handleWorkflowNodeStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onNodeStarted) - onNodeStarted(params) - }, - onNodeFinished: (params) => { - handleWorkflowNodeFinished(params) - - if (onNodeFinished) - onNodeFinished(params) - }, - onIterationStart: (params) => { - handleWorkflowNodeIterationStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onIterationStart) - onIterationStart(params) - }, - onIterationNext: (params) => { - handleWorkflowNodeIterationNext(params) - - if (onIterationNext) - onIterationNext(params) - }, - onIterationFinish: (params) => { - handleWorkflowNodeIterationFinished(params) - - if (onIterationFinish) - onIterationFinish(params) - }, - onLoopStart: (params) => { - handleWorkflowNodeLoopStarted( - params, - { - clientWidth, - clientHeight, - }, - ) - - if (onLoopStart) - onLoopStart(params) - }, - onLoopNext: (params) => { - handleWorkflowNodeLoopNext(params) - - if (onLoopNext) - onLoopNext(params) - }, - onLoopFinish: (params) => { - handleWorkflowNodeLoopFinished(params) - - if (onLoopFinish) - onLoopFinish(params) - }, - onNodeRetry: (params) => { - handleWorkflowNodeRetry(params) - - if (onNodeRetry) - onNodeRetry(params) - }, - onAgentLog: (params) => { - handleWorkflowAgentLog(params) - - if (onAgentLog) - onAgentLog(params) - }, - onTextChunk: (params) => { - handleWorkflowTextChunk(params) - }, - onTextReplace: (params) => { - handleWorkflowTextReplace(params) - }, - onTTSChunk: (messageId: string, audio: string) => { - if (!audio || audio === '') - return - player.playAudioWithAudio(audio, true) - AudioPlayerManager.getInstance().resetMsgId(messageId) - }, - onTTSEnd: (messageId: string, audio: string) => { - player.playAudioWithAudio(audio, false) - }, - ...restCallback, }, ) }, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace], ) const handleStopRun = useCallback((taskId: string) => { - const appId = useAppStore.getState().appDetail?.id + const setStoppedState = () => { + const { + setWorkflowRunningData, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + setListeningTriggerNodeId, + } = workflowStore.getState() - stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`) - }, []) + setWorkflowRunningData({ + result: { + status: WorkflowRunningStatus.Stopped, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + resultText: '', + }) + setIsListening(false) + setListeningTriggerType(null) + setListeningTriggerNodeId(null) + setShowVariableInspectPanel(true) + } + + if (taskId) { + const appId = useAppStore.getState().appDetail?.id + stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`) + setStoppedState() + return + } + + // Try webhook debug controller from global variable first + const webhookController = (window as any).__webhookDebugAbortController + if (webhookController) + webhookController.abort() + + const pluginController = (window as any).__pluginDebugAbortController + if (pluginController) + pluginController.abort() + + const scheduleController = (window as any).__scheduleDebugAbortController + if (scheduleController) + scheduleController.abort() + + const allTriggerController = (window as any).__allTriggersDebugAbortController + if (allTriggerController) + allTriggerController.abort() + + // Also try the ref + if (abortControllerRef.current) + abortControllerRef.current.abort() + + abortControllerRef.current = null + setStoppedState() + }, [workflowStore]) const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => { const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } })) diff --git a/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx b/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx index 3f5ea1c1df..d2e3b3e3c9 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx +++ b/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx @@ -12,6 +12,7 @@ import { useNodesSyncDraft, useWorkflowRun, } from '.' +import { TriggerType } from '@/app/components/workflow/header/test-run-menu' export const useWorkflowStartRun = () => { const store = useStoreApi() @@ -40,9 +41,11 @@ export const useWorkflowStartRun = () => { setShowDebugAndPreviewPanel, setShowInputsPanel, setShowEnvPanel, + setShowGlobalVariablePanel, } = workflowStore.getState() setShowEnvPanel(false) + setShowGlobalVariablePanel(false) if (showDebugAndPreviewPanel) { handleCancelDebugAndPreviewPanel() @@ -61,6 +64,203 @@ export const useWorkflowStartRun = () => { } }, [store, workflowStore, featuresStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft]) + const handleWorkflowTriggerScheduleRunInWorkflow = useCallback(async (nodeId?: string) => { + if (!nodeId) + return + + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) + return + + const { getNodes } = store.getState() + const nodes = getNodes() + const scheduleNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerSchedule) + + if (!scheduleNode) { + console.warn('handleWorkflowTriggerScheduleRunInWorkflow: schedule node not found', nodeId) + return + } + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + + if (showDebugAndPreviewPanel) { + handleCancelDebugAndPreviewPanel() + return + } + + setListeningTriggerType(BlockEnum.TriggerSchedule) + setListeningTriggerNodeId(nodeId) + setListeningTriggerNodeIds([nodeId]) + setListeningTriggerIsAll(false) + + await doSyncWorkflowDraft() + handleRun( + {}, + undefined, + { + mode: TriggerType.Schedule, + scheduleNodeId: nodeId, + }, + ) + setShowDebugAndPreviewPanel(true) + setShowInputsPanel(false) + }, [store, workflowStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft]) + + const handleWorkflowTriggerWebhookRunInWorkflow = useCallback(async ({ nodeId }: { nodeId: string }) => { + if (!nodeId) + return + + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) + return + + const { getNodes } = store.getState() + const nodes = getNodes() + const webhookNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerWebhook) + + if (!webhookNode) { + console.warn('handleWorkflowTriggerWebhookRunInWorkflow: webhook node not found', nodeId) + return + } + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + + if (!showDebugAndPreviewPanel) + setShowDebugAndPreviewPanel(true) + + setShowInputsPanel(false) + setListeningTriggerType(BlockEnum.TriggerWebhook) + setListeningTriggerNodeId(nodeId) + setListeningTriggerNodeIds([nodeId]) + setListeningTriggerIsAll(false) + + await doSyncWorkflowDraft() + handleRun( + { node_id: nodeId }, + undefined, + { + mode: TriggerType.Webhook, + webhookNodeId: nodeId, + }, + ) + }, [store, workflowStore, handleRun, doSyncWorkflowDraft]) + + const handleWorkflowTriggerPluginRunInWorkflow = useCallback(async (nodeId?: string) => { + if (!nodeId) + return + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) + return + + const { getNodes } = store.getState() + const nodes = getNodes() + const pluginNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerPlugin) + + if (!pluginNode) { + console.warn('handleWorkflowTriggerPluginRunInWorkflow: plugin node not found', nodeId) + return + } + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + + if (!showDebugAndPreviewPanel) + setShowDebugAndPreviewPanel(true) + + setShowInputsPanel(false) + setListeningTriggerType(BlockEnum.TriggerPlugin) + setListeningTriggerNodeId(nodeId) + setListeningTriggerNodeIds([nodeId]) + setListeningTriggerIsAll(false) + + await doSyncWorkflowDraft() + handleRun( + { node_id: nodeId }, + undefined, + { + mode: TriggerType.Plugin, + pluginNodeId: nodeId, + }, + ) + }, [store, workflowStore, handleRun, doSyncWorkflowDraft]) + + const handleWorkflowRunAllTriggersInWorkflow = useCallback(async (nodeIds: string[]) => { + if (!nodeIds.length) + return + const { + workflowRunningData, + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + setShowEnvPanel, + setShowGlobalVariablePanel, + setListeningTriggerIsAll, + setListeningTriggerNodeIds, + setListeningTriggerNodeId, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) + return + + setShowEnvPanel(false) + setShowGlobalVariablePanel(false) + setShowInputsPanel(false) + setListeningTriggerIsAll(true) + setListeningTriggerNodeIds(nodeIds) + setListeningTriggerNodeId(null) + + if (!showDebugAndPreviewPanel) + setShowDebugAndPreviewPanel(true) + + await doSyncWorkflowDraft() + handleRun( + { node_ids: nodeIds }, + undefined, + { + mode: TriggerType.All, + allNodeIds: nodeIds, + }, + ) + }, [store, workflowStore, handleRun, doSyncWorkflowDraft]) + const handleWorkflowStartRunInChatflow = useCallback(async () => { const { showDebugAndPreviewPanel, @@ -68,10 +268,12 @@ export const useWorkflowStartRun = () => { setHistoryWorkflowData, setShowEnvPanel, setShowChatVariablePanel, + setShowGlobalVariablePanel, } = workflowStore.getState() setShowEnvPanel(false) setShowChatVariablePanel(false) + setShowGlobalVariablePanel(false) if (showDebugAndPreviewPanel) handleCancelDebugAndPreviewPanel() @@ -92,5 +294,9 @@ export const useWorkflowStartRun = () => { handleStartWorkflowRun, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, } } diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index df83b3ca26..fcd247ef22 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -10,6 +10,10 @@ import { import { useWorkflowInit, } from './hooks/use-workflow-init' +import { useAppTriggers } from '@/service/use-tools' +import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useWorkflowStore } from '@/app/components/workflow/store' import { initialEdges, initialNodes, @@ -24,13 +28,13 @@ import { WorkflowContextProvider, } from '@/app/components/workflow/context' import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store' -import { useWorkflowStore } from '@/app/components/workflow/store' import { createWorkflowSlice } from './store/workflow/workflow-slice' import WorkflowAppMain from './components/workflow-main' import { useSearchParams } from 'next/navigation' import { fetchRunDetail } from '@/service/log' import { useGetRunAndTraceUrl } from './hooks/use-get-run-and-trace-url' +import { AppModeEnum } from '@/types/app' const WorkflowAppWithAdditionalContext = () => { const { @@ -38,8 +42,46 @@ const WorkflowAppWithAdditionalContext = () => { isLoading, fileUploadConfigResponse, } = useWorkflowInit() + const workflowStore = useWorkflowStore() const { isLoadingCurrentWorkspace, currentWorkspace } = useAppContext() + // Initialize trigger status at application level + const { setTriggerStatuses } = useTriggerStatusStore() + const appDetail = useAppStore(s => s.appDetail) + const appId = appDetail?.id + const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW + const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId : undefined, { + staleTime: 5 * 60 * 1000, // 5 minutes cache + refetchOnWindowFocus: false, + }) + + // Sync trigger statuses to store when data loads + useEffect(() => { + if (triggersResponse?.data) { + // Map API status to EntryNodeStatus: 'enabled' stays 'enabled', all others become 'disabled' + const statusMap = triggersResponse.data.reduce((acc, trigger) => { + acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' + return acc + }, {} as Record<string, 'enabled' | 'disabled'>) + + setTriggerStatuses(statusMap) + } + }, [triggersResponse?.data, setTriggerStatuses]) + + // Cleanup on unmount + useEffect(() => { + return () => { + // Reset the loaded flag when component unmounts + workflowStore.setState({ isWorkflowDataLoaded: false }) + + // Cancel any pending debounced sync operations + const { debouncedSyncWorkflowDraft } = workflowStore.getState() + // The debounced function from lodash has a cancel method + if (debouncedSyncWorkflowDraft && 'cancel' in debouncedSyncWorkflowDraft) + (debouncedSyncWorkflowDraft as any).cancel() + } + }, [workflowStore]) + const nodesData = useMemo(() => { if (data) return initialNodes(data.graph.nodes, data.graph.edges) @@ -54,7 +96,6 @@ const WorkflowAppWithAdditionalContext = () => { }, [data]) const searchParams = useSearchParams() - const workflowStore = useWorkflowStore() const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl() const replayRunId = searchParams.get('replayRunId') diff --git a/web/app/components/workflow-app/store/workflow/workflow-slice.ts b/web/app/components/workflow-app/store/workflow/workflow-slice.ts index f26d9b509b..72230629f0 100644 --- a/web/app/components/workflow-app/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow-app/store/workflow/workflow-slice.ts @@ -5,8 +5,16 @@ export type WorkflowSliceShape = { appName: string notInitialWorkflow: boolean setNotInitialWorkflow: (notInitialWorkflow: boolean) => void + shouldAutoOpenStartNodeSelector: boolean + setShouldAutoOpenStartNodeSelector: (shouldAutoOpen: boolean) => void nodesDefaultConfigs: Record<string, any> setNodesDefaultConfigs: (nodesDefaultConfigs: Record<string, any>) => void + showOnboarding: boolean + setShowOnboarding: (showOnboarding: boolean) => void + hasSelectedStartNode: boolean + setHasSelectedStartNode: (hasSelectedStartNode: boolean) => void + hasShownOnboarding: boolean + setHasShownOnboarding: (hasShownOnboarding: boolean) => void } export type CreateWorkflowSlice = StateCreator<WorkflowSliceShape> @@ -15,6 +23,14 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({ appName: '', notInitialWorkflow: false, setNotInitialWorkflow: notInitialWorkflow => set(() => ({ notInitialWorkflow })), + shouldAutoOpenStartNodeSelector: false, + setShouldAutoOpenStartNodeSelector: shouldAutoOpenStartNodeSelector => set(() => ({ shouldAutoOpenStartNodeSelector })), nodesDefaultConfigs: {}, setNodesDefaultConfigs: nodesDefaultConfigs => set(() => ({ nodesDefaultConfigs })), + showOnboarding: false, + setShowOnboarding: showOnboarding => set(() => ({ showOnboarding })), + hasSelectedStartNode: false, + setHasSelectedStartNode: hasSelectedStartNode => set(() => ({ hasSelectedStartNode })), + hasShownOnboarding: false, + setHasShownOnboarding: hasShownOnboarding => set(() => ({ hasShownOnboarding })), }) diff --git a/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx b/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx new file mode 100644 index 0000000000..dc208047cb --- /dev/null +++ b/web/app/components/workflow/__tests__/trigger-status-sync.test.tsx @@ -0,0 +1,339 @@ +import React, { useCallback } from 'react' +import { act, render } from '@testing-library/react' +import { useTriggerStatusStore } from '../store/trigger-status' +import { isTriggerNode } from '../types' +import type { EntryNodeStatus } from '../store/trigger-status' + +// Mock the isTriggerNode function +jest.mock('../types', () => ({ + isTriggerNode: jest.fn(), +})) + +const mockIsTriggerNode = isTriggerNode as jest.MockedFunction<typeof isTriggerNode> + +// Test component that mimics BaseNode's usage pattern +const TestTriggerNode: React.FC<{ + nodeId: string + nodeType: string +}> = ({ nodeId, nodeType }) => { + const triggerStatus = useTriggerStatusStore(state => + mockIsTriggerNode(nodeType) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', + ) + + return ( + <div data-testid={`node-${nodeId}`} data-status={triggerStatus}> + Status: {triggerStatus} + </div> + ) +} + +// Test component that mimics TriggerCard's usage pattern +const TestTriggerController: React.FC = () => { + const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore() + + const handleToggle = (nodeId: string, enabled: boolean) => { + const newStatus = enabled ? 'enabled' : 'disabled' + setTriggerStatus(nodeId, newStatus) + } + + const handleBatchUpdate = (statuses: Record<string, EntryNodeStatus>) => { + setTriggerStatuses(statuses) + } + + return ( + <div> + <button + data-testid="toggle-node-1" + onClick={() => handleToggle('node-1', true)} + > + Enable Node 1 + </button> + <button + data-testid="toggle-node-2" + onClick={() => handleToggle('node-2', false)} + > + Disable Node 2 + </button> + <button + data-testid="batch-update" + onClick={() => handleBatchUpdate({ + 'node-1': 'disabled', + 'node-2': 'enabled', + 'node-3': 'enabled', + })} + > + Batch Update + </button> + </div> + ) +} + +describe('Trigger Status Synchronization Integration', () => { + beforeEach(() => { + // Clear store state + act(() => { + const store = useTriggerStatusStore.getState() + store.clearTriggerStatuses() + }) + + // Reset mocks + jest.clearAllMocks() + }) + + describe('Real-time Status Synchronization', () => { + it('should sync status changes between trigger controller and nodes', () => { + mockIsTriggerNode.mockReturnValue(true) + + const { getByTestId } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + <TestTriggerNode nodeId="node-2" nodeType="trigger-schedule" /> + </>, + ) + + // Initial state - should be 'disabled' by default + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled') + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled') + + // Enable node-1 + act(() => { + getByTestId('toggle-node-1').click() + }) + + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled') + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled') + + // Disable node-2 (should remain disabled) + act(() => { + getByTestId('toggle-node-2').click() + }) + + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled') + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled') + }) + + it('should handle batch status updates correctly', () => { + mockIsTriggerNode.mockReturnValue(true) + + const { getByTestId } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + <TestTriggerNode nodeId="node-2" nodeType="trigger-schedule" /> + <TestTriggerNode nodeId="node-3" nodeType="trigger-plugin" /> + </>, + ) + + // Initial state + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled') + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled') + expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'disabled') + + // Batch update + act(() => { + getByTestId('batch-update').click() + }) + + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled') + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled') + expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled') + }) + + it('should handle mixed node types (trigger vs non-trigger)', () => { + // Mock different node types + mockIsTriggerNode.mockImplementation((nodeType: string) => { + return nodeType.startsWith('trigger-') + }) + + const { getByTestId } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + <TestTriggerNode nodeId="node-2" nodeType="start" /> + <TestTriggerNode nodeId="node-3" nodeType="llm" /> + </>, + ) + + // Trigger node should use store status, non-trigger nodes should be 'enabled' + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled') // trigger node + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled') // start node + expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled') // llm node + + // Update trigger node status + act(() => { + getByTestId('toggle-node-1').click() + }) + + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled') // updated + expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled') // unchanged + expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled') // unchanged + }) + }) + + describe('Store State Management', () => { + it('should maintain state consistency across multiple components', () => { + mockIsTriggerNode.mockReturnValue(true) + + // Render multiple instances of the same node + const { getByTestId, rerender } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" /> + </>, + ) + + // Update status + act(() => { + getByTestId('toggle-node-1').click() // This updates node-1, not shared-node + }) + + // Add another component with the same nodeId + rerender( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" /> + <TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" /> + </>, + ) + + // Both components should show the same status + const nodes = document.querySelectorAll('[data-testid="node-shared-node"]') + expect(nodes).toHaveLength(2) + nodes.forEach((node) => { + expect(node).toHaveAttribute('data-status', 'disabled') + }) + }) + + it('should handle rapid status changes correctly', () => { + mockIsTriggerNode.mockReturnValue(true) + + const { getByTestId } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + </>, + ) + + // Rapid consecutive updates + act(() => { + // Multiple rapid clicks + getByTestId('toggle-node-1').click() // enable + getByTestId('toggle-node-2').click() // disable (different node) + getByTestId('toggle-node-1').click() // enable again + }) + + // Should reflect the final state + expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled') + }) + }) + + describe('Error Scenarios', () => { + it('should handle non-existent node IDs gracefully', () => { + mockIsTriggerNode.mockReturnValue(true) + + const { getByTestId } = render( + <TestTriggerNode nodeId="non-existent-node" nodeType="trigger-webhook" />, + ) + + // Should default to 'disabled' for non-existent nodes + expect(getByTestId('node-non-existent-node')).toHaveAttribute('data-status', 'disabled') + }) + + it('should handle component unmounting gracefully', () => { + mockIsTriggerNode.mockReturnValue(true) + + const { getByTestId, unmount } = render( + <> + <TestTriggerController /> + <TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + </>, + ) + + // Update status + act(() => { + getByTestId('toggle-node-1').click() + }) + + // Unmount components + expect(() => unmount()).not.toThrow() + + // Store should still maintain the state + const store = useTriggerStatusStore.getState() + expect(store.triggerStatuses['node-1']).toBe('enabled') + }) + }) + + describe('Performance Optimization', () => { + // Component that uses optimized selector with useCallback + const OptimizedTriggerNode: React.FC<{ + nodeId: string + nodeType: string + }> = ({ nodeId, nodeType }) => { + const triggerStatusSelector = useCallback((state: any) => + mockIsTriggerNode(nodeType) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled', + [nodeId, nodeType], + ) + const triggerStatus = useTriggerStatusStore(triggerStatusSelector) + + return ( + <div data-testid={`optimized-node-${nodeId}`} data-status={triggerStatus}> + Status: {triggerStatus} + </div> + ) + } + + it('should work correctly with optimized selector using useCallback', () => { + mockIsTriggerNode.mockImplementation(nodeType => nodeType === 'trigger-webhook') + + const { getByTestId } = render( + <> + <OptimizedTriggerNode nodeId="node-1" nodeType="trigger-webhook" /> + <OptimizedTriggerNode nodeId="node-2" nodeType="start" /> + <TestTriggerController /> + </>, + ) + + // Initial state + expect(getByTestId('optimized-node-node-1')).toHaveAttribute('data-status', 'disabled') + expect(getByTestId('optimized-node-node-2')).toHaveAttribute('data-status', 'enabled') + + // Update status via controller + act(() => { + getByTestId('toggle-node-1').click() + }) + + // Verify optimized component updates correctly + expect(getByTestId('optimized-node-node-1')).toHaveAttribute('data-status', 'enabled') + expect(getByTestId('optimized-node-node-2')).toHaveAttribute('data-status', 'enabled') + }) + + it('should handle selector dependency changes correctly', () => { + mockIsTriggerNode.mockImplementation(nodeType => nodeType === 'trigger-webhook') + + const TestComponent: React.FC<{ nodeType: string }> = ({ nodeType }) => { + const triggerStatusSelector = useCallback((state: any) => + mockIsTriggerNode(nodeType) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled', + ['test-node', nodeType], // Dependencies should match implementation + ) + const status = useTriggerStatusStore(triggerStatusSelector) + return <div data-testid="test-component" data-status={status} /> + } + + const { getByTestId, rerender } = render(<TestComponent nodeType="trigger-webhook" />) + + // Initial trigger node + expect(getByTestId('test-component')).toHaveAttribute('data-status', 'disabled') + + // Set status for the node + act(() => { + useTriggerStatusStore.getState().setTriggerStatus('test-node', 'enabled') + }) + expect(getByTestId('test-component')).toHaveAttribute('data-status', 'enabled') + + // Change node type to non-trigger - should return 'enabled' regardless of store + rerender(<TestComponent nodeType="start" />) + expect(getByTestId('test-component')).toHaveAttribute('data-status', 'enabled') + }) + }) +}) diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index 60fa813cd9..a4f53f2a64 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -21,8 +21,10 @@ import { LoopEnd, ParameterExtractor, QuestionClassifier, + Schedule, TemplatingTransform, VariableX, + WebhookLine, } from '@/app/components/base/icons/src/vender/workflow' import AppIcon from '@/app/components/base/app-icon' import cn from '@/utils/classnames' @@ -38,35 +40,45 @@ const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record<string, string> = { sm: 'w-5 h-5 rounded-md shadow-xs', md: 'w-6 h-6 rounded-lg shadow-md', } + +const DEFAULT_ICON_MAP: Record<BlockEnum, React.ComponentType<{ className: string }>> = { + [BlockEnum.Start]: Home, + [BlockEnum.LLM]: Llm, + [BlockEnum.Code]: Code, + [BlockEnum.End]: End, + [BlockEnum.IfElse]: IfElse, + [BlockEnum.HttpRequest]: Http, + [BlockEnum.Answer]: Answer, + [BlockEnum.KnowledgeRetrieval]: KnowledgeRetrieval, + [BlockEnum.QuestionClassifier]: QuestionClassifier, + [BlockEnum.TemplateTransform]: TemplatingTransform, + [BlockEnum.VariableAssigner]: VariableX, + [BlockEnum.VariableAggregator]: VariableX, + [BlockEnum.Assigner]: Assigner, + [BlockEnum.Tool]: VariableX, + [BlockEnum.IterationStart]: VariableX, + [BlockEnum.Iteration]: Iteration, + [BlockEnum.LoopStart]: VariableX, + [BlockEnum.Loop]: Loop, + [BlockEnum.LoopEnd]: LoopEnd, + [BlockEnum.ParameterExtractor]: ParameterExtractor, + [BlockEnum.DocExtractor]: DocsExtractor, + [BlockEnum.ListFilter]: ListFilter, + [BlockEnum.Agent]: Agent, + [BlockEnum.KnowledgeBase]: KnowledgeBase, + [BlockEnum.DataSource]: Datasource, + [BlockEnum.DataSourceEmpty]: () => null, + [BlockEnum.TriggerSchedule]: Schedule, + [BlockEnum.TriggerWebhook]: WebhookLine, + [BlockEnum.TriggerPlugin]: VariableX, +} + const getIcon = (type: BlockEnum, className: string) => { - return { - [BlockEnum.Start]: <Home className={className} />, - [BlockEnum.LLM]: <Llm className={className} />, - [BlockEnum.Code]: <Code className={className} />, - [BlockEnum.End]: <End className={className} />, - [BlockEnum.IfElse]: <IfElse className={className} />, - [BlockEnum.HttpRequest]: <Http className={className} />, - [BlockEnum.Answer]: <Answer className={className} />, - [BlockEnum.KnowledgeRetrieval]: <KnowledgeRetrieval className={className} />, - [BlockEnum.QuestionClassifier]: <QuestionClassifier className={className} />, - [BlockEnum.TemplateTransform]: <TemplatingTransform className={className} />, - [BlockEnum.VariableAssigner]: <VariableX className={className} />, - [BlockEnum.VariableAggregator]: <VariableX className={className} />, - [BlockEnum.Assigner]: <Assigner className={className} />, - [BlockEnum.Tool]: <VariableX className={className} />, - [BlockEnum.IterationStart]: <VariableX className={className} />, - [BlockEnum.Iteration]: <Iteration className={className} />, - [BlockEnum.LoopStart]: <VariableX className={className} />, - [BlockEnum.Loop]: <Loop className={className} />, - [BlockEnum.LoopEnd]: <LoopEnd className={className} />, - [BlockEnum.ParameterExtractor]: <ParameterExtractor className={className} />, - [BlockEnum.DocExtractor]: <DocsExtractor className={className} />, - [BlockEnum.ListFilter]: <ListFilter className={className} />, - [BlockEnum.Agent]: <Agent className={className} />, - [BlockEnum.KnowledgeBase]: <KnowledgeBase className={className} />, - [BlockEnum.DataSource]: <Datasource className={className} />, - [BlockEnum.DataSourceEmpty]: <></>, - }[type] + const DefaultIcon = DEFAULT_ICON_MAP[type] + if (!DefaultIcon) + return null + + return <DefaultIcon className={className} /> } const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = { [BlockEnum.Start]: 'bg-util-colors-blue-brand-blue-brand-500', @@ -92,6 +104,9 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = { [BlockEnum.Agent]: 'bg-util-colors-indigo-indigo-500', [BlockEnum.KnowledgeBase]: 'bg-util-colors-warning-warning-500', [BlockEnum.DataSource]: 'bg-components-icon-bg-midnight-solid', + [BlockEnum.TriggerSchedule]: 'bg-util-colors-violet-violet-500', + [BlockEnum.TriggerWebhook]: 'bg-util-colors-blue-blue-500', + [BlockEnum.TriggerPlugin]: 'bg-util-colors-blue-blue-500', } const BlockIcon: FC<BlockIconProps> = ({ type, @@ -99,8 +114,8 @@ const BlockIcon: FC<BlockIconProps> = ({ className, toolIcon, }) => { - const isToolOrDataSource = type === BlockEnum.Tool || type === BlockEnum.DataSource - const showDefaultIcon = !isToolOrDataSource || !toolIcon + const isToolOrDataSourceOrTriggerPlugin = type === BlockEnum.Tool || type === BlockEnum.DataSource || type === BlockEnum.TriggerPlugin + const showDefaultIcon = !isToolOrDataSourceOrTriggerPlugin || !toolIcon return ( <div className={ @@ -114,11 +129,15 @@ const BlockIcon: FC<BlockIconProps> = ({ > { showDefaultIcon && ( - getIcon(type, size === 'xs' ? 'w-3 h-3' : 'w-3.5 h-3.5') + getIcon(type, + (type === BlockEnum.TriggerSchedule || type === BlockEnum.TriggerWebhook) + ? (size === 'xs' ? 'w-4 h-4' : 'w-4.5 h-4.5') + : (size === 'xs' ? 'w-3 h-3' : 'w-3.5 h-3.5'), + ) ) } { - isToolOrDataSource && toolIcon && ( + !showDefaultIcon && ( <> { typeof toolIcon === 'string' diff --git a/web/app/components/workflow/block-selector/all-start-blocks.tsx b/web/app/components/workflow/block-selector/all-start-blocks.tsx new file mode 100644 index 0000000000..a089978bdd --- /dev/null +++ b/web/app/components/workflow/block-selector/all-start-blocks.tsx @@ -0,0 +1,179 @@ +'use client' +import { + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import type { BlockEnum, OnSelectBlock } from '../types' +import type { TriggerDefaultValue, TriggerWithProvider } from './types' +import StartBlocks from './start-blocks' +import TriggerPluginList from './trigger-plugin/list' +import { ENTRY_NODE_TYPES } from './constants' +import cn from '@/utils/classnames' +import Link from 'next/link' +import { RiArrowRightUpLine } from '@remixicon/react' +import { getMarketplaceUrl } from '@/utils/var' +import Button from '@/app/components/base/button' +import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general' +import { BlockEnum as BlockEnumValue } from '../types' +import FeaturedTriggers from './featured-triggers' +import Divider from '@/app/components/base/divider' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers' +import { useFeaturedTriggersRecommendations } from '@/service/use-plugins' + +const marketplaceFooterClassName = 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' + +type AllStartBlocksProps = { + className?: string + searchText: string + onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void + availableBlocksTypes?: BlockEnum[] + tags?: string[] + allowUserInputSelection?: boolean // Allow user input option even when trigger node already exists (e.g. when no Start node yet or changing node type). +} + +const AllStartBlocks = ({ + className, + searchText, + onSelect, + availableBlocksTypes, + tags = [], + allowUserInputSelection = false, +}: AllStartBlocksProps) => { + const { t } = useTranslation() + const [hasStartBlocksContent, setHasStartBlocksContent] = useState(false) + const [hasPluginContent, setHasPluginContent] = useState(false) + const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + + const entryNodeTypes = availableBlocksTypes?.length + ? availableBlocksTypes + : ENTRY_NODE_TYPES + const enableTriggerPlugin = entryNodeTypes.includes(BlockEnumValue.TriggerPlugin) + const { data: triggerProviders = [] } = useAllTriggerPlugins(enableTriggerPlugin) + const providerMap = useMemo(() => { + const map = new Map<string, TriggerWithProvider>() + triggerProviders.forEach((provider) => { + const keys = [ + provider.plugin_id, + provider.plugin_unique_identifier, + provider.id, + ].filter(Boolean) as string[] + keys.forEach((key) => { + if (!map.has(key)) + map.set(key, provider) + }) + }) + return map + }, [triggerProviders]) + const invalidateTriggers = useInvalidateAllTriggerPlugins() + const trimmedSearchText = searchText.trim() + const hasSearchText = trimmedSearchText.length > 0 + const { + plugins: featuredPlugins = [], + isLoading: featuredLoading, + } = useFeaturedTriggersRecommendations(enableTriggerPlugin && enable_marketplace && !hasSearchText) + + const shouldShowFeatured = enableTriggerPlugin + && enable_marketplace + && !hasSearchText + + const handleStartBlocksContentChange = useCallback((hasContent: boolean) => { + setHasStartBlocksContent(hasContent) + }, []) + + const handlePluginContentChange = useCallback((hasContent: boolean) => { + setHasPluginContent(hasContent) + }, []) + + const hasAnyContent = hasStartBlocksContent || hasPluginContent || shouldShowFeatured + const shouldShowEmptyState = hasSearchText && !hasAnyContent + + useEffect(() => { + if (!enableTriggerPlugin && hasPluginContent) + setHasPluginContent(false) + }, [enableTriggerPlugin, hasPluginContent]) + + return ( + <div className={cn('min-w-[400px] max-w-[500px]', className)}> + <div className='flex max-h-[640px] flex-col'> + <div className='flex-1 overflow-y-auto'> + <div className={cn(shouldShowEmptyState && 'hidden')}> + {shouldShowFeatured && ( + <> + <FeaturedTriggers + plugins={featuredPlugins} + providerMap={providerMap} + onSelect={onSelect} + isLoading={featuredLoading} + onInstallSuccess={async () => { + invalidateTriggers() + }} + /> + <div className='px-3'> + <Divider className='!h-px' /> + </div> + </> + )} + <div className='px-3 pb-1 pt-2'> + <span className='system-xs-medium text-text-primary'>{t('workflow.tabs.allTriggers')}</span> + </div> + <StartBlocks + searchText={trimmedSearchText} + onSelect={onSelect as OnSelectBlock} + availableBlocksTypes={entryNodeTypes as unknown as BlockEnum[]} + hideUserInput={!allowUserInputSelection} + onContentStateChange={handleStartBlocksContentChange} + /> + + {enableTriggerPlugin && ( + <TriggerPluginList + onSelect={onSelect} + searchText={trimmedSearchText} + onContentStateChange={handlePluginContentChange} + tags={tags} + /> + )} + </div> + + {shouldShowEmptyState && ( + <div className='flex h-full flex-col items-center justify-center gap-3 py-12 text-center'> + <SearchMenu className='h-8 w-8 text-text-quaternary' /> + <div className='text-sm font-medium text-text-secondary'> + {t('workflow.tabs.noPluginsFound')} + </div> + <Link + href='https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml' + target='_blank' + > + <Button + size='small' + variant='secondary-accent' + className='h-6 cursor-pointer px-3 text-xs' + > + {t('workflow.tabs.requestToCommunity')} + </Button> + </Link> + </div> + )} + </div> + + {!shouldShowEmptyState && ( + // Footer - Same as Tools tab marketplace footer + <Link + className={marketplaceFooterClassName} + href={getMarketplaceUrl('')} + target='_blank' + > + <span>{t('plugin.findMoreInMarketplace')}</span> + <RiArrowRightUpLine className='ml-0.5 h-3 w-3' /> + </Link> + )} + </div> + </div> + ) +} + +export default AllStartBlocks diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index 7db8b9acf5..d330eb182b 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -1,16 +1,12 @@ import type { Dispatch, + RefObject, SetStateAction, } from 'react' -import { - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import type { BlockEnum, - OnSelectBlock, ToolWithProvider, } from '../types' import type { ToolDefaultValue, ToolValue } from './types' @@ -19,13 +15,24 @@ import Tools from './tools' import { useToolTabs } from './hooks' import ViewTypeSelect, { ViewType } from './view-type-select' import cn from '@/utils/classnames' -import { useGetLanguage } from '@/context/i18n' +import Button from '@/app/components/base/button' +import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general' import type { ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list' import PluginList, { type ListProps } from '@/app/components/workflow/block-selector/market-place-plugin/list' -import { PluginType } from '../../plugins/types' +import type { Plugin } from '../../plugins/types' +import { PluginCategoryEnum } from '../../plugins/types' import { useMarketplacePlugins } from '../../plugins/marketplace/hooks' import { useGlobalPublicStore } from '@/context/global-public-context' import RAGToolRecommendations from './rag-tool-recommendations' +import FeaturedTools from './featured-tools' +import Link from 'next/link' +import Divider from '@/app/components/base/divider' +import { RiArrowRightUpLine } from '@remixicon/react' +import { getMarketplaceUrl } from '@/utils/var' +import { useGetLanguage } from '@/context/i18n' +import type { OnSelectBlock } from '@/app/components/workflow/types' + +const marketplaceFooterClassName = 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' type AllToolsProps = { className?: string @@ -36,13 +43,17 @@ type AllToolsProps = { customTools: ToolWithProvider[] workflowTools: ToolWithProvider[] mcpTools: ToolWithProvider[] - onSelect: OnSelectBlock + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] canChooseMCPTool?: boolean - onTagsChange: Dispatch<SetStateAction<string[]>> + onTagsChange?: Dispatch<SetStateAction<string[]>> isInRAGPipeline?: boolean + featuredPlugins?: Plugin[] + featuredLoading?: boolean + showFeatured?: boolean + onFeaturedInstallSuccess?: () => Promise<void> | void } const DEFAULT_TAGS: AllToolsProps['tags'] = [] @@ -63,15 +74,33 @@ const AllTools = ({ canChooseMCPTool, onTagsChange, isInRAGPipeline = false, + featuredPlugins = [], + featuredLoading = false, + showFeatured = false, + onFeaturedInstallSuccess, }: AllToolsProps) => { + const { t } = useTranslation() const language = useGetLanguage() const tabs = useToolTabs() const [activeTab, setActiveTab] = useState(ToolTypeEnum.All) const [activeView, setActiveView] = useState<ViewType>(ViewType.flat) - const hasFilter = searchText || tags.length > 0 + const trimmedSearchText = searchText.trim() + const hasSearchText = trimmedSearchText.length > 0 + const hasTags = tags.length > 0 + const hasFilter = hasSearchText || hasTags const isMatchingKeywords = (text: string, keywords: string) => { return text.toLowerCase().includes(keywords.toLowerCase()) } + const allProviders = useMemo(() => [...buildInTools, ...customTools, ...workflowTools, ...mcpTools], [buildInTools, customTools, workflowTools, mcpTools]) + const providerMap = useMemo(() => { + const map = new Map<string, ToolWithProvider>() + allProviders.forEach((provider) => { + const key = provider.plugin_id || provider.id + if (key) + map.set(key, provider) + }) + return map + }, [allProviders]) const tools = useMemo(() => { let mergedTools: ToolWithProvider[] = [] if (activeTab === ToolTypeEnum.All) @@ -85,15 +114,55 @@ const AllTools = ({ if (activeTab === ToolTypeEnum.MCP) mergedTools = mcpTools - if (!hasFilter) + const normalizedSearch = trimmedSearchText.toLowerCase() + const getLocalizedText = (text?: Record<string, string> | null) => { + if (!text) + return '' + + if (text[language]) + return text[language] + + if (text['en-US']) + return text['en-US'] + + const firstValue = Object.values(text).find(Boolean) + return firstValue || '' + } + + if (!hasFilter || !normalizedSearch) return mergedTools.filter(toolWithProvider => toolWithProvider.tools.length > 0) - return mergedTools.filter((toolWithProvider) => { - return isMatchingKeywords(toolWithProvider.name, searchText) || toolWithProvider.tools.some((tool) => { - return tool.label[language].toLowerCase().includes(searchText.toLowerCase()) || tool.name.toLowerCase().includes(searchText.toLowerCase()) + return mergedTools.reduce<ToolWithProvider[]>((acc, toolWithProvider) => { + const providerLabel = getLocalizedText(toolWithProvider.label) + const providerMatches = [ + toolWithProvider.name, + providerLabel, + ].some(text => isMatchingKeywords(text || '', normalizedSearch)) + + if (providerMatches) { + if (toolWithProvider.tools.length > 0) + acc.push(toolWithProvider) + return acc + } + + const matchedTools = toolWithProvider.tools.filter((tool) => { + const toolLabel = getLocalizedText(tool.label) + return [ + tool.name, + toolLabel, + ].some(text => isMatchingKeywords(text || '', normalizedSearch)) }) - }) - }, [activeTab, buildInTools, customTools, workflowTools, mcpTools, searchText, language, hasFilter]) + + if (matchedTools.length > 0) { + acc.push({ + ...toolWithProvider, + tools: matchedTools, + }) + } + + return acc + }, []) + }, [activeTab, buildInTools, customTools, workflowTools, mcpTools, trimmedSearchText, hasFilter, language]) const { queryPluginsWithDebounced: fetchPlugins, @@ -101,22 +170,38 @@ const AllTools = ({ } = useMarketplacePlugins() const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + useEffect(() => { if (!enable_marketplace) return - if (searchText || tags.length > 0) { + if (hasFilter) { fetchPlugins({ query: searchText, tags, - category: PluginType.tool, + category: PluginCategoryEnum.tool, }) } - }, [searchText, tags, enable_marketplace]) + }, [searchText, tags, enable_marketplace, hasFilter, fetchPlugins]) const pluginRef = useRef<ListRef>(null) const wrapElemRef = useRef<HTMLDivElement>(null) const isSupportGroupView = [ToolTypeEnum.All, ToolTypeEnum.BuiltIn].includes(activeTab) const isShowRAGRecommendations = isInRAGPipeline && activeTab === ToolTypeEnum.All && !hasFilter + const hasToolsListContent = tools.length > 0 || isShowRAGRecommendations + const hasPluginContent = enable_marketplace && notInstalledPlugins.length > 0 + const shouldShowEmptyState = hasFilter && !hasToolsListContent && !hasPluginContent + const shouldShowFeatured = showFeatured + && enable_marketplace + && !isInRAGPipeline + && activeTab === ToolTypeEnum.All + && !hasFilter + const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter + + const handleRAGSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { + if (!pluginDefaultValue) + return + onSelect(type, pluginDefaultValue as ToolDefaultValue) + }, [onSelect]) return ( <div className={cn('min-w-[400px] max-w-[500px]', className)}> @@ -142,41 +227,100 @@ const AllTools = ({ <ViewTypeSelect viewType={activeView} onChange={setActiveView} /> )} </div> - <div - ref={wrapElemRef} - className='max-h-[464px] overflow-y-auto' - onScroll={pluginRef.current?.handleScroll} - > - {isShowRAGRecommendations && ( - <RAGToolRecommendations - viewType={isSupportGroupView ? activeView : ViewType.flat} - onSelect={onSelect} - onTagsChange={onTagsChange} - /> - )} - <Tools - className={toolContentClassName} - tools={tools} - onSelect={onSelect} - canNotSelectMultiple={canNotSelectMultiple} - onSelectMultiple={onSelectMultiple} - toolType={activeTab} - viewType={isSupportGroupView ? activeView : ViewType.flat} - hasSearchText={!!searchText} - selectedTools={selectedTools} - canChooseMCPTool={canChooseMCPTool} - isShowRAGRecommendations={isShowRAGRecommendations} - /> - {/* Plugins from marketplace */} - {enable_marketplace && ( - <PluginList - ref={pluginRef} - wrapElemRef={wrapElemRef} - list={notInstalledPlugins} - searchText={searchText} - toolContentClassName={toolContentClassName} - tags={tags} - /> + <div className='flex max-h-[464px] flex-col'> + <div + ref={wrapElemRef} + className='flex-1 overflow-y-auto' + onScroll={pluginRef.current?.handleScroll} + > + <div className={cn(shouldShowEmptyState && 'hidden')}> + {isShowRAGRecommendations && onTagsChange && ( + <RAGToolRecommendations + viewType={isSupportGroupView ? activeView : ViewType.flat} + onSelect={handleRAGSelect} + onTagsChange={onTagsChange} + /> + )} + {shouldShowFeatured && ( + <> + <FeaturedTools + plugins={featuredPlugins} + providerMap={providerMap} + onSelect={onSelect} + selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} + isLoading={featuredLoading} + onInstallSuccess={async () => { + await onFeaturedInstallSuccess?.() + }} + /> + <div className='px-3'> + <Divider className='!h-px' /> + </div> + </> + )} + {hasToolsListContent && ( + <> + <div className='px-3 pb-1 pt-2'> + <span className='system-xs-medium text-text-primary'>{t('tools.allTools')}</span> + </div> + <Tools + className={toolContentClassName} + tools={tools} + onSelect={onSelect} + canNotSelectMultiple={canNotSelectMultiple} + onSelectMultiple={onSelectMultiple} + toolType={activeTab} + viewType={isSupportGroupView ? activeView : ViewType.flat} + hasSearchText={hasSearchText} + selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} + /> + </> + )} + {enable_marketplace && ( + <PluginList + ref={pluginRef} + wrapElemRef={wrapElemRef as RefObject<HTMLElement>} + list={notInstalledPlugins} + searchText={searchText} + toolContentClassName={toolContentClassName} + tags={tags} + hideFindMoreFooter + /> + )} + </div> + + {shouldShowEmptyState && ( + <div className='flex h-full flex-col items-center justify-center gap-3 py-12 text-center'> + <SearchMenu className='h-8 w-8 text-text-quaternary' /> + <div className='text-sm font-medium text-text-secondary'> + {t('workflow.tabs.noPluginsFound')} + </div> + <Link + href='https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml' + target='_blank' + > + <Button + size='small' + variant='secondary-accent' + className='h-6 cursor-pointer px-3 text-xs' + > + {t('workflow.tabs.requestToCommunity')} + </Button> + </Link> + </div> + )} + </div> + {shouldShowMarketplaceFooter && ( + <Link + className={marketplaceFooterClassName} + href={getMarketplaceUrl('')} + target='_blank' + > + <span>{t('plugin.findMoreInMarketplace')}</span> + <RiArrowRightUpLine className='ml-0.5 h-3 w-3' /> + </Link> )} </div> </div> diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index 18bf55f3f9..cae1ec32a5 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/web/app/components/workflow/block-selector/blocks.tsx @@ -10,28 +10,50 @@ import BlockIcon from '../block-icon' import { BlockEnum } from '../types' import type { NodeDefault } from '../types' import { BLOCK_CLASSIFICATIONS } from './constants' -import type { ToolDefaultValue } from './types' +import { useBlocks } from './hooks' import Tooltip from '@/app/components/base/tooltip' import Badge from '@/app/components/base/badge' type BlocksProps = { searchText: string - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum) => void availableBlocksTypes?: BlockEnum[] - blocks: NodeDefault[] + blocks?: NodeDefault[] } const Blocks = ({ searchText, onSelect, availableBlocksTypes = [], - blocks, + blocks: blocksFromProps, }: BlocksProps) => { const { t } = useTranslation() const store = useStoreApi() + const blocksFromHooks = useBlocks() + + // Use external blocks if provided, otherwise fallback to hook-based blocks + const blocks = blocksFromProps || blocksFromHooks.map(block => ({ + metaData: { + classification: block.classification, + sort: 0, // Default sort order + type: block.type, + title: block.title, + author: 'Dify', + description: block.description, + }, + defaultValue: {}, + checkValid: () => ({ isValid: true }), + }) as NodeDefault) const groups = useMemo(() => { return BLOCK_CLASSIFICATIONS.reduce((acc, classification) => { - const list = groupBy(blocks, 'metaData.classification')[classification].filter((block) => { + const grouped = groupBy(blocks, 'metaData.classification') + const list = (grouped[classification] || []).filter((block) => { + // Filter out trigger types from Blocks tab + if (block.metaData.type === BlockEnum.TriggerWebhook + || block.metaData.type === BlockEnum.TriggerSchedule + || block.metaData.type === BlockEnum.TriggerPlugin) + return false + return block.metaData.title.toLowerCase().includes(searchText.toLowerCase()) && availableBlocksTypes.includes(block.metaData.type) }) @@ -44,7 +66,7 @@ const Blocks = ({ const isEmpty = Object.values(groups).every(list => !list.length) const renderGroup = useCallback((classification: string) => { - const list = groups[classification].sort((a, b) => a.metaData.sort - b.metaData.sort) + const list = groups[classification].sort((a, b) => (a.metaData.sort || 0) - (b.metaData.sort || 0)) const { getNodes } = store.getState() const nodes = getNodes() const hasKnowledgeBaseNode = nodes.some(node => node.data.type === BlockEnum.KnowledgeBase) @@ -71,7 +93,7 @@ const Blocks = ({ <Tooltip key={block.metaData.type} position='right' - popupClassName='w-[200px]' + popupClassName='w-[200px] rounded-xl' needsDelay={false} popupContent={( <div> @@ -112,7 +134,7 @@ const Blocks = ({ }, [groups, onSelect, t, store]) return ( - <div className='max-h-[480px] overflow-y-auto p-1'> + <div className='max-h-[480px] min-w-[400px] max-w-[500px] overflow-y-auto p-1'> { isEmpty && ( <div className='flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary'>{t('workflow.tabs.noResult')}</div> diff --git a/web/app/components/workflow/block-selector/constants.tsx b/web/app/components/workflow/block-selector/constants.tsx index ab0c9586dc..ec05985453 100644 --- a/web/app/components/workflow/block-selector/constants.tsx +++ b/web/app/components/workflow/block-selector/constants.tsx @@ -1,3 +1,5 @@ +import type { Block } from '../types' +import { BlockEnum } from '../types' import { BlockClassificationEnum } from './types' export const BLOCK_CLASSIFICATIONS: string[] = [ @@ -29,3 +31,125 @@ export const DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE = [ 'ppt', 'md', ] + +export const START_BLOCKS: Block[] = [ + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.Start, + title: 'User Input', + description: 'Traditional start node for user input', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.TriggerSchedule, + title: 'Schedule Trigger', + description: 'Time-based workflow trigger', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.TriggerWebhook, + title: 'Webhook Trigger', + description: 'HTTP callback trigger', + }, +] + +export const ENTRY_NODE_TYPES = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, +] as const + +export const BLOCKS: Block[] = [ + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.LLM, + title: 'LLM', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.KnowledgeRetrieval, + title: 'Knowledge Retrieval', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.End, + title: 'End', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.Answer, + title: 'Direct Answer', + }, + { + classification: BlockClassificationEnum.QuestionUnderstand, + type: BlockEnum.QuestionClassifier, + title: 'Question Classifier', + }, + { + classification: BlockClassificationEnum.Logic, + type: BlockEnum.IfElse, + title: 'IF/ELSE', + }, + { + classification: BlockClassificationEnum.Logic, + type: BlockEnum.LoopEnd, + title: 'Exit Loop', + description: '', + }, + { + classification: BlockClassificationEnum.Logic, + type: BlockEnum.Iteration, + title: 'Iteration', + }, + { + classification: BlockClassificationEnum.Logic, + type: BlockEnum.Loop, + title: 'Loop', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.Code, + title: 'Code', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.TemplateTransform, + title: 'Templating Transform', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.VariableAggregator, + title: 'Variable Aggregator', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.DocExtractor, + title: 'Doc Extractor', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.Assigner, + title: 'Variable Assigner', + }, + { + classification: BlockClassificationEnum.Transform, + type: BlockEnum.ParameterExtractor, + title: 'Parameter Extractor', + }, + { + classification: BlockClassificationEnum.Utilities, + type: BlockEnum.HttpRequest, + title: 'HTTP Request', + }, + { + classification: BlockClassificationEnum.Utilities, + type: BlockEnum.ListFilter, + title: 'List Filter', + }, + { + classification: BlockClassificationEnum.Default, + type: BlockEnum.Agent, + title: 'Agent', + }, +] diff --git a/web/app/components/workflow/block-selector/data-sources.tsx b/web/app/components/workflow/block-selector/data-sources.tsx index 441ede2334..b98a52dcff 100644 --- a/web/app/components/workflow/block-selector/data-sources.tsx +++ b/web/app/components/workflow/block-selector/data-sources.tsx @@ -17,7 +17,7 @@ import PluginList, { type ListRef } from '@/app/components/workflow/block-select import { useGlobalPublicStore } from '@/context/global-public-context' import { DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE } from './constants' import { useMarketplacePlugins } from '../../plugins/marketplace/hooks' -import { PluginType } from '../../plugins/types' +import { PluginCategoryEnum } from '../../plugins/types' import { useGetLanguage } from '@/context/i18n' type AllToolsProps = { @@ -55,7 +55,7 @@ const DataSources = ({ }) }, [searchText, dataSources, language]) - const handleSelect = useCallback((_: any, toolDefaultValue: ToolDefaultValue) => { + const handleSelect = useCallback((_: BlockEnum, toolDefaultValue: ToolDefaultValue) => { let defaultValue: DataSourceDefaultValue = { plugin_id: toolDefaultValue?.provider_id, provider_type: toolDefaultValue?.provider_type, @@ -63,6 +63,7 @@ const DataSources = ({ datasource_name: toolDefaultValue?.tool_name, datasource_label: toolDefaultValue?.tool_label, title: toolDefaultValue?.title, + plugin_unique_identifier: toolDefaultValue?.plugin_unique_identifier, } // Update defaultValue with fileExtensions if this is the local file data source if (toolDefaultValue?.provider_id === 'langgenius/file' && toolDefaultValue?.provider_name === 'file') { @@ -86,16 +87,16 @@ const DataSources = ({ if (searchText) { fetchPlugins({ query: searchText, - category: PluginType.datasource, + category: PluginCategoryEnum.datasource, }) } }, [searchText, enable_marketplace]) return ( - <div className={cn(className)}> + <div className={cn('w-[400px] min-w-0 max-w-full', className)}> <div ref={wrapElemRef} - className='max-h-[464px] overflow-y-auto' + className='max-h-[464px] overflow-y-auto overflow-x-hidden' onScroll={pluginRef.current?.handleScroll} > <Tools diff --git a/web/app/components/workflow/block-selector/featured-tools.tsx b/web/app/components/workflow/block-selector/featured-tools.tsx new file mode 100644 index 0000000000..fe5c561362 --- /dev/null +++ b/web/app/components/workflow/block-selector/featured-tools.tsx @@ -0,0 +1,333 @@ +'use client' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { BlockEnum, type ToolWithProvider } from '../types' +import type { ToolDefaultValue, ToolValue } from './types' +import type { Plugin } from '@/app/components/plugins/types' +import { useGetLanguage } from '@/context/i18n' +import BlockIcon from '../block-icon' +import Tooltip from '@/app/components/base/tooltip' +import { RiMoreLine } from '@remixicon/react' +import Loading from '@/app/components/base/loading' +import Link from 'next/link' +import { getMarketplaceUrl } from '@/utils/var' +import { ToolTypeEnum } from './types' +import { ViewType } from './view-type-select' +import Tools from './tools' +import { formatNumber } from '@/utils/format' +import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' +import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows' +import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' + +const MAX_RECOMMENDED_COUNT = 15 +const INITIAL_VISIBLE_COUNT = 5 + +type FeaturedToolsProps = { + plugins: Plugin[] + providerMap: Map<string, ToolWithProvider> + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void + selectedTools?: ToolValue[] + canChooseMCPTool?: boolean + isLoading?: boolean + onInstallSuccess?: () => void +} + +const STORAGE_KEY = 'workflow_tools_featured_collapsed' + +const FeaturedTools = ({ + plugins, + providerMap, + onSelect, + selectedTools, + canChooseMCPTool, + isLoading = false, + onInstallSuccess, +}: FeaturedToolsProps) => { + const { t } = useTranslation() + const language = useGetLanguage() + const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT) + const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { + if (typeof window === 'undefined') + return false + const stored = window.localStorage.getItem(STORAGE_KEY) + return stored === 'true' + }) + + useEffect(() => { + if (typeof window === 'undefined') + return + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored !== null) + setIsCollapsed(stored === 'true') + }, []) + + useEffect(() => { + if (typeof window === 'undefined') + return + window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) + }, [isCollapsed]) + + useEffect(() => { + setVisibleCount(INITIAL_VISIBLE_COUNT) + }, [plugins]) + + const limitedPlugins = useMemo( + () => plugins.slice(0, MAX_RECOMMENDED_COUNT), + [plugins], + ) + + const { + installedProviders, + uninstalledPlugins, + } = useMemo(() => { + const installed: ToolWithProvider[] = [] + const uninstalled: Plugin[] = [] + const visitedProviderIds = new Set<string>() + + limitedPlugins.forEach((plugin) => { + const provider = providerMap.get(plugin.plugin_id) + if (provider) { + if (!visitedProviderIds.has(provider.id)) { + installed.push(provider) + visitedProviderIds.add(provider.id) + } + } + else { + uninstalled.push(plugin) + } + }) + + return { + installedProviders: installed, + uninstalledPlugins: uninstalled, + } + }, [limitedPlugins, providerMap]) + + const totalQuota = Math.min(visibleCount, MAX_RECOMMENDED_COUNT) + + const visibleInstalledProviders = useMemo( + () => installedProviders.slice(0, totalQuota), + [installedProviders, totalQuota], + ) + + const remainingSlots = Math.max(totalQuota - visibleInstalledProviders.length, 0) + + const visibleUninstalledPlugins = useMemo( + () => (remainingSlots > 0 ? uninstalledPlugins.slice(0, remainingSlots) : []), + [uninstalledPlugins, remainingSlots], + ) + + const totalVisible = visibleInstalledProviders.length + visibleUninstalledPlugins.length + const maxAvailable = Math.min(MAX_RECOMMENDED_COUNT, installedProviders.length + uninstalledPlugins.length) + const hasMoreToShow = totalVisible < maxAvailable + const canToggleVisibility = maxAvailable > INITIAL_VISIBLE_COUNT + const isExpanded = canToggleVisibility && !hasMoreToShow + const showEmptyState = !isLoading && totalVisible === 0 + + return ( + <div className='px-3 pb-3 pt-2'> + <button + type='button' + className='flex w-full items-center rounded-md px-0 py-1 text-left text-text-primary' + onClick={() => setIsCollapsed(prev => !prev)} + > + <span className='system-xs-medium text-text-primary'>{t('workflow.tabs.featuredTools')}</span> + <ArrowDownRoundFill className={`ml-0.5 h-4 w-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} /> + </button> + + {!isCollapsed && ( + <> + {isLoading && ( + <div className='py-3'> + <Loading type='app' /> + </div> + )} + + {showEmptyState && ( + <p className='system-xs-regular py-2 text-text-tertiary'> + <Link className='text-text-accent' href={getMarketplaceUrl('', { category: 'tool' })} target='_blank' rel='noopener noreferrer'> + {t('workflow.tabs.noFeaturedPlugins')} + </Link> + </p> + )} + + {!showEmptyState && !isLoading && ( + <> + {visibleInstalledProviders.length > 0 && ( + <Tools + className='p-0' + tools={visibleInstalledProviders} + onSelect={onSelect} + canNotSelectMultiple + toolType={ToolTypeEnum.All} + viewType={ViewType.flat} + hasSearchText={false} + selectedTools={selectedTools} + canChooseMCPTool={canChooseMCPTool} + /> + )} + + {visibleUninstalledPlugins.length > 0 && ( + <div className='mt-1 flex flex-col gap-1'> + {visibleUninstalledPlugins.map(plugin => ( + <FeaturedToolUninstalledItem + key={plugin.plugin_id} + plugin={plugin} + language={language} + onInstallSuccess={async () => { + await onInstallSuccess?.() + }} + t={t} + /> + ))} + </div> + )} + </> + )} + + {!isLoading && totalVisible > 0 && canToggleVisibility && ( + <div + className='group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pl-3 pr-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary' + onClick={() => { + setVisibleCount((count) => { + if (count >= maxAvailable) + return INITIAL_VISIBLE_COUNT + + return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable) + }) + }} + > + <div className='flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary'> + <RiMoreLine className='size-4 group-hover:hidden' /> + {isExpanded ? ( + <ArrowUpDoubleLine className='hidden size-4 group-hover:block' /> + ) : ( + <ArrowDownDoubleLine className='hidden size-4 group-hover:block' /> + )} + </div> + <div className='system-xs-regular'> + {t(isExpanded ? 'workflow.tabs.showLessFeatured' : 'workflow.tabs.showMoreFeatured')} + </div> + </div> + )} + </> + )} + </div> + ) +} + +type FeaturedToolUninstalledItemProps = { + plugin: Plugin + language: string + onInstallSuccess?: () => Promise<void> | void + t: (key: string, options?: Record<string, any>) => string +} + +function FeaturedToolUninstalledItem({ + plugin, + language, + onInstallSuccess, + t, +}: FeaturedToolUninstalledItemProps) { + const label = plugin.label?.[language] || plugin.name + const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief + const installCountLabel = t('plugin.install', { num: formatNumber(plugin.install_count || 0) }) + const [actionOpen, setActionOpen] = useState(false) + const [isActionHovered, setIsActionHovered] = useState(false) + const [isInstallModalOpen, setIsInstallModalOpen] = useState(false) + + useEffect(() => { + if (!actionOpen) + return + + const handleScroll = () => { + setActionOpen(false) + setIsActionHovered(false) + } + + window.addEventListener('scroll', handleScroll, true) + + return () => { + window.removeEventListener('scroll', handleScroll, true) + } + }, [actionOpen]) + + return ( + <> + <Tooltip + position='right' + needsDelay={false} + popupClassName='!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg' + popupContent={( + <div> + <BlockIcon size='md' className='mb-2' type={BlockEnum.Tool} toolIcon={plugin.icon} /> + <div className='mb-1 text-sm leading-5 text-text-primary'>{label}</div> + <div className='text-xs leading-[18px] text-text-secondary'>{description}</div> + </div> + )} + disabled={!description || isActionHovered || actionOpen || isInstallModalOpen} + > + <div + className='group flex h-8 w-full items-center rounded-lg pl-3 pr-1 hover:bg-state-base-hover' + > + <div className='flex h-full min-w-0 items-center'> + <BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} /> + <div className='ml-2 min-w-0'> + <div className='system-sm-medium truncate text-text-secondary'>{label}</div> + </div> + </div> + <div className='ml-auto flex h-full items-center gap-1 pl-1'> + <span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span> + <div + className={`system-xs-medium flex h-full items-center gap-1 text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? 'flex' : 'hidden group-hover:flex'}`} + onMouseEnter={() => setIsActionHovered(true)} + onMouseLeave={() => { + if (!actionOpen) + setIsActionHovered(false) + }} + > + <button + type='button' + className='cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover' + onClick={() => { + setActionOpen(false) + setIsInstallModalOpen(true) + setIsActionHovered(true) + }} + > + {t('plugin.installAction')} + </button> + <Action + open={actionOpen} + onOpenChange={(value) => { + setActionOpen(value) + setIsActionHovered(value) + }} + author={plugin.org} + name={plugin.name} + version={plugin.latest_version} + /> + </div> + </div> + </div> + </Tooltip> + {isInstallModalOpen && ( + <InstallFromMarketplace + uniqueIdentifier={plugin.latest_package_identifier} + manifest={plugin} + onSuccess={async () => { + setIsInstallModalOpen(false) + setIsActionHovered(false) + await onInstallSuccess?.() + }} + onClose={() => { + setIsInstallModalOpen(false) + setIsActionHovered(false) + }} + /> + )} + </> + ) +} + +export default FeaturedTools diff --git a/web/app/components/workflow/block-selector/featured-triggers.tsx b/web/app/components/workflow/block-selector/featured-triggers.tsx new file mode 100644 index 0000000000..561ebc1784 --- /dev/null +++ b/web/app/components/workflow/block-selector/featured-triggers.tsx @@ -0,0 +1,326 @@ +'use client' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { BlockEnum } from '../types' +import type { TriggerDefaultValue, TriggerWithProvider } from './types' +import type { Plugin } from '@/app/components/plugins/types' +import { useGetLanguage } from '@/context/i18n' +import BlockIcon from '../block-icon' +import Tooltip from '@/app/components/base/tooltip' +import { RiMoreLine } from '@remixicon/react' +import Loading from '@/app/components/base/loading' +import Link from 'next/link' +import { getMarketplaceUrl } from '@/utils/var' +import TriggerPluginItem from './trigger-plugin/item' +import { formatNumber } from '@/utils/format' +import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' +import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows' +import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' + +const MAX_RECOMMENDED_COUNT = 15 +const INITIAL_VISIBLE_COUNT = 5 + +type FeaturedTriggersProps = { + plugins: Plugin[] + providerMap: Map<string, TriggerWithProvider> + onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void + isLoading?: boolean + onInstallSuccess?: () => void | Promise<void> +} + +const STORAGE_KEY = 'workflow_triggers_featured_collapsed' + +const FeaturedTriggers = ({ + plugins, + providerMap, + onSelect, + isLoading = false, + onInstallSuccess, +}: FeaturedTriggersProps) => { + const { t } = useTranslation() + const language = useGetLanguage() + const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT) + const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { + if (typeof window === 'undefined') + return false + const stored = window.localStorage.getItem(STORAGE_KEY) + return stored === 'true' + }) + + useEffect(() => { + if (typeof window === 'undefined') + return + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored !== null) + setIsCollapsed(stored === 'true') + }, []) + + useEffect(() => { + if (typeof window === 'undefined') + return + window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) + }, [isCollapsed]) + + useEffect(() => { + setVisibleCount(INITIAL_VISIBLE_COUNT) + }, [plugins]) + + const limitedPlugins = useMemo( + () => plugins.slice(0, MAX_RECOMMENDED_COUNT), + [plugins], + ) + + const { + installedProviders, + uninstalledPlugins, + } = useMemo(() => { + const installed: TriggerWithProvider[] = [] + const uninstalled: Plugin[] = [] + const visitedProviderIds = new Set<string>() + + limitedPlugins.forEach((plugin) => { + const provider = providerMap.get(plugin.plugin_id) || providerMap.get(plugin.latest_package_identifier) + if (provider) { + if (!visitedProviderIds.has(provider.id)) { + installed.push(provider) + visitedProviderIds.add(provider.id) + } + } + else { + uninstalled.push(plugin) + } + }) + + return { + installedProviders: installed, + uninstalledPlugins: uninstalled, + } + }, [limitedPlugins, providerMap]) + + const totalQuota = Math.min(visibleCount, MAX_RECOMMENDED_COUNT) + + const visibleInstalledProviders = useMemo( + () => installedProviders.slice(0, totalQuota), + [installedProviders, totalQuota], + ) + + const remainingSlots = Math.max(totalQuota - visibleInstalledProviders.length, 0) + + const visibleUninstalledPlugins = useMemo( + () => (remainingSlots > 0 ? uninstalledPlugins.slice(0, remainingSlots) : []), + [uninstalledPlugins, remainingSlots], + ) + + const totalVisible = visibleInstalledProviders.length + visibleUninstalledPlugins.length + const maxAvailable = Math.min(MAX_RECOMMENDED_COUNT, installedProviders.length + uninstalledPlugins.length) + const hasMoreToShow = totalVisible < maxAvailable + const canToggleVisibility = maxAvailable > INITIAL_VISIBLE_COUNT + const isExpanded = canToggleVisibility && !hasMoreToShow + const showEmptyState = !isLoading && totalVisible === 0 + + return ( + <div className='px-3 pb-3 pt-2'> + <button + type='button' + className='flex w-full items-center rounded-md px-0 py-1 text-left text-text-primary' + onClick={() => setIsCollapsed(prev => !prev)} + > + <span className='system-xs-medium text-text-primary'>{t('workflow.tabs.featuredTools')}</span> + <ArrowDownRoundFill className={`ml-0.5 h-4 w-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} /> + </button> + + {!isCollapsed && ( + <> + {isLoading && ( + <div className='py-3'> + <Loading type='app' /> + </div> + )} + + {showEmptyState && ( + <p className='system-xs-regular py-2 text-text-tertiary'> + <Link className='text-text-accent' href={getMarketplaceUrl('', { category: 'trigger' })} target='_blank' rel='noopener noreferrer'> + {t('workflow.tabs.noFeaturedTriggers')} + </Link> + </p> + )} + + {!showEmptyState && !isLoading && ( + <> + {visibleInstalledProviders.length > 0 && ( + <div className='mt-1'> + {visibleInstalledProviders.map(provider => ( + <TriggerPluginItem + key={provider.id} + payload={provider} + hasSearchText={false} + onSelect={onSelect} + /> + ))} + </div> + )} + + {visibleUninstalledPlugins.length > 0 && ( + <div className='mt-1 flex flex-col gap-1'> + {visibleUninstalledPlugins.map(plugin => ( + <FeaturedTriggerUninstalledItem + key={plugin.plugin_id} + plugin={plugin} + language={language} + onInstallSuccess={async () => { + await onInstallSuccess?.() + }} + t={t} + /> + ))} + </div> + )} + </> + )} + + {!isLoading && totalVisible > 0 && canToggleVisibility && ( + <div + className='group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pl-3 pr-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary' + onClick={() => { + setVisibleCount((count) => { + if (count >= maxAvailable) + return INITIAL_VISIBLE_COUNT + + return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable) + }) + }} + > + <div className='flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary'> + <RiMoreLine className='size-4 group-hover:hidden' /> + {isExpanded ? ( + <ArrowUpDoubleLine className='hidden size-4 group-hover:block' /> + ) : ( + <ArrowDownDoubleLine className='hidden size-4 group-hover:block' /> + )} + </div> + <div className='system-xs-regular'> + {t(isExpanded ? 'workflow.tabs.showLessFeatured' : 'workflow.tabs.showMoreFeatured')} + </div> + </div> + )} + </> + )} + </div> + ) +} + +type FeaturedTriggerUninstalledItemProps = { + plugin: Plugin + language: string + onInstallSuccess?: () => Promise<void> | void + t: (key: string, options?: Record<string, any>) => string +} + +function FeaturedTriggerUninstalledItem({ + plugin, + language, + onInstallSuccess, + t, +}: FeaturedTriggerUninstalledItemProps) { + const label = plugin.label?.[language] || plugin.name + const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief + const installCountLabel = t('plugin.install', { num: formatNumber(plugin.install_count || 0) }) + const [actionOpen, setActionOpen] = useState(false) + const [isActionHovered, setIsActionHovered] = useState(false) + const [isInstallModalOpen, setIsInstallModalOpen] = useState(false) + + useEffect(() => { + if (!actionOpen) + return + + const handleScroll = () => { + setActionOpen(false) + setIsActionHovered(false) + } + + window.addEventListener('scroll', handleScroll, true) + + return () => { + window.removeEventListener('scroll', handleScroll, true) + } + }, [actionOpen]) + + return ( + <> + <Tooltip + position='right' + needsDelay={false} + popupClassName='!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg' + popupContent={( + <div> + <BlockIcon size='md' className='mb-2' type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} /> + <div className='mb-1 text-sm leading-5 text-text-primary'>{label}</div> + <div className='text-xs leading-[18px] text-text-secondary'>{description}</div> + </div> + )} + disabled={!description || isActionHovered || actionOpen || isInstallModalOpen} + > + <div + className='group flex h-8 w-full items-center rounded-lg pl-3 pr-1 hover:bg-state-base-hover' + > + <div className='flex h-full min-w-0 items-center'> + <BlockIcon type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} /> + <div className='ml-2 min-w-0'> + <div className='system-sm-medium truncate text-text-secondary'>{label}</div> + </div> + </div> + <div className='ml-auto flex h-full items-center gap-1 pl-1'> + <span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span> + <div + className={`system-xs-medium flex h-full items-center gap-1 text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? 'flex' : 'hidden group-hover:flex'}`} + onMouseEnter={() => setIsActionHovered(true)} + onMouseLeave={() => { + if (!actionOpen) + setIsActionHovered(false) + }} + > + <button + type='button' + className='cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover' + onClick={() => { + setActionOpen(false) + setIsInstallModalOpen(true) + setIsActionHovered(true) + }} + > + {t('plugin.installAction')} + </button> + <Action + open={actionOpen} + onOpenChange={(value) => { + setActionOpen(value) + setIsActionHovered(value) + }} + author={plugin.org} + name={plugin.name} + version={plugin.latest_version} + /> + </div> + </div> + </div> + </Tooltip> + {isInstallModalOpen && ( + <InstallFromMarketplace + uniqueIdentifier={plugin.latest_package_identifier} + manifest={plugin} + onSuccess={async () => { + setIsInstallModalOpen(false) + setIsActionHovered(false) + await onInstallSuccess?.() + }} + onClose={() => { + setIsInstallModalOpen(false) + setIsActionHovered(false) + }} + /> + )} + </> + ) +} + +export default FeaturedTriggers diff --git a/web/app/components/workflow/block-selector/hooks.ts b/web/app/components/workflow/block-selector/hooks.ts index b974922e6b..e2dd14e16c 100644 --- a/web/app/components/workflow/block-selector/hooks.ts +++ b/web/app/components/workflow/block-selector/hooks.ts @@ -1,60 +1,123 @@ import { + useCallback, + useEffect, useMemo, useState, } from 'react' import { useTranslation } from 'react-i18next' +import { BLOCKS, START_BLOCKS } from './constants' import { TabsEnum, ToolTypeEnum, } from './types' -export const useTabs = (noBlocks?: boolean, noSources?: boolean, noTools?: boolean) => { +export const useBlocks = () => { const { t } = useTranslation() + + return BLOCKS.map((block) => { + return { + ...block, + title: t(`workflow.blocks.${block.type}`), + } + }) +} + +export const useStartBlocks = () => { + const { t } = useTranslation() + + return START_BLOCKS.map((block) => { + return { + ...block, + title: t(`workflow.blocks.${block.type}`), + } + }) +} + +export const useTabs = ({ + noBlocks, + noSources, + noTools, + noStart = true, + defaultActiveTab, + hasUserInputNode = false, + forceEnableStartTab = false, // When true, Start tab remains enabled even if trigger/user input nodes already exist. +}: { + noBlocks?: boolean + noSources?: boolean + noTools?: boolean + noStart?: boolean + defaultActiveTab?: TabsEnum + hasUserInputNode?: boolean + forceEnableStartTab?: boolean +}) => { + const { t } = useTranslation() + const shouldShowStartTab = !noStart + const shouldDisableStartTab = !forceEnableStartTab && hasUserInputNode const tabs = useMemo(() => { - return [ - ...( - noBlocks - ? [] - : [ - { - key: TabsEnum.Blocks, - name: t('workflow.tabs.blocks'), - }, - ] - ), - ...( - noSources - ? [] - : [ - { - key: TabsEnum.Sources, - name: t('workflow.tabs.sources'), - }, - ] - ), - ...( - noTools - ? [] - : [ - { - key: TabsEnum.Tools, - name: t('workflow.tabs.tools'), - }, - ] - ), - ] - }, [t, noBlocks, noSources, noTools]) + const tabConfigs = [{ + key: TabsEnum.Blocks, + name: t('workflow.tabs.blocks'), + show: !noBlocks, + }, { + key: TabsEnum.Sources, + name: t('workflow.tabs.sources'), + show: !noSources, + }, { + key: TabsEnum.Tools, + name: t('workflow.tabs.tools'), + show: !noTools, + }, + { + key: TabsEnum.Start, + name: t('workflow.tabs.start'), + show: shouldShowStartTab, + disabled: shouldDisableStartTab, + }] + + return tabConfigs.filter(tab => tab.show) + }, [t, noBlocks, noSources, noTools, shouldShowStartTab, shouldDisableStartTab]) + + const getValidTabKey = useCallback((targetKey?: TabsEnum) => { + if (!targetKey) + return undefined + const tab = tabs.find(tabItem => tabItem.key === targetKey) + if (!tab || tab.disabled) + return undefined + return tab.key + }, [tabs]) + const initialTab = useMemo(() => { - if (noBlocks) - return noTools ? TabsEnum.Sources : TabsEnum.Tools + const fallbackTab = tabs.find(tab => !tab.disabled)?.key ?? TabsEnum.Blocks + const preferredDefault = getValidTabKey(defaultActiveTab) + if (preferredDefault) + return preferredDefault - if (noTools) - return noBlocks ? TabsEnum.Sources : TabsEnum.Blocks + const preferredOrder: TabsEnum[] = [] + if (!noBlocks) + preferredOrder.push(TabsEnum.Blocks) + if (!noTools) + preferredOrder.push(TabsEnum.Tools) + if (!noSources) + preferredOrder.push(TabsEnum.Sources) + if (!noStart) + preferredOrder.push(TabsEnum.Start) - return TabsEnum.Blocks - }, [noBlocks, noSources, noTools]) + for (const tabKey of preferredOrder) { + const validKey = getValidTabKey(tabKey) + if (validKey) + return validKey + } + + return fallbackTab + }, [defaultActiveTab, noBlocks, noSources, noTools, noStart, tabs, getValidTabKey]) const [activeTab, setActiveTab] = useState(initialTab) + useEffect(() => { + const currentTab = tabs.find(tab => tab.key === activeTab) + if (!currentTab || currentTab.disabled) + setActiveTab(initialTab) + }, [tabs, activeTab, initialTab]) + return { tabs, activeTab, diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index 54e8078e7b..9f7989265a 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -40,8 +40,8 @@ const NodeSelectorWrapper = (props: NodeSelectorProps) => { return ( <NodeSelector {...props} - blocks={blocks} - dataSources={dataSourceList || []} + blocks={props.blocks || blocks} + dataSources={props.dataSources || dataSourceList || []} /> ) } diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 631b85cd8c..3e13384785 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -9,16 +9,18 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' +import { useNodes } from 'reactflow' import type { OffsetOptions, Placement, } from '@floating-ui/react' import type { - BlockEnum, + CommonNodeType, NodeDefault, OnSelectBlock, ToolWithProvider, } from '../types' +import { BlockEnum, isTriggerNode } from '../types' import Tabs from './tabs' import { TabsEnum } from './types' import { useTabs } from './hooks' @@ -51,6 +53,12 @@ export type NodeSelectorProps = { dataSources?: ToolWithProvider[] noBlocks?: boolean noTools?: boolean + showStartTab?: boolean + defaultActiveTab?: TabsEnum + forceShowStartContent?: boolean + ignoreNodeIds?: string[] + forceEnableStartTab?: boolean // Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type). + allowUserInputSelection?: boolean // Override user-input availability; default logic blocks it when triggers exist. } const NodeSelector: FC<NodeSelectorProps> = ({ open: openFromProps, @@ -70,11 +78,47 @@ const NodeSelector: FC<NodeSelectorProps> = ({ dataSources = [], noBlocks = false, noTools = false, + showStartTab = false, + defaultActiveTab, + forceShowStartContent = false, + ignoreNodeIds = [], + forceEnableStartTab = false, + allowUserInputSelection, }) => { const { t } = useTranslation() + const nodes = useNodes() const [searchText, setSearchText] = useState('') const [tags, setTags] = useState<string[]>([]) const [localOpen, setLocalOpen] = useState(false) + // Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state. + const filteredNodes = useMemo(() => { + if (!ignoreNodeIds.length) + return nodes + const ignoreSet = new Set(ignoreNodeIds) + return nodes.filter(node => !ignoreSet.has(node.id)) + }, [nodes, ignoreNodeIds]) + + const { hasTriggerNode, hasUserInputNode } = useMemo(() => { + const result = { + hasTriggerNode: false, + hasUserInputNode: false, + } + for (const node of filteredNodes) { + const nodeType = (node.data as CommonNodeType | undefined)?.type + if (!nodeType) + continue + if (nodeType === BlockEnum.Start) + result.hasUserInputNode = true + if (isTriggerNode(nodeType)) + result.hasTriggerNode = true + if (result.hasTriggerNode && result.hasUserInputNode) + break + } + return result + }, [filteredNodes]) + // Default rule: user input option is only available when no Start node nor Trigger node exists on canvas. + const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode + const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection const open = openFromProps === undefined ? localOpen : openFromProps const handleOpenChange = useCallback((newOpen: boolean) => { setLocalOpen(newOpen) @@ -91,22 +135,34 @@ const NodeSelector: FC<NodeSelectorProps> = ({ e.stopPropagation() handleOpenChange(!open) }, [handleOpenChange, open, disabled]) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { + + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { handleOpenChange(false) - onSelect(type, toolDefaultValue) + onSelect(type, pluginDefaultValue) }, [handleOpenChange, onSelect]) const { activeTab, setActiveTab, tabs, - } = useTabs(noBlocks, !dataSources.length, noTools) + } = useTabs({ + noBlocks, + noSources: !dataSources.length, + noTools, + noStart: !showStartTab, + defaultActiveTab, + hasUserInputNode, + forceEnableStartTab, + }) const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => { setActiveTab(newActiveTab) }, [setActiveTab]) const searchPlaceholder = useMemo(() => { + if (activeTab === TabsEnum.Start) + return t('workflow.tabs.searchTrigger') + if (activeTab === TabsEnum.Blocks) return t('workflow.tabs.searchBlock') @@ -136,7 +192,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({ : ( <div className={` - z-10 flex h-4 + z-10 flex h-4 w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover ${triggerClassName?.(open)} `} @@ -153,9 +209,21 @@ const NodeSelector: FC<NodeSelectorProps> = ({ tabs={tabs} activeTab={activeTab} blocks={blocks} + allowStartNodeSelection={canSelectUserInput} onActiveTabChange={handleActiveTabChange} filterElem={ <div className='relative m-2' onClick={e => e.stopPropagation()}> + {activeTab === TabsEnum.Start && ( + <SearchBox + autoFocus + search={searchText} + onSearchChange={setSearchText} + tags={tags} + onTagsChange={setTags} + placeholder={searchPlaceholder} + inputClassName='grow' + /> + )} {activeTab === TabsEnum.Blocks && ( <Input showLeftIcon @@ -180,6 +248,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({ )} {activeTab === TabsEnum.Tools && ( <SearchBox + autoFocus search={searchText} onSearchChange={setSearchText} tags={tags} @@ -198,6 +267,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({ dataSources={dataSources} noTools={noTools} onTagsChange={setTags} + forceShowStartContent={forceShowStartContent} /> </div> </PortalToFollowElemContent> diff --git a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx index 56ee420cff..034ecbad45 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' import { RiMoreFill } from '@remixicon/react' @@ -15,6 +15,7 @@ import cn from '@/utils/classnames' import { useDownloadPlugin } from '@/service/use-plugins' import { downloadFile } from '@/utils/format' import { getMarketplaceUrl } from '@/utils/var' +import { useQueryClient } from '@tanstack/react-query' type Props = { open: boolean @@ -33,6 +34,7 @@ const OperationDropdown: FC<Props> = ({ }) => { const { t } = useTranslation() const { theme } = useTheme() + const queryClient = useQueryClient() const openRef = useRef(open) const setOpen = useCallback((v: boolean) => { onOpenChange(v) @@ -44,23 +46,32 @@ const OperationDropdown: FC<Props> = ({ }, [setOpen]) const [needDownload, setNeedDownload] = useState(false) - const { data: blob, isLoading } = useDownloadPlugin({ + const downloadInfo = useMemo(() => ({ organization: author, pluginName: name, version, - }, needDownload) + }), [author, name, version]) + const { data: blob, isLoading } = useDownloadPlugin(downloadInfo, needDownload) const handleDownload = useCallback(() => { if (isLoading) return + queryClient.removeQueries({ + queryKey: ['plugins', 'downloadPlugin', downloadInfo], + exact: true, + }) setNeedDownload(true) - }, [isLoading]) + }, [downloadInfo, isLoading, queryClient]) useEffect(() => { - if (blob) { - const fileName = `${author}-${name}_${version}.zip` - downloadFile({ data: blob, fileName }) - setNeedDownload(false) - } - }, [blob]) + if (!needDownload || !blob) + return + const fileName = `${author}-${name}_${version}.zip` + downloadFile({ data: blob, fileName }) + setNeedDownload(false) + queryClient.removeQueries({ + queryKey: ['plugins', 'downloadPlugin', downloadInfo], + exact: true, + }) + }, [author, blob, downloadInfo, name, needDownload, queryClient, version]) return ( <PortalToFollowElem open={open} @@ -77,7 +88,7 @@ const OperationDropdown: FC<Props> = ({ </ActionButton> </PortalToFollowElemTrigger> <PortalToFollowElemContent className='z-[9999]'> - <div className='w-[112px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'> + <div className='min-w-[176px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'> <div onClick={handleDownload} className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>{t('common.operation.download')}</div> <a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target='_blank' className='system-md-regular block cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>{t('common.operation.viewDetails')}</a> </div> diff --git a/web/app/components/workflow/block-selector/market-place-plugin/item.tsx b/web/app/components/workflow/block-selector/market-place-plugin/item.tsx index 4826108c5c..3c9c9b9f59 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/item.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/item.tsx @@ -52,8 +52,13 @@ const Item: FC<Props> = ({ </div> </div> {/* Action */} - <div className={cn(!open ? 'hidden' : 'flex', 'system-xs-medium h-4 items-center space-x-1 text-components-button-secondary-accent-text group-hover/plugin:flex')}> - <div className='cursor-pointer px-1.5' onClick={showInstallModal}>{t('plugin.installAction')}</div> + <div className={cn(!open ? 'hidden' : 'flex', 'system-xs-medium h-4 items-center space-x-1 text-components-button-secondary-accent-text group-hover/plugin:flex')}> + <div + className='cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover' + onClick={showInstallModal} + > + {t('plugin.installAction')} + </div> <Action open={open} onOpenChange={setOpen} diff --git a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx index 9f5ce22568..8c050b60d6 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx @@ -1,5 +1,6 @@ 'use client' -import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react' +import { useEffect, useImperativeHandle, useMemo, useRef } from 'react' +import type { RefObject } from 'react' import { useTranslation } from 'react-i18next' import useStickyScroll, { ScrollPosition } from '../use-sticky-scroll' import Item from './item' @@ -17,6 +18,7 @@ export type ListProps = { tags: string[] toolContentClassName?: string disableMaxWidth?: boolean + hideFindMoreFooter?: boolean ref?: React.Ref<ListRef> } @@ -29,6 +31,7 @@ const List = ({ list, toolContentClassName, disableMaxWidth = false, + hideFindMoreFooter = false, ref, }: ListProps) => { const { t } = useTranslation() @@ -39,7 +42,7 @@ const List = ({ const { handleScroll, scrollPosition } = useStickyScroll({ wrapElemRef, - nextToStickyELemRef, + nextToStickyELemRef: nextToStickyELemRef as RefObject<HTMLElement>, }) const stickyClassName = useMemo(() => { switch (scrollPosition) { @@ -69,6 +72,9 @@ const List = ({ } if (noFilter) { + if (hideFindMoreFooter) + return null + return ( <Link className='system-sm-medium sticky bottom-0 z-10 flex h-8 cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx index eecd874335..240c0814a1 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx @@ -1,5 +1,6 @@ +'use client' import type { Dispatch, SetStateAction } from 'react' -import React, { useCallback, useMemo } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import type { OnSelectBlock } from '@/app/components/workflow/types' import type { ViewType } from '@/app/components/workflow/block-selector/view-type-select' @@ -10,6 +11,7 @@ import { getMarketplaceUrl } from '@/utils/var' import { useRAGRecommendedPlugins } from '@/service/use-tools' import List from './list' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' +import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/arrows' type RAGToolRecommendationsProps = { viewType: ViewType @@ -17,12 +19,34 @@ type RAGToolRecommendationsProps = { onTagsChange: Dispatch<SetStateAction<string[]>> } +const STORAGE_KEY = 'workflow_rag_recommendations_collapsed' + const RAGToolRecommendations = ({ viewType, onSelect, onTagsChange, }: RAGToolRecommendationsProps) => { const { t } = useTranslation() + const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { + if (typeof window === 'undefined') + return false + const stored = window.localStorage.getItem(STORAGE_KEY) + return stored === 'true' + }) + + useEffect(() => { + if (typeof window === 'undefined') + return + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored !== null) + setIsCollapsed(stored === 'true') + }, []) + + useEffect(() => { + if (typeof window === 'undefined') + return + window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) + }, [isCollapsed]) const { data: ragRecommendedPlugins, @@ -52,51 +76,60 @@ const RAGToolRecommendations = ({ return ( <div className='flex flex-col p-1'> - <div className='system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary'> - {t('pipeline.ragToolSuggestions.title')} - </div> - {/* For first time loading, show loading */} - {isLoadingRAGRecommendedPlugins && ( - <div className='py-2'> - <Loading type='app' /> - </div> - )} - {!isFetchingRAGRecommendedPlugins && recommendedPlugins.length === 0 && unInstalledPlugins.length === 0 && ( - <p className='system-xs-regular px-3 py-1 text-text-tertiary'> - <Trans - i18nKey='pipeline.ragToolSuggestions.noRecommendationPlugins' - components={{ - CustomLink: ( - <Link - className='text-text-accent' - target='_blank' - rel='noopener noreferrer' - href={getMarketplaceUrl('', { tags: 'rag' })} - /> - ), - }} - /> - </p> - )} - {(recommendedPlugins.length > 0 || unInstalledPlugins.length > 0) && ( + <button + type='button' + className='flex w-full items-center rounded-md px-3 pb-0.5 pt-1 text-left text-text-tertiary' + onClick={() => setIsCollapsed(prev => !prev)} + > + <span className='system-xs-medium text-text-tertiary'>{t('pipeline.ragToolSuggestions.title')}</span> + <ArrowDownRoundFill className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} /> + </button> + {!isCollapsed && ( <> - <List - tools={recommendedPlugins} - unInstalledPlugins={unInstalledPlugins} - onSelect={onSelect} - viewType={viewType} - /> - <div - className='flex cursor-pointer items-center gap-x-2 py-1 pl-3 pr-2' - onClick={loadMore} - > - <div className='px-1'> - <RiMoreLine className='size-4 text-text-tertiary' /> + {/* For first time loading, show loading */} + {isLoadingRAGRecommendedPlugins && ( + <div className='py-2'> + <Loading type='app' /> </div> - <div className='system-xs-regular text-text-tertiary'> - {t('common.operation.more')} - </div> - </div> + )} + {!isFetchingRAGRecommendedPlugins && recommendedPlugins.length === 0 && unInstalledPlugins.length === 0 && ( + <p className='system-xs-regular px-3 py-1 text-text-tertiary'> + <Trans + i18nKey='pipeline.ragToolSuggestions.noRecommendationPlugins' + components={{ + CustomLink: ( + <Link + className='text-text-accent' + target='_blank' + rel='noopener noreferrer' + href={getMarketplaceUrl('', { tags: 'rag' })} + /> + ), + }} + /> + </p> + )} + {(recommendedPlugins.length > 0 || unInstalledPlugins.length > 0) && ( + <> + <List + tools={recommendedPlugins} + unInstalledPlugins={unInstalledPlugins} + onSelect={onSelect} + viewType={viewType} + /> + <div + className='flex cursor-pointer items-center gap-x-2 py-1 pl-3 pr-2' + onClick={loadMore} + > + <div className='px-1'> + <RiMoreLine className='size-4 text-text-tertiary' /> + </div> + <div className='system-xs-regular text-text-tertiary'> + {t('common.operation.more')} + </div> + </div> + </> + )} </> )} </div> diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx index 19378caf48..8c98fa9d7c 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx @@ -1,7 +1,4 @@ -import { - useMemo, - useRef, -} from 'react' +import { useCallback, useMemo, useRef } from 'react' import type { BlockEnum, ToolWithProvider } from '../../types' import type { ToolDefaultValue } from '../types' import { ViewType } from '../view-type-select' @@ -12,9 +9,10 @@ import ToolListTreeView from '../tool/tool-list-tree-view/list' import ToolListFlatView from '../tool/tool-list-flat-view/list' import UninstalledItem from './uninstalled-item' import type { Plugin } from '@/app/components/plugins/types' +import type { OnSelectBlock } from '@/app/components/workflow/types' type ListProps = { - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: OnSelectBlock tools: ToolWithProvider[] viewType: ViewType unInstalledPlugins: Plugin[] @@ -62,6 +60,10 @@ const List = ({ const toolRefs = useRef({}) + const handleSelect = useCallback((type: BlockEnum, tool: ToolDefaultValue) => { + onSelect(type, tool) + }, [onSelect]) + return ( <div className={cn('max-w-[100%] p-1', className)}> {!!tools.length && ( @@ -72,7 +74,7 @@ const List = ({ payload={listViewToolData} isShowLetterIndex={false} hasSearchText={false} - onSelect={onSelect} + onSelect={handleSelect} canNotSelectMultiple indexBar={null} /> @@ -80,7 +82,7 @@ const List = ({ <ToolListTreeView payload={treeViewToolsData} hasSearchText={false} - onSelect={onSelect} + onSelect={handleSelect} canNotSelectMultiple /> ) diff --git a/web/app/components/workflow/block-selector/start-blocks.tsx b/web/app/components/workflow/block-selector/start-blocks.tsx new file mode 100644 index 0000000000..31b6abce6c --- /dev/null +++ b/web/app/components/workflow/block-selector/start-blocks.tsx @@ -0,0 +1,139 @@ +import { + memo, + useCallback, + useEffect, + useMemo, +} from 'react' +import { useNodes } from 'reactflow' +import { useTranslation } from 'react-i18next' +import BlockIcon from '../block-icon' +import type { BlockEnum, CommonNodeType } from '../types' +import { BlockEnum as BlockEnumValues } from '../types' +// import { useNodeMetaData } from '../hooks' +import { START_BLOCKS } from './constants' +import type { TriggerDefaultValue } from './types' +import Tooltip from '@/app/components/base/tooltip' +import { useAvailableNodesMetaData } from '../../workflow-app/hooks' + +type StartBlocksProps = { + searchText: string + onSelect: (type: BlockEnum, triggerDefaultValue?: TriggerDefaultValue) => void + availableBlocksTypes?: BlockEnum[] + onContentStateChange?: (hasContent: boolean) => void + hideUserInput?: boolean +} + +const StartBlocks = ({ + searchText, + onSelect, + availableBlocksTypes = [], + onContentStateChange, + hideUserInput = false, // Allow parent to explicitly hide Start node option (e.g. when one already exists). +}: StartBlocksProps) => { + const { t } = useTranslation() + const nodes = useNodes() + // const nodeMetaData = useNodeMetaData() + const availableNodesMetaData = useAvailableNodesMetaData() + + const filteredBlocks = useMemo(() => { + // Check if Start node already exists in workflow + const hasStartNode = nodes.some(node => (node.data as CommonNodeType)?.type === BlockEnumValues.Start) + const normalizedSearch = searchText.toLowerCase() + const getDisplayName = (blockType: BlockEnum) => { + if (blockType === BlockEnumValues.TriggerWebhook) + return t('workflow.customWebhook') + + return t(`workflow.blocks.${blockType}`) + } + + return START_BLOCKS.filter((block) => { + // Hide User Input (Start) if it already exists in workflow or if hideUserInput is true + if (block.type === BlockEnumValues.Start && (hasStartNode || hideUserInput)) + return false + + // Filter by search text + const displayName = getDisplayName(block.type).toLowerCase() + if (!displayName.includes(normalizedSearch) && !block.title.toLowerCase().includes(normalizedSearch)) + return false + + // availableBlocksTypes now contains properly filtered entry node types from parent + return availableBlocksTypes.includes(block.type) + }) + }, [searchText, availableBlocksTypes, nodes, t, hideUserInput]) + + const isEmpty = filteredBlocks.length === 0 + + useEffect(() => { + onContentStateChange?.(!isEmpty) + }, [isEmpty, onContentStateChange]) + + const renderBlock = useCallback((block: { type: BlockEnum; title: string; description?: string }) => ( + <Tooltip + key={block.type} + position='right' + popupClassName='w-[224px] rounded-xl' + needsDelay={false} + popupContent={( + <div> + <BlockIcon + size='md' + className='mb-2' + type={block.type} + /> + <div className='system-md-medium mb-1 text-text-primary'> + {block.type === BlockEnumValues.TriggerWebhook + ? t('workflow.customWebhook') + : t(`workflow.blocks.${block.type}`) + } + </div> + <div className='system-xs-regular text-text-secondary'> + {t(`workflow.blocksAbout.${block.type}`)} + </div> + {(block.type === BlockEnumValues.TriggerWebhook || block.type === BlockEnumValues.TriggerSchedule) && ( + <div className='system-xs-regular mb-1 mt-1 text-text-tertiary'> + {t('tools.author')} {t('workflow.difyTeam')} + </div> + )} + </div> + )} + > + <div + className='flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover' + onClick={() => onSelect(block.type)} + > + <BlockIcon + className='mr-2 shrink-0' + type={block.type} + /> + <div className='flex w-0 grow items-center justify-between text-sm text-text-secondary'> + <span className='truncate'>{t(`workflow.blocks.${block.type}`)}</span> + {block.type === BlockEnumValues.Start && ( + <span className='system-xs-regular ml-2 shrink-0 text-text-quaternary'>{t('workflow.blocks.originalStartNode')}</span> + )} + </div> + </div> + </Tooltip> + ), [availableNodesMetaData, onSelect, t]) + + if (isEmpty) + return null + + return ( + <div className='p-1'> + <div className='mb-1'> + {filteredBlocks.map((block, index) => ( + <div key={block.type}> + {renderBlock(block)} + {block.type === BlockEnumValues.Start && index < filteredBlocks.length - 1 && ( + <div className='my-1 px-3'> + <div className='border-t border-divider-subtle' /> + </div> + )} + </div> + ))} + </div> + </div> + ) +} + +export default memo(StartBlocks) diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index 91d5ac3af6..ecdb8797c0 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -1,6 +1,7 @@ import type { Dispatch, FC, SetStateAction } from 'react' -import { memo } from 'react' -import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' +import { memo, useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools } from '@/service/use-tools' import type { BlockEnum, NodeDefault, @@ -9,9 +10,15 @@ import type { } from '../types' import { TabsEnum } from './types' import Blocks from './blocks' +import AllStartBlocks from './all-start-blocks' import AllTools from './all-tools' import DataSources from './data-sources' import cn from '@/utils/classnames' +import { useFeaturedToolsRecommendations } from '@/service/use-plugins' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useWorkflowStore } from '../store' +import { basePath } from '@/utils/var' +import Tooltip from '@/app/components/base/tooltip' export type TabsProps = { activeTab: TabsEnum @@ -26,10 +33,13 @@ export type TabsProps = { tabs: Array<{ key: TabsEnum name: string + disabled?: boolean }> filterElem: React.ReactNode noBlocks?: boolean noTools?: boolean + forceShowStartContent?: boolean // Force show Start content even when noBlocks=true + allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet). } const Tabs: FC<TabsProps> = ({ activeTab, @@ -45,11 +55,75 @@ const Tabs: FC<TabsProps> = ({ filterElem, noBlocks, noTools, + forceShowStartContent = false, + allowStartNodeSelection = false, }) => { + const { t } = useTranslation() const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() + const invalidateBuiltInTools = useInvalidateAllBuiltInTools() + const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + const workflowStore = useWorkflowStore() + const inRAGPipeline = dataSources.length > 0 + const { + plugins: featuredPlugins = [], + isLoading: isFeaturedLoading, + } = useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline) + + const normalizeToolList = useMemo(() => { + return (list?: ToolWithProvider[]) => { + if (!list) + return list + if (!basePath) + return list + let changed = false + const normalized = list.map((provider) => { + if (typeof provider.icon === 'string') { + const icon = provider.icon + const shouldPrefix = Boolean(basePath) + && icon.startsWith('/') + && !icon.startsWith(`${basePath}/`) + + if (shouldPrefix) { + changed = true + return { + ...provider, + icon: `${basePath}${icon}`, + } + } + } + return provider + }) + return changed ? normalized : list + } + }, [basePath]) + + useEffect(() => { + workflowStore.setState((state) => { + const updates: Partial<typeof state> = {} + const normalizedBuiltIn = normalizeToolList(buildInTools) + const normalizedCustom = normalizeToolList(customTools) + const normalizedWorkflow = normalizeToolList(workflowTools) + const normalizedMCP = normalizeToolList(mcpTools) + + if (normalizedBuiltIn !== undefined && state.buildInTools !== normalizedBuiltIn) + updates.buildInTools = normalizedBuiltIn + if (normalizedCustom !== undefined && state.customTools !== normalizedCustom) + updates.customTools = normalizedCustom + if (normalizedWorkflow !== undefined && state.workflowTools !== normalizedWorkflow) + updates.workflowTools = normalizedWorkflow + if (normalizedMCP !== undefined && state.mcpTools !== normalizedMCP) + updates.mcpTools = normalizedMCP + if (!Object.keys(updates).length) + return state + return { + ...state, + ...updates, + } + }) + }, [workflowStore, normalizeToolList, buildInTools, customTools, workflowTools, mcpTools]) return ( <div onClick={e => e.stopPropagation()}> @@ -57,25 +131,64 @@ const Tabs: FC<TabsProps> = ({ !noBlocks && ( <div className='relative flex bg-background-section-burn pl-1 pt-1'> { - tabs.map(tab => ( - <div - key={tab.key} - className={cn( - 'system-sm-medium relative mr-0.5 flex h-8 cursor-pointer items-center rounded-t-lg px-3 ', - activeTab === tab.key - ? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent' - : 'text-text-tertiary', - )} - onClick={() => onActiveTabChange(tab.key)} - > - {tab.name} - </div> - )) + tabs.map((tab) => { + const commonProps = { + 'className': cn( + 'system-sm-medium relative mr-0.5 flex h-8 items-center rounded-t-lg px-3', + tab.disabled + ? 'cursor-not-allowed text-text-disabled opacity-60' + : activeTab === tab.key + ? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent' + : 'cursor-pointer text-text-tertiary', + ), + 'aria-disabled': tab.disabled, + 'onClick': () => { + if (tab.disabled || activeTab === tab.key) + return + onActiveTabChange(tab.key) + }, + } as const + if (tab.disabled) { + return ( + <Tooltip + key={tab.key} + position='top' + popupClassName='max-w-[200px]' + popupContent={t('workflow.tabs.startDisabledTip')} + > + <div {...commonProps}> + {tab.name} + </div> + </Tooltip> + ) + } + return ( + <div + key={tab.key} + {...commonProps} + > + {tab.name} + </div> + ) + }) } </div> ) } {filterElem} + { + activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && ( + <div className='border-t border-divider-subtle'> + <AllStartBlocks + allowUserInputSelection={allowStartNodeSelection} + searchText={searchText} + onSelect={onSelect} + availableBlocksTypes={availableBlocksTypes} + tags={tags} + /> + </div> + ) + } { activeTab === TabsEnum.Blocks && !noBlocks && ( <div className='border-t border-divider-subtle'> @@ -112,7 +225,13 @@ const Tabs: FC<TabsProps> = ({ mcpTools={mcpTools || []} canChooseMCPTool onTagsChange={onTagsChange} - isInRAGPipeline={dataSources.length > 0} + isInRAGPipeline={inRAGPipeline} + featuredPlugins={featuredPlugins} + featuredLoading={isFeaturedLoading} + showFeatured={enable_marketplace && !inRAGPipeline} + onFeaturedInstallSuccess={async () => { + invalidateBuiltInTools() + }} /> ) } diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx index ae4b0d4f02..660cdad71e 100644 --- a/web/app/components/workflow/block-selector/tool-picker.tsx +++ b/web/app/components/workflow/block-selector/tool-picker.tsx @@ -23,7 +23,18 @@ import { } from '@/service/tools' import type { CustomCollectionBackend } from '@/app/components/tools/types' import Toast from '@/app/components/base/toast' -import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllCustomTools } from '@/service/use-tools' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, + useInvalidateAllBuiltInTools, + useInvalidateAllCustomTools, + useInvalidateAllMCPTools, + useInvalidateAllWorkflowTools, +} from '@/service/use-tools' +import { useFeaturedToolsRecommendations } from '@/service/use-plugins' +import { useGlobalPublicStore } from '@/context/global-public-context' import cn from '@/utils/classnames' type Props = { @@ -61,11 +72,20 @@ const ToolPicker: FC<Props> = ({ const [searchText, setSearchText] = useState('') const [tags, setTags] = useState<string[]>([]) + const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const invalidateCustomTools = useInvalidateAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() + const invalidateBuiltInTools = useInvalidateAllBuiltInTools() + const invalidateWorkflowTools = useInvalidateAllWorkflowTools() + const invalidateMcpTools = useInvalidateAllMCPTools() + + const { + plugins: featuredPlugins = [], + isLoading: isFeaturedLoading, + } = useFeaturedToolsRecommendations(enable_marketplace) const { builtinToolList, customToolList, workflowToolList } = useMemo(() => { if (scope === 'plugins') { @@ -179,6 +199,15 @@ const ToolPicker: FC<Props> = ({ selectedTools={selectedTools} canChooseMCPTool={canChooseMCPTool} onTagsChange={setTags} + featuredPlugins={featuredPlugins} + featuredLoading={isFeaturedLoading} + showFeatured={scope === 'all' && enable_marketplace} + onFeaturedInstallSuccess={async () => { + invalidateBuiltInTools() + invalidateCustomTools() + invalidateWorkflowTools() + invalidateMcpTools() + }} /> </div> </PortalToFollowElemContent> diff --git a/web/app/components/workflow/block-selector/tool/action-item.tsx b/web/app/components/workflow/block-selector/tool/action-item.tsx index 1005758d43..01c319327a 100644 --- a/web/app/components/workflow/block-selector/tool/action-item.tsx +++ b/web/app/components/workflow/block-selector/tool/action-item.tsx @@ -10,13 +10,20 @@ import { useGetLanguage } from '@/context/i18n' import BlockIcon from '../../block-icon' import cn from '@/utils/classnames' import { useTranslation } from 'react-i18next' +import { basePath } from '@/utils/var' + +const normalizeProviderIcon = (icon: ToolWithProvider['icon']) => { + if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`)) + return `${basePath}${icon}` + return icon +} type Props = { provider: ToolWithProvider payload: Tool disabled?: boolean isAdded?: boolean - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void } const ToolItem: FC<Props> = ({ @@ -64,6 +71,9 @@ const ToolItem: FC<Props> = ({ provider_id: provider.id, provider_type: provider.type, provider_name: provider.name, + plugin_id: provider.plugin_id, + plugin_unique_identifier: provider.plugin_unique_identifier, + provider_icon: normalizeProviderIcon(provider.icon), tool_name: payload.name, tool_label: payload.label[language], tool_description: payload.description[language], diff --git a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx index ca462c082e..510d6f2f4b 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx @@ -13,7 +13,7 @@ type Props = { isShowLetterIndex: boolean indexBar: React.ReactNode hasSearchText: boolean - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void letters: string[] diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx index ac0955da0b..a2833646f3 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/item.tsx @@ -11,7 +11,7 @@ type Props = { groupName: string toolList: ToolWithProvider[] hasSearchText: boolean - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx index d85d1ea682..162b816069 100644 --- a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx +++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/list.tsx @@ -11,7 +11,7 @@ import { AGENT_GROUP_NAME, CUSTOM_GROUP_NAME, WORKFLOW_GROUP_NAME } from '../../ type Props = { payload: Record<string, ToolWithProvider[]> hasSearchText: boolean - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] diff --git a/web/app/components/workflow/block-selector/tool/tool.tsx b/web/app/components/workflow/block-selector/tool/tool.tsx index 30d3e218d2..38be8d19d6 100644 --- a/web/app/components/workflow/block-selector/tool/tool.tsx +++ b/web/app/components/workflow/block-selector/tool/tool.tsx @@ -16,17 +16,25 @@ import { useTranslation } from 'react-i18next' import { useHover } from 'ahooks' import McpToolNotSupportTooltip from '../../nodes/_base/components/mcp-tool-not-support-tooltip' import { Mcp } from '@/app/components/base/icons/src/vender/other' +import { basePath } from '@/utils/var' + +const normalizeProviderIcon = (icon: ToolWithProvider['icon']) => { + if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`)) + return `${basePath}${icon}` + return icon +} type Props = { className?: string payload: ToolWithProvider viewType: ViewType hasSearchText: boolean - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void selectedTools?: ToolValue[] canChooseMCPTool?: boolean + isShowLetterIndex?: boolean } const Tool: FC<Props> = ({ @@ -85,6 +93,9 @@ const Tool: FC<Props> = ({ provider_id: payload.id, provider_type: payload.type, provider_name: payload.name, + plugin_id: payload.plugin_id, + plugin_unique_identifier: payload.plugin_unique_identifier, + provider_icon: normalizeProviderIcon(payload.icon), tool_name: tool.name, tool_label: tool.label[language], tool_description: tool.description[language], @@ -164,6 +175,9 @@ const Tool: FC<Props> = ({ provider_id: payload.id, provider_type: payload.type, provider_name: payload.name, + plugin_id: payload.plugin_id, + plugin_unique_identifier: payload.plugin_unique_identifier, + provider_icon: normalizeProviderIcon(payload.icon), tool_name: tool.name, tool_label: tool.label[language], tool_description: tool.description[language], diff --git a/web/app/components/workflow/block-selector/tools.tsx b/web/app/components/workflow/block-selector/tools.tsx index 71ed4092a3..c62f6a67f9 100644 --- a/web/app/components/workflow/block-selector/tools.tsx +++ b/web/app/components/workflow/block-selector/tools.tsx @@ -1,9 +1,4 @@ -import { - memo, - useMemo, - useRef, -} from 'react' -import { useTranslation } from 'react-i18next' +import { memo, useMemo, useRef } from 'react' import type { BlockEnum, ToolWithProvider } from '../types' import IndexBar, { groupItems } from './index-bar' import type { ToolDefaultValue, ToolValue } from './types' @@ -16,7 +11,7 @@ import ToolListFlatView from './tool/tool-list-flat-view/list' import classNames from '@/utils/classnames' type ToolsProps = { - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void canNotSelectMultiple?: boolean onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void tools: ToolWithProvider[] @@ -28,7 +23,6 @@ type ToolsProps = { indexBarClassName?: string selectedTools?: ToolValue[] canChooseMCPTool?: boolean - isShowRAGRecommendations?: boolean } const Tools = ({ onSelect, @@ -43,10 +37,8 @@ const Tools = ({ indexBarClassName, selectedTools, canChooseMCPTool, - isShowRAGRecommendations = false, }: ToolsProps) => { // const tools: any = [] - const { t } = useTranslation() const language = useGetLanguage() const isFlatView = viewType === ViewType.flat const isShowLetterIndex = isFlatView && tools.length > 10 @@ -100,21 +92,11 @@ const Tools = ({ return ( <div className={classNames('max-w-[100%] p-1', className)}> - { - !tools.length && hasSearchText && ( - <div className='mt-2 flex h-[22px] items-center px-3 text-xs font-medium text-text-secondary'>{t('workflow.tabs.noResult')}</div> - ) - } {!tools.length && !hasSearchText && ( <div className='py-10'> <Empty type={toolType!} isAgent={isAgent} /> </div> )} - {!!tools.length && isShowRAGRecommendations && ( - <div className='system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary'> - {t('tools.allTools')} - </div> - )} {!!tools.length && ( isFlatView ? ( <ToolListFlatView diff --git a/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx new file mode 100644 index 0000000000..d2bdda8a82 --- /dev/null +++ b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx @@ -0,0 +1,90 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import type { TriggerWithProvider } from '../types' +import type { Event } from '@/app/components/tools/types' +import { BlockEnum } from '../../types' +import type { TriggerDefaultValue } from '../types' +import Tooltip from '@/app/components/base/tooltip' +import { useGetLanguage } from '@/context/i18n' +import BlockIcon from '../../block-icon' +import cn from '@/utils/classnames' +import { useTranslation } from 'react-i18next' + +type Props = { + provider: TriggerWithProvider + payload: Event + disabled?: boolean + isAdded?: boolean + onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void +} + +const TriggerPluginActionItem: FC<Props> = ({ + provider, + payload, + onSelect, + disabled, + isAdded, +}) => { + const { t } = useTranslation() + const language = useGetLanguage() + + return ( + <Tooltip + key={payload.name} + position='right' + needsDelay={false} + popupClassName='!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg' + popupContent={( + <div> + <BlockIcon + size='md' + className='mb-2' + type={BlockEnum.TriggerPlugin} + toolIcon={provider.icon} + /> + <div className='mb-1 text-sm leading-5 text-text-primary'>{payload.label[language]}</div> + <div className='text-xs leading-[18px] text-text-secondary'>{payload.description[language]}</div> + </div> + )} + > + <div + key={payload.name} + className='flex cursor-pointer items-center justify-between rounded-lg pl-[21px] pr-1 hover:bg-state-base-hover' + onClick={() => { + if (disabled) return + const params: Record<string, string> = {} + if (payload.parameters) { + payload.parameters.forEach((item: any) => { + params[item.name] = '' + }) + } + onSelect(BlockEnum.TriggerPlugin, { + plugin_id: provider.plugin_id, + provider_id: provider.name, + provider_type: provider.type as string, + provider_name: provider.name, + event_name: payload.name, + event_label: payload.label[language], + event_description: payload.description[language], + plugin_unique_identifier: provider.plugin_unique_identifier, + title: payload.label[language], + is_team_authorization: provider.is_team_authorization, + output_schema: payload.output_schema || {}, + paramSchemas: payload.parameters, + params, + meta: provider.meta, + }) + }} + > + <div className={cn('system-sm-medium h-8 truncate border-l-2 border-divider-subtle pl-4 leading-8 text-text-secondary')}> + <span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span> + </div> + {isAdded && ( + <div className='system-xs-regular mr-4 text-text-tertiary'>{t('tools.addToolModal.added')}</div> + )} + </div> + </Tooltip > + ) +} +export default React.memo(TriggerPluginActionItem) diff --git a/web/app/components/workflow/block-selector/trigger-plugin/item.tsx b/web/app/components/workflow/block-selector/trigger-plugin/item.tsx new file mode 100644 index 0000000000..702d3603fb --- /dev/null +++ b/web/app/components/workflow/block-selector/trigger-plugin/item.tsx @@ -0,0 +1,133 @@ +'use client' +import { useGetLanguage } from '@/context/i18n' +import cn from '@/utils/classnames' +import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' +import type { FC } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { CollectionType } from '@/app/components/tools/types' +import BlockIcon from '@/app/components/workflow/block-icon' +import { BlockEnum } from '@/app/components/workflow/types' +import type { TriggerDefaultValue, TriggerWithProvider } from '@/app/components/workflow/block-selector/types' +import TriggerPluginActionItem from './action-item' + +type Props = { + className?: string + payload: TriggerWithProvider + hasSearchText: boolean + onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void +} + +const TriggerPluginItem: FC<Props> = ({ + className, + payload, + hasSearchText, + onSelect, +}) => { + const { t } = useTranslation() + const language = useGetLanguage() + const notShowProvider = payload.type === CollectionType.workflow + const actions = payload.events + const hasAction = !notShowProvider + const [isFold, setFold] = React.useState<boolean>(true) + const ref = useRef(null) + + useEffect(() => { + if (hasSearchText && isFold) { + setFold(false) + return + } + if (!hasSearchText && !isFold) + setFold(true) + }, [hasSearchText]) + + const FoldIcon = isFold ? RiArrowRightSLine : RiArrowDownSLine + + const groupName = useMemo(() => { + if (payload.type === CollectionType.builtIn) + return payload.author + + if (payload.type === CollectionType.custom) + return t('workflow.tabs.customTool') + + if (payload.type === CollectionType.workflow) + return t('workflow.tabs.workflowTool') + + return payload.author || '' + }, [payload.author, payload.type, t]) + + return ( + <div + key={payload.id} + className={cn('mb-1 last-of-type:mb-0')} + ref={ref} + > + <div className={cn(className)}> + <div + className='group/item flex w-full cursor-pointer select-none items-center justify-between rounded-lg pl-3 pr-1 hover:bg-state-base-hover' + onClick={() => { + if (hasAction) { + setFold(!isFold) + return + } + + const event = actions[0] + const params: Record<string, string> = {} + if (event.parameters) { + event.parameters.forEach((item: any) => { + params[item.name] = '' + }) + } + onSelect(BlockEnum.TriggerPlugin, { + plugin_id: payload.plugin_id, + provider_id: payload.name, + provider_type: payload.type, + provider_name: payload.name, + event_name: event.name, + event_label: event.label[language], + event_description: event.description[language], + title: event.label[language], + plugin_unique_identifier: payload.plugin_unique_identifier, + is_team_authorization: payload.is_team_authorization, + output_schema: event.output_schema || {}, + paramSchemas: event.parameters, + params, + }) + }} + > + <div className='flex h-8 grow items-center'> + <BlockIcon + className='shrink-0' + type={BlockEnum.TriggerPlugin} + toolIcon={payload.icon} + /> + <div className='ml-2 flex min-w-0 flex-1 items-center text-sm text-text-primary'> + <span className='max-w-[200px] truncate'>{notShowProvider ? actions[0]?.label[language] : payload.label[language]}</span> + <span className='system-xs-regular ml-2 truncate text-text-quaternary'>{groupName}</span> + </div> + </div> + + <div className='ml-2 flex items-center'> + {hasAction && ( + <FoldIcon className={cn('h-4 w-4 shrink-0 text-text-tertiary group-hover/item:text-text-tertiary', isFold && 'text-text-quaternary')} /> + )} + </div> + </div> + + {!notShowProvider && hasAction && !isFold && ( + actions.map(action => ( + <TriggerPluginActionItem + key={action.name} + provider={payload} + payload={action} + onSelect={onSelect} + disabled={false} + isAdded={false} + /> + )) + )} + </div> + </div> + ) +} +export default React.memo(TriggerPluginItem) diff --git a/web/app/components/workflow/block-selector/trigger-plugin/list.tsx b/web/app/components/workflow/block-selector/trigger-plugin/list.tsx new file mode 100644 index 0000000000..3caf1149dd --- /dev/null +++ b/web/app/components/workflow/block-selector/trigger-plugin/list.tsx @@ -0,0 +1,105 @@ +'use client' +import { memo, useEffect, useMemo } from 'react' +import { useAllTriggerPlugins } from '@/service/use-triggers' +import TriggerPluginItem from './item' +import type { BlockEnum } from '../../types' +import type { TriggerDefaultValue, TriggerWithProvider } from '../types' +import { useGetLanguage } from '@/context/i18n' + +type TriggerPluginListProps = { + onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void + searchText: string + onContentStateChange?: (hasContent: boolean) => void + tags?: string[] +} + +const TriggerPluginList = ({ + onSelect, + searchText, + onContentStateChange, +}: TriggerPluginListProps) => { + const { data: triggerPluginsData } = useAllTriggerPlugins() + const language = useGetLanguage() + + const normalizedSearch = searchText.trim().toLowerCase() + const triggerPlugins = useMemo(() => { + const plugins = triggerPluginsData || [] + const getLocalizedText = (text?: Record<string, string> | null) => { + if (!text) + return '' + + if (text[language]) + return text[language] + + if (text['en-US']) + return text['en-US'] + + const firstValue = Object.values(text).find(Boolean) + return (typeof firstValue === 'string') ? firstValue : '' + } + const getSearchableTexts = (name: string, label?: Record<string, string> | null) => { + const localized = getLocalizedText(label) + const values = [localized, name].filter(Boolean) + return values.length > 0 ? values : [''] + } + const isMatchingKeywords = (value: string) => value.toLowerCase().includes(normalizedSearch) + + if (!normalizedSearch) + return plugins.filter(triggerWithProvider => triggerWithProvider.events.length > 0) + + return plugins.reduce<TriggerWithProvider[]>((acc, triggerWithProvider) => { + if (triggerWithProvider.events.length === 0) + return acc + + const providerMatches = getSearchableTexts( + triggerWithProvider.name, + triggerWithProvider.label, + ).some(text => isMatchingKeywords(text)) + + if (providerMatches) { + acc.push(triggerWithProvider) + return acc + } + + const matchedEvents = triggerWithProvider.events.filter((event) => { + return getSearchableTexts( + event.name, + event.label, + ).some(text => isMatchingKeywords(text)) + }) + + if (matchedEvents.length > 0) { + acc.push({ + ...triggerWithProvider, + events: matchedEvents, + }) + } + + return acc + }, []) + }, [triggerPluginsData, normalizedSearch, language]) + + const hasContent = triggerPlugins.length > 0 + + useEffect(() => { + onContentStateChange?.(hasContent) + }, [hasContent, onContentStateChange]) + + if (!hasContent) + return null + + return ( + <div className="p-1"> + {triggerPlugins.map(plugin => ( + <TriggerPluginItem + key={plugin.id} + payload={plugin} + onSelect={onSelect} + hasSearchText={!!searchText} + /> + ))} + </div> + ) +} + +export default memo(TriggerPluginList) diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index 48fbf6a500..b69453e937 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -1,8 +1,9 @@ -import type { PluginMeta } from '../../plugins/types' - import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ParametersSchema, PluginMeta, PluginTriggerSubscriptionConstructor, SupportedCreationMethods, TriggerEvent } from '../../plugins/types' +import type { Collection, Event } from '../../tools/types' export enum TabsEnum { + Start = 'start', Blocks = 'blocks', Tools = 'tools', Sources = 'sources', @@ -24,10 +25,28 @@ export enum BlockClassificationEnum { Utilities = 'utilities', } -export type ToolDefaultValue = { +type PluginCommonDefaultValue = { provider_id: string provider_type: string provider_name: string +} + +export type TriggerDefaultValue = PluginCommonDefaultValue & { + plugin_id?: string + event_name: string + event_label: string + event_description: string + title: string + plugin_unique_identifier: string + is_team_authorization: boolean + params: Record<string, any> + paramSchemas: Record<string, any>[] + output_schema: Record<string, any> + subscription_id?: string + meta?: PluginMeta +} + +export type ToolDefaultValue = PluginCommonDefaultValue & { tool_name: string tool_label: string tool_description: string @@ -35,12 +54,15 @@ export type ToolDefaultValue = { is_team_authorization: boolean params: Record<string, any> paramSchemas: Record<string, any>[] + output_schema?: Record<string, any> credential_id?: string meta?: PluginMeta - output_schema?: Record<string, any> + plugin_id?: string + provider_icon?: Collection['icon'] + plugin_unique_identifier?: string } -export type DataSourceDefaultValue = { +export type DataSourceDefaultValue = Omit<PluginCommonDefaultValue, 'provider_id'> & { plugin_id: string provider_type: string provider_name: string @@ -48,8 +70,11 @@ export type DataSourceDefaultValue = { datasource_label: string title: string fileExtensions?: string[] + plugin_unique_identifier?: string } +export type PluginDefaultValue = ToolDefaultValue | DataSourceDefaultValue | TriggerDefaultValue + export type ToolValue = { provider_name: string provider_show_name?: string @@ -96,3 +121,218 @@ export type DataSourceItem = { } is_authorized: boolean } + +// Backend API types - exact match with Python definitions +export type TriggerParameter = { + multiple: boolean + name: string + label: TypeWithI18N + description?: TypeWithI18N + type: 'string' | 'number' | 'boolean' | 'select' | 'file' | 'files' + | 'model-selector' | 'app-selector' | 'object' | 'array' | 'dynamic-select' + auto_generate?: { + type: string + value?: any + } | null + template?: { + type: string + value?: any + } | null + scope?: string | null + required?: boolean + default?: any + min?: number | null + max?: number | null + precision?: number | null + options?: Array<{ + value: string + label: TypeWithI18N + icon?: string | null + }> | null +} + +export type TriggerCredentialField = { + type: 'secret-input' | 'text-input' | 'select' | 'boolean' + | 'app-selector' | 'model-selector' | 'tools-selector' + name: string + scope?: string | null + required: boolean + default?: string | number | boolean | Array<any> | null + options?: Array<{ + value: string + label: TypeWithI18N + }> | null + label: TypeWithI18N + help?: TypeWithI18N + url?: string | null + placeholder?: TypeWithI18N +} + +export type TriggerSubscriptionSchema = { + parameters_schema: TriggerParameter[] + properties_schema: TriggerCredentialField[] +} + +export type TriggerIdentity = { + author: string + name: string + label: TypeWithI18N + provider: string +} + +export type TriggerDescription = { + human: TypeWithI18N + llm: TypeWithI18N +} + +export type TriggerApiEntity = { + name: string + identity: TriggerIdentity + description: TypeWithI18N + parameters: TriggerParameter[] + output_schema?: Record<string, any> +} + +export type TriggerProviderApiEntity = { + author: string + name: string + label: TypeWithI18N + description: TypeWithI18N + icon?: string + icon_dark?: string + tags: string[] + plugin_id?: string + plugin_unique_identifier: string + supported_creation_methods: SupportedCreationMethods[] + credentials_schema?: TriggerCredentialField[] + subscription_constructor?: PluginTriggerSubscriptionConstructor | null + subscription_schema: ParametersSchema[] + events: TriggerEvent[] +} + +// Frontend types - compatible with ToolWithProvider +export type TriggerWithProvider = Collection & { + events: Event[] + meta: PluginMeta + plugin_unique_identifier: string + credentials_schema?: TriggerCredentialField[] + subscription_constructor?: PluginTriggerSubscriptionConstructor | null + subscription_schema?: ParametersSchema[] + supported_creation_methods: SupportedCreationMethods[] +} + +// ===== API Service Types ===== + +// Trigger subscription instance types + +export enum TriggerCredentialTypeEnum { + ApiKey = 'api-key', + Oauth2 = 'oauth2', + Unauthorized = 'unauthorized', +} + +type TriggerSubscriptionStructure = { + id: string + name: string + provider: string + credential_type: TriggerCredentialTypeEnum + credentials: TriggerSubCredentials + endpoint: string + parameters: TriggerSubParameters + properties: TriggerSubProperties + workflows_in_use: number +} + +export type TriggerSubscription = TriggerSubscriptionStructure + +export type TriggerSubCredentials = { + access_tokens: string +} + +export type TriggerSubParameters = { + repository: string + webhook_secret?: string +} + +export type TriggerSubProperties = { + active: boolean + events: string[] + external_id: string + repository: string + webhook_secret?: string +} + +export type TriggerSubscriptionBuilder = TriggerSubscriptionStructure + +// OAuth configuration types +export type TriggerOAuthConfig = { + configured: boolean + custom_configured: boolean + custom_enabled: boolean + redirect_uri: string + oauth_client_schema: ParametersSchema[] + params: { + client_id: string + client_secret: string + [key: string]: any + } + system_configured: boolean +} + +export type TriggerOAuthClientParams = { + client_id: string + client_secret: string + authorization_url?: string + token_url?: string + scope?: string +} + +export type TriggerOAuthResponse = { + authorization_url: string + subscription_builder: TriggerSubscriptionBuilder +} + +export type TriggerLogEntity = { + id: string + endpoint: string + request: LogRequest + response: LogResponse + created_at: string +} + +export type LogRequest = { + method: string + url: string + headers: LogRequestHeaders + data: string +} + +export type LogRequestHeaders = { + 'Host': string + 'User-Agent': string + 'Content-Length': string + 'Accept': string + 'Content-Type': string + 'X-Forwarded-For': string + 'X-Forwarded-Host': string + 'X-Forwarded-Proto': string + 'X-Github-Delivery': string + 'X-Github-Event': string + 'X-Github-Hook-Id': string + 'X-Github-Hook-Installation-Target-Id': string + 'X-Github-Hook-Installation-Target-Type': string + 'Accept-Encoding': string + [key: string]: string +} + +export type LogResponse = { + status_code: number + headers: LogResponseHeaders + data: string +} + +export type LogResponseHeaders = { + 'Content-Type': string + 'Content-Length': string + [key: string]: string +} diff --git a/web/app/components/workflow/block-selector/utils.ts b/web/app/components/workflow/block-selector/utils.ts index 9b7a5fc076..4272e61644 100644 --- a/web/app/components/workflow/block-selector/utils.ts +++ b/web/app/components/workflow/block-selector/utils.ts @@ -17,6 +17,7 @@ export const transformDataSourceToTool = (dataSourceItem: DataSourceItem) => { is_authorized: dataSourceItem.is_authorized, labels: dataSourceItem.declaration.identity.tags || [], plugin_id: dataSourceItem.plugin_id, + plugin_unique_identifier: dataSourceItem.plugin_unique_identifier, tools: dataSourceItem.declaration.datasources.map((datasource) => { return { name: datasource.identity.name, diff --git a/web/app/components/workflow/candidate-node.tsx b/web/app/components/workflow/candidate-node.tsx index 6f2389aad2..54daf13ebc 100644 --- a/web/app/components/workflow/candidate-node.tsx +++ b/web/app/components/workflow/candidate-node.tsx @@ -12,7 +12,7 @@ import { useStore, useWorkflowStore, } from './store' -import { WorkflowHistoryEvent, useNodesInteractions, useWorkflowHistory } from './hooks' +import { WorkflowHistoryEvent, useAutoGenerateWebhookUrl, useNodesInteractions, useNodesSyncDraft, useWorkflowHistory } from './hooks' import { CUSTOM_NODE } from './constants' import { getIterationStartNode, getLoopStartNode } from './utils' import CustomNode from './nodes' @@ -29,6 +29,8 @@ const CandidateNode = () => { const { zoom } = useViewport() const { handleNodeSelect } = useNodesInteractions() const { saveStateToHistory } = useWorkflowHistory() + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl() useEventListener('click', (e) => { const { candidateNode, mousePosition } = workflowStore.getState() @@ -70,6 +72,12 @@ const CandidateNode = () => { if (candidateNode.type === CUSTOM_NOTE_NODE) handleNodeSelect(candidateNode.id) + + if (candidateNode.data.type === BlockEnum.TriggerWebhook) { + handleSyncWorkflowDraft(true, true, { + onSuccess: () => autoGenerateWebhookUrl(candidateNode.id), + }) + } } }) diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index a8c6a458fc..ad498ff65b 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -35,6 +35,54 @@ export const NODE_LAYOUT_HORIZONTAL_PADDING = 60 export const NODE_LAYOUT_VERTICAL_PADDING = 60 export const NODE_LAYOUT_MIN_DISTANCE = 100 +export const isInWorkflowPage = () => { + const pathname = globalThis.location.pathname + return /^\/app\/[^/]+\/workflow$/.test(pathname) || /^\/workflow\/[^/]+$/.test(pathname) +} +export const getGlobalVars = (isChatMode: boolean): Var[] => { + const isInWorkflow = isInWorkflowPage() + const vars: Var[] = [ + ...(isChatMode ? [ + { + variable: 'sys.dialogue_count', + type: VarType.number, + }, + { + variable: 'sys.conversation_id', + type: VarType.string, + }, + ] : []), + { + variable: 'sys.user_id', + type: VarType.string, + }, + { + variable: 'sys.app_id', + type: VarType.string, + }, + { + variable: 'sys.workflow_id', + type: VarType.string, + }, + { + variable: 'sys.workflow_run_id', + type: VarType.string, + }, + ...((isInWorkflow && !isChatMode) ? [ + { + variable: 'sys.timestamp', + type: VarType.number, + }, + ] : []), + ] + return vars +} + +export const VAR_SHOW_NAME_MAP: Record<string, string> = { + 'sys.query': 'query', + 'sys.files': 'files', +} + export const RETRIEVAL_OUTPUT_STRUCT = `{ "content": "", "title": "", @@ -56,7 +104,7 @@ export const RETRIEVAL_OUTPUT_STRUCT = `{ }` export const SUPPORT_OUTPUT_VARS_NODE = [ - BlockEnum.Start, BlockEnum.LLM, BlockEnum.KnowledgeRetrieval, BlockEnum.Code, BlockEnum.TemplateTransform, + BlockEnum.Start, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin, BlockEnum.LLM, BlockEnum.KnowledgeRetrieval, BlockEnum.Code, BlockEnum.TemplateTransform, BlockEnum.HttpRequest, BlockEnum.Tool, BlockEnum.VariableAssigner, BlockEnum.VariableAggregator, BlockEnum.QuestionClassifier, BlockEnum.ParameterExtractor, BlockEnum.Iteration, BlockEnum.Loop, BlockEnum.DocExtractor, BlockEnum.ListFilter, diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index c38b0ef47d..d4cbc9199d 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -83,11 +83,11 @@ const CustomEdge = ({ setOpen(v) }, []) - const handleInsert = useCallback<OnSelectBlock>((nodeType, toolDefaultValue) => { + const handleInsert = useCallback<OnSelectBlock>((nodeType, pluginDefaultValue) => { handleNodeAdd( { nodeType, - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId: source, diff --git a/web/app/components/workflow/header/chat-variable-button.tsx b/web/app/components/workflow/header/chat-variable-button.tsx index 36c4a640c4..aa68182c23 100644 --- a/web/app/components/workflow/header/chat-variable-button.tsx +++ b/web/app/components/workflow/header/chat-variable-button.tsx @@ -7,13 +7,16 @@ import cn from '@/utils/classnames' const ChatVariableButton = ({ disabled }: { disabled: boolean }) => { const { theme } = useTheme() + const showChatVariablePanel = useStore(s => s.showChatVariablePanel) const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) const setShowEnvPanel = useStore(s => s.setShowEnvPanel) + const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) const handleClick = () => { setShowChatVariablePanel(true) setShowEnvPanel(false) + setShowGlobalVariablePanel(false) setShowDebugAndPreviewPanel(false) } @@ -21,10 +24,11 @@ const ChatVariableButton = ({ disabled }: { disabled: boolean }) => { <Button className={cn( 'p-2', - theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', + theme === 'dark' && showChatVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', )} disabled={disabled} onClick={handleClick} + variant='ghost' > <BubbleX className='h-4 w-4 text-components-button-secondary-text' /> </Button> diff --git a/web/app/components/workflow/header/checklist.tsx b/web/app/components/workflow/header/checklist.tsx index 9da16c59c6..794a8997a9 100644 --- a/web/app/components/workflow/header/checklist.tsx +++ b/web/app/components/workflow/header/checklist.tsx @@ -16,6 +16,7 @@ import { useChecklist, useNodesInteractions, } from '../hooks' +import type { ChecklistItem } from '../hooks/use-checklist' import type { CommonEdgeType, CommonNodeType, @@ -29,7 +30,9 @@ import { import { ChecklistSquare, } from '@/app/components/base/icons/src/vender/line/general' -import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback' +import { Warning } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback' +import { IconR } from '@/app/components/base/icons/src/vender/line/arrows' +import type { BlockEnum } from '../types' type WorkflowChecklistProps = { disabled: boolean @@ -44,6 +47,13 @@ const WorkflowChecklist = ({ const needWarningNodes = useChecklist(nodes, edges) const { handleNodeSelect } = useNodesInteractions() + const handleChecklistItemClick = (item: ChecklistItem) => { + if (!item.canNavigate) + return + handleNodeSelect(item.id) + setOpen(false) + } + return ( <PortalToFollowElem placement='bottom-end' @@ -93,38 +103,53 @@ const WorkflowChecklist = ({ <RiCloseLine className='h-4 w-4 text-text-tertiary' /> </div> </div> - <div className='py-2'> + <div className='pb-2'> { !!needWarningNodes.length && ( <> - <div className='px-4 text-xs text-text-tertiary'>{t('workflow.panel.checklistTip')}</div> + <div className='px-4 pt-1 text-xs text-text-tertiary'>{t('workflow.panel.checklistTip')}</div> <div className='px-4 py-2'> { needWarningNodes.map(node => ( <div key={node.id} - className='mb-2 cursor-pointer rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs last-of-type:mb-0' - onClick={() => { - handleNodeSelect(node.id) - setOpen(false) - }} + className={cn( + 'group mb-2 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs last-of-type:mb-0', + node.canNavigate ? 'cursor-pointer' : 'cursor-default opacity-80', + )} + onClick={() => handleChecklistItemClick(node)} > <div className='flex h-9 items-center p-2 text-xs font-medium text-text-secondary'> <BlockIcon - type={node.type} + type={node.type as BlockEnum} className='mr-1.5' toolIcon={node.toolIcon} /> <span className='grow truncate'> {node.title} </span> + { + node.canNavigate && ( + <div className='flex h-4 w-[60px] shrink-0 items-center justify-center gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100'> + <span className='whitespace-nowrap text-xs font-medium leading-4 text-primary-600'> + {t('workflow.panel.goTo')} + </span> + <IconR className='h-3.5 w-3.5 text-primary-600' /> + </div> + ) + } </div> - <div className='border-t-[0.5px] border-divider-regular'> + <div + className={cn( + 'rounded-b-lg border-t-[0.5px] border-divider-regular', + (node.unConnected || node.errorMessage) && 'bg-gradient-to-r from-components-badge-bg-orange-soft to-transparent', + )} + > { node.unConnected && ( - <div className='px-3 py-2 last:rounded-b-lg'> - <div className='flex text-xs leading-[18px] text-text-tertiary'> - <AlertTriangle className='mr-2 mt-[3px] h-3 w-3 text-[#F79009]' /> + <div className='px-3 py-1 first:pt-1.5 last:pb-1.5'> + <div className='flex text-xs leading-4 text-text-tertiary'> + <Warning className='mr-2 mt-[2px] h-3 w-3 text-[#F79009]' /> {t('workflow.common.needConnectTip')} </div> </div> @@ -132,9 +157,9 @@ const WorkflowChecklist = ({ } { node.errorMessage && ( - <div className='px-3 py-2 last:rounded-b-lg'> - <div className='flex text-xs leading-[18px] text-text-tertiary'> - <AlertTriangle className='mr-2 mt-[3px] h-3 w-3 text-[#F79009]' /> + <div className='px-3 py-1 first:pt-1.5 last:pb-1.5'> + <div className='flex text-xs leading-4 text-text-tertiary'> + <Warning className='mr-2 mt-[2px] h-3 w-3 text-[#F79009]' /> {node.errorMessage} </div> </div> diff --git a/web/app/components/workflow/header/editing-title.tsx b/web/app/components/workflow/header/editing-title.tsx index 32cfd36b4f..81249b05bd 100644 --- a/web/app/components/workflow/header/editing-title.tsx +++ b/web/app/components/workflow/header/editing-title.tsx @@ -11,9 +11,10 @@ const EditingTitle = () => { const draftUpdatedAt = useStore(state => state.draftUpdatedAt) const publishedAt = useStore(state => state.publishedAt) const isSyncingWorkflowDraft = useStore(s => s.isSyncingWorkflowDraft) + const maximizeCanvas = useStore(s => s.maximizeCanvas) return ( - <div className='system-xs-regular flex h-[18px] items-center text-text-tertiary'> + <div className={`system-xs-regular flex h-[18px] min-w-[300px] items-center whitespace-nowrap text-text-tertiary ${maximizeCanvas ? 'ml-2' : ''}`}> { !!draftUpdatedAt && ( <> diff --git a/web/app/components/workflow/header/env-button.tsx b/web/app/components/workflow/header/env-button.tsx index fbb664fbf5..26723305f1 100644 --- a/web/app/components/workflow/header/env-button.tsx +++ b/web/app/components/workflow/header/env-button.tsx @@ -9,13 +9,16 @@ import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' const EnvButton = ({ disabled }: { disabled: boolean }) => { const { theme } = useTheme() const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) + const showEnvPanel = useStore(s => s.showEnvPanel) const setShowEnvPanel = useStore(s => s.setShowEnvPanel) + const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) const { closeAllInputFieldPanels } = useInputFieldPanel() const handleClick = () => { setShowEnvPanel(true) setShowChatVariablePanel(false) + setShowGlobalVariablePanel(false) setShowDebugAndPreviewPanel(false) closeAllInputFieldPanels() } @@ -24,8 +27,9 @@ const EnvButton = ({ disabled }: { disabled: boolean }) => { <Button className={cn( 'p-2', - theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', + theme === 'dark' && showEnvPanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', )} + variant='ghost' disabled={disabled} onClick={handleClick} > diff --git a/web/app/components/workflow/header/global-variable-button.tsx b/web/app/components/workflow/header/global-variable-button.tsx index 597c91651e..a133cdeda5 100644 --- a/web/app/components/workflow/header/global-variable-button.tsx +++ b/web/app/components/workflow/header/global-variable-button.tsx @@ -2,16 +2,37 @@ import { memo } from 'react' import Button from '@/app/components/base/button' import { GlobalVariable } from '@/app/components/base/icons/src/vender/line/others' import { useStore } from '@/app/components/workflow/store' +import useTheme from '@/hooks/use-theme' +import cn from '@/utils/classnames' +import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => { - const setShowPanel = useStore(s => s.setShowGlobalVariablePanel) + const { theme } = useTheme() + const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel) + const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) + const setShowEnvPanel = useStore(s => s.setShowEnvPanel) + const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) + const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) + const { closeAllInputFieldPanels } = useInputFieldPanel() const handleClick = () => { - setShowPanel(true) + setShowGlobalVariablePanel(true) + setShowEnvPanel(false) + setShowChatVariablePanel(false) + setShowDebugAndPreviewPanel(false) + closeAllInputFieldPanels() } return ( - <Button className='p-2' disabled={disabled} onClick={handleClick}> + <Button + className={cn( + 'p-2', + theme === 'dark' && showGlobalVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm', + )} + disabled={disabled} + onClick={handleClick} + variant='ghost' + > <GlobalVariable className='h-4 w-4 text-components-button-secondary-text' /> </Button> ) diff --git a/web/app/components/workflow/header/header-in-normal.tsx b/web/app/components/workflow/header/header-in-normal.tsx index 1c3a442422..20fdafaff5 100644 --- a/web/app/components/workflow/header/header-in-normal.tsx +++ b/web/app/components/workflow/header/header-in-normal.tsx @@ -19,11 +19,14 @@ import EditingTitle from './editing-title' import EnvButton from './env-button' import VersionHistoryButton from './version-history-button' import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' +import ScrollToSelectedNodeButton from './scroll-to-selected-node-button' +import GlobalVariableButton from './global-variable-button' export type HeaderInNormalProps = { components?: { left?: React.ReactNode middle?: React.ReactNode + chatVariableTrigger?: React.ReactNode } runAndHistoryProps?: RunAndHistoryProps } @@ -39,6 +42,7 @@ const HeaderInNormal = ({ const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel) const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) + const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) const nodes = useNodes<StartNodeType>() const selectedNode = nodes.find(node => node.data.selected) const { handleBackupDraft } = useWorkflowRun() @@ -55,23 +59,31 @@ const HeaderInNormal = ({ setShowDebugAndPreviewPanel(false) setShowVariableInspectPanel(false) setShowChatVariablePanel(false) + setShowGlobalVariablePanel(false) closeAllInputFieldPanels() - }, [workflowStore, handleBackupDraft, selectedNode, handleNodeSelect, setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel, setShowVariableInspectPanel, setShowChatVariablePanel]) + }, [workflowStore, handleBackupDraft, selectedNode, handleNodeSelect, setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel, setShowVariableInspectPanel, setShowChatVariablePanel, setShowGlobalVariablePanel]) return ( - <> + <div className='flex w-full items-center justify-between'> <div> <EditingTitle /> </div> + <div> + <ScrollToSelectedNodeButton /> + </div> <div className='flex items-center gap-2'> {components?.left} - <EnvButton disabled={nodesReadOnly} /> <Divider type='vertical' className='mx-auto h-3.5' /> <RunAndHistory {...runAndHistoryProps} /> + <div className='shrink-0 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[10px]'> + {components?.chatVariableTrigger} + <EnvButton disabled={nodesReadOnly} /> + <GlobalVariableButton disabled={nodesReadOnly} /> + </div> {components?.middle} <VersionHistoryButton onClick={onStartRestoring} /> </div> - </> + </div> ) } diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx index d1fd3510cc..7a1d444d30 100644 --- a/web/app/components/workflow/header/run-mode.tsx +++ b/web/app/components/workflow/header/run-mode.tsx @@ -1,6 +1,6 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks' +import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks' import { useStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { useEventEmitterContextContext } from '@/context/event-emitter' @@ -9,6 +9,9 @@ import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' import cn from '@/utils/classnames' import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' +import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options' +import TestRunMenu, { type TestRunMenuRef, type TriggerOption, TriggerType } from './test-run-menu' +import { useToastContext } from '@/app/components/base/toast' type RunModeProps = { text?: string @@ -18,16 +21,84 @@ const RunMode = ({ text, }: RunModeProps) => { const { t } = useTranslation() - const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun() + const { + handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, + } = useWorkflowStartRun() const { handleStopRun } = useWorkflowRun() + const { validateBeforeRun, warningNodes } = useWorkflowRunValidation() const workflowRunningData = useStore(s => s.workflowRunningData) + const isListening = useStore(s => s.isListening) - const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running + const status = workflowRunningData?.result.status + const isRunning = status === WorkflowRunningStatus.Running || isListening + + const dynamicOptions = useDynamicTestRunOptions() + const testRunMenuRef = useRef<TestRunMenuRef>(null) + const { notify } = useToastContext() + + useEffect(() => { + // @ts-expect-error - Dynamic property for backward compatibility with keyboard shortcuts + window._toggleTestRunDropdown = () => { + testRunMenuRef.current?.toggle() + } + return () => { + // @ts-expect-error - Dynamic property cleanup + delete window._toggleTestRunDropdown + } + }, []) const handleStop = useCallback(() => { handleStopRun(workflowRunningData?.task_id || '') }, [handleStopRun, workflowRunningData?.task_id]) + const handleTriggerSelect = useCallback((option: TriggerOption) => { + // Validate checklist before running any workflow + let isValid: boolean = true + warningNodes.forEach((node) => { + if (node.id === option.nodeId) + isValid = false + }) + if (!isValid) { + notify({ type: 'error', message: t('workflow.panel.checklistTip') }) + return + } + + if (option.type === TriggerType.UserInput) { + handleWorkflowStartRunInWorkflow() + } + else if (option.type === TriggerType.Schedule) { + handleWorkflowTriggerScheduleRunInWorkflow(option.nodeId) + } + else if (option.type === TriggerType.Webhook) { + if (option.nodeId) + handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: option.nodeId }) + } + else if (option.type === TriggerType.Plugin) { + if (option.nodeId) + handleWorkflowTriggerPluginRunInWorkflow(option.nodeId) + } + else if (option.type === TriggerType.All) { + const targetNodeIds = option.relatedNodeIds?.filter(Boolean) + if (targetNodeIds && targetNodeIds.length > 0) + handleWorkflowRunAllTriggersInWorkflow(targetNodeIds) + } + else { + // Placeholder for trigger-specific execution logic for schedule, webhook, plugin types + console.log('TODO: Handle trigger execution for type:', option.type, 'nodeId:', option.nodeId) + } + }, [ + validateBeforeRun, + handleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, + ]) + const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v: any) => { if (v.type === EVENT_WORKFLOW_STOP) @@ -36,46 +107,46 @@ const RunMode = ({ return ( <div className='flex items-center gap-x-px'> - <button - type='button' - className={cn( - 'system-xs-medium flex h-7 items-center gap-x-1 px-1.5 text-text-accent hover:bg-state-accent-hover', - isRunning && 'cursor-not-allowed bg-state-accent-hover', - isRunning ? 'rounded-l-md' : 'rounded-md', - )} - onClick={() => { - handleWorkflowStartRunInWorkflow() - }} - disabled={isRunning} - > - { - isRunning - ? ( - <> - <RiLoader2Line className='mr-1 size-4 animate-spin' /> - {t('workflow.common.running')} - </> - ) - : ( - <> + { + isRunning + ? ( + <button + type='button' + className={cn( + 'system-xs-medium flex h-7 cursor-not-allowed items-center gap-x-1 rounded-l-md bg-state-accent-hover px-1.5 text-text-accent', + )} + disabled={true} + > + <RiLoader2Line className='mr-1 size-4 animate-spin' /> + {isListening ? t('workflow.common.listening') : t('workflow.common.running')} + </button> + ) + : ( + <TestRunMenu + ref={testRunMenuRef} + options={dynamicOptions} + onSelect={handleTriggerSelect} + > + <div + className={cn( + 'system-xs-medium flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 text-text-accent hover:bg-state-accent-hover', + )} + style={{ userSelect: 'none' }} + > <RiPlayLargeLine className='mr-1 size-4' /> {text ?? t('workflow.common.run')} - </> - ) - } - { - !isRunning && ( - <div className='system-kbd flex items-center gap-x-0.5 text-text-tertiary'> - <div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'> - {getKeyboardKeyNameBySystem('alt')} + <div className='system-kbd flex items-center gap-x-0.5 text-text-tertiary'> + <div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'> + {getKeyboardKeyNameBySystem('alt')} + </div> + <div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'> + R + </div> + </div> </div> - <div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'> - R - </div> - </div> + </TestRunMenu> ) - } - </button> + } { isRunning && ( <button diff --git a/web/app/components/workflow/header/scroll-to-selected-node-button.tsx b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx new file mode 100644 index 0000000000..d3e7248d9a --- /dev/null +++ b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx @@ -0,0 +1,34 @@ +import type { FC } from 'react' +import { useCallback } from 'react' +import { useNodes } from 'reactflow' +import { useTranslation } from 'react-i18next' +import type { CommonNodeType } from '../types' +import { scrollToWorkflowNode } from '../utils/node-navigation' +import cn from '@/utils/classnames' + +const ScrollToSelectedNodeButton: FC = () => { + const { t } = useTranslation() + const nodes = useNodes<CommonNodeType>() + const selectedNode = nodes.find(node => node.data.selected) + + const handleScrollToSelectedNode = useCallback(() => { + if (!selectedNode) return + scrollToWorkflowNode(selectedNode.id) + }, [selectedNode]) + + if (!selectedNode) + return null + + return ( + <div + className={cn( + 'system-xs-medium flex h-6 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-3 text-text-tertiary shadow-lg backdrop-blur-sm transition-colors duration-200 hover:text-text-accent', + )} + onClick={handleScrollToSelectedNode} + > + {t('workflow.panel.scrollToSelectedNode')} + </div> + ) +} + +export default ScrollToSelectedNodeButton diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx new file mode 100644 index 0000000000..40aabab6f8 --- /dev/null +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -0,0 +1,251 @@ +import { + type MouseEvent, + type MouseEventHandler, + type ReactElement, + cloneElement, + forwardRef, + isValidElement, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import ShortcutsName from '../shortcuts-name' + +export enum TriggerType { + UserInput = 'user_input', + Schedule = 'schedule', + Webhook = 'webhook', + Plugin = 'plugin', + All = 'all', +} + +export type TriggerOption = { + id: string + type: TriggerType + name: string + icon: React.ReactNode + nodeId?: string + relatedNodeIds?: string[] + enabled: boolean +} + +export type TestRunOptions = { + userInput?: TriggerOption + triggers: TriggerOption[] + runAll?: TriggerOption +} + +type TestRunMenuProps = { + options: TestRunOptions + onSelect: (option: TriggerOption) => void + children: React.ReactNode +} + +export type TestRunMenuRef = { + toggle: () => void +} + +type ShortcutMapping = { + option: TriggerOption + shortcutKey: string +} + +const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => { + const mappings: ShortcutMapping[] = [] + + if (options.userInput && options.userInput.enabled !== false) + mappings.push({ option: options.userInput, shortcutKey: '~' }) + + let numericShortcut = 0 + + if (options.runAll && options.runAll.enabled !== false) + mappings.push({ option: options.runAll, shortcutKey: String(numericShortcut++) }) + + options.triggers.forEach((trigger) => { + if (trigger.enabled !== false) + mappings.push({ option: trigger, shortcutKey: String(numericShortcut++) }) + }) + + return mappings +} + +const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({ + options, + onSelect, + children, +}, ref) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const shortcutMappings = useMemo(() => buildShortcutMappings(options), [options]) + const shortcutKeyById = useMemo(() => { + const map = new Map<string, string>() + shortcutMappings.forEach(({ option, shortcutKey }) => { + map.set(option.id, shortcutKey) + }) + return map + }, [shortcutMappings]) + + const handleSelect = useCallback((option: TriggerOption) => { + onSelect(option) + setOpen(false) + }, [onSelect]) + + const enabledOptions = useMemo(() => { + const flattened: TriggerOption[] = [] + + if (options.userInput) + flattened.push(options.userInput) + if (options.runAll) + flattened.push(options.runAll) + flattened.push(...options.triggers) + + return flattened.filter(option => option.enabled !== false) + }, [options]) + + const hasSingleEnabledOption = enabledOptions.length === 1 + const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined + + const runSoleOption = useCallback(() => { + if (soleEnabledOption) + handleSelect(soleEnabledOption) + }, [handleSelect, soleEnabledOption]) + + useImperativeHandle(ref, () => ({ + toggle: () => { + if (hasSingleEnabledOption) { + runSoleOption() + return + } + + setOpen(prev => !prev) + }, + }), [hasSingleEnabledOption, runSoleOption]) + + useEffect(() => { + if (!open) + return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) + return + + const normalizedKey = event.key === '`' ? '~' : event.key + const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey) + + if (mapping) { + event.preventDefault() + handleSelect(mapping.option) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [handleSelect, open, shortcutMappings]) + + const renderOption = (option: TriggerOption) => { + const shortcutKey = shortcutKeyById.get(option.id) + + return ( + <div + key={option.id} + className='system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' + onClick={() => handleSelect(option)} + > + <div className='flex min-w-0 flex-1 items-center'> + <div className='flex h-6 w-6 shrink-0 items-center justify-center'> + {option.icon} + </div> + <span className='ml-2 truncate'>{option.name}</span> + </div> + {shortcutKey && ( + <ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" /> + )} + </div> + ) + } + + const hasUserInput = !!options.userInput && options.userInput.enabled !== false + const hasTriggers = options.triggers.some(trigger => trigger.enabled !== false) + const hasRunAll = !!options.runAll && options.runAll.enabled !== false + + if (hasSingleEnabledOption && soleEnabledOption) { + const handleRunClick = (event?: MouseEvent<HTMLElement>) => { + if (event?.defaultPrevented) + return + + runSoleOption() + } + + if (isValidElement(children)) { + const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }> + const originalOnClick = childElement.props?.onClick + + return cloneElement(childElement, { + onClick: (event: MouseEvent<HTMLElement>) => { + if (typeof originalOnClick === 'function') + originalOnClick(event) + + if (event?.defaultPrevented) + return + + runSoleOption() + }, + }) + } + + return ( + <span onClick={handleRunClick}> + {children} + </span> + ) + } + + return ( + <PortalToFollowElem + open={open} + onOpenChange={setOpen} + placement='bottom-start' + offset={{ mainAxis: 8, crossAxis: -4 }} + > + <PortalToFollowElemTrigger asChild onClick={() => setOpen(!open)}> + <div style={{ userSelect: 'none' }}> + {children} + </div> + </PortalToFollowElemTrigger> + <PortalToFollowElemContent className='z-[12]'> + <div className='w-[284px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg'> + <div className='mb-2 px-3 pt-2 text-sm font-medium text-text-primary'> + {t('workflow.common.chooseStartNodeToRun')} + </div> + <div> + {hasUserInput && renderOption(options.userInput!)} + + {(hasTriggers || hasRunAll) && hasUserInput && ( + <div className='mx-3 my-1 h-px bg-divider-subtle' /> + )} + + {hasRunAll && renderOption(options.runAll!)} + + {hasTriggers && options.triggers + .filter(trigger => trigger.enabled !== false) + .map(trigger => renderOption(trigger))} + </div> + </div> + </PortalToFollowElemContent> + </PortalToFollowElem> + ) +}) + +TestRunMenu.displayName = 'TestRunMenu' + +export default TestRunMenu diff --git a/web/app/components/workflow/hooks-store/store.ts b/web/app/components/workflow/hooks-store/store.ts index bd09bc501b..3e205f9521 100644 --- a/web/app/components/workflow/hooks-store/store.ts +++ b/web/app/components/workflow/hooks-store/store.ts @@ -40,11 +40,15 @@ export type CommonHooksFnMap = { handleBackupDraft: () => void handleLoadBackupDraft: () => void handleRestoreFromPublishedWorkflow: (...args: any[]) => void - handleRun: (params: any, callback?: IOtherOptions) => void + handleRun: (params: any, callback?: IOtherOptions, options?: any) => void handleStopRun: (...args: any[]) => void handleStartWorkflowRun: () => void handleWorkflowStartRunInWorkflow: () => void handleWorkflowStartRunInChatflow: () => void + handleWorkflowTriggerScheduleRunInWorkflow: (nodeId?: string) => void + handleWorkflowTriggerWebhookRunInWorkflow: (params: { nodeId: string }) => void + handleWorkflowTriggerPluginRunInWorkflow: (nodeId?: string) => void + handleWorkflowRunAllTriggersInWorkflow: (nodeIds: string[]) => void availableNodesMetaData?: AvailableNodesMetaData getWorkflowRunAndTraceUrl: (runId?: string) => { runUrl: string; traceUrl: string } exportCheck?: () => Promise<void> @@ -87,6 +91,10 @@ export const createHooksStore = ({ handleStartWorkflowRun = noop, handleWorkflowStartRunInWorkflow = noop, handleWorkflowStartRunInChatflow = noop, + handleWorkflowTriggerScheduleRunInWorkflow = noop, + handleWorkflowTriggerWebhookRunInWorkflow = noop, + handleWorkflowTriggerPluginRunInWorkflow = noop, + handleWorkflowRunAllTriggersInWorkflow = noop, availableNodesMetaData = { nodes: [], }, @@ -125,6 +133,10 @@ export const createHooksStore = ({ handleStartWorkflowRun, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, availableNodesMetaData, getWorkflowRunAndTraceUrl, exportCheck, diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts index 1dbba6b0e2..1131836b35 100644 --- a/web/app/components/workflow/hooks/index.ts +++ b/web/app/components/workflow/hooks/index.ts @@ -22,3 +22,5 @@ export * from './use-DSL' export * from './use-inspect-vars-crud' export * from './use-set-workflow-vars-with-value' export * from './use-workflow-search' +export * from './use-auto-generate-webhook-url' +export * from './use-serial-async-callback' diff --git a/web/app/components/workflow/hooks/use-auto-generate-webhook-url.ts b/web/app/components/workflow/hooks/use-auto-generate-webhook-url.ts new file mode 100644 index 0000000000..d7d66e31ef --- /dev/null +++ b/web/app/components/workflow/hooks/use-auto-generate-webhook-url.ts @@ -0,0 +1,48 @@ +import { useCallback } from 'react' +import { produce } from 'immer' +import { useStoreApi } from 'reactflow' +import { useStore as useAppStore } from '@/app/components/app/store' +import { BlockEnum } from '@/app/components/workflow/types' +import { fetchWebhookUrl } from '@/service/apps' + +export const useAutoGenerateWebhookUrl = () => { + const reactFlowStore = useStoreApi() + + return useCallback(async (nodeId: string) => { + const appId = useAppStore.getState().appDetail?.id + if (!appId) + return + + const { getNodes } = reactFlowStore.getState() + const node = getNodes().find(n => n.id === nodeId) + if (!node || node.data.type !== BlockEnum.TriggerWebhook) + return + + if (node.data.webhook_url && node.data.webhook_url.length > 0) + return + + try { + const response = await fetchWebhookUrl({ appId, nodeId }) + const { getNodes: getLatestNodes, setNodes } = reactFlowStore.getState() + let hasUpdated = false + const updatedNodes = produce(getLatestNodes(), (draft) => { + const targetNode = draft.find(n => n.id === nodeId) + if (!targetNode || targetNode.data.type !== BlockEnum.TriggerWebhook) + return + + targetNode.data = { + ...targetNode.data, + webhook_url: response.webhook_url, + webhook_debug_url: response.webhook_debug_url, + } + hasUpdated = true + }) + + if (hasUpdated) + setNodes(updatedNodes) + } + catch (error: unknown) { + console.error('Failed to auto-generate webhook URL:', error) + } + }, [reactFlowStore]) +} diff --git a/web/app/components/workflow/hooks/use-available-blocks.ts b/web/app/components/workflow/hooks/use-available-blocks.ts index b4e037d29f..e1a1919afd 100644 --- a/web/app/components/workflow/hooks/use-available-blocks.ts +++ b/web/app/components/workflow/hooks/use-available-blocks.ts @@ -21,7 +21,9 @@ export const useAvailableBlocks = (nodeType?: BlockEnum, inContainer?: boolean) } = useNodesMetaData() const availableNodesType = useMemo(() => availableNodes.map(node => node.metaData.type), [availableNodes]) const availablePrevBlocks = useMemo(() => { - if (!nodeType || nodeType === BlockEnum.Start || nodeType === BlockEnum.DataSource) + if (!nodeType || nodeType === BlockEnum.Start || nodeType === BlockEnum.DataSource + || nodeType === BlockEnum.TriggerPlugin || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerSchedule) return [] return availableNodesType diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index d29827f273..8c4ec7299e 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -4,8 +4,9 @@ import { useRef, } from 'react' import { useTranslation } from 'react-i18next' -import { useStoreApi } from 'reactflow' +import { useEdges, useNodes, useStoreApi } from 'reactflow' import type { + CommonEdgeType, CommonNodeType, Edge, Node, @@ -21,20 +22,22 @@ import { getToolCheckParams, getValidTreeNodes, } from '../utils' +import { getTriggerCheckParams } from '../utils/trigger' import { CUSTOM_NODE, } from '../constants' import { useGetToolIcon, - useWorkflow, + useNodesMetaData, } from '../hooks' import type { ToolNodeType } from '../nodes/tool/types' import type { DataSourceNodeType } from '../nodes/data-source/types' -import { useNodesMetaData } from './use-nodes-meta-data' +import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types' import { useToastContext } from '@/app/components/base/toast' import { useGetLanguage } from '@/context/i18n' import type { AgentNodeType } from '../nodes/agent/types' import { useStrategyProviders } from '@/service/use-strategy' +import { useAllTriggerPlugins } from '@/service/use-triggers' import { useDatasetsDetailStore } from '../datasets-detail-store/store' import type { KnowledgeRetrievalNodeType } from '../nodes/knowledge-retrieval/types' import type { DataSet } from '@/models/datasets' @@ -42,6 +45,7 @@ import { fetchDatasets } from '@/service/datasets' import { MAX_TREE_DEPTH } from '@/config' import useNodesAvailableVarList, { useGetNodesAvailableVarList } from './use-nodes-available-var-list' import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variable/utils' +import type { Emoji } from '@/app/components/tools/types' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { KnowledgeBaseNodeType } from '../nodes/knowledge-base/types' @@ -50,6 +54,25 @@ import { useAllCustomTools, useAllWorkflowTools, } from '@/service/use-tools' +import { useStore as useAppStore } from '@/app/components/app/store' +import { AppModeEnum } from '@/types/app' + +export type ChecklistItem = { + id: string + type: BlockEnum | string + title: string + toolIcon?: string | Emoji + unConnected?: boolean + errorMessage?: string + canNavigate: boolean +} + +const START_NODE_TYPES: BlockEnum[] = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, +] export const useChecklist = (nodes: Node[], edges: Edge[]) => { const { t } = useTranslation() @@ -60,9 +83,11 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { const { data: workflowTools } = useAllWorkflowTools() const dataSourceList = useStore(s => s.dataSourceList) const { data: strategyProviders } = useStrategyProviders() + const { data: triggerPlugins } = useAllTriggerPlugins() const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail) - const { getStartNodes } = useWorkflow() const getToolIcon = useGetToolIcon() + const appMode = useAppStore.getState().appDetail?.mode + const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT const map = useNodesAvailableVarList(nodes) const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) @@ -92,16 +117,10 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { return checkData }, [datasetsDetail, embeddingModelList, rerankModelList]) - const needWarningNodes = useMemo(() => { - const list = [] + const needWarningNodes = useMemo<ChecklistItem[]>(() => { + const list: ChecklistItem[] = [] const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE) - const startNodes = getStartNodes(filteredNodes) - const validNodesFlattened = startNodes.map(startNode => getValidTreeNodes(startNode, filteredNodes, edges)) - const validNodes = validNodesFlattened.reduce((acc, curr) => { - if (curr.validNodes) - acc.push(...curr.validNodes) - return acc - }, [] as Node[]) + const { validNodes } = getValidTreeNodes(filteredNodes, edges) for (let i = 0; i < filteredNodes.length; i++) { const node = filteredNodes[i] @@ -114,6 +133,9 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { if (node.data.type === BlockEnum.DataSource) moreDataForCheckValid = getDataSourceCheckParams(node.data as DataSourceNodeType, dataSourceList || [], language) + if (node.data.type === BlockEnum.TriggerPlugin) + moreDataForCheckValid = getTriggerCheckParams(node.data as PluginTriggerNodeType, triggerPlugins, language) + const toolIcon = getToolIcon(node.data) if (node.data.type === BlockEnum.Agent) { const data = node.data as AgentNodeType @@ -133,7 +155,8 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { if (node.type === CUSTOM_NODE) { const checkData = getCheckData(node.data) - let { errorMessage } = nodesExtraData![node.data.type].checkValid(checkData, t, moreDataForCheckValid) + const validator = nodesExtraData?.[node.data.type as BlockEnum]?.checkValid + let errorMessage = validator ? validator(checkData, t, moreDataForCheckValid).errorMessage : undefined if (!errorMessage) { const availableVars = map[node.id].availableVars @@ -153,19 +176,43 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { } } } - if (errorMessage || !validNodes.find(n => n.id === node.id)) { + + // Start nodes and Trigger nodes should not show unConnected error if they have validation errors + // or if they are valid start nodes (even without incoming connections) + const isStartNodeMeta = nodesExtraData?.[node.data.type as BlockEnum]?.metaData.isStart ?? false + const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true + + const isUnconnected = !validNodes.find(n => n.id === node.id) + const shouldShowError = errorMessage || (isUnconnected && !canSkipConnectionCheck) + + if (shouldShowError) { list.push({ id: node.id, type: node.data.type, title: node.data.title, toolIcon, - unConnected: !validNodes.find(n => n.id === node.id), + unConnected: isUnconnected && !canSkipConnectionCheck, errorMessage, + canNavigate: true, }) } } } + // Check for start nodes (including triggers) + if (shouldCheckStartNode) { + const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum)) + if (startNodesFiltered.length === 0) { + list.push({ + id: 'start-node-required', + type: BlockEnum.Start, + title: t('workflow.panel.startNode'), + errorMessage: t('workflow.common.needStartNode'), + canNavigate: false, + }) + } + } + const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired) isRequiredNodesType.forEach((type: string) => { @@ -175,12 +222,13 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { type, title: t(`workflow.blocks.${type}`), errorMessage: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}`) }), + canNavigate: false, }) } }) return list - }, [nodes, getStartNodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map]) + }, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map, shouldCheckStartNode]) return needWarningNodes } @@ -194,7 +242,6 @@ export const useChecklistBeforePublish = () => { const { data: strategyProviders } = useStrategyProviders() const updateDatasetsDetail = useDatasetsDetailStore(s => s.updateDatasetsDetail) const updateTime = useRef(0) - const { getStartNodes } = useWorkflow() const workflowStore = useWorkflowStore() const { getNodesAvailableVarList } = useGetNodesAvailableVarList() const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) @@ -241,20 +288,11 @@ export const useChecklistBeforePublish = () => { } = workflowStore.getState() const nodes = getNodes() const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE) - const startNodes = getStartNodes(filteredNodes) - const validNodesFlattened = startNodes.map(startNode => getValidTreeNodes(startNode, filteredNodes, edges)) - const validNodes = validNodesFlattened.reduce((acc, curr) => { - if (curr.validNodes) - acc.push(...curr.validNodes) - return acc - }, [] as Node[]) - const maxDepthArr = validNodesFlattened.map(item => item.maxDepth) + const { validNodes, maxDepth } = getValidTreeNodes(filteredNodes, edges) - for (let i = 0; i < maxDepthArr.length; i++) { - if (maxDepthArr[i] > MAX_TREE_DEPTH) { - notify({ type: 'error', message: t('workflow.common.maxTreeDepth', { depth: MAX_TREE_DEPTH }) }) - return false - } + if (maxDepth > MAX_TREE_DEPTH) { + notify({ type: 'error', message: t('workflow.common.maxTreeDepth', { depth: MAX_TREE_DEPTH }) }) + return false } // Before publish, we need to fetch datasets detail, in case of the settings of datasets have been changed const knowledgeRetrievalNodes = filteredNodes.filter(node => node.data.type === BlockEnum.KnowledgeRetrieval) @@ -334,10 +372,18 @@ export const useChecklistBeforePublish = () => { } } + const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum)) + + if (startNodesFiltered.length === 0) { + notify({ type: 'error', message: t('workflow.common.needStartNode') }) + return false + } + const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired) for (let i = 0; i < isRequiredNodesType.length; i++) { const type = isRequiredNodesType[i] + if (!filteredNodes.find(node => node.data.type === type)) { notify({ type: 'error', message: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}`) }) }) return false @@ -345,9 +391,31 @@ export const useChecklistBeforePublish = () => { } return true - }, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, getStartNodes, workflowStore, buildInTools, customTools, workflowTools]) + }, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools]) return { handleCheckBeforePublish, } } + +export const useWorkflowRunValidation = () => { + const { t } = useTranslation() + const nodes = useNodes<CommonNodeType>() + const edges = useEdges<CommonEdgeType>() + const needWarningNodes = useChecklist(nodes, edges) + const { notify } = useToastContext() + + const validateBeforeRun = useCallback(() => { + if (needWarningNodes.length > 0) { + notify({ type: 'error', message: t('workflow.panel.checklistTip') }) + return false + } + return true + }, [needWarningNodes, notify, t]) + + return { + validateBeforeRun, + hasValidationErrors: needWarningNodes.length > 0, + warningNodes: needWarningNodes, + } +} diff --git a/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx b/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx new file mode 100644 index 0000000000..3e35ff0168 --- /dev/null +++ b/web/app/components/workflow/hooks/use-dynamic-test-run-options.tsx @@ -0,0 +1,144 @@ +import { useMemo } from 'react' +import { useNodes } from 'reactflow' +import { useTranslation } from 'react-i18next' +import { BlockEnum, type CommonNodeType } from '../types' +import { getWorkflowEntryNode } from '../utils/workflow-entry' +import { type TestRunOptions, type TriggerOption, TriggerType } from '../header/test-run-menu' +import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow' +import BlockIcon from '../block-icon' +import { useStore } from '../store' +import { useAllTriggerPlugins } from '@/service/use-triggers' + +export const useDynamicTestRunOptions = (): TestRunOptions => { + const { t } = useTranslation() + const nodes = useNodes() + const buildInTools = useStore(s => s.buildInTools) + const customTools = useStore(s => s.customTools) + const workflowTools = useStore(s => s.workflowTools) + const mcpTools = useStore(s => s.mcpTools) + const { data: triggerPlugins } = useAllTriggerPlugins() + + return useMemo(() => { + const allTriggers: TriggerOption[] = [] + let userInput: TriggerOption | undefined + + for (const node of nodes) { + const nodeData = node.data as CommonNodeType + + if (!nodeData?.type) continue + + if (nodeData.type === BlockEnum.Start) { + userInput = { + id: node.id, + type: TriggerType.UserInput, + name: nodeData.title || t('workflow.blocks.start'), + icon: ( + <BlockIcon + type={BlockEnum.Start} + size='md' + /> + ), + nodeId: node.id, + enabled: true, + } + } + else if (nodeData.type === BlockEnum.TriggerSchedule) { + allTriggers.push({ + id: node.id, + type: TriggerType.Schedule, + name: nodeData.title || t('workflow.blocks.trigger-schedule'), + icon: ( + <BlockIcon + type={BlockEnum.TriggerSchedule} + size='md' + /> + ), + nodeId: node.id, + enabled: true, + }) + } + else if (nodeData.type === BlockEnum.TriggerWebhook) { + allTriggers.push({ + id: node.id, + type: TriggerType.Webhook, + name: nodeData.title || t('workflow.blocks.trigger-webhook'), + icon: ( + <BlockIcon + type={BlockEnum.TriggerWebhook} + size='md' + /> + ), + nodeId: node.id, + enabled: true, + }) + } + else if (nodeData.type === BlockEnum.TriggerPlugin) { + let triggerIcon: string | any + + if (nodeData.provider_id) { + const targetTriggers = triggerPlugins || [] + triggerIcon = targetTriggers.find(toolWithProvider => toolWithProvider.name === nodeData.provider_id)?.icon + } + + const icon = ( + <BlockIcon + type={BlockEnum.TriggerPlugin} + size='md' + toolIcon={triggerIcon} + /> + ) + + allTriggers.push({ + id: node.id, + type: TriggerType.Plugin, + name: nodeData.title || (nodeData as any).plugin_name || t('workflow.blocks.trigger-plugin'), + icon, + nodeId: node.id, + enabled: true, + }) + } + } + + if (!userInput) { + const startNode = getWorkflowEntryNode(nodes as any[]) + if (startNode && startNode.data?.type === BlockEnum.Start) { + userInput = { + id: startNode.id, + type: TriggerType.UserInput, + name: (startNode.data as CommonNodeType)?.title || t('workflow.blocks.start'), + icon: ( + <BlockIcon + type={BlockEnum.Start} + size='md' + /> + ), + nodeId: startNode.id, + enabled: true, + } + } + } + + const triggerNodeIds = allTriggers + .map(trigger => trigger.nodeId) + .filter((nodeId): nodeId is string => Boolean(nodeId)) + + const runAll: TriggerOption | undefined = triggerNodeIds.length > 1 ? { + id: 'run-all', + type: TriggerType.All, + name: t('workflow.common.runAllTriggers'), + icon: ( + <div className="flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 bg-util-colors-purple-purple-500 text-white shadow-md"> + <TriggerAll className="h-4.5 w-4.5" /> + </div> + ), + relatedNodeIds: triggerNodeIds, + enabled: true, + } : undefined + + return { + userInput, + triggers: allTriggers, + runAll, + } + }, [nodes, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, t]) +} diff --git a/web/app/components/workflow/hooks/use-helpline.ts b/web/app/components/workflow/hooks/use-helpline.ts index 2eed71a807..55979904fb 100644 --- a/web/app/components/workflow/hooks/use-helpline.ts +++ b/web/app/components/workflow/hooks/use-helpline.ts @@ -1,12 +1,40 @@ import { useCallback } from 'react' import { useStoreApi } from 'reactflow' import type { Node } from '../types' +import { BlockEnum, isTriggerNode } from '../types' import { useWorkflowStore } from '../store' +// Entry node (Start/Trigger) wrapper offsets +// The EntryNodeContainer adds a wrapper with status indicator above the actual node +// These offsets ensure alignment happens on the inner node, not the wrapper +const ENTRY_NODE_WRAPPER_OFFSET = { + x: 0, // No horizontal padding on wrapper (px-0) + y: 21, // Actual measured: pt-0.5 (2px) + status bar height (~19px) +} as const + export const useHelpline = () => { const store = useStoreApi() const workflowStore = useWorkflowStore() + // Check if a node is an entry node (Start or Trigger) + const isEntryNode = useCallback((node: Node): boolean => { + return isTriggerNode(node.data.type as any) || node.data.type === BlockEnum.Start + }, []) + + // Get the actual alignment position of a node (accounting for wrapper offset) + const getNodeAlignPosition = useCallback((node: Node) => { + if (isEntryNode(node)) { + return { + x: node.position.x + ENTRY_NODE_WRAPPER_OFFSET.x, + y: node.position.y + ENTRY_NODE_WRAPPER_OFFSET.y, + } + } + return { + x: node.position.x, + y: node.position.y, + } + }, [isEntryNode]) + const handleSetHelpline = useCallback((node: Node) => { const { getNodes } = store.getState() const nodes = getNodes() @@ -29,6 +57,9 @@ export const useHelpline = () => { } } + // Get the actual alignment position for the dragging node + const nodeAlignPos = getNodeAlignPosition(node) + const showHorizontalHelpLineNodes = nodes.filter((n) => { if (n.id === node.id) return false @@ -39,33 +70,52 @@ export const useHelpline = () => { if (n.data.isInLoop) return false - const nY = Math.ceil(n.position.y) - const nodeY = Math.ceil(node.position.y) + // Get actual alignment position for comparison node + const nAlignPos = getNodeAlignPosition(n) + const nY = Math.ceil(nAlignPos.y) + const nodeY = Math.ceil(nodeAlignPos.y) if (nY - nodeY < 5 && nY - nodeY > -5) return true return false - }).sort((a, b) => a.position.x - b.position.x) + }).sort((a, b) => { + const aPos = getNodeAlignPosition(a) + const bPos = getNodeAlignPosition(b) + return aPos.x - bPos.x + }) const showHorizontalHelpLineNodesLength = showHorizontalHelpLineNodes.length if (showHorizontalHelpLineNodesLength > 0) { const first = showHorizontalHelpLineNodes[0] const last = showHorizontalHelpLineNodes[showHorizontalHelpLineNodesLength - 1] + // Use actual alignment positions for help line rendering + const firstPos = getNodeAlignPosition(first) + const lastPos = getNodeAlignPosition(last) + + // For entry nodes, we need to subtract the offset from width since lastPos already includes it + const lastIsEntryNode = isEntryNode(last) + const lastNodeWidth = lastIsEntryNode ? last.width! - ENTRY_NODE_WRAPPER_OFFSET.x : last.width! + const helpLine = { - top: first.position.y, - left: first.position.x, - width: last.position.x + last.width! - first.position.x, + top: firstPos.y, + left: firstPos.x, + width: lastPos.x + lastNodeWidth - firstPos.x, } - if (node.position.x < first.position.x) { - helpLine.left = node.position.x - helpLine.width = first.position.x + first.width! - node.position.x + if (nodeAlignPos.x < firstPos.x) { + const firstIsEntryNode = isEntryNode(first) + const firstNodeWidth = firstIsEntryNode ? first.width! - ENTRY_NODE_WRAPPER_OFFSET.x : first.width! + helpLine.left = nodeAlignPos.x + helpLine.width = firstPos.x + firstNodeWidth - nodeAlignPos.x } - if (node.position.x > last.position.x) - helpLine.width = node.position.x + node.width! - first.position.x + if (nodeAlignPos.x > lastPos.x) { + const nodeIsEntryNode = isEntryNode(node) + const nodeWidth = nodeIsEntryNode ? node.width! - ENTRY_NODE_WRAPPER_OFFSET.x : node.width! + helpLine.width = nodeAlignPos.x + nodeWidth - firstPos.x + } setHelpLineHorizontal(helpLine) } @@ -81,33 +131,52 @@ export const useHelpline = () => { if (n.data.isInLoop) return false - const nX = Math.ceil(n.position.x) - const nodeX = Math.ceil(node.position.x) + // Get actual alignment position for comparison node + const nAlignPos = getNodeAlignPosition(n) + const nX = Math.ceil(nAlignPos.x) + const nodeX = Math.ceil(nodeAlignPos.x) if (nX - nodeX < 5 && nX - nodeX > -5) return true return false - }).sort((a, b) => a.position.x - b.position.x) + }).sort((a, b) => { + const aPos = getNodeAlignPosition(a) + const bPos = getNodeAlignPosition(b) + return aPos.x - bPos.x + }) const showVerticalHelpLineNodesLength = showVerticalHelpLineNodes.length if (showVerticalHelpLineNodesLength > 0) { const first = showVerticalHelpLineNodes[0] const last = showVerticalHelpLineNodes[showVerticalHelpLineNodesLength - 1] + // Use actual alignment positions for help line rendering + const firstPos = getNodeAlignPosition(first) + const lastPos = getNodeAlignPosition(last) + + // For entry nodes, we need to subtract the offset from height since lastPos already includes it + const lastIsEntryNode = isEntryNode(last) + const lastNodeHeight = lastIsEntryNode ? last.height! - ENTRY_NODE_WRAPPER_OFFSET.y : last.height! + const helpLine = { - top: first.position.y, - left: first.position.x, - height: last.position.y + last.height! - first.position.y, + top: firstPos.y, + left: firstPos.x, + height: lastPos.y + lastNodeHeight - firstPos.y, } - if (node.position.y < first.position.y) { - helpLine.top = node.position.y - helpLine.height = first.position.y + first.height! - node.position.y + if (nodeAlignPos.y < firstPos.y) { + const firstIsEntryNode = isEntryNode(first) + const firstNodeHeight = firstIsEntryNode ? first.height! - ENTRY_NODE_WRAPPER_OFFSET.y : first.height! + helpLine.top = nodeAlignPos.y + helpLine.height = firstPos.y + firstNodeHeight - nodeAlignPos.y } - if (node.position.y > last.position.y) - helpLine.height = node.position.y + node.height! - first.position.y + if (nodeAlignPos.y > lastPos.y) { + const nodeIsEntryNode = isEntryNode(node) + const nodeHeight = nodeIsEntryNode ? node.height! - ENTRY_NODE_WRAPPER_OFFSET.y : node.height! + helpLine.height = nodeAlignPos.y + nodeHeight - firstPos.y + } setHelpLineVertical(helpLine) } @@ -119,7 +188,7 @@ export const useHelpline = () => { showHorizontalHelpLineNodes, showVerticalHelpLineNodes, } - }, [store, workflowStore]) + }, [store, workflowStore, getNodeAlignPosition]) return { handleSetHelpline, diff --git a/web/app/components/workflow/hooks/use-inspect-vars-crud.ts b/web/app/components/workflow/hooks/use-inspect-vars-crud.ts index c922192267..0f58cf8be2 100644 --- a/web/app/components/workflow/hooks/use-inspect-vars-crud.ts +++ b/web/app/components/workflow/hooks/use-inspect-vars-crud.ts @@ -5,13 +5,35 @@ import { useSysVarValues, } from '@/service/use-workflow' import { FlowType } from '@/types/common' +import { produce } from 'immer' +import { BlockEnum } from '../types' +const varsAppendStartNodeKeys = ['query', 'files'] const useInspectVarsCrud = () => { - const nodesWithInspectVars = useStore(s => s.nodesWithInspectVars) + const partOfNodesWithInspectVars = useStore(s => s.nodesWithInspectVars) const configsMap = useHooksStore(s => s.configsMap) const isRagPipeline = configsMap?.flowType === FlowType.ragPipeline const { data: conversationVars } = useConversationVarValues(configsMap?.flowType, !isRagPipeline ? configsMap?.flowId : '') - const { data: systemVars } = useSysVarValues(configsMap?.flowType, !isRagPipeline ? configsMap?.flowId : '') + const { data: allSystemVars } = useSysVarValues(configsMap?.flowType, !isRagPipeline ? configsMap?.flowId : '') + const { varsAppendStartNode, systemVars } = (() => { + if(allSystemVars?.length === 0) + return { varsAppendStartNode: [], systemVars: [] } + const varsAppendStartNode = allSystemVars?.filter(({ name }) => varsAppendStartNodeKeys.includes(name)) || [] + const systemVars = allSystemVars?.filter(({ name }) => !varsAppendStartNodeKeys.includes(name)) || [] + return { varsAppendStartNode, systemVars } + })() + const nodesWithInspectVars = (() => { + if(!partOfNodesWithInspectVars || partOfNodesWithInspectVars.length === 0) + return [] + + const nodesWithInspectVars = produce(partOfNodesWithInspectVars, (draft) => { + draft.forEach((nodeWithVars) => { + if(nodeWithVars.nodeType === BlockEnum.Start) + nodeWithVars.vars = [...nodeWithVars.vars, ...varsAppendStartNode] + }) + }) + return nodesWithInspectVars + })() const hasNodeInspectVars = useHooksStore(s => s.hasNodeInspectVars) const hasSetInspectVar = useHooksStore(s => s.hasSetInspectVar) const fetchInspectVarValue = useHooksStore(s => s.fetchInspectVarValue) diff --git a/web/app/components/workflow/hooks/use-node-data-update.ts b/web/app/components/workflow/hooks/use-node-data-update.ts index edacc31a7c..ac7dca9e4c 100644 --- a/web/app/components/workflow/hooks/use-node-data-update.ts +++ b/web/app/components/workflow/hooks/use-node-data-update.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { produce } from 'immer' import { useStoreApi } from 'reactflow' +import type { SyncCallback } from './use-nodes-sync-draft' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesReadOnly } from './use-workflow' @@ -28,12 +29,19 @@ export const useNodeDataUpdate = () => { setNodes(newNodes) }, [store]) - const handleNodeDataUpdateWithSyncDraft = useCallback((payload: NodeDataUpdatePayload) => { + const handleNodeDataUpdateWithSyncDraft = useCallback(( + payload: NodeDataUpdatePayload, + options?: { + sync?: boolean + notRefreshWhenSyncError?: boolean + callback?: SyncCallback + }, + ) => { if (getNodesReadOnly()) return handleNodeDataUpdate(payload) - handleSyncWorkflowDraft() + handleSyncWorkflowDraft(options?.sync, options?.notRefreshWhenSyncError, options?.callback) }, [handleSyncWorkflowDraft, handleNodeDataUpdate, getNodesReadOnly]) return { diff --git a/web/app/components/workflow/hooks/use-node-plugin-installation.ts b/web/app/components/workflow/hooks/use-node-plugin-installation.ts new file mode 100644 index 0000000000..96e3919e67 --- /dev/null +++ b/web/app/components/workflow/hooks/use-node-plugin-installation.ts @@ -0,0 +1,218 @@ +import { useCallback, useMemo } from 'react' +import { BlockEnum, type CommonNodeType } from '../types' +import type { ToolNodeType } from '../nodes/tool/types' +import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types' +import type { DataSourceNodeType } from '../nodes/data-source/types' +import { CollectionType } from '@/app/components/tools/types' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, + useInvalidToolsByType, +} from '@/service/use-tools' +import { + useAllTriggerPlugins, + useInvalidateAllTriggerPlugins, +} from '@/service/use-triggers' +import { useInvalidDataSourceList } from '@/service/use-pipeline' +import { useStore } from '../store' +import { canFindTool } from '@/utils' + +type InstallationState = { + isChecking: boolean + isMissing: boolean + uniqueIdentifier?: string + canInstall: boolean + onInstallSuccess: () => void + shouldDim: boolean +} + +const useToolInstallation = (data: ToolNodeType): InstallationState => { + const builtInQuery = useAllBuiltInTools() + const customQuery = useAllCustomTools() + const workflowQuery = useAllWorkflowTools() + const mcpQuery = useAllMCPTools() + const invalidateTools = useInvalidToolsByType(data.provider_type) + + const collectionInfo = useMemo(() => { + switch (data.provider_type) { + case CollectionType.builtIn: + return { + list: builtInQuery.data, + isLoading: builtInQuery.isLoading, + } + case CollectionType.custom: + return { + list: customQuery.data, + isLoading: customQuery.isLoading, + } + case CollectionType.workflow: + return { + list: workflowQuery.data, + isLoading: workflowQuery.isLoading, + } + case CollectionType.mcp: + return { + list: mcpQuery.data, + isLoading: mcpQuery.isLoading, + } + default: + return undefined + } + }, [ + builtInQuery.data, + builtInQuery.isLoading, + customQuery.data, + customQuery.isLoading, + data.provider_type, + mcpQuery.data, + mcpQuery.isLoading, + workflowQuery.data, + workflowQuery.isLoading, + ]) + + const collection = collectionInfo?.list + const isLoading = collectionInfo?.isLoading ?? false + const isResolved = !!collectionInfo && !isLoading + + const matchedCollection = useMemo(() => { + if (!collection || !collection.length) + return undefined + + return collection.find((toolWithProvider) => { + if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) + return true + if (canFindTool(toolWithProvider.id, data.provider_id)) + return true + if (toolWithProvider.name === data.provider_name) + return true + return false + }) + }, [collection, data.plugin_id, data.provider_id, data.provider_name]) + + const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id + const canInstall = Boolean(data.plugin_unique_identifier) + + const onInstallSuccess = useCallback(() => { + if (invalidateTools) + invalidateTools() + }, [invalidateTools]) + + const shouldDim = (!!collectionInfo && !isResolved) || (isResolved && !matchedCollection) + + return { + isChecking: !!collectionInfo && !isResolved, + isMissing: isResolved && !matchedCollection, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } +} + +const useTriggerInstallation = (data: PluginTriggerNodeType): InstallationState => { + const triggerPluginsQuery = useAllTriggerPlugins() + const invalidateTriggers = useInvalidateAllTriggerPlugins() + + const triggerProviders = triggerPluginsQuery.data + const isLoading = triggerPluginsQuery.isLoading + + const matchedProvider = useMemo(() => { + if (!triggerProviders || !triggerProviders.length) + return undefined + + return triggerProviders.find(provider => + provider.name === data.provider_name + || provider.id === data.provider_id + || (data.plugin_id && provider.plugin_id === data.plugin_id), + ) + }, [ + data.plugin_id, + data.provider_id, + data.provider_name, + triggerProviders, + ]) + + const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id + const canInstall = Boolean(data.plugin_unique_identifier) + + const onInstallSuccess = useCallback(() => { + invalidateTriggers() + }, [invalidateTriggers]) + + const shouldDim = isLoading || (!isLoading && !!triggerProviders && !matchedProvider) + + return { + isChecking: isLoading, + isMissing: !isLoading && !!triggerProviders && !matchedProvider, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } +} + +const useDataSourceInstallation = (data: DataSourceNodeType): InstallationState => { + const dataSourceList = useStore(s => s.dataSourceList) + const invalidateDataSourceList = useInvalidDataSourceList() + + const matchedPlugin = useMemo(() => { + if (!dataSourceList || !dataSourceList.length) + return undefined + + return dataSourceList.find((item) => { + if (data.plugin_unique_identifier && item.plugin_unique_identifier === data.plugin_unique_identifier) + return true + if (data.plugin_id && item.plugin_id === data.plugin_id) + return true + if (data.provider_name && item.provider === data.provider_name) + return true + return false + }) + }, [data.plugin_id, data.plugin_unique_identifier, data.provider_name, dataSourceList]) + + const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id + const canInstall = Boolean(data.plugin_unique_identifier) + + const onInstallSuccess = useCallback(() => { + invalidateDataSourceList() + }, [invalidateDataSourceList]) + + const hasLoadedList = dataSourceList !== undefined + + const shouldDim = !hasLoadedList || (hasLoadedList && !matchedPlugin) + + return { + isChecking: !hasLoadedList, + isMissing: hasLoadedList && !matchedPlugin, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } +} + +export const useNodePluginInstallation = (data: CommonNodeType): InstallationState => { + const toolInstallation = useToolInstallation(data as ToolNodeType) + const triggerInstallation = useTriggerInstallation(data as PluginTriggerNodeType) + const dataSourceInstallation = useDataSourceInstallation(data as DataSourceNodeType) + + switch (data.type as BlockEnum) { + case BlockEnum.Tool: + return toolInstallation + case BlockEnum.TriggerPlugin: + return triggerInstallation + case BlockEnum.DataSource: + return dataSourceInstallation + default: + return { + isChecking: false, + isMissing: false, + uniqueIdentifier: undefined, + canInstall: false, + onInstallSuccess: () => undefined, + shouldDim: false, + } + } +} diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 4de53c431c..3cbdf08e43 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -16,9 +16,9 @@ import { useReactFlow, useStoreApi, } from 'reactflow' -import type { DataSourceDefaultValue, ToolDefaultValue } from '../block-selector/types' +import type { PluginDefaultValue } from '../block-selector/types' import type { Edge, Node, OnNodeAdd } from '../types' -import { BlockEnum } from '../types' +import { BlockEnum, isTriggerNode } from '../types' import { useWorkflowStore } from '../store' import { CUSTOM_EDGE, @@ -63,6 +63,15 @@ import type { RAGPipelineVariables } from '@/models/pipeline' import useInspectVarsCrud from './use-inspect-vars-crud' import { getNodeUsedVars } from '../nodes/_base/components/variable/utils' +// Entry node deletion restriction has been removed to allow empty workflows + +// Entry node (Start/Trigger) wrapper offsets for alignment +// Must match the values in use-helpline.ts +const ENTRY_NODE_WRAPPER_OFFSET = { + x: 0, + y: 21, // Adjusted based on visual testing feedback +} as const + export const useNodesInteractions = () => { const { t } = useTranslation() const store = useStoreApi() @@ -138,21 +147,51 @@ export const useNodesInteractions = () => { const newNodes = produce(nodes, (draft) => { const currentNode = draft.find(n => n.id === node.id)! - if (showVerticalHelpLineNodesLength > 0) - currentNode.position.x = showVerticalHelpLineNodes[0].position.x - else if (restrictPosition.x !== undefined) - currentNode.position.x = restrictPosition.x - else if (restrictLoopPosition.x !== undefined) - currentNode.position.x = restrictLoopPosition.x - else currentNode.position.x = node.position.x + // Check if current dragging node is an entry node + const isCurrentEntryNode = isTriggerNode(node.data.type as any) || node.data.type === BlockEnum.Start - if (showHorizontalHelpLineNodesLength > 0) - currentNode.position.y = showHorizontalHelpLineNodes[0].position.y - else if (restrictPosition.y !== undefined) + // X-axis alignment with offset consideration + if (showVerticalHelpLineNodesLength > 0) { + const targetNode = showVerticalHelpLineNodes[0] + const isTargetEntryNode = isTriggerNode(targetNode.data.type as any) || targetNode.data.type === BlockEnum.Start + + // Calculate the wrapper position needed to align the inner nodes + // Target inner position = target.position + target.offset + // Current inner position should equal target inner position + // So: current.position + current.offset = target.position + target.offset + // Therefore: current.position = target.position + target.offset - current.offset + const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0 + const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0 + currentNode.position.x = targetNode.position.x + targetOffset - currentOffset + } + else if (restrictPosition.x !== undefined) { + currentNode.position.x = restrictPosition.x + } + else if (restrictLoopPosition.x !== undefined) { + currentNode.position.x = restrictLoopPosition.x + } + else { + currentNode.position.x = node.position.x + } + + // Y-axis alignment with offset consideration + if (showHorizontalHelpLineNodesLength > 0) { + const targetNode = showHorizontalHelpLineNodes[0] + const isTargetEntryNode = isTriggerNode(targetNode.data.type as any) || targetNode.data.type === BlockEnum.Start + + const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0 + const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0 + currentNode.position.y = targetNode.position.y + targetOffset - currentOffset + } + else if (restrictPosition.y !== undefined) { currentNode.position.y = restrictPosition.y - else if (restrictLoopPosition.y !== undefined) + } + else if (restrictLoopPosition.y !== undefined) { currentNode.position.y = restrictLoopPosition.y - else currentNode.position.y = node.position.y + } + else { + currentNode.position.y = node.position.y + } }) setNodes(newNodes) }, @@ -357,6 +396,7 @@ export const useNodesInteractions = () => { if (node.type === CUSTOM_ITERATION_START_NODE) return if (node.type === CUSTOM_LOOP_START_NODE) return if (node.data.type === BlockEnum.DataSourceEmpty) return + if (node.data._pluginInstallLocked) return handleNodeSelect(node.id) }, [handleNodeSelect], @@ -735,7 +775,7 @@ export const useNodesInteractions = () => { nodeType, sourceHandle = 'source', targetHandle = 'target', - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle }, ) => { @@ -756,7 +796,7 @@ export const useNodesInteractions = () => { nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title, - ...toolDefaultValue, + ...pluginDefaultValue, selected: true, _showAddVariablePopup: (nodeType === BlockEnum.VariableAssigner @@ -1286,7 +1326,7 @@ export const useNodesInteractions = () => { currentNodeId: string, nodeType: BlockEnum, sourceHandle: string, - toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue, + pluginDefaultValue?: PluginDefaultValue, ) => { if (getNodesReadOnly()) return @@ -1310,7 +1350,7 @@ export const useNodesInteractions = () => { nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title, - ...toolDefaultValue, + ...pluginDefaultValue, _connectedSourceHandleIds: [], _connectedTargetHandleIds: [], selected: currentNode.data.selected, @@ -1656,7 +1696,7 @@ export const useNodesInteractions = () => { const nodes = getNodes() const bundledNodes = nodes.filter( - node => node.data._isBundled && node.data.type !== BlockEnum.Start, + node => node.data._isBundled, ) if (bundledNodes.length) { @@ -1669,7 +1709,7 @@ export const useNodesInteractions = () => { if (edgeSelected) return const selectedNode = nodes.find( - node => node.data.selected && node.data.type !== BlockEnum.Start, + node => node.data.selected, ) if (selectedNode) handleNodeDelete(selectedNode.id) diff --git a/web/app/components/workflow/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow/hooks/use-nodes-sync-draft.ts index e6cc3a97e3..a4c9a45542 100644 --- a/web/app/components/workflow/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow/hooks/use-nodes-sync-draft.ts @@ -1,12 +1,14 @@ import { useCallback } from 'react' -import { - useStore, -} from '../store' -import { - useNodesReadOnly, -} from './use-workflow' +import { useStore } from '../store' +import { useNodesReadOnly } from './use-workflow' import { useHooksStore } from '@/app/components/workflow/hooks-store' +export type SyncCallback = { + onSuccess?: () => void + onError?: () => void + onSettled?: () => void +} + export const useNodesSyncDraft = () => { const { getNodesReadOnly } = useNodesReadOnly() const debouncedSyncWorkflowDraft = useStore(s => s.debouncedSyncWorkflowDraft) @@ -16,11 +18,7 @@ export const useNodesSyncDraft = () => { const handleSyncWorkflowDraft = useCallback(( sync?: boolean, notRefreshWhenSyncError?: boolean, - callback?: { - onSuccess?: () => void - onError?: () => void - onSettled?: () => void - }, + callback?: SyncCallback, ) => { if (getNodesReadOnly()) return diff --git a/web/app/components/workflow/hooks/use-serial-async-callback.ts b/web/app/components/workflow/hooks/use-serial-async-callback.ts new file mode 100644 index 0000000000..c36409a776 --- /dev/null +++ b/web/app/components/workflow/hooks/use-serial-async-callback.ts @@ -0,0 +1,22 @@ +import { + useCallback, + useRef, +} from 'react' + +export const useSerialAsyncCallback = <Args extends any[], Result = void>( + fn: (...args: Args) => Promise<Result> | Result, + shouldSkip?: () => boolean, +) => { + const queueRef = useRef<Promise<unknown>>(Promise.resolve()) + + return useCallback((...args: Args) => { + if (shouldSkip?.()) + return Promise.resolve(undefined as Result) + + const lastPromise = queueRef.current.catch(() => undefined) + const nextPromise = lastPromise.then(() => fn(...args)) + queueRef.current = nextPromise + + return nextPromise + }, [fn, shouldSkip]) +} diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index a744fefd50..fa9b019011 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -14,7 +14,6 @@ import { useWorkflowCanvasMaximize, useWorkflowMoveMode, useWorkflowOrganize, - useWorkflowStartRun, } from '.' export const useShortcuts = (): void => { @@ -28,7 +27,6 @@ export const useShortcuts = (): void => { dimOtherNodes, undimAllNodes, } = useNodesInteractions() - const { handleStartWorkflowRun } = useWorkflowStartRun() const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { handleEdgeDelete } = useEdgesInteractions() @@ -61,9 +59,8 @@ export const useShortcuts = (): void => { } const shouldHandleShortcut = useCallback((e: KeyboardEvent) => { - const { showFeaturesPanel } = workflowStore.getState() - return !showFeaturesPanel && !isEventTargetInputArea(e.target as HTMLElement) - }, [workflowStore]) + return !isEventTargetInputArea(e.target as HTMLElement) + }, []) useKeyPress(['delete', 'backspace'], (e) => { if (shouldHandleShortcut(e)) { @@ -99,7 +96,11 @@ export const useShortcuts = (): void => { useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => { if (shouldHandleShortcut(e)) { e.preventDefault() - handleStartWorkflowRun() + // @ts-expect-error - Dynamic property added by run-and-history component + if (window._toggleTestRunDropdown) { + // @ts-expect-error - Dynamic property added by run-and-history component + window._toggleTestRunDropdown() + } } }, { exactMatch: true, useCapture: true }) diff --git a/web/app/components/workflow/hooks/use-tool-icon.ts b/web/app/components/workflow/hooks/use-tool-icon.ts index 32d65365db..8276989ee3 100644 --- a/web/app/components/workflow/hooks/use-tool-icon.ts +++ b/web/app/components/workflow/hooks/use-tool-icon.ts @@ -1,17 +1,7 @@ -import { - useCallback, - useMemo, -} from 'react' -import type { - Node, -} from '../types' -import { - BlockEnum, -} from '../types' -import { - useStore, - useWorkflowStore, -} from '../store' +import { useCallback, useMemo } from 'react' +import type { Node, ToolWithProvider } from '../types' +import { BlockEnum } from '../types' +import { useStore, useWorkflowStore } from '../store' import { CollectionType } from '@/app/components/tools/types' import { canFindTool } from '@/utils' import { @@ -20,6 +10,32 @@ import { useAllMCPTools, useAllWorkflowTools, } from '@/service/use-tools' +import { useAllTriggerPlugins } from '@/service/use-triggers' +import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types' +import type { ToolNodeType } from '../nodes/tool/types' +import type { DataSourceNodeType } from '../nodes/data-source/types' +import type { TriggerWithProvider } from '../block-selector/types' + +const isTriggerPluginNode = (data: Node['data']): data is PluginTriggerNodeType => data.type === BlockEnum.TriggerPlugin + +const isToolNode = (data: Node['data']): data is ToolNodeType => data.type === BlockEnum.Tool + +const isDataSourceNode = (data: Node['data']): data is DataSourceNodeType => data.type === BlockEnum.DataSource + +const findTriggerPluginIcon = ( + identifiers: (string | undefined)[], + triggers: TriggerWithProvider[] | undefined, +) => { + const targetTriggers = triggers || [] + for (const identifier of identifiers) { + if (!identifier) + continue + const matched = targetTriggers.find(trigger => trigger.id === identifier || canFindTool(trigger.id, identifier)) + if (matched?.icon) + return matched.icon + } + return undefined +} export const useToolIcon = (data?: Node['data']) => { const { data: buildInTools } = useAllBuiltInTools() @@ -27,26 +43,78 @@ export const useToolIcon = (data?: Node['data']) => { const { data: workflowTools } = useAllWorkflowTools() const { data: mcpTools } = useAllMCPTools() const dataSourceList = useStore(s => s.dataSourceList) - // const a = useStore(s => s.data) + const { data: triggerPlugins } = useAllTriggerPlugins() + const toolIcon = useMemo(() => { if (!data) return '' - if (data.type === BlockEnum.Tool) { - // eslint-disable-next-line sonarjs/no-dead-store - let targetTools = buildInTools || [] - if (data.provider_type === CollectionType.builtIn) - targetTools = buildInTools || [] - else if (data.provider_type === CollectionType.custom) - targetTools = customTools || [] - else if (data.provider_type === CollectionType.mcp) - targetTools = mcpTools || [] - else - targetTools = workflowTools || [] - return targetTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.icon + + if (isTriggerPluginNode(data)) { + const icon = findTriggerPluginIcon( + [ + data.plugin_id, + data.provider_id, + data.provider_name, + ], + triggerPlugins, + ) + if (icon) + return icon } - if (data.type === BlockEnum.DataSource) - return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon - }, [data, dataSourceList, buildInTools, customTools, mcpTools, workflowTools]) + + if (isToolNode(data)) { + let primaryCollection: ToolWithProvider[] | undefined + switch (data.provider_type) { + case CollectionType.custom: + primaryCollection = customTools + break + case CollectionType.mcp: + primaryCollection = mcpTools + break + case CollectionType.workflow: + primaryCollection = workflowTools + break + case CollectionType.builtIn: + default: + primaryCollection = buildInTools + break + } + + const collectionsToSearch = [ + primaryCollection, + buildInTools, + customTools, + workflowTools, + mcpTools, + ] as Array<ToolWithProvider[] | undefined> + + const seen = new Set<ToolWithProvider[]>() + for (const collection of collectionsToSearch) { + if (!collection || seen.has(collection)) + continue + seen.add(collection) + const matched = collection.find((toolWithProvider) => { + if (canFindTool(toolWithProvider.id, data.provider_id)) + return true + if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) + return true + return data.provider_name === toolWithProvider.name + }) + if (matched?.icon) + return matched.icon + } + + if (data.provider_icon) + return data.provider_icon + + return '' + } + + if (isDataSourceNode(data)) + return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon || '' + + return '' + }, [data, dataSourceList, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins]) return toolIcon } @@ -55,27 +123,80 @@ export const useGetToolIcon = () => { const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() + const { data: triggerPlugins } = useAllTriggerPlugins() const workflowStore = useWorkflowStore() + const getToolIcon = useCallback((data: Node['data']) => { const { + buildInTools: storeBuiltInTools, + customTools: storeCustomTools, + workflowTools: storeWorkflowTools, + mcpTools: storeMcpTools, dataSourceList, } = workflowStore.getState() - if (data.type === BlockEnum.Tool) { - // eslint-disable-next-line sonarjs/no-dead-store - let targetTools = buildInTools || [] - if (data.provider_type === CollectionType.builtIn) - targetTools = buildInTools || [] - else if (data.provider_type === CollectionType.custom) - targetTools = customTools || [] - else - targetTools = workflowTools || [] - return targetTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.icon + if (isTriggerPluginNode(data)) { + return findTriggerPluginIcon( + [ + data.plugin_id, + data.provider_id, + data.provider_name, + ], + triggerPlugins, + ) } - if (data.type === BlockEnum.DataSource) + if (isToolNode(data)) { + const primaryCollection = (() => { + switch (data.provider_type) { + case CollectionType.custom: + return storeCustomTools ?? customTools + case CollectionType.mcp: + return storeMcpTools ?? mcpTools + case CollectionType.workflow: + return storeWorkflowTools ?? workflowTools + case CollectionType.builtIn: + default: + return storeBuiltInTools ?? buildInTools + } + })() + + const collectionsToSearch = [ + primaryCollection, + storeBuiltInTools ?? buildInTools, + storeCustomTools ?? customTools, + storeWorkflowTools ?? workflowTools, + storeMcpTools ?? mcpTools, + ] as Array<ToolWithProvider[] | undefined> + + const seen = new Set<ToolWithProvider[]>() + for (const collection of collectionsToSearch) { + if (!collection || seen.has(collection)) + continue + seen.add(collection) + const matched = collection.find((toolWithProvider) => { + if (canFindTool(toolWithProvider.id, data.provider_id)) + return true + if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) + return true + return data.provider_name === toolWithProvider.name + }) + if (matched?.icon) + return matched.icon + } + + if (data.provider_icon) + return data.provider_icon + + return undefined + } + + if (isDataSourceNode(data)) return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon - }, [workflowStore]) + + return undefined + }, [workflowStore, triggerPlugins, buildInTools, customTools, workflowTools, mcpTools]) return getToolIcon } diff --git a/web/app/components/workflow/hooks/use-workflow-interactions.ts b/web/app/components/workflow/hooks/use-workflow-interactions.ts index c080d6279e..e56c39d51e 100644 --- a/web/app/components/workflow/hooks/use-workflow-interactions.ts +++ b/web/app/components/workflow/hooks/use-workflow-interactions.ts @@ -316,7 +316,10 @@ export const useWorkflowUpdate = () => { edges: initialEdges(edges, nodes), }, } as any) - setViewport(viewport) + + // Only set viewport if it exists and is valid + if (viewport && typeof viewport.x === 'number' && typeof viewport.y === 'number' && typeof viewport.zoom === 'number') + setViewport(viewport) }, [eventEmitter, reactflow]) return { diff --git a/web/app/components/workflow/hooks/use-workflow-start-run.tsx b/web/app/components/workflow/hooks/use-workflow-start-run.tsx index 0f4e68fe95..46fe5649c8 100644 --- a/web/app/components/workflow/hooks/use-workflow-start-run.tsx +++ b/web/app/components/workflow/hooks/use-workflow-start-run.tsx @@ -4,10 +4,17 @@ export const useWorkflowStartRun = () => { const handleStartWorkflowRun = useHooksStore(s => s.handleStartWorkflowRun) const handleWorkflowStartRunInWorkflow = useHooksStore(s => s.handleWorkflowStartRunInWorkflow) const handleWorkflowStartRunInChatflow = useHooksStore(s => s.handleWorkflowStartRunInChatflow) - + const handleWorkflowTriggerScheduleRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerScheduleRunInWorkflow) + const handleWorkflowTriggerWebhookRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerWebhookRunInWorkflow) + const handleWorkflowTriggerPluginRunInWorkflow = useHooksStore(s => s.handleWorkflowTriggerPluginRunInWorkflow) + const handleWorkflowRunAllTriggersInWorkflow = useHooksStore(s => s.handleWorkflowRunAllTriggersInWorkflow) return { handleStartWorkflowRun, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow, + handleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow, } } diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 66c499dc59..e6746085b8 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -23,6 +23,10 @@ import { useStore, useWorkflowStore, } from '../store' +import { + getWorkflowEntryNode, + isWorkflowEntryNode, +} from '../utils/workflow-entry' import { SUPPORT_OUTPUT_VARS_NODE, } from '../constants' @@ -36,11 +40,12 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' import { useNodesMetaData } from '.' +import { AppModeEnum } from '@/types/app' export const useIsChatMode = () => { const appDetail = useAppStore(s => s.appDetail) - return appDetail?.mode === 'advanced-chat' + return appDetail?.mode === AppModeEnum.ADVANCED_CHAT } export const useWorkflow = () => { @@ -63,6 +68,7 @@ export const useWorkflow = () => { edges, } = store.getState() const nodes = getNodes() + // let startNode = getWorkflowEntryNode(nodes) const currentNode = nodes.find(node => node.id === nodeId) let startNodes = nodes.filter(node => nodesMap?.[node.data.type as BlockEnum]?.metaData.isStart) || [] @@ -232,6 +238,33 @@ export const useWorkflow = () => { return nodes.filter(node => node.parentId === nodeId) }, [store]) + const isFromStartNode = useCallback((nodeId: string) => { + const { getNodes } = store.getState() + const nodes = getNodes() + const currentNode = nodes.find(node => node.id === nodeId) + + if (!currentNode) + return false + + if (isWorkflowEntryNode(currentNode.data.type)) + return true + + const checkPreviousNodes = (node: Node) => { + const previousNodes = getBeforeNodeById(node.id) + + for (const prevNode of previousNodes) { + if (isWorkflowEntryNode(prevNode.data.type)) + return true + if (checkPreviousNodes(prevNode)) + return true + } + + return false + } + + return checkPreviousNodes(currentNode) + }, [store, getBeforeNodeById]) + const handleOutVarRenameChange = useCallback((nodeId: string, oldValeSelector: ValueSelector, newVarSelector: ValueSelector) => { const { getNodes, setNodes } = store.getState() const allNodes = getNodes() @@ -391,6 +424,13 @@ export const useWorkflow = () => { return !hasCycle(targetNode) }, [store, getAvailableBlocks]) + const getNode = useCallback((nodeId?: string) => { + const { getNodes } = store.getState() + const nodes = getNodes() + + return nodes.find(node => node.id === nodeId) || getWorkflowEntryNode(nodes) + }, [store]) + return { getNodeById, getTreeLeafNodes, @@ -407,6 +447,8 @@ export const useWorkflow = () => { getLoopNodeChildren, getRootNodesById, getStartNodes, + isFromStartNode, + getNode, } } @@ -430,14 +472,14 @@ export const useNodesReadOnly = () => { const historyWorkflowData = useStore(s => s.historyWorkflowData) const isRestoring = useStore(s => s.isRestoring) - const getNodesReadOnly = useCallback(() => { + const getNodesReadOnly = useCallback((): boolean => { const { workflowRunningData, historyWorkflowData, isRestoring, } = workflowStore.getState() - return workflowRunningData?.result.status === WorkflowRunningStatus.Running || historyWorkflowData || isRestoring + return !!(workflowRunningData?.result.status === WorkflowRunningStatus.Running || historyWorkflowData || isRestoring) }, [workflowStore]) return { diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx index 0c24dcfd2c..fe6266dea3 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx @@ -12,7 +12,7 @@ import SearchInput from '@/app/components/base/search-input' import Tools from '../../../block-selector/tools' import { useTranslation } from 'react-i18next' import { useStrategyProviders } from '@/service/use-strategy' -import { PluginType, type StrategyPluginDetail } from '@/app/components/plugins/types' +import { PluginCategoryEnum, type StrategyPluginDetail } from '@/app/components/plugins/types' import type { ToolWithProvider } from '../../../types' import { CollectionType } from '@/app/components/tools/types' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' @@ -140,7 +140,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => if (query) { fetchPlugins({ query, - category: PluginType.agent, + category: PluginCategoryEnum.agent, }) } }, [query]) diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index b447c3f70e..4b15e57d5c 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -22,6 +22,7 @@ import type { Node } from 'reactflow' import type { PluginMeta } from '@/app/components/plugins/types' import { noop } from 'lodash-es' import { useDocLink } from '@/context/i18n' +import { AppModeEnum } from '@/types/app' export type Strategy = { agent_strategy_provider_name: string @@ -99,7 +100,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { modelConfig={ defaultModel.data ? { - mode: 'chat', + mode: AppModeEnum.CHAT, name: defaultModel.data.model, provider: defaultModel.data.provider.provider, completion_params: {}, diff --git a/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx b/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx index a62ffeb55f..21b1cf0595 100644 --- a/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx @@ -6,7 +6,7 @@ import cn from 'classnames' import type { CodeLanguage } from '../../code/types' import { Generator } from '@/app/components/base/icons/src/vender/other' import { ActionButton } from '@/app/components/base/action-button' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import type { GenRes } from '@/service/debug' import { GetCodeGeneratorResModal } from '@/app/components/app/configuration/config/code-generator/get-code-generator-res' import { useHooksStore } from '../../../hooks-store' @@ -42,7 +42,7 @@ const CodeGenerateBtn: FC<Props> = ({ </ActionButton> {showAutomatic && ( <GetCodeGeneratorResModal - mode={AppType.chat} + mode={AppModeEnum.CHAT} isShow={showAutomatic} codeLanguages={codeLanguages} onClose={showAutomaticFalse} diff --git a/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx b/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx new file mode 100644 index 0000000000..b0cecdd0ae --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/entry-node-container.tsx @@ -0,0 +1,40 @@ +import type { FC, ReactNode } from 'react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +export enum StartNodeTypeEnum { + Start = 'start', + Trigger = 'trigger', +} + +type EntryNodeContainerProps = { + children: ReactNode + customLabel?: string + nodeType?: StartNodeTypeEnum +} + +const EntryNodeContainer: FC<EntryNodeContainerProps> = ({ + children, + customLabel, + nodeType = StartNodeTypeEnum.Trigger, +}) => { + const { t } = useTranslation() + + const label = useMemo(() => { + const translationKey = nodeType === StartNodeTypeEnum.Start ? 'entryNodeStatus' : 'triggerStatus' + return customLabel || t(`workflow.${translationKey}.enabled`) + }, [customLabel, nodeType, t]) + + return ( + <div className="w-fit min-w-[242px] rounded-2xl bg-workflow-block-wrapper-bg-1 px-0 pb-0 pt-0.5"> + <div className="mb-0.5 flex items-center px-1.5 pt-0.5"> + <span className="text-2xs font-semibold uppercase text-text-tertiary"> + {label} + </span> + </div> + {children} + </div> + ) +} + +export default EntryNodeContainer diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx index 70212a8581..14a0f19317 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -1,38 +1,50 @@ 'use client' import type { FC } from 'react' -import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types' -import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useEffect, useMemo, useState } from 'react' +import { type ResourceVarInputs, VarKindType } from '../types' +import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import { VarType } from '@/app/components/workflow/types' +import { useFetchDynamicOptions } from '@/service/use-plugins' +import { useTriggerPluginDynamicOptions } from '@/service/use-triggers' import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' +import type { Tool } from '@/app/components/tools/types' import FormInputTypeSwitch from './form-input-type-switch' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import Input from '@/app/components/base/input' import { SimpleSelect } from '@/app/components/base/select' import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input' -import FormInputBoolean from './form-input-boolean' import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector' import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector' import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import cn from '@/utils/classnames' -import type { Tool } from '@/app/components/tools/types' +import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react' +import { ChevronDownIcon } from '@heroicons/react/20/solid' +import { RiCheckLine, RiLoader4Line } from '@remixicon/react' +import type { Event } from '@/app/components/tools/types' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import CheckboxList from '@/app/components/base/checkbox-list' +import FormInputBoolean from './form-input-boolean' type Props = { readOnly: boolean nodeId: string schema: CredentialFormSchema - value: ToolVarInputs + value: ResourceVarInputs onChange: (value: any) => void inPanel?: boolean - currentTool?: Tool - currentProvider?: ToolWithProvider + currentTool?: Tool | Event + currentProvider?: ToolWithProvider | TriggerWithProvider showManageInputField?: boolean onManageInputField?: () => void + extraParams?: Record<string, any> + providerType?: string + disableVariableInsertion?: boolean } const FormInputItem: FC<Props> = ({ @@ -46,15 +58,22 @@ const FormInputItem: FC<Props> = ({ currentProvider, showManageInputField, onManageInputField, + extraParams, + providerType, + disableVariableInsertion = false, }) => { const language = useLanguage() + const [toolsOptions, setToolsOptions] = useState<FormOption[] | null>(null) + const [isLoadingToolsOptions, setIsLoadingToolsOptions] = useState(false) const { placeholder, variable, type, + _type, default: defaultValue, options, + multiple, scope, } = schema as any const varInput = value[variable] @@ -64,13 +83,16 @@ const FormInputItem: FC<Props> = ({ const isArray = type === FormTypeEnum.array const isShowJSONEditor = isObject || isArray const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files - const isBoolean = type === FormTypeEnum.boolean - const isSelect = type === FormTypeEnum.select || type === FormTypeEnum.dynamicSelect + const isBoolean = _type === FormTypeEnum.boolean + const isCheckbox = _type === FormTypeEnum.checkbox + const isSelect = type === FormTypeEnum.select + const isDynamicSelect = type === FormTypeEnum.dynamicSelect const isAppSelector = type === FormTypeEnum.appSelector const isModelSelector = type === FormTypeEnum.modelSelector const showTypeSwitch = isNumber || isBoolean || isObject || isArray || isSelect const isConstant = varInput?.type === VarKindType.constant || !varInput?.type const showVariableSelector = isFile || varInput?.type === VarKindType.variable + const isMultipleSelect = multiple && (isSelect || isDynamicSelect) const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, { onlyLeafNodeVar: false, @@ -123,12 +145,71 @@ const FormInputItem: FC<Props> = ({ const getVarKindType = () => { if (isFile) return VarKindType.variable - if (isSelect || isBoolean || isNumber || isArray || isObject) + if (isSelect || isDynamicSelect || isBoolean || isNumber || isArray || isObject) return VarKindType.constant if (isString) return VarKindType.mixed } + // Fetch dynamic options hook for tools + const { mutateAsync: fetchDynamicOptions } = useFetchDynamicOptions( + currentProvider?.plugin_id || '', + currentProvider?.name || '', + currentTool?.name || '', + variable || '', + providerType, + extraParams, + ) + + // Fetch dynamic options hook for triggers + const { data: triggerDynamicOptions, isLoading: isTriggerOptionsLoading } = useTriggerPluginDynamicOptions({ + plugin_id: currentProvider?.plugin_id || '', + provider: currentProvider?.name || '', + action: currentTool?.name || '', + parameter: variable || '', + extra: extraParams, + credential_id: currentProvider?.credential_id || '', + }, isDynamicSelect && providerType === PluginCategoryEnum.trigger && !!currentTool && !!currentProvider) + + // Computed values for dynamic options (unified for triggers and tools) + const triggerOptions = triggerDynamicOptions?.options + const dynamicOptions = providerType === PluginCategoryEnum.trigger + ? triggerOptions ?? toolsOptions + : toolsOptions + const isLoadingOptions = providerType === PluginCategoryEnum.trigger + ? (isTriggerOptionsLoading || isLoadingToolsOptions) + : isLoadingToolsOptions + + // Fetch dynamic options for tools only (triggers use hook directly) + useEffect(() => { + const fetchPanelDynamicOptions = async () => { + if (isDynamicSelect && currentTool && currentProvider && (providerType === PluginCategoryEnum.tool || providerType === PluginCategoryEnum.trigger)) { + setIsLoadingToolsOptions(true) + try { + const data = await fetchDynamicOptions() + setToolsOptions(data?.options || []) + } + catch (error) { + console.error('Failed to fetch dynamic options:', error) + setToolsOptions([]) + } + finally { + setIsLoadingToolsOptions(false) + } + } + } + + fetchPanelDynamicOptions() + }, [ + isDynamicSelect, + currentTool?.name, + currentProvider?.name, + variable, + extraParams, + providerType, + fetchDynamicOptions, + ]) + const handleTypeChange = (newType: string) => { if (newType === VarKindType.variable) { onChange({ @@ -163,6 +244,24 @@ const FormInputItem: FC<Props> = ({ }) } + const getSelectedLabels = (selectedValues: any[]) => { + if (!selectedValues || selectedValues.length === 0) + return '' + + const optionsList = isDynamicSelect ? (dynamicOptions || options || []) : (options || []) + const selectedOptions = optionsList.filter((opt: any) => + selectedValues.includes(opt.value), + ) + + if (selectedOptions.length <= 2) { + return selectedOptions + .map((opt: any) => opt.label?.[language] || opt.label?.en_US || opt.value) + .join(', ') + } + + return `${selectedOptions.length} selected` + } + const handleAppOrModelSelect = (newValue: any) => { onChange({ ...value, @@ -184,6 +283,45 @@ const FormInputItem: FC<Props> = ({ }) } + const availableCheckboxOptions = useMemo(() => ( + (options || []).filter((option: { show_on?: Array<{ variable: string; value: any }> }) => { + if (option.show_on?.length) + return option.show_on.every(showOnItem => value[showOnItem.variable]?.value === showOnItem.value || value[showOnItem.variable] === showOnItem.value) + return true + }) + ), [options, value]) + + const checkboxListOptions = useMemo(() => ( + availableCheckboxOptions.map((option: { value: string; label: Record<string, string> }) => ({ + value: option.value, + label: option.label?.[language] || option.label?.en_US || option.value, + })) + ), [availableCheckboxOptions, language]) + + const checkboxListValue = useMemo(() => { + let current: string[] = [] + if (Array.isArray(varInput?.value)) + current = varInput.value as string[] + else if (typeof varInput?.value === 'string') + current = [varInput.value as string] + else if (Array.isArray(defaultValue)) + current = defaultValue as string[] + + const allowedValues = new Set(availableCheckboxOptions.map((option: { value: string }) => option.value)) + return current.filter(item => allowedValues.has(item)) + }, [varInput?.value, defaultValue, availableCheckboxOptions]) + + const handleCheckboxListChange = (selected: string[]) => { + onChange({ + ...value, + [variable]: { + ...varInput, + type: VarKindType.constant, + value: selected, + }, + }) + } + return ( <div className={cn('gap-1', !(isShowJSONEditor && isConstant) && 'flex')}> {showTypeSwitch && ( @@ -198,6 +336,7 @@ const FormInputItem: FC<Props> = ({ availableNodes={availableNodesWithParent} showManageInputField={showManageInputField} onManageInputField={onManageInputField} + disableVariableInsertion={disableVariableInsertion} /> )} {isNumber && isConstant && ( @@ -209,13 +348,23 @@ const FormInputItem: FC<Props> = ({ placeholder={placeholder?.[language] || placeholder?.en_US} /> )} + {isCheckbox && isConstant && ( + <CheckboxList + title={schema.label?.[language] || schema.label?.en_US || variable} + value={checkboxListValue} + onChange={handleCheckboxListChange} + options={checkboxListOptions} + disabled={readOnly} + maxHeight='200px' + /> + )} {isBoolean && isConstant && ( <FormInputBoolean value={varInput?.value as boolean} onChange={handleValueChange} /> )} - {isSelect && isConstant && ( + {isSelect && isConstant && !isMultipleSelect && ( <SimpleSelect wrapperClassName='h-8 grow' disabled={readOnly} @@ -225,11 +374,175 @@ const FormInputItem: FC<Props> = ({ return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) return true - }).map((option: { value: any; label: { [x: string]: any; en_US: any } }) => ({ value: option.value, name: option.label[language] || option.label.en_US }))} + }).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ({ + value: option.value, + name: option.label[language] || option.label.en_US, + icon: option.icon, + }))} onSelect={item => handleValueChange(item.value as string)} placeholder={placeholder?.[language] || placeholder?.en_US} + renderOption={options.some((opt: any) => opt.icon) ? ({ item }) => ( + <div className="flex items-center"> + {item.icon && ( + <img src={item.icon} alt="" className="mr-2 h-4 w-4" /> + )} + <span>{item.name}</span> + </div> + ) : undefined} /> )} + {isSelect && isConstant && isMultipleSelect && ( + <Listbox + multiple + value={varInput?.value || []} + onChange={handleValueChange} + disabled={readOnly} + > + <div className="group/simple-select relative h-8 grow"> + <ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6"> + <span className={cn('system-sm-regular block truncate text-left', + varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', + )}> + {getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'} + </span> + <span className="absolute inset-y-0 right-0 flex items-center pr-2"> + <ChevronDownIcon + className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary" + aria-hidden="true" + /> + </span> + </ListboxButton> + <ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm"> + {options.filter((option: { show_on: any[] }) => { + if (option.show_on?.length) + return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) + return true + }).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ( + <ListboxOption + key={option.value} + value={option.value} + className={({ focus }) => + cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', + focus && 'bg-state-base-hover', + ) + } + > + {({ selected }) => ( + <> + <div className="flex items-center"> + {option.icon && ( + <img src={option.icon} alt="" className="mr-2 h-4 w-4" /> + )} + <span className={cn('block truncate', selected && 'font-normal')}> + {option.label[language] || option.label.en_US} + </span> + </div> + {selected && ( + <span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent"> + <RiCheckLine className="h-4 w-4" aria-hidden="true" /> + </span> + )} + </> + )} + </ListboxOption> + ))} + </ListboxOptions> + </div> + </Listbox> + )} + {isDynamicSelect && !isMultipleSelect && ( + <SimpleSelect + wrapperClassName='h-8 grow' + disabled={readOnly || isLoadingOptions} + defaultValue={varInput?.value} + items={(dynamicOptions || options || []).filter((option: { show_on?: any[] }) => { + if (option.show_on?.length) + return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) + + return true + }).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ({ + value: option.value, + name: option.label[language] || option.label.en_US, + icon: option.icon, + }))} + onSelect={item => handleValueChange(item.value as string)} + placeholder={isLoadingOptions ? 'Loading...' : (placeholder?.[language] || placeholder?.en_US)} + renderOption={({ item }) => ( + <div className="flex items-center"> + {item.icon && ( + <img src={item.icon} alt="" className="mr-2 h-4 w-4" /> + )} + <span>{item.name}</span> + </div> + )} + /> + )} + {isDynamicSelect && isMultipleSelect && ( + <Listbox + multiple + value={varInput?.value || []} + onChange={handleValueChange} + disabled={readOnly || isLoadingOptions} + > + <div className="group/simple-select relative h-8 grow"> + <ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6"> + <span className={cn('system-sm-regular block truncate text-left', + isLoadingOptions ? 'text-components-input-text-placeholder' + : varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', + )}> + {isLoadingOptions + ? 'Loading...' + : getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'} + </span> + <span className="absolute inset-y-0 right-0 flex items-center pr-2"> + {isLoadingOptions ? ( + <RiLoader4Line className='h-3.5 w-3.5 animate-spin text-text-secondary' /> + ) : ( + <ChevronDownIcon + className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary" + aria-hidden="true" + /> + )} + </span> + </ListboxButton> + <ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm"> + {(dynamicOptions || options || []).filter((option: { show_on?: any[] }) => { + if (option.show_on?.length) + return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value) + return true + }).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ( + <ListboxOption + key={option.value} + value={option.value} + className={({ focus }) => + cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', + focus && 'bg-state-base-hover', + ) + } + > + {({ selected }) => ( + <> + <div className="flex items-center"> + {option.icon && ( + <img src={option.icon} alt="" className="mr-2 h-4 w-4" /> + )} + <span className={cn('block truncate', selected && 'font-normal')}> + {option.label[language] || option.label.en_US} + </span> + </div> + {selected && ( + <span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent"> + <RiCheckLine className="h-4 w-4" aria-hidden="true" /> + </span> + )} + </> + )} + </ListboxOption> + ))} + </ListboxOptions> + </div> + </Listbox> + )} {isShowJSONEditor && isConstant && ( <div className='mt-1 w-full'> <CodeEditor diff --git a/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx b/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx index 23119f0213..b0d878d53d 100644 --- a/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx @@ -1,37 +1,96 @@ import Button from '@/app/components/base/button' import { RiInstallLine, RiLoader2Line } from '@remixicon/react' import type { ComponentProps, MouseEventHandler } from 'react' +import { useState } from 'react' import classNames from '@/utils/classnames' import { useTranslation } from 'react-i18next' +import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status' +import { TaskStatus } from '@/app/components/plugins/types' import { useCheckInstalled, useInstallPackageFromMarketPlace } from '@/service/use-plugins' type InstallPluginButtonProps = Omit<ComponentProps<typeof Button>, 'children' | 'loading'> & { uniqueIdentifier: string + extraIdentifiers?: string[] onSuccess?: () => void } export const InstallPluginButton = (props: InstallPluginButtonProps) => { - const { className, uniqueIdentifier, onSuccess, ...rest } = props + const { + className, + uniqueIdentifier, + extraIdentifiers = [], + onSuccess, + ...rest + } = props const { t } = useTranslation() + const identifiers = Array.from(new Set( + [uniqueIdentifier, ...extraIdentifiers].filter((item): item is string => Boolean(item)), + )) const manifest = useCheckInstalled({ - pluginIds: [uniqueIdentifier], - enabled: !!uniqueIdentifier, + pluginIds: identifiers, + enabled: identifiers.length > 0, }) const install = useInstallPackageFromMarketPlace() - const isLoading = manifest.isLoading || install.isPending - // await for refetch to get the new installed plugin, when manifest refetch, this component will unmount - || install.isSuccess + const [isTracking, setIsTracking] = useState(false) + const isLoading = manifest.isLoading || install.isPending || isTracking const handleInstall: MouseEventHandler = (e) => { e.stopPropagation() + if (isLoading) + return + setIsTracking(true) install.mutate(uniqueIdentifier, { - onSuccess: async () => { - await manifest.refetch() - onSuccess?.() + onSuccess: async (response) => { + const finish = async () => { + await manifest.refetch() + onSuccess?.() + setIsTracking(false) + install.reset() + } + + if (!response) { + await finish() + return + } + + if (response.all_installed) { + await finish() + return + } + + const { check } = checkTaskStatus() + try { + const { status } = await check({ + taskId: response.task_id, + pluginUniqueIdentifier: uniqueIdentifier, + }) + + if (status === TaskStatus.failed) { + setIsTracking(false) + install.reset() + return + } + + await finish() + } + catch { + setIsTracking(false) + install.reset() + } + }, + onError: () => { + setIsTracking(false) + install.reset() }, }) } if (!manifest.data) return null - if (manifest.data.plugins.some(plugin => plugin.id === uniqueIdentifier)) return null + const identifierSet = new Set(identifiers) + const isInstalled = manifest.data.plugins.some(plugin => ( + identifierSet.has(plugin.id) + || (plugin.plugin_unique_identifier && identifierSet.has(plugin.plugin_unique_identifier)) + || (plugin.plugin_id && identifierSet.has(plugin.plugin_id)) + )) + if (isInstalled) return null return <Button variant={'secondary'} disabled={isLoading} diff --git a/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/index.tsx new file mode 100644 index 0000000000..6680c8ebb6 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/index.tsx @@ -0,0 +1,62 @@ +import { + memo, +} from 'react' +import { useTranslation } from 'react-i18next' +import PromptEditor from '@/app/components/base/prompt-editor' +import Placeholder from './placeholder' +import type { + Node, + NodeOutPutVar, +} from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import cn from '@/utils/classnames' + +type MixedVariableTextInputProps = { + readOnly?: boolean + nodesOutputVars?: NodeOutPutVar[] + availableNodes?: Node[] + value?: string + onChange?: (text: string) => void +} +const MixedVariableTextInput = ({ + readOnly = false, + nodesOutputVars, + availableNodes = [], + value = '', + onChange, +}: MixedVariableTextInputProps) => { + const { t } = useTranslation() + return ( + <PromptEditor + wrapperClassName={cn( + 'w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1', + 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', + 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs', + )} + className='caret:text-text-accent' + editable={!readOnly} + value={value} + workflowVariableBlock={{ + show: true, + variables: nodesOutputVars || [], + workflowNodesMap: availableNodes.reduce((acc, node) => { + acc[node.id] = { + title: node.data.title, + type: node.data.type, + } + if (node.data.type === BlockEnum.Start) { + acc.sys = { + title: t('workflow.blocks.start'), + type: BlockEnum.Start, + } + } + return acc + }, {} as any), + }} + placeholder={<Placeholder />} + onChange={onChange} + /> + ) +} + +export default memo(MixedVariableTextInput) diff --git a/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx new file mode 100644 index 0000000000..75d4c91996 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx @@ -0,0 +1,52 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { FOCUS_COMMAND } from 'lexical' +import { $insertNodes } from 'lexical' +import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node' +import Badge from '@/app/components/base/badge' + +const Placeholder = () => { + const { t } = useTranslation() + const [editor] = useLexicalComposerContext() + + const handleInsert = useCallback((text: string) => { + editor.update(() => { + const textNode = new CustomTextNode(text) + $insertNodes([textNode]) + }) + editor.dispatchCommand(FOCUS_COMMAND, undefined as any) + }, [editor]) + + return ( + <div + className='pointer-events-auto flex h-full w-full cursor-text items-center px-2' + onClick={(e) => { + e.stopPropagation() + handleInsert('') + }} + > + <div className='flex grow items-center'> + {t('workflow.nodes.tool.insertPlaceholder1')} + <div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div> + <div + className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary' + onMouseDown={((e) => { + e.preventDefault() + e.stopPropagation() + handleInsert('/') + })} + > + {t('workflow.nodes.tool.insertPlaceholder2')} + </div> + </div> + <Badge + className='shrink-0' + text='String' + uppercase={false} + /> + </div> + ) +} + +export default Placeholder diff --git a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx index 601bc8ea75..3001274c31 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx @@ -39,11 +39,11 @@ const Add = ({ const { nodesReadOnly } = useNodesReadOnly() const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { handleNodeAdd( { nodeType: type, - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId: nodeId, diff --git a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx index c54a63d8ad..7143e6fe43 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx @@ -38,8 +38,8 @@ const ChangeItem = ({ availableNextBlocks, } = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { - handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue) + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { + handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue) }, [nodeId, sourceHandle, handleNodeChange]) const renderTrigger = useCallback(() => { diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx index 0e3f54f108..544e595ecf 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx @@ -9,7 +9,6 @@ import { RiPlayLargeLine, } from '@remixicon/react' import { - useNodeDataUpdate, useNodesInteractions, } from '../../../hooks' import { type Node, NodeRunningStatus } from '../../../types' @@ -19,6 +18,9 @@ import { Stop, } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import Tooltip from '@/app/components/base/tooltip' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { useWorkflowRunValidation } from '@/app/components/workflow/hooks/use-checklist' +import Toast from '@/app/components/base/toast' type NodeControlProps = Pick<Node, 'id' | 'data'> const NodeControl: FC<NodeControlProps> = ({ @@ -27,9 +29,11 @@ const NodeControl: FC<NodeControlProps> = ({ }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) - const { handleNodeDataUpdate } = useNodeDataUpdate() const { handleNodeSelect } = useNodesInteractions() + const workflowStore = useWorkflowStore() const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running + const { warningNodes } = useWorkflowRunValidation() + const warningForNode = warningNodes.find(item => item.id === id) const handleOpenChange = useCallback((newOpen: boolean) => { setOpen(newOpen) }, []) @@ -38,7 +42,8 @@ const NodeControl: FC<NodeControlProps> = ({ return ( <div className={` - absolute -top-7 right-0 hidden h-7 pb-1 group-hover:flex + absolute -top-7 right-0 hidden h-7 pb-1 + ${!data._pluginInstallLocked && 'group-hover:flex'} ${data.selected && '!flex'} ${open && '!flex'} `} @@ -50,17 +55,20 @@ const NodeControl: FC<NodeControlProps> = ({ { canRunBySingle(data.type, isChildNode) && ( <div - className='flex h-5 w-5 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover' + className={`flex h-5 w-5 items-center justify-center rounded-md ${isSingleRunning ? 'cursor-pointer hover:bg-state-base-hover' : warningForNode ? 'cursor-not-allowed text-text-disabled' : 'cursor-pointer hover:bg-state-base-hover'}`} onClick={() => { - const nextData: Record<string, any> = { - _isSingleRun: !isSingleRunning, + const action = isSingleRunning ? 'stop' : 'run' + if (!isSingleRunning && warningForNode) { + const message = warningForNode.errorMessage || t('workflow.panel.checklistTip') + Toast.notify({ type: 'error', message }) + return } - if(isSingleRunning) - nextData._singleRunningStatus = undefined - handleNodeDataUpdate({ - id, - data: nextData, + const store = workflowStore.getState() + store.setInitShowLastRunTab(true) + store.setPendingSingleRun({ + nodeId: id, + action, }) handleNodeSelect(id) }} @@ -70,7 +78,7 @@ const NodeControl: FC<NodeControlProps> = ({ ? <Stop className='h-3 w-3' /> : ( <Tooltip - popupContent={t('workflow.panel.runThisStep')} + popupContent={warningForNode ? warningForNode.errorMessage || t('workflow.panel.checklistTip') : t('workflow.panel.runThisStep')} asChild={false} > <RiPlayLargeLine className='h-3 w-3' /> diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index 90968a4580..6cfa7a7b9e 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx @@ -16,7 +16,7 @@ import { } from '../../../types' import type { Node } from '../../../types' import BlockSelector from '../../../block-selector' -import type { DataSourceDefaultValue, ToolDefaultValue } from '../../../block-selector/types' +import type { PluginDefaultValue } from '../../../block-selector/types' import { useAvailableBlocks, useIsChatMode, @@ -25,6 +25,7 @@ import { } from '../../../hooks' import { useStore, + useWorkflowStore, } from '../../../store' import cn from '@/utils/classnames' @@ -57,11 +58,11 @@ export const NodeTargetHandle = memo(({ if (!connected) setOpen(v => !v) }, [connected]) - const handleSelect = useCallback((type: BlockEnum, toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue) => { + const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => { handleNodeAdd( { nodeType: type, - toolDefaultValue, + pluginDefaultValue, }, { nextNodeId: id, @@ -84,7 +85,10 @@ export const NodeTargetHandle = memo(({ data._runningStatus === NodeRunningStatus.Failed && 'after:bg-workflow-link-line-error-handle', data._runningStatus === NodeRunningStatus.Exception && 'after:bg-workflow-link-line-failure-handle', !connected && 'after:opacity-0', - data.type === BlockEnum.Start && 'opacity-0', + (data.type === BlockEnum.Start + || data.type === BlockEnum.TriggerWebhook + || data.type === BlockEnum.TriggerSchedule + || data.type === BlockEnum.TriggerPlugin) && 'opacity-0', handleClassName, )} isConnectable={isConnectable} @@ -124,7 +128,10 @@ export const NodeSourceHandle = memo(({ showExceptionStatus, }: NodeHandleProps) => { const { t } = useTranslation() - const notInitialWorkflow = useStore(s => s.notInitialWorkflow) + const shouldAutoOpenStartNodeSelector = useStore(s => s.shouldAutoOpenStartNodeSelector) + const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector) + const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode) + const workflowStoreApi = useWorkflowStore() const [open, setOpen] = useState(false) const { handleNodeAdd } = useNodesInteractions() const { getNodesReadOnly } = useNodesReadOnly() @@ -140,11 +147,11 @@ export const NodeSourceHandle = memo(({ e.stopPropagation() setOpen(v => !v) }, []) - const handleSelect = useCallback((type: BlockEnum, toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue) => { + const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => { handleNodeAdd( { nodeType: type, - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId: id, @@ -154,9 +161,27 @@ export const NodeSourceHandle = memo(({ }, [handleNodeAdd, id, handleId]) useEffect(() => { - if (notInitialWorkflow && data.type === BlockEnum.Start && !isChatMode) + if (!shouldAutoOpenStartNodeSelector) + return + + if (isChatMode) { + setShouldAutoOpenStartNodeSelector?.(false) + return + } + + if (data.type === BlockEnum.Start || data.type === BlockEnum.TriggerSchedule || data.type === BlockEnum.TriggerWebhook || data.type === BlockEnum.TriggerPlugin) { setOpen(true) - }, [notInitialWorkflow, data.type, isChatMode]) + if (setShouldAutoOpenStartNodeSelector) + setShouldAutoOpenStartNodeSelector(false) + else + workflowStoreApi?.setState?.({ shouldAutoOpenStartNodeSelector: false }) + + if (setHasSelectedStartNode) + setHasSelectedStartNode(false) + else + workflowStoreApi?.setState?.({ hasSelectedStartNode: false }) + } + }, [shouldAutoOpenStartNodeSelector, data.type, isChatMode, setShouldAutoOpenStartNodeSelector, setHasSelectedStartNode, workflowStoreApi]) return ( <Handle diff --git a/web/app/components/workflow/nodes/_base/components/node-position.tsx b/web/app/components/workflow/nodes/_base/components/node-position.tsx deleted file mode 100644 index e844726b4f..0000000000 --- a/web/app/components/workflow/nodes/_base/components/node-position.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { memo } from 'react' -import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' -import { RiCrosshairLine } from '@remixicon/react' -import { useReactFlow, useStore } from 'reactflow' -import TooltipPlus from '@/app/components/base/tooltip' -import { useNodesSyncDraft } from '@/app/components/workflow-app/hooks' - -type NodePositionProps = { - nodeId: string -} -const NodePosition = ({ - nodeId, -}: NodePositionProps) => { - const { t } = useTranslation() - const reactflow = useReactFlow() - const { doSyncWorkflowDraft } = useNodesSyncDraft() - const { - nodePosition, - nodeWidth, - nodeHeight, - } = useStore(useShallow((s) => { - const nodes = s.getNodes() - const currentNode = nodes.find(node => node.id === nodeId)! - - return { - nodePosition: currentNode.position, - nodeWidth: currentNode.width, - nodeHeight: currentNode.height, - } - })) - const transform = useStore(s => s.transform) - - if (!nodePosition || !nodeWidth || !nodeHeight) return null - - const workflowContainer = document.getElementById('workflow-container') - const zoom = transform[2] - - const { clientWidth, clientHeight } = workflowContainer! - const { setViewport } = reactflow - - return ( - <TooltipPlus - popupContent={t('workflow.panel.moveToThisNode')} - > - <div - className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover' - onClick={() => { - setViewport({ - x: (clientWidth - 400 - nodeWidth * zoom) / 2 - nodePosition.x * zoom, - y: (clientHeight - nodeHeight * zoom) / 2 - nodePosition.y * zoom, - zoom: transform[2], - }) - doSyncWorkflowDraft() - }} - > - <RiCrosshairLine className='h-4 w-4 text-text-tertiary' /> - </div> - </TooltipPlus> - ) -} - -export default memo(NodePosition) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx index d7b2188ed5..8b6d137127 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx @@ -8,12 +8,17 @@ import { intersection } from 'lodash-es' import BlockSelector from '@/app/components/workflow/block-selector' import { useAvailableBlocks, + useIsChatMode, useNodesInteractions, } from '@/app/components/workflow/hooks' +import { useHooksStore } from '@/app/components/workflow/hooks-store' import type { Node, OnSelectBlock, } from '@/app/components/workflow/types' +import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types' + +import { FlowType } from '@/types/common' type ChangeBlockProps = { nodeId: string @@ -31,6 +36,14 @@ const ChangeBlock = ({ availablePrevBlocks, availableNextBlocks, } = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop) + const isChatMode = useIsChatMode() + const flowType = useHooksStore(s => s.configsMap?.flowType) + const showStartTab = flowType !== FlowType.ragPipeline && !isChatMode + const ignoreNodeIds = useMemo(() => { + if (isTriggerNode(nodeData.type as BlockEnum)) + return [nodeId] + return undefined + }, [nodeData.type, nodeId]) const availableNodes = useMemo(() => { if (availablePrevBlocks.length && availableNextBlocks.length) @@ -41,8 +54,8 @@ const ChangeBlock = ({ return availableNextBlocks }, [availablePrevBlocks, availableNextBlocks]) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { - handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue) + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { + handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue) }, [handleNodeChange, nodeId, sourceHandle]) const renderTrigger = useCallback(() => { @@ -64,6 +77,9 @@ const ChangeBlock = ({ trigger={renderTrigger} popupClassName='min-w-[240px]' availableBlocksTypes={availableNodes} + showStartTab={showStartTab} + ignoreNodeIds={ignoreNodeIds} + forceEnableStartTab={nodeData.type === BlockEnum.Start} /> ) } diff --git a/web/app/components/workflow/nodes/_base/components/variable-tag.tsx b/web/app/components/workflow/nodes/_base/components/variable-tag.tsx index 4d3dfe217c..71af3ad4fd 100644 --- a/web/app/components/workflow/nodes/_base/components/variable-tag.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable-tag.tsx @@ -8,7 +8,7 @@ import type { VarType, } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types' -import { getNodeInfoById, isConversationVar, isENV, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import { isExceptionVariable } from '@/app/components/workflow/utils' import { VariableLabelInSelect, @@ -39,7 +39,8 @@ const VariableTag = ({ const isEnv = isENV(valueSelector) const isChatVar = isConversationVar(valueSelector) - const isValid = Boolean(node) || isEnv || isChatVar || isRagVar + const isGlobal = isGlobalVar(valueSelector) + const isValid = Boolean(node) || isEnv || isChatVar || isRagVar || isGlobal const variableName = isSystemVar(valueSelector) ? valueSelector.slice(0).join('.') : valueSelector.slice(1).join('.') const isException = isExceptionVariable(variableName, node?.data.type) diff --git a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx index 7862dc824c..62133f3212 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field.tsx @@ -1,14 +1,14 @@ 'use client' +import cn from '@/utils/classnames' +import { RiArrowDropDownLine } from '@remixicon/react' +import { useBoolean } from 'ahooks' import type { FC } from 'react' import React from 'react' +import { useTranslation } from 'react-i18next' +import type { Field as FieldType } from '../../../../../llm/types' import { Type } from '../../../../../llm/types' import { getFieldType } from '../../../../../llm/utils' -import type { Field as FieldType } from '../../../../../llm/types' -import cn from '@/utils/classnames' import TreeIndentLine from '../tree-indent-line' -import { useTranslation } from 'react-i18next' -import { useBoolean } from 'ahooks' -import { RiArrowDropDownLine } from '@remixicon/react' type Props = { name: string, @@ -28,6 +28,7 @@ const Field: FC<Props> = ({ const { t } = useTranslation() const isRoot = depth === 1 const hasChildren = payload.type === Type.object && payload.properties + const hasEnum = payload.enum && payload.enum.length > 0 const [fold, { toggle: toggleFold, }] = useBoolean(false) @@ -44,7 +45,10 @@ const Field: FC<Props> = ({ /> )} <div className={cn('system-sm-medium ml-[7px] h-6 truncate leading-6 text-text-secondary', isRoot && rootClassName)}>{name}</div> - <div className='system-xs-regular ml-3 shrink-0 leading-6 text-text-tertiary'>{getFieldType(payload)}{(payload.schemaType && payload.schemaType !== 'file' && ` (${payload.schemaType})`)}</div> + <div className='system-xs-regular ml-3 shrink-0 leading-6 text-text-tertiary'> + {getFieldType(payload)} + {(payload.schemaType && payload.schemaType !== 'file' && ` (${payload.schemaType})`)} + </div> {required && <div className='system-2xs-medium-uppercase ml-3 leading-6 text-text-warning'>{t('app.structOutput.required')}</div>} </div> {payload.description && ( @@ -52,6 +56,18 @@ const Field: FC<Props> = ({ <div className='system-xs-regular w-0 grow truncate text-text-tertiary'>{payload.description}</div> </div> )} + {hasEnum && ( + <div className='ml-[7px] flex'> + <div className='system-xs-regular w-0 grow text-text-quaternary'> + {payload.enum!.map((value, index) => ( + <span key={index}> + {typeof value === 'string' ? `"${value}"` : value} + {index < payload.enum!.length - 1 && ' | '} + </span> + ))} + </div> + </div> + )} </div> </div> diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 715551cbff..3bd43bd29a 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -39,6 +39,9 @@ import type { import type { VariableAssignerNodeType } from '@/app/components/workflow/nodes/variable-assigner/types' import type { Field as StructField } from '@/app/components/workflow/nodes/llm/types' import type { RAGPipelineVariable } from '@/models/pipeline' +import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types' +import type { PluginTriggerNodeType } from '@/app/components/workflow/nodes/trigger-plugin/types' +import PluginTriggerNodeDefault from '@/app/components/workflow/nodes/trigger-plugin/default' import { AGENT_OUTPUT_STRUCT, @@ -51,6 +54,7 @@ import { SUPPORT_OUTPUT_VARS_NODE, TEMPLATE_TRANSFORM_OUTPUT_STRUCT, TOOL_OUTPUT_STRUCT, + getGlobalVars, } from '@/app/components/workflow/constants' import ToolNodeDefault from '@/app/components/workflow/nodes/tool/default' import DataSourceNodeDefault from '@/app/components/workflow/nodes/data-source/default' @@ -59,11 +63,21 @@ import type { PromptItem } from '@/models/debug' import { VAR_REGEX } from '@/config' import type { AgentNodeType } from '../../../agent/types' import type { SchemaTypeDefinition } from '@/service/use-common' +import { AppModeEnum } from '@/types/app' export const isSystemVar = (valueSelector: ValueSelector) => { return valueSelector[0] === 'sys' || valueSelector[1] === 'sys' } +export const isGlobalVar = (valueSelector: ValueSelector) => { + if(!isSystemVar(valueSelector)) return false + const second = valueSelector[1] + + if(['query', 'files'].includes(second)) + return false + return true +} + export const isENV = (valueSelector: ValueSelector) => { return valueSelector[0] === 'env' } @@ -348,34 +362,29 @@ const formatItem = ( variable: 'sys.query', type: VarType.string, }) - res.vars.push({ - variable: 'sys.dialogue_count', - type: VarType.number, - }) - res.vars.push({ - variable: 'sys.conversation_id', - type: VarType.string, - }) } - res.vars.push({ - variable: 'sys.user_id', - type: VarType.string, - }) res.vars.push({ variable: 'sys.files', type: VarType.arrayFile, }) - res.vars.push({ - variable: 'sys.app_id', - type: VarType.string, - }) - res.vars.push({ - variable: 'sys.workflow_id', - type: VarType.string, - }) - res.vars.push({ - variable: 'sys.workflow_run_id', - type: VarType.string, + break + } + + case BlockEnum.TriggerWebhook: { + const { + variables = [], + } = data as WebhookTriggerNodeType + res.vars = variables.map((v) => { + const type = v.value_type || VarType.string + const varRes: Var = { + variable: v.variable, + type, + isParagraph: false, + isSelect: false, + options: v.options, + required: v.required, + } + return varRes }) break @@ -612,6 +621,17 @@ const formatItem = ( break } + case BlockEnum.TriggerPlugin: { + const outputSchema = PluginTriggerNodeDefault.getOutputVars?.( + data as PluginTriggerNodeType, + allPluginInfoList, + [], + { schemaTypeDefinitions }, + ) || [] + res.vars = outputSchema + break + } + case 'env': { res.vars = data.envList.map((env: EnvironmentVariable) => { return { @@ -634,6 +654,11 @@ const formatItem = ( break } + case 'global': { + res.vars = data.globalVarList + break + } + case 'rag': { res.vars = data.ragVariables.map((ragVar: RAGPipelineVariable) => { return { @@ -774,6 +799,15 @@ export const toNodeOutputVars = ( chatVarList: conversationVariables, }, } + // GLOBAL_VAR_NODE data format + const GLOBAL_VAR_NODE = { + id: 'global', + data: { + title: 'SYSTEM', + type: 'global', + globalVarList: getGlobalVars(isChatMode), + }, + } // RAG_PIPELINE_NODE data format const RAG_PIPELINE_NODE = { id: 'rag', @@ -793,6 +827,8 @@ export const toNodeOutputVars = ( if (b.data.type === 'env') return -1 if (a.data.type === 'conversation') return 1 if (b.data.type === 'conversation') return -1 + if (a.data.type === 'global') return 1 + if (b.data.type === 'global') return -1 // sort nodes by x position return (b.position?.x || 0) - (a.position?.x || 0) }) @@ -803,6 +839,7 @@ export const toNodeOutputVars = ( ), ...(environmentVariables.length > 0 ? [ENV_NODE] : []), ...(isChatMode && conversationVariables.length > 0 ? [CHAT_VAR_NODE] : []), + GLOBAL_VAR_NODE, ...(RAG_PIPELINE_NODE.data.ragVariables.length > 0 ? [RAG_PIPELINE_NODE] : []), @@ -1026,7 +1063,8 @@ export const getVarType = ({ if (valueSelector[1] === 'index') return VarType.number } - const isSystem = isSystemVar(valueSelector) + const isGlobal = isGlobalVar(valueSelector) + const isInStartNodeSysVar = isSystemVar(valueSelector) && !isGlobal const isEnv = isENV(valueSelector) const isChatVar = isConversationVar(valueSelector) const isSharedRagVariable @@ -1039,7 +1077,8 @@ export const getVarType = ({ }) const targetVarNodeId = (() => { - if (isSystem) return startNode?.id + if (isInStartNodeSysVar) return startNode?.id + if (isGlobal) return 'global' if (isInNodeRagVariable) return valueSelector[1] return valueSelector[0] })() @@ -1052,7 +1091,7 @@ export const getVarType = ({ let type: VarType = VarType.string let curr: any = targetVar.vars - if (isSystem || isEnv || isChatVar || isSharedRagVariable) { + if (isInStartNodeSysVar || isEnv || isChatVar || isSharedRagVariable || isGlobal) { return curr.find( (v: any) => v.variable === (valueSelector as ValueSelector).join('.'), )?.type @@ -1242,7 +1281,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { } case BlockEnum.LLM: { const payload = data as LLMNodeType - const isChatModel = payload.model?.mode === 'chat' + const isChatModel = payload.model?.mode === AppModeEnum.CHAT let prompts: string[] = [] if (isChatModel) { prompts @@ -1545,7 +1584,7 @@ export const updateNodeVars = ( } case BlockEnum.LLM: { const payload = data as LLMNodeType - const isChatModel = payload.model?.mode === 'chat' + const isChatModel = payload.model?.mode === AppModeEnum.CHAT if (isChatModel) { payload.prompt_template = ( payload.prompt_template as PromptItem[] diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 85424cdaf4..82c2dfd470 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -18,10 +18,11 @@ import { import RemoveButton from '../remove-button' import useAvailableVarList from '../../hooks/use-available-var-list' import VarReferencePopup from './var-reference-popup' -import { getNodeInfoById, isConversationVar, isENV, isRagVariableVar, isSystemVar, removeFileVars, varTypeToStructType } from './utils' +import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar, removeFileVars, varTypeToStructType } from './utils' import ConstantField from './constant-field' import cn from '@/utils/classnames' import type { CommonNodeType, Node, NodeOutPutVar, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' import type { CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations' import { type CredentialFormSchema, type FormOption, FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { BlockEnum } from '@/app/components/workflow/types' @@ -38,6 +39,7 @@ import { useWorkflowVariables, } from '@/app/components/workflow/hooks' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +// import type { BaseResource, BaseResourceProvider } from '@/app/components/workflow/nodes/_base/types' import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector' import AddButton from '@/app/components/base/button/add-button' import Badge from '@/app/components/base/badge' @@ -45,9 +47,10 @@ import Tooltip from '@/app/components/base/tooltip' import { isExceptionVariable } from '@/app/components/workflow/utils' import VarFullPathPanel from './var-full-path-panel' import { noop } from 'lodash-es' -import { useFetchDynamicOptions } from '@/service/use-plugins' import type { Tool } from '@/app/components/tools/types' +import { useFetchDynamicOptions } from '@/service/use-plugins' import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' +import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants' const TRIGGER_DEFAULT_WIDTH = 227 @@ -78,7 +81,7 @@ type Props = { popupFor?: 'assigned' | 'toAssigned' zIndex?: number currentTool?: Tool - currentProvider?: ToolWithProvider + currentProvider?: ToolWithProvider | TriggerWithProvider preferSchemaType?: boolean } @@ -203,6 +206,9 @@ const VarReferencePicker: FC<Props> = ({ const varName = useMemo(() => { if (!hasValue) return '' + const showName = VAR_SHOW_NAME_MAP[(value as ValueSelector).join('.')] + if(showName) + return showName const isSystem = isSystemVar(value as ValueSelector) const varName = Array.isArray(value) ? value[(value as ValueSelector).length - 1] : '' @@ -291,15 +297,17 @@ const VarReferencePicker: FC<Props> = ({ preferSchemaType, }) - const { isEnv, isChatVar, isRagVar, isValidVar, isException } = useMemo(() => { + const { isEnv, isChatVar, isGlobal, isRagVar, isValidVar, isException } = useMemo(() => { const isEnv = isENV(value as ValueSelector) const isChatVar = isConversationVar(value as ValueSelector) + const isGlobal = isGlobalVar(value as ValueSelector) const isRagVar = isRagVariableVar(value as ValueSelector) - const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar || isRagVar + const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar || isGlobal || isRagVar const isException = isExceptionVariable(varName, outputVarNode?.type) return { isEnv, isChatVar, + isGlobal, isRagVar, isValidVar, isException, @@ -392,10 +400,11 @@ const VarReferencePicker: FC<Props> = ({ const variableCategory = useMemo(() => { if (isEnv) return 'environment' if (isChatVar) return 'conversation' + if (isGlobal) return 'global' if (isLoopVar) return 'loop' if (isRagVar) return 'rag' return 'system' - }, [isEnv, isChatVar, isLoopVar, isRagVar]) + }, [isEnv, isChatVar, isGlobal, isLoopVar, isRagVar]) return ( <div className={cn(className, !readonly && 'cursor-pointer')}> @@ -473,7 +482,7 @@ const VarReferencePicker: FC<Props> = ({ {hasValue ? ( <> - {isShowNodeName && !isEnv && !isChatVar && !isRagVar && ( + {isShowNodeName && !isEnv && !isChatVar && !isGlobal && !isRagVar && ( <div className='flex items-center' onClick={(e) => { if (e.metaKey || e.ctrlKey) { e.stopPropagation() @@ -501,10 +510,11 @@ const VarReferencePicker: FC<Props> = ({ <div className='flex items-center text-text-accent'> {isLoading && <RiLoader4Line className='h-3.5 w-3.5 animate-spin text-text-secondary' />} <VariableIconWithColor + variables={value as ValueSelector} variableCategory={variableCategory} isExceptionVariable={isException} /> - <div className={cn('ml-0.5 truncate text-xs font-medium', isEnv && '!text-text-secondary', isChatVar && 'text-util-colors-teal-teal-700', isException && 'text-text-warning')} title={varName} style={{ + <div className={cn('ml-0.5 truncate text-xs font-medium', isEnv && '!text-text-secondary', isChatVar && 'text-util-colors-teal-teal-700', isException && 'text-text-warning', isGlobal && 'text-util-colors-orange-orange-600')} title={varName} style={{ maxWidth: maxVarNameWidth, }}>{varName}</div> </div> diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index e70cfed97c..ced4b7c65f 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -23,6 +23,7 @@ import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender import ManageInputField from './manage-input-field' import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' +import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants' type ItemProps = { nodeId: string @@ -82,10 +83,14 @@ const Item: FC<ItemProps> = ({ }, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable]) const varName = useMemo(() => { + if(VAR_SHOW_NAME_MAP[itemData.variable]) + return VAR_SHOW_NAME_MAP[itemData.variable] + if (!isFlat) return itemData.variable if (itemData.variable === 'current') return isInCodeGeneratorInstructionEditor ? 'current_code' : 'current_prompt' + return itemData.variable }, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable]) @@ -182,6 +187,7 @@ const Item: FC<ItemProps> = ({ > <div className='flex w-0 grow items-center'> {!isFlat && <VariableIconWithColor + variables={itemData.variable.split('.')} variableCategory={variableCategory} isExceptionVariable={isException} />} diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx index 99f080f545..a8acda7e2c 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx @@ -11,6 +11,7 @@ import VariableIcon from './variable-icon' import VariableName from './variable-name' import cn from '@/utils/classnames' import Tooltip from '@/app/components/base/tooltip' +import { isConversationVar, isENV, isGlobalVar, isRagVariableVar } from '../../utils' const VariableLabel = ({ nodeType, @@ -26,6 +27,7 @@ const VariableLabel = ({ rightSlot, }: VariablePayload) => { const varColorClassName = useVarColor(variables, isExceptionVariable) + const isHideNodeLabel = !(isENV(variables) || isConversationVar(variables) || isGlobalVar(variables) || isRagVariableVar(variables)) return ( <div className={cn( @@ -35,10 +37,12 @@ const VariableLabel = ({ onClick={onClick} ref={ref} > - <VariableNodeLabel - nodeType={nodeType} - nodeTitle={nodeTitle} - /> + { isHideNodeLabel && ( + <VariableNodeLabel + nodeType={nodeType} + nodeTitle={nodeTitle} + /> + )} { notShowFullPath && ( <> diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts b/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts index fef6d8c396..bb388d429a 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts @@ -1,15 +1,17 @@ import { useMemo } from 'react' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' -import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others' +import { BubbleX, Env, GlobalVariable } from '@/app/components/base/icons/src/vender/line/others' import { Loop } from '@/app/components/base/icons/src/vender/workflow' import { InputField } from '@/app/components/base/icons/src/vender/pipeline' import { isConversationVar, isENV, + isGlobalVar, isRagVariableVar, isSystemVar, } from '../utils' import { VarInInspectType } from '@/types/workflow' +import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants' export const useVarIcon = (variables: string[], variableCategory?: VarInInspectType | string) => { if (variableCategory === 'loop') @@ -24,6 +26,9 @@ export const useVarIcon = (variables: string[], variableCategory?: VarInInspectT if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation') return BubbleX + if (isGlobalVar(variables) || variableCategory === VarInInspectType.system) + return GlobalVariable + return Variable02 } @@ -41,13 +46,22 @@ export const useVarColor = (variables: string[], isExceptionVariable?: boolean, if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation') return 'text-util-colors-teal-teal-700' + if (isGlobalVar(variables) || variableCategory === VarInInspectType.system) + return 'text-util-colors-orange-orange-600' + return 'text-text-accent' }, [variables, isExceptionVariable, variableCategory]) } export const useVarName = (variables: string[], notShowFullPath?: boolean) => { + const showName = VAR_SHOW_NAME_MAP[variables.join('.')] + let variableFullPathName = variables.slice(1).join('.') + + if (isRagVariableVar(variables)) + variableFullPathName = variables.slice(2).join('.') + const varName = useMemo(() => { - let variableFullPathName = variables.slice(1).join('.') + variableFullPathName = variables.slice(1).join('.') if (isRagVariableVar(variables)) variableFullPathName = variables.slice(2).join('.') @@ -58,6 +72,8 @@ export const useVarName = (variables: string[], notShowFullPath?: boolean) => { return `${isSystem ? 'sys.' : ''}${varName}` }, [variables, notShowFullPath]) + if (showName) + return showName return varName } diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 29aebd4fd5..eaafab550e 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -1,36 +1,18 @@ -import type { - FC, - ReactNode, -} from 'react' -import React, { - cloneElement, - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { useStore as useAppStore } from '@/app/components/app/store' +import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' +import Tooltip from '@/app/components/base/tooltip' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { - RiCloseLine, - RiPlayLargeLine, -} from '@remixicon/react' -import { useShallow } from 'zustand/react/shallow' -import { useTranslation } from 'react-i18next' -import NextStep from '../next-step' -import PanelOperator from '../panel-operator' -import NodePosition from '@/app/components/workflow/nodes/_base/components/node-position' -import HelpLink from '../help-link' -import { - DescriptionInput, - TitleInput, -} from '../title-description-input' -import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel' -import RetryOnPanel from '../retry/retry-on-panel' -import { useResizePanel } from '../../hooks/use-resize-panel' -import cn from '@/utils/classnames' + AuthCategory, + AuthorizedInDataSourceNode, + AuthorizedInNode, + PluginAuth, + PluginAuthInDataSourceNode, +} from '@/app/components/plugins/plugin-auth' +import { usePluginStore } from '@/app/components/plugins/plugin-detail-panel/store' +import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list' +import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import BlockIcon from '@/app/components/workflow/block-icon' -import Split from '@/app/components/workflow/nodes/_base/components/split' import { WorkflowHistoryEvent, useAvailableBlocks, @@ -41,41 +23,59 @@ import { useToolIcon, useWorkflowHistory, } from '@/app/components/workflow/hooks' +import { useHooksStore } from '@/app/components/workflow/hooks-store' +import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' +import Split from '@/app/components/workflow/nodes/_base/components/split' +import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form' +import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types' +import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types' +import { useLogs } from '@/app/components/workflow/run/hooks' +import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel' +import { useStore } from '@/app/components/workflow/store' +import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workflow/types' import { canRunBySingle, hasErrorHandleNode, hasRetryNode, isSupportCustomRunForm, } from '@/app/components/workflow/utils' -import Tooltip from '@/app/components/base/tooltip' -import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workflow/types' -import { useStore as useAppStore } from '@/app/components/app/store' -import { useStore } from '@/app/components/workflow/store' -import Tab, { TabType } from './tab' +import { useModalContext } from '@/context/modal-context' +import { useAllBuiltInTools } from '@/service/use-tools' +import { useAllTriggerPlugins } from '@/service/use-triggers' +import { FlowType } from '@/types/common' +import { canFindTool } from '@/utils' +import cn from '@/utils/classnames' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' +import { + RiCloseLine, + RiPlayLargeLine, +} from '@remixicon/react' +import { debounce } from 'lodash-es' +import type { FC, ReactNode } from 'react' +import React, { + cloneElement, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { useShallow } from 'zustand/react/shallow' +import { useResizePanel } from '../../hooks/use-resize-panel' +import BeforeRunForm from '../before-run-form' +import PanelWrap from '../before-run-form/panel-wrap' +import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel' +import HelpLink from '../help-link' +import NextStep from '../next-step' +import PanelOperator from '../panel-operator' +import RetryOnPanel from '../retry/retry-on-panel' +import { DescriptionInput, TitleInput } from '../title-description-input' import LastRun from './last-run' import useLastRun from './last-run/use-last-run' -import BeforeRunForm from '../before-run-form' -import { debounce } from 'lodash-es' -import { useLogs } from '@/app/components/workflow/run/hooks' -import PanelWrap from '../before-run-form/panel-wrap' -import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel' -import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' -import { useHooksStore } from '@/app/components/workflow/hooks-store' -import { FlowType } from '@/types/common' -import { - AuthorizedInDataSourceNode, - AuthorizedInNode, - PluginAuth, - PluginAuthInDataSourceNode, -} from '@/app/components/plugins/plugin-auth' -import { AuthCategory } from '@/app/components/plugins/plugin-auth' -import { canFindTool } from '@/utils' -import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types' -import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types' -import { useModalContext } from '@/context/modal-context' -import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form' -import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' -import { useAllBuiltInTools } from '@/service/use-tools' +import Tab, { TabType } from './tab' +import { TriggerSubscription } from './trigger-subscription' const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => { const nodeType = params.payload.type @@ -86,6 +86,7 @@ const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => { return <div>Custom Run Form: {nodeType} not found</div> } } + type BasePanelProps = { children: ReactNode id: Node['id'] @@ -98,6 +99,7 @@ const BasePanel: FC<BasePanelProps> = ({ children, }) => { const { t } = useTranslation() + const language = useLanguage() const { showMessageLogModal } = useAppStore(useShallow(state => ({ showMessageLogModal: state.showMessageLogModal, }))) @@ -108,6 +110,13 @@ const BasePanel: FC<BasePanelProps> = ({ const nodePanelWidth = useStore(s => s.nodePanelWidth) const otherPanelWidth = useStore(s => s.otherPanelWidth) const setNodePanelWidth = useStore(s => s.setNodePanelWidth) + const { + pendingSingleRun, + setPendingSingleRun, + } = useStore(s => ({ + pendingSingleRun: s.pendingSingleRun, + setPendingSingleRun: s.setPendingSingleRun, + })) const reservedCanvasWidth = 400 // Reserve the minimum visible width for the canvas @@ -212,6 +221,7 @@ const BasePanel: FC<BasePanelProps> = ({ useEffect(() => { hasClickRunning.current = false }, [id]) + const { nodesMap, } = useNodesMetaData() @@ -235,6 +245,7 @@ const BasePanel: FC<BasePanelProps> = ({ singleRunParams, nodeInfo, setRunInputData, + handleStop, handleSingleRun, handleRunWithParams, getExistVarValuesInForms, @@ -252,26 +263,65 @@ const BasePanel: FC<BasePanelProps> = ({ setIsPaused(false) }, [tabType]) + useEffect(() => { + if (!pendingSingleRun || pendingSingleRun.nodeId !== id) + return + + if (pendingSingleRun.action === 'run') + handleSingleRun() + else + handleStop() + + setPendingSingleRun(undefined) + }, [pendingSingleRun, id, handleSingleRun, handleStop, setPendingSingleRun]) + const logParams = useLogs() - const passedLogParams = (() => { - if ([BlockEnum.Tool, BlockEnum.Agent, BlockEnum.Iteration, BlockEnum.Loop].includes(data.type)) - return logParams - - return {} - })() + const passedLogParams = useMemo(() => [BlockEnum.Tool, BlockEnum.Agent, BlockEnum.Iteration, BlockEnum.Loop].includes(data.type) ? logParams : {}, [data.type, logParams]) + const storeBuildInTools = useStore(s => s.buildInTools) const { data: buildInTools } = useAllBuiltInTools() - const currCollection = useMemo(() => { - return buildInTools?.find(item => canFindTool(item.id, data.provider_id)) - }, [buildInTools, data.provider_id]) - const showPluginAuth = useMemo(() => { - return data.type === BlockEnum.Tool && currCollection?.allow_delete - }, [currCollection, data.type]) + const currToolCollection = useMemo(() => { + const candidates = buildInTools ?? storeBuildInTools + return candidates?.find(item => canFindTool(item.id, data.provider_id)) + }, [buildInTools, storeBuildInTools, data.provider_id]) + const needsToolAuth = useMemo(() => { + return data.type === BlockEnum.Tool && currToolCollection?.allow_delete + }, [data.type, currToolCollection?.allow_delete]) + + // only fetch trigger plugins when the node is a trigger plugin + const { data: triggerPlugins = [] } = useAllTriggerPlugins(data.type === BlockEnum.TriggerPlugin) + const currentTriggerPlugin = useMemo(() => { + if (data.type !== BlockEnum.TriggerPlugin || !data.plugin_id || !triggerPlugins?.length) + return undefined + return triggerPlugins?.find(p => p.plugin_id === data.plugin_id) + }, [data.type, data.plugin_id, triggerPlugins]) + const { setDetail } = usePluginStore() + + useEffect(() => { + if (currentTriggerPlugin?.subscription_constructor) { + setDetail({ + name: currentTriggerPlugin.label[language], + plugin_id: currentTriggerPlugin.plugin_id || '', + plugin_unique_identifier: currentTriggerPlugin.plugin_unique_identifier || '', + id: currentTriggerPlugin.id, + provider: currentTriggerPlugin.name, + declaration: { + trigger: { + subscription_schema: currentTriggerPlugin.subscription_schema || [], + subscription_constructor: currentTriggerPlugin.subscription_constructor, + }, + }, + }) + } + }, [currentTriggerPlugin, language, setDetail]) + const dataSourceList = useStore(s => s.dataSourceList) + const currentDataSource = useMemo(() => { if (data.type === BlockEnum.DataSource && data.provider_type !== DataSourceClassification.localFile) return dataSourceList?.find(item => item.plugin_id === data.plugin_id) - }, [dataSourceList, data.plugin_id, data.type, data.provider_type]) + }, [dataSourceList, data.provider_id, data.type, data.provider_type]) + const handleAuthorizationItemClick = useCallback((credential_id: string) => { handleNodeDataUpdateWithSyncDraft({ id, @@ -280,15 +330,46 @@ const BasePanel: FC<BasePanelProps> = ({ }, }) }, [handleNodeDataUpdateWithSyncDraft, id]) + const { setShowAccountSettingModal } = useModalContext() + const handleJumpToDataSourcePage = useCallback(() => { - setShowAccountSettingModal({ payload: 'data-source' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE }) }, [setShowAccountSettingModal]) const { appendNodeInspectVars, } = useInspectVarsCrud() + const handleSubscriptionChange = useCallback((v: SimpleSubscription, callback?: () => void) => { + handleNodeDataUpdateWithSyncDraft( + { id, data: { subscription_id: v.id } }, + { + sync: true, + callback: { onSettled: callback }, + }, + ) + }, [handleNodeDataUpdateWithSyncDraft, id]) + + const readmeEntranceComponent = useMemo(() => { + let pluginDetail + switch (data.type) { + case BlockEnum.Tool: + pluginDetail = currToolCollection + break + case BlockEnum.DataSource: + pluginDetail = currentDataSource + break + case BlockEnum.TriggerPlugin: + pluginDetail = currentTriggerPlugin + break + + default: + break + } + return !pluginDetail ? null : <ReadmeEntrance pluginDetail={pluginDetail as any} className='mt-auto' /> + }, [data.type, currToolCollection, currentDataSource, currentTriggerPlugin]) + if (logParams.showSpecialResultPanel) { return ( <div className={cn( @@ -405,18 +486,10 @@ const BasePanel: FC<BasePanelProps> = ({ <div className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover' onClick={() => { - if (isSingleRunning) { - handleNodeDataUpdate({ - id, - data: { - _isSingleRun: false, - _singleRunningStatus: undefined, - }, - }) - } - else { + if (isSingleRunning) + handleStop() + else handleSingleRun() - } }} > { @@ -427,7 +500,6 @@ const BasePanel: FC<BasePanelProps> = ({ </Tooltip> ) } - <NodePosition nodeId={id}></NodePosition> <HelpLink nodeType={data.type} /> <PanelOperator id={id} data={data} showHelpLink={false} /> <div className='mx-3 h-3.5 w-[1px] bg-divider-regular' /> @@ -446,13 +518,14 @@ const BasePanel: FC<BasePanelProps> = ({ /> </div> { - showPluginAuth && ( + needsToolAuth && ( <PluginAuth className='px-4 pb-2' pluginPayload={{ - provider: currCollection?.name || '', - providerType: currCollection?.type || '', + provider: currToolCollection?.name || '', + providerType: currToolCollection?.type || '', category: AuthCategory.tool, + detail: currToolCollection as any, }} > <div className='flex items-center justify-between pl-4 pr-3'> @@ -462,9 +535,10 @@ const BasePanel: FC<BasePanelProps> = ({ /> <AuthorizedInNode pluginPayload={{ - provider: currCollection?.name || '', - providerType: currCollection?.type || '', + provider: currToolCollection?.name || '', + providerType: currToolCollection?.type || '', category: AuthCategory.tool, + detail: currToolCollection as any, }} onAuthorizationItemClick={handleAuthorizationItemClick} credentialId={data.credential_id} @@ -493,7 +567,20 @@ const BasePanel: FC<BasePanelProps> = ({ ) } { - !showPluginAuth && !currentDataSource && ( + currentTriggerPlugin && ( + <TriggerSubscription + subscriptionIdSelected={data.subscription_id} + onSubscriptionChange={handleSubscriptionChange} + > + <Tab + value={tabType} + onChange={setTabType} + /> + </TriggerSubscription> + ) + } + { + !needsToolAuth && !currentDataSource && !currentTriggerPlugin && ( <div className='flex items-center justify-between pl-4 pr-3'> <Tab value={tabType} @@ -505,7 +592,7 @@ const BasePanel: FC<BasePanelProps> = ({ <Split /> </div> {tabType === TabType.settings && ( - <div className='flex-1 overflow-y-auto'> + <div className='flex flex-1 flex-col overflow-y-auto'> <div> {cloneElement(children as any, { id, @@ -550,6 +637,7 @@ const BasePanel: FC<BasePanelProps> = ({ </div> ) } + {readmeEntranceComponent} </div> )} @@ -568,6 +656,7 @@ const BasePanel: FC<BasePanelProps> = ({ {...passedLogParams} /> )} + </div> </div> ) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx index b26dd74714..43dab49ed8 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx @@ -60,6 +60,19 @@ const LastRun: FC<Props> = ({ const noLastRun = (error as any)?.status === 404 const runResult = (canRunLastRun ? lastRunResult : singleRunResult) || lastRunResult || {} + const resolvedStatus = useMemo(() => { + if (isPaused) + return NodeRunningStatus.Stopped + + if (oneStepRunRunningStatus === NodeRunningStatus.Stopped) + return NodeRunningStatus.Stopped + + if (oneStepRunRunningStatus === NodeRunningStatus.Listening) + return NodeRunningStatus.Listening + + return (runResult as any).status || otherResultPanelProps.status + }, [isPaused, oneStepRunRunningStatus, runResult, otherResultPanelProps.status]) + const resetHidePageStatus = useCallback(() => { setPageHasHide(false) setPageShowed(false) @@ -104,18 +117,18 @@ const LastRun: FC<Props> = ({ if (isRunning) return <ResultPanel status='running' showSteps={false} /> - if (!isPaused && (noLastRun || !runResult)) { return ( <NoData canSingleRun={canSingleRun} onSingleRun={onSingleRunClicked} /> ) } + return ( <div> <ResultPanel {...runResult as any} {...otherResultPanelProps} - status={isPaused ? NodeRunningStatus.Stopped : ((runResult as any).status || otherResultPanelProps.status)} + status={resolvedStatus} total_tokens={(runResult as any)?.execution_metadata?.total_tokens || otherResultPanelProps?.total_tokens} created_by={(runResult as any)?.created_by_account?.created_by || otherResultPanelProps?.created_by} nodeInfo={runResult as NodeTracing} diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts index 21462de939..ac9f2051c3 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts @@ -22,6 +22,7 @@ import useVariableAssignerSingleRunFormParams from '@/app/components/workflow/no import useKnowledgeBaseSingleRunFormParams from '@/app/components/workflow/nodes/knowledge-base/use-single-run-form-params' import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/use-get-data-for-check-more' +import useTriggerPluginGetDataForCheckMore from '@/app/components/workflow/nodes/trigger-plugin/use-check-params' import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config' // import @@ -30,10 +31,12 @@ import { BlockEnum } from '@/app/components/workflow/types' import { useNodesSyncDraft, } from '@/app/components/workflow/hooks' +import { useWorkflowRunValidation } from '@/app/components/workflow/hooks/use-checklist' import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import { useInvalidLastRun } from '@/service/use-workflow' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { isSupportCustomRunForm } from '@/app/components/workflow/utils' +import Toast from '@/app/components/base/toast' const singleRunFormParamsHooks: Record<BlockEnum, any> = { [BlockEnum.LLM]: useLLMSingleRunFormParams, @@ -62,6 +65,9 @@ const singleRunFormParamsHooks: Record<BlockEnum, any> = { [BlockEnum.LoopEnd]: undefined, [BlockEnum.DataSource]: undefined, [BlockEnum.DataSourceEmpty]: undefined, + [BlockEnum.TriggerWebhook]: undefined, + [BlockEnum.TriggerSchedule]: undefined, + [BlockEnum.TriggerPlugin]: undefined, } const useSingleRunFormParamsHooks = (nodeType: BlockEnum) => { @@ -97,6 +103,9 @@ const getDataForCheckMoreHooks: Record<BlockEnum, any> = { [BlockEnum.DataSource]: undefined, [BlockEnum.DataSourceEmpty]: undefined, [BlockEnum.KnowledgeBase]: undefined, + [BlockEnum.TriggerWebhook]: undefined, + [BlockEnum.TriggerSchedule]: undefined, + [BlockEnum.TriggerPlugin]: useTriggerPluginGetDataForCheckMore, } const useGetDataForCheckMoreHooks = <T>(nodeType: BlockEnum) => { @@ -139,6 +148,17 @@ const useLastRun = <T>({ isRunAfterSingleRun, }) + const { warningNodes } = useWorkflowRunValidation() + const blockIfChecklistFailed = useCallback(() => { + const warningForNode = warningNodes.find(item => item.id === id) + if (!warningForNode) + return false + + const message = warningForNode.errorMessage || 'This node has unresolved checklist issues' + Toast.notify({ type: 'error', message }) + return true + }, [warningNodes, id]) + const { hideSingleRun, handleRun: doCallRunApi, @@ -199,7 +219,7 @@ const useLastRun = <T>({ }) } const workflowStore = useWorkflowStore() - const { setInitShowLastRunTab } = workflowStore.getState() + const { setInitShowLastRunTab, setShowVariableInspectPanel } = workflowStore.getState() const initShowLastRunTab = useStore(s => s.initShowLastRunTab) const [tabType, setTabType] = useState<TabType>(initShowLastRunTab ? TabType.lastRun : TabType.settings) useEffect(() => { @@ -211,6 +231,8 @@ const useLastRun = <T>({ const invalidLastRun = useInvalidLastRun(flowType, flowId, id) const handleRunWithParams = async (data: Record<string, any>) => { + if (blockIfChecklistFailed()) + return const { isValid } = checkValid() if (!isValid) return @@ -309,9 +331,13 @@ const useLastRun = <T>({ } const handleSingleRun = () => { + if (blockIfChecklistFailed()) + return const { isValid } = checkValid() if (!isValid) return + if (blockType === BlockEnum.TriggerWebhook || blockType === BlockEnum.TriggerPlugin || blockType === BlockEnum.TriggerSchedule) + setShowVariableInspectPanel(true) if (isCustomRunNode) { showSingleRun() return diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/trigger-subscription.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/trigger-subscription.tsx new file mode 100644 index 0000000000..811516df3d --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/trigger-subscription.tsx @@ -0,0 +1,26 @@ +import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list' +import { CreateButtonType, CreateSubscriptionButton } from '@/app/components/plugins/plugin-detail-panel/subscription-list/create' +import { SubscriptionSelectorEntry } from '@/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry' +import { useSubscriptionList } from '@/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list' +import cn from '@/utils/classnames' +import type { FC } from 'react' + +type TriggerSubscriptionProps = { + subscriptionIdSelected?: string + onSubscriptionChange: (v: SimpleSubscription, callback?: () => void) => void + children: React.ReactNode +} + +export const TriggerSubscription: FC<TriggerSubscriptionProps> = ({ subscriptionIdSelected, onSubscriptionChange, children }) => { + const { subscriptions } = useSubscriptionList() + const subscriptionCount = subscriptions?.length || 0 + + return <div className={cn('px-4', subscriptionCount > 0 && 'flex items-center justify-between pr-3')}> + {!subscriptionCount && <CreateSubscriptionButton buttonType={CreateButtonType.FULL_BUTTON} />} + {children} + {subscriptionCount > 0 && <SubscriptionSelectorEntry + selectedId={subscriptionIdSelected} + onSelect={onSubscriptionChange} + />} + </div> +} diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index 93602f9751..77d75ccc4f 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -10,7 +10,15 @@ import { import { getNodeInfoById, isConversationVar, isENV, isSystemVar, toNodeOutputVars } from '@/app/components/workflow/nodes/_base/components/variable/utils' import type { CommonNodeType, InputVar, ValueSelector, Var, Variable } from '@/app/components/workflow/types' -import { BlockEnum, InputVarType, NodeRunningStatus, VarType } from '@/app/components/workflow/types' +import { + BlockEnum, + InputVarType, + NodeRunningStatus, + VarType, + WorkflowRunningStatus, +} from '@/app/components/workflow/types' +import type { TriggerNodeType } from '@/app/components/workflow/types' +import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { fetchNodeInspectVars, getIterationSingleNodeRunUrl, getLoopSingleNodeRunUrl, singleNodeRun } from '@/service/workflow' import Toast from '@/app/components/base/toast' @@ -28,7 +36,7 @@ import ParameterExtractorDefault from '@/app/components/workflow/nodes/parameter import IterationDefault from '@/app/components/workflow/nodes/iteration/default' import DocumentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default' import LoopDefault from '@/app/components/workflow/nodes/loop/default' -import { ssePost } from '@/service/base' +import { post, ssePost } from '@/service/base' import { noop } from 'lodash-es' import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants' import type { NodeRunResult, NodeTracing } from '@/types/workflow' @@ -50,9 +58,10 @@ import { useStoreApi, } from 'reactflow' import { useInvalidLastRun } from '@/service/use-workflow' -import useInspectVarsCrud from '../../../hooks/use-inspect-vars-crud' +import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import type { FlowType } from '@/types/common' import useMatchSchemaType from '../components/variable/use-match-schema-type' +import { useEventEmitterContextContext } from '@/context/event-emitter' import { useAllBuiltInTools, useAllCustomTools, @@ -61,7 +70,7 @@ import { } from '@/service/use-tools' // eslint-disable-next-line ts/no-unsafe-function-type -const checkValidFns: Record<BlockEnum, Function> = { +const checkValidFns: Partial<Record<BlockEnum, Function>> = { [BlockEnum.LLM]: checkLLMValid, [BlockEnum.KnowledgeRetrieval]: checkKnowledgeRetrievalValid, [BlockEnum.IfElse]: checkIfElseValid, @@ -76,7 +85,12 @@ const checkValidFns: Record<BlockEnum, Function> = { [BlockEnum.Iteration]: checkIterationValid, [BlockEnum.DocExtractor]: checkDocumentExtractorValid, [BlockEnum.Loop]: checkLoopValid, -} as any +} + +type RequestError = { + message: string + status: string +} export type Params<T> = { id: string @@ -198,7 +212,52 @@ const useOneStepRun = <T>({ const store = useStoreApi() const { setShowSingleRunPanel, + setIsListening, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + setShowVariableInspectPanel, } = workflowStore.getState() + const updateNodeInspectRunningState = useCallback((nodeId: string, isRunning: boolean) => { + const { + nodesWithInspectVars, + setNodesWithInspectVars, + } = workflowStore.getState() + + let hasChanges = false + const nodes = produce(nodesWithInspectVars, (draft) => { + const index = draft.findIndex(node => node.nodeId === nodeId) + if (index !== -1) { + const targetNode = draft[index] + if (targetNode.isSingRunRunning !== isRunning) { + targetNode.isSingRunRunning = isRunning + if (isRunning) + targetNode.isValueFetched = false + hasChanges = true + } + } + else if (isRunning) { + const { getNodes } = store.getState() + const target = getNodes().find(node => node.id === nodeId) + if (target) { + draft.unshift({ + nodeId, + nodeType: target.data.type, + title: target.data.title, + vars: [], + nodePayload: target.data, + isSingRunRunning: true, + isValueFetched: false, + }) + hasChanges = true + } + } + }) + + if (hasChanges) + setNodesWithInspectVars(nodes) + }, [workflowStore, store]) const invalidLastRun = useInvalidLastRun(flowType, flowId!, id) const [runResult, doSetRunResult] = useState<NodeRunResult | null>(null) const { @@ -207,10 +266,26 @@ const useOneStepRun = <T>({ invalidateConversationVarValues, } = useInspectVarsCrud() const runningStatus = data._singleRunningStatus || NodeRunningStatus.NotStart + const webhookSingleRunActiveRef = useRef(false) + const webhookSingleRunAbortRef = useRef<AbortController | null>(null) + const webhookSingleRunTimeoutRef = useRef<number | undefined>(undefined) + const webhookSingleRunTokenRef = useRef(0) + const webhookSingleRunDelayResolveRef = useRef<(() => void) | null>(null) + const pluginSingleRunActiveRef = useRef(false) + const pluginSingleRunAbortRef = useRef<AbortController | null>(null) + const pluginSingleRunTimeoutRef = useRef<number | undefined>(undefined) + const pluginSingleRunTokenRef = useRef(0) + const pluginSingleRunDelayResolveRef = useRef<(() => void) | null>(null) const isPausedRef = useRef(isPaused) useEffect(() => { isPausedRef.current = isPaused }, [isPaused]) + const { eventEmitter } = useEventEmitterContextContext() + + const isScheduleTriggerNode = data.type === BlockEnum.TriggerSchedule + const isWebhookTriggerNode = data.type === BlockEnum.TriggerWebhook + const isPluginTriggerNode = data.type === BlockEnum.TriggerPlugin + const isTriggerNode = isWebhookTriggerNode || isPluginTriggerNode || isScheduleTriggerNode const setRunResult = useCallback(async (data: NodeRunResult | null) => { const isPaused = isPausedRef.current @@ -230,13 +305,27 @@ const useOneStepRun = <T>({ const { getNodes } = store.getState() const nodes = getNodes() appendNodeInspectVars(id, vars, nodes) + updateNodeInspectRunningState(id, false) if (data?.status === NodeRunningStatus.Succeeded) { invalidLastRun() - if (isStartNode) + if (isStartNode || isTriggerNode) invalidateSysVarValues() invalidateConversationVarValues() // loop, iteration, variable assigner node can update the conversation variables, but to simple the logic(some nodes may also can update in the future), all nodes refresh. } - }, [isRunAfterSingleRun, runningStatus, flowId, id, store, appendNodeInspectVars, invalidLastRun, isStartNode, invalidateSysVarValues, invalidateConversationVarValues]) + }, [ + isRunAfterSingleRun, + runningStatus, + flowId, + id, + store, + appendNodeInspectVars, + updateNodeInspectRunningState, + invalidLastRun, + isStartNode, + isTriggerNode, + invalidateSysVarValues, + invalidateConversationVarValues, + ]) const { handleNodeDataUpdate }: { handleNodeDataUpdate: (data: any) => void } = useNodeDataUpdate() const setNodeRunning = () => { @@ -248,6 +337,299 @@ const useOneStepRun = <T>({ }, }) } + + const cancelWebhookSingleRun = useCallback(() => { + webhookSingleRunActiveRef.current = false + webhookSingleRunTokenRef.current += 1 + if (webhookSingleRunAbortRef.current) + webhookSingleRunAbortRef.current.abort() + webhookSingleRunAbortRef.current = null + if (webhookSingleRunTimeoutRef.current !== undefined) { + window.clearTimeout(webhookSingleRunTimeoutRef.current) + webhookSingleRunTimeoutRef.current = undefined + } + if (webhookSingleRunDelayResolveRef.current) { + webhookSingleRunDelayResolveRef.current() + webhookSingleRunDelayResolveRef.current = null + } + }, []) + + const cancelPluginSingleRun = useCallback(() => { + pluginSingleRunActiveRef.current = false + pluginSingleRunTokenRef.current += 1 + if (pluginSingleRunAbortRef.current) + pluginSingleRunAbortRef.current.abort() + pluginSingleRunAbortRef.current = null + if (pluginSingleRunTimeoutRef.current !== undefined) { + window.clearTimeout(pluginSingleRunTimeoutRef.current) + pluginSingleRunTimeoutRef.current = undefined + } + if (pluginSingleRunDelayResolveRef.current) { + pluginSingleRunDelayResolveRef.current() + pluginSingleRunDelayResolveRef.current = null + } + }, []) + + const startTriggerListening = useCallback(() => { + if (!isTriggerNode) + return + + setIsListening(true) + setShowVariableInspectPanel(true) + setListeningTriggerType(data.type as TriggerNodeType) + setListeningTriggerNodeId(id) + setListeningTriggerNodeIds([id]) + setListeningTriggerIsAll(false) + }, [ + isTriggerNode, + setIsListening, + setShowVariableInspectPanel, + setListeningTriggerType, + data.type, + setListeningTriggerNodeId, + id, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + ]) + + const stopTriggerListening = useCallback(() => { + if (!isTriggerNode) + return + + setIsListening(false) + setListeningTriggerType(null) + setListeningTriggerNodeId(null) + setListeningTriggerNodeIds([]) + setListeningTriggerIsAll(false) + }, [ + isTriggerNode, + setIsListening, + setListeningTriggerType, + setListeningTriggerNodeId, + setListeningTriggerNodeIds, + setListeningTriggerIsAll, + ]) + + const runScheduleSingleRun = useCallback(async (): Promise<NodeRunResult | null> => { + const urlPath = `/apps/${flowId}/workflows/draft/nodes/${id}/trigger/run` + + try { + const response: any = await post(urlPath, { + body: JSON.stringify({}), + }) + + if (!response) { + const message = 'Schedule trigger run failed' + Toast.notify({ type: 'error', message }) + throw new Error(message) + } + + if (response?.status === 'error') { + const message = response?.message || 'Schedule trigger run failed' + Toast.notify({ type: 'error', message }) + throw new Error(message) + } + + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Succeeded, + }, + }) + + return response as NodeRunResult + } + catch (error) { + console.error('handleRun: schedule trigger single run error', error) + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Failed, + }, + }) + Toast.notify({ type: 'error', message: 'Schedule trigger run failed' }) + throw error + } + }, [flowId, id, handleNodeDataUpdate, data]) + + const runWebhookSingleRun = useCallback(async (): Promise<any | null> => { + const urlPath = `/apps/${flowId}/workflows/draft/nodes/${id}/trigger/run` + + webhookSingleRunActiveRef.current = true + const token = ++webhookSingleRunTokenRef.current + + while (webhookSingleRunActiveRef.current && token === webhookSingleRunTokenRef.current) { + const controller = new AbortController() + webhookSingleRunAbortRef.current = controller + + try { + const response: any = await post(urlPath, { + body: JSON.stringify({}), + signal: controller.signal, + }) + + if (!webhookSingleRunActiveRef.current || token !== webhookSingleRunTokenRef.current) + return null + + if (!response) { + const message = response?.message || 'Webhook debug failed' + Toast.notify({ type: 'error', message }) + cancelWebhookSingleRun() + throw new Error(message) + } + + if (response?.status === 'waiting') { + const delay = Number(response.retry_in) || 2000 + webhookSingleRunAbortRef.current = null + if (!webhookSingleRunActiveRef.current || token !== webhookSingleRunTokenRef.current) + return null + + await new Promise<void>((resolve) => { + const timeoutId = window.setTimeout(resolve, delay) + webhookSingleRunTimeoutRef.current = timeoutId + webhookSingleRunDelayResolveRef.current = resolve + controller.signal.addEventListener('abort', () => { + window.clearTimeout(timeoutId) + resolve() + }, { once: true }) + }) + + webhookSingleRunTimeoutRef.current = undefined + webhookSingleRunDelayResolveRef.current = null + continue + } + + if (response?.status === 'error') { + const message = response.message || 'Webhook debug failed' + Toast.notify({ type: 'error', message }) + cancelWebhookSingleRun() + throw new Error(message) + } + + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Listening, + }, + }) + + cancelWebhookSingleRun() + return response + } + catch (error) { + if (controller.signal.aborted && (!webhookSingleRunActiveRef.current || token !== webhookSingleRunTokenRef.current)) + return null + if (controller.signal.aborted) + return null + + Toast.notify({ type: 'error', message: 'Webhook debug request failed' }) + cancelWebhookSingleRun() + if (error instanceof Error) + throw error + throw new Error(String(error)) + } + finally { + webhookSingleRunAbortRef.current = null + } + } + + return null + }, [flowId, id, data, handleNodeDataUpdate, cancelWebhookSingleRun]) + + const runPluginSingleRun = useCallback(async (): Promise<any | null> => { + const urlPath = `/apps/${flowId}/workflows/draft/nodes/${id}/trigger/run` + + pluginSingleRunActiveRef.current = true + const token = ++pluginSingleRunTokenRef.current + + while (pluginSingleRunActiveRef.current && token === pluginSingleRunTokenRef.current) { + const controller = new AbortController() + pluginSingleRunAbortRef.current = controller + + let requestError: RequestError | undefined + const response: any = await post(urlPath, { + body: JSON.stringify({}), + signal: controller.signal, + }).catch(async (error: Response) => { + const data = await error.clone().json() as Record<string, any> + const { error: respError, status } = data || {} + requestError = { + message: respError, + status, + } + return null + }).finally(() => { + pluginSingleRunAbortRef.current = null + }) + + if (!pluginSingleRunActiveRef.current || token !== pluginSingleRunTokenRef.current) + return null + + if (requestError) { + if (controller.signal.aborted) + return null + + Toast.notify({ type: 'error', message: requestError.message }) + cancelPluginSingleRun() + throw requestError + } + + if (!response) { + const message = 'Plugin debug failed' + Toast.notify({ type: 'error', message }) + cancelPluginSingleRun() + throw new Error(message) + } + + if (response?.status === 'waiting') { + const delay = Number(response.retry_in) || 2000 + if (!pluginSingleRunActiveRef.current || token !== pluginSingleRunTokenRef.current) + return null + + await new Promise<void>((resolve) => { + const timeoutId = window.setTimeout(resolve, delay) + pluginSingleRunTimeoutRef.current = timeoutId + pluginSingleRunDelayResolveRef.current = resolve + controller.signal.addEventListener('abort', () => { + window.clearTimeout(timeoutId) + resolve() + }, { once: true }) + }) + + pluginSingleRunTimeoutRef.current = undefined + pluginSingleRunDelayResolveRef.current = null + continue + } + + if (response?.status === 'error') { + const message = response.message || 'Plugin debug failed' + Toast.notify({ type: 'error', message }) + cancelPluginSingleRun() + throw new Error(message) + } + + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Listening, + }, + }) + + cancelPluginSingleRun() + return response + } + + return null + }, [flowId, id, data, handleNodeDataUpdate, cancelPluginSingleRun]) + const checkValidWrap = () => { if (!checkValid) return { isValid: true, errorMessage: '' } @@ -262,7 +644,7 @@ const useOneStepRun = <T>({ }) Toast.notify({ type: 'error', - message: res.errorMessage, + message: res.errorMessage || '', }) } return res @@ -309,33 +691,84 @@ const useOneStepRun = <T>({ const isCompleted = runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed const handleRun = async (submitData: Record<string, any>) => { + if (isWebhookTriggerNode) + cancelWebhookSingleRun() + if (isPluginTriggerNode) + cancelPluginSingleRun() + + updateNodeInspectRunningState(id, true) + + if (isTriggerNode) + startTriggerListening() + else + stopTriggerListening() + handleNodeDataUpdate({ id, data: { ...data, _isSingleRun: false, - _singleRunningStatus: NodeRunningStatus.Running, + _singleRunningStatus: isTriggerNode + ? NodeRunningStatus.Listening + : NodeRunningStatus.Running, }, }) let res: any let hasError = false try { if (!isIteration && !isLoop) { - const isStartNode = data.type === BlockEnum.Start - const postData: Record<string, any> = {} - if (isStartNode) { - const { '#sys.query#': query, '#sys.files#': files, ...inputs } = submitData - if (isChatMode) - postData.conversation_id = '' - - postData.inputs = inputs - postData.query = query - postData.files = files || [] + if (isScheduleTriggerNode) { + res = await runScheduleSingleRun() + } + else if (isWebhookTriggerNode) { + res = await runWebhookSingleRun() + if (!res) { + if (webhookSingleRunActiveRef.current) { + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Stopped, + }, + }) + } + return false + } + } + else if (isPluginTriggerNode) { + res = await runPluginSingleRun() + if (!res) { + if (pluginSingleRunActiveRef.current) { + handleNodeDataUpdate({ + id, + data: { + ...data, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Stopped, + }, + }) + } + return false + } } else { - postData.inputs = submitData + const isStartNode = data.type === BlockEnum.Start + const postData: Record<string, any> = {} + if (isStartNode) { + const { '#sys.query#': query, '#sys.files#': files, ...inputs } = submitData + if (isChatMode) + postData.conversation_id = '' + + postData.inputs = inputs + postData.query = query + postData.files = files || [] + } + else { + postData.inputs = submitData + } + res = await singleNodeRun(flowType, flowId!, id, postData) as any } - res = await singleNodeRun(flowType, flowId!, id, postData) as any } else if (isIteration) { setIterationRunResult([]) @@ -566,6 +999,14 @@ const useOneStepRun = <T>({ } } finally { + if (isWebhookTriggerNode) + cancelWebhookSingleRun() + if (isPluginTriggerNode) + cancelPluginSingleRun() + if (isTriggerNode) + stopTriggerListening() + if (!isIteration && !isLoop) + updateNodeInspectRunningState(id, false) if (!isPausedRef.current && !isIteration && !isLoop && res) { setRunResult({ ...res, @@ -591,15 +1032,55 @@ const useOneStepRun = <T>({ } } - const handleStop = () => { + const handleStop = useCallback(() => { + if (isTriggerNode) { + const isTriggerActive = runningStatus === NodeRunningStatus.Listening + || webhookSingleRunActiveRef.current + || pluginSingleRunActiveRef.current + if (!isTriggerActive) + return + } + else if (runningStatus !== NodeRunningStatus.Running) { + return + } + + cancelWebhookSingleRun() + cancelPluginSingleRun() handleNodeDataUpdate({ id, data: { - ...data, - _singleRunningStatus: NodeRunningStatus.NotStart, + _isSingleRun: false, + _singleRunningStatus: NodeRunningStatus.Stopped, }, }) - } + stopTriggerListening() + updateNodeInspectRunningState(id, false) + const { + workflowRunningData, + setWorkflowRunningData, + nodesWithInspectVars, + deleteNodeInspectVars, + } = workflowStore.getState() + if (workflowRunningData) { + setWorkflowRunningData(produce(workflowRunningData, (draft) => { + draft.result.status = WorkflowRunningStatus.Stopped + })) + } + + const inspectNode = nodesWithInspectVars.find(node => node.nodeId === id) + if (inspectNode && !inspectNode.isValueFetched && (!inspectNode.vars || inspectNode.vars.length === 0)) + deleteNodeInspectVars(id) + }, [ + isTriggerNode, + runningStatus, + cancelWebhookSingleRun, + cancelPluginSingleRun, + handleNodeDataUpdate, + id, + stopTriggerListening, + updateNodeInspectRunningState, + workflowStore, + ]) const toVarInputs = (variables: Variable[]): InputVar[] => { if (!variables) @@ -662,6 +1143,11 @@ const useOneStepRun = <T>({ }) } + eventEmitter?.useSubscription((v: any) => { + if (v.type === EVENT_WORKFLOW_STOP) + handleStop() + }) + return { isShowSingleRun, hideSingleRun, diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 4725f86ad5..73f78401ac 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -16,23 +16,18 @@ import { RiLoader2Line, } from '@remixicon/react' import { useTranslation } from 'react-i18next' -import type { NodeProps } from '../../types' +import type { NodeProps } from '@/app/components/workflow/types' import { BlockEnum, NodeRunningStatus, -} from '../../types' -import { - useNodesReadOnly, - useToolIcon, -} from '../../hooks' -import { - hasErrorHandleNode, - hasRetryNode, -} from '../../utils' -import { useNodeIterationInteractions } from '../iteration/use-interactions' -import { useNodeLoopInteractions } from '../loop/use-interactions' -import type { IterationNodeType } from '../iteration/types' -import CopyID from '../tool/components/copy-id' + isTriggerNode, +} from '@/app/components/workflow/types' +import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks' +import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils' +import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions' +import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions' +import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types' +import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id' import { NodeSourceHandle, NodeTargetHandle, @@ -42,11 +37,12 @@ import NodeControl from './components/node-control' import ErrorHandleOnNode from './components/error-handle/error-handle-on-node' import RetryOnNode from './components/retry/retry-on-node' import AddVariablePopupWithPosition from './components/add-variable-popup-with-position' +import EntryNodeContainer, { StartNodeTypeEnum } from './components/entry-node-container' import cn from '@/utils/classnames' import BlockIcon from '@/app/components/workflow/block-icon' import Tooltip from '@/app/components/base/tooltip' -import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' -import { ToolTypeEnum } from '../../block-selector/types' +import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' +import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types' type NodeChildProps = { id: string @@ -67,6 +63,7 @@ const BaseNode: FC<BaseNodeProps> = ({ const { t } = useTranslation() const nodeRef = useRef<HTMLDivElement>(null) const { nodesReadOnly } = useNodesReadOnly() + const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions() const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions() const toolIcon = useToolIcon(data) @@ -141,13 +138,13 @@ const BaseNode: FC<BaseNodeProps> = ({ return null }, [data._loopIndex, data._runningStatus, t]) - return ( + const nodeContent = ( <div className={cn( 'relative flex rounded-2xl border', showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent', data._waitingRun && 'opacity-70', - data._dimmed && 'opacity-30', + data._pluginInstallLocked && 'cursor-not-allowed', )} ref={nodeRef} style={{ @@ -155,6 +152,17 @@ const BaseNode: FC<BaseNodeProps> = ({ height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto', }} > + {(data._dimmed || data._pluginInstallLocked) && ( + <div + className={cn( + 'absolute inset-0 rounded-2xl transition-opacity', + data._pluginInstallLocked + ? 'pointer-events-auto z-30 bg-workflow-block-parma-bg opacity-80 backdrop-blur-[2px]' + : 'pointer-events-none z-20 bg-workflow-block-parma-bg opacity-50', + )} + data-testid='workflow-node-install-overlay' + /> + )} { data.type === BlockEnum.DataSource && ( <div className='absolute inset-[-2px] top-[-22px] z-[-1] rounded-[18px] bg-node-data-source-bg p-0.5 backdrop-blur-[6px]'> @@ -297,13 +305,13 @@ const BaseNode: FC<BaseNodeProps> = ({ </div> { data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && ( - cloneElement(children, { id, data }) + cloneElement(children, { id, data } as any) ) } { (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && ( <div className='grow pb-1 pl-1 pr-1'> - {cloneElement(children, { id, data })} + {cloneElement(children, { id, data } as any)} </div> ) } @@ -338,6 +346,17 @@ const BaseNode: FC<BaseNodeProps> = ({ </div> </div> ) + + const isStartNode = data.type === BlockEnum.Start + const isEntryNode = isTriggerNode(data.type as any) || isStartNode + + return isEntryNode ? ( + <EntryNodeContainer + nodeType={isStartNode ? StartNodeTypeEnum.Start : StartNodeTypeEnum.Trigger} + > + {nodeContent} + </EntryNodeContainer> + ) : nodeContent } export default memo(BaseNode) diff --git a/web/app/components/workflow/nodes/_base/types.ts b/web/app/components/workflow/nodes/_base/types.ts new file mode 100644 index 0000000000..18ad9c4e71 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/types.ts @@ -0,0 +1,27 @@ +import type { ValueSelector } from '@/app/components/workflow/types' + +// Generic variable types for all resource forms +export enum VarKindType { + variable = 'variable', + constant = 'constant', + mixed = 'mixed', +} + +// Generic resource variable inputs +export type ResourceVarInputs = Record<string, { + type: VarKindType + value?: string | ValueSelector | any +}> + +// Base resource interface +export type BaseResource = { + name: string + [key: string]: any +} + +// Base resource provider interface +export type BaseResourceProvider = { + plugin_id?: string + name: string + [key: string]: any +} diff --git a/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx b/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx index bfb48d4eb2..b81277d740 100644 --- a/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx +++ b/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx @@ -68,7 +68,7 @@ const VarList: FC<Props> = ({ draft[index].value = '' // Clear value when operation changes if (item.value === WriteMode.set || item.value === WriteMode.increment || item.value === WriteMode.decrement || item.value === WriteMode.multiply || item.value === WriteMode.divide) { - if(varType === VarType.boolean) + if (varType === VarType.boolean) draft[index].value = false draft[index].input_type = AssignerNodeInputType.constant } diff --git a/web/app/components/workflow/nodes/components.ts b/web/app/components/workflow/nodes/components.ts index cdf3a21598..d8da8b9dae 100644 --- a/web/app/components/workflow/nodes/components.ts +++ b/web/app/components/workflow/nodes/components.ts @@ -42,6 +42,12 @@ import DataSourceNode from './data-source/node' import DataSourcePanel from './data-source/panel' import KnowledgeBaseNode from './knowledge-base/node' import KnowledgeBasePanel from './knowledge-base/panel' +import TriggerScheduleNode from './trigger-schedule/node' +import TriggerSchedulePanel from './trigger-schedule/panel' +import TriggerWebhookNode from './trigger-webhook/node' +import TriggerWebhookPanel from './trigger-webhook/panel' +import TriggerPluginNode from './trigger-plugin/node' +import TriggerPluginPanel from './trigger-plugin/panel' export const NodeComponentMap: Record<string, ComponentType<any>> = { [BlockEnum.Start]: StartNode, @@ -66,6 +72,9 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = { [BlockEnum.Agent]: AgentNode, [BlockEnum.DataSource]: DataSourceNode, [BlockEnum.KnowledgeBase]: KnowledgeBaseNode, + [BlockEnum.TriggerSchedule]: TriggerScheduleNode, + [BlockEnum.TriggerWebhook]: TriggerWebhookNode, + [BlockEnum.TriggerPlugin]: TriggerPluginNode, } export const PanelComponentMap: Record<string, ComponentType<any>> = { @@ -91,4 +100,7 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = { [BlockEnum.Agent]: AgentPanel, [BlockEnum.DataSource]: DataSourcePanel, [BlockEnum.KnowledgeBase]: KnowledgeBasePanel, + [BlockEnum.TriggerSchedule]: TriggerSchedulePanel, + [BlockEnum.TriggerWebhook]: TriggerWebhookPanel, + [BlockEnum.TriggerPlugin]: TriggerPluginPanel, } diff --git a/web/app/components/workflow/nodes/constants.ts b/web/app/components/workflow/nodes/constants.ts index 78684577f2..b09b27343a 100644 --- a/web/app/components/workflow/nodes/constants.ts +++ b/web/app/components/workflow/nodes/constants.ts @@ -1,5 +1,7 @@ import { TransferMethod } from '@/types/app' +export const CUSTOM_NODE_TYPE = 'custom' + export const FILE_TYPE_OPTIONS = [ { value: 'image', i18nKey: 'image' }, { value: 'document', i18nKey: 'doc' }, diff --git a/web/app/components/workflow/nodes/data-source-empty/hooks.ts b/web/app/components/workflow/nodes/data-source-empty/hooks.ts index e22e87485c..a17f0b2acb 100644 --- a/web/app/components/workflow/nodes/data-source-empty/hooks.ts +++ b/web/app/components/workflow/nodes/data-source-empty/hooks.ts @@ -11,7 +11,7 @@ export const useReplaceDataSourceNode = (id: string) => { const handleReplaceNode = useCallback<OnSelectBlock>(( type, - toolDefaultValue, + pluginDefaultValue, ) => { const { getNodes, @@ -28,7 +28,7 @@ export const useReplaceDataSourceNode = (id: string) => { const { newNode } = generateNewNode({ data: { ...(defaultValue as any), - ...toolDefaultValue, + ...pluginDefaultValue, }, position: { x: emptyNode.position.x, diff --git a/web/app/components/workflow/nodes/data-source/node.tsx b/web/app/components/workflow/nodes/data-source/node.tsx index f97098e52f..b490aea2a9 100644 --- a/web/app/components/workflow/nodes/data-source/node.tsx +++ b/web/app/components/workflow/nodes/data-source/node.tsx @@ -1,10 +1,57 @@ import type { FC } from 'react' -import { memo } from 'react' -import type { DataSourceNodeType } from './types' +import { memo, useEffect } from 'react' import type { NodeProps } from '@/app/components/workflow/types' -const Node: FC<NodeProps<DataSourceNodeType>> = () => { +import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' +import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update' +import type { DataSourceNodeType } from './types' + +const Node: FC<NodeProps<DataSourceNodeType>> = ({ + id, + data, +}) => { + const { + isChecking, + isMissing, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } = useNodePluginInstallation(data) + const { handleNodeDataUpdate } = useNodeDataUpdate() + const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier) + + useEffect(() => { + if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim) + return + handleNodeDataUpdate({ + id, + data: { + _pluginInstallLocked: shouldLock, + _dimmed: shouldDim, + }, + }) + }, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock]) + + const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier + + if (!showInstallButton) + return null + return ( - <div> + <div className='relative mb-1 px-3 py-1'> + <div className='pointer-events-auto absolute right-3 top-[-32px] z-40'> + <InstallPluginButton + size='small' + extraIdentifiers={[ + data.plugin_id, + data.provider_name, + ].filter(Boolean) as string[]} + className='!font-medium !text-text-accent' + uniqueIdentifier={uniqueIdentifier!} + onSuccess={onInstallSuccess} + /> + </div> </div> ) } diff --git a/web/app/components/workflow/nodes/data-source/types.ts b/web/app/components/workflow/nodes/data-source/types.ts index da887244b8..d0bc034b89 100644 --- a/web/app/components/workflow/nodes/data-source/types.ts +++ b/web/app/components/workflow/nodes/data-source/types.ts @@ -1,13 +1,9 @@ -import type { CommonNodeType, Node, ValueSelector } from '@/app/components/workflow/types' +import type { CommonNodeType, Node } from '@/app/components/workflow/types' import type { FlowType } from '@/types/common' import type { NodeRunResult, VarInInspect } from '@/types/workflow' import type { Dispatch, SetStateAction } from 'react' - -export enum VarType { - variable = 'variable', - constant = 'constant', - mixed = 'mixed', -} +import type { ResourceVarInputs } from '../_base/types' +export { VarKindType as VarType } from '../_base/types' export enum DataSourceClassification { localFile = 'local_file', @@ -16,10 +12,7 @@ export enum DataSourceClassification { onlineDrive = 'online_drive', } -export type ToolVarInputs = Record<string, { - type: VarType - value?: string | ValueSelector | any -}> +export type ToolVarInputs = ResourceVarInputs export type DataSourceNodeType = CommonNodeType & { fileExtensions?: string[] @@ -30,6 +23,7 @@ export type DataSourceNodeType = CommonNodeType & { datasource_label: string datasource_parameters: ToolVarInputs datasource_configurations: Record<string, any> + plugin_unique_identifier?: string } export type CustomRunFormProps = { diff --git a/web/app/components/workflow/nodes/end/default.ts b/web/app/components/workflow/nodes/end/default.ts index cadb580c34..881c16986b 100644 --- a/web/app/components/workflow/nodes/end/default.ts +++ b/web/app/components/workflow/nodes/end/default.ts @@ -6,17 +6,34 @@ import { BlockEnum } from '@/app/components/workflow/types' const metaData = genNodeMetaData({ sort: 2.1, type: BlockEnum.End, - isRequired: true, + isRequired: false, }) const nodeDefault: NodeDefault<EndNodeType> = { metaData, defaultValue: { outputs: [], }, - checkValid() { + checkValid(payload: EndNodeType, t: any) { + const outputs = payload.outputs || [] + + let errorMessage = '' + if (!outputs.length) { + errorMessage = t('workflow.errorMsg.fieldRequired', { field: t('workflow.nodes.end.output.variable') }) + } + else { + const invalidOutput = outputs.find((output) => { + const variableName = output.variable?.trim() + const hasSelector = Array.isArray(output.value_selector) && output.value_selector.length > 0 + return !variableName || !hasSelector + }) + + if (invalidOutput) + errorMessage = t('workflow.errorMsg.fieldRequired', { field: t('workflow.nodes.end.output.variable') }) + } + return { - isValid: true, - errorMessage: '', + isValid: !errorMessage, + errorMessage, } }, } diff --git a/web/app/components/workflow/nodes/end/panel.tsx b/web/app/components/workflow/nodes/end/panel.tsx index 2ad90ff5ac..420280d7c5 100644 --- a/web/app/components/workflow/nodes/end/panel.tsx +++ b/web/app/components/workflow/nodes/end/panel.tsx @@ -30,6 +30,7 @@ const Panel: FC<NodePanelProps<EndNodeType>> = ({ <Field title={t(`${i18nPrefix}.output.variable`)} + required operations={ !readOnly ? <AddButton onClick={handleAddVariable} /> : undefined } diff --git a/web/app/components/workflow/nodes/iteration/add-block.tsx b/web/app/components/workflow/nodes/iteration/add-block.tsx index 10aa8bb3e2..05d69caef4 100644 --- a/web/app/components/workflow/nodes/iteration/add-block.tsx +++ b/web/app/components/workflow/nodes/iteration/add-block.tsx @@ -33,11 +33,11 @@ const AddBlock = ({ const { handleNodeAdd } = useNodesInteractions() const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, true) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { handleNodeAdd( { nodeType: type, - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId: iterationNodeData.start_node_id, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx index 8ea313dd26..1c6158a60e 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx @@ -84,7 +84,6 @@ const MetadataFilter = ({ popupClassName='!w-[387px]' isInWorkflow isAdvancedMode={true} - mode={metadataModelConfig?.mode || 'chat'} provider={metadataModelConfig?.provider || ''} completionParams={metadataModelConfig?.completion_params || { temperature: 0.7 }} modelId={metadataModelConfig?.name || ''} diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts b/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts index 60789e6863..73d1c15872 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts @@ -32,7 +32,7 @@ import { getMultipleRetrievalConfig, getSelectedDatasetsMode, } from './utils' -import { RETRIEVE_TYPE } from '@/types/app' +import { AppModeEnum, RETRIEVE_TYPE } from '@/types/app' import { DATASET_DEFAULT } from '@/config' import type { DataSet } from '@/models/datasets' import { fetchDatasets } from '@/service/datasets' @@ -344,7 +344,7 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => { draft.metadata_model_config = { provider: model.provider, name: model.modelId, - mode: model.mode || 'chat', + mode: model.mode || AppModeEnum.CHAT, completion_params: draft.metadata_model_config?.completion_params || { temperature: 0.7 }, } }) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx index 9387813ee5..62d156253a 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx @@ -65,7 +65,6 @@ const PromptEditor: FC<PromptEditorProps> = ({ portalToFollowElemContentClassName='z-[1000]' isAdvancedMode={true} provider={model.provider} - mode={model.mode} completionParams={model.completion_params} modelId={model.name} setModel={onModelChange} diff --git a/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx b/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx index c3c0483bec..a2b96535fa 100644 --- a/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx +++ b/web/app/components/workflow/nodes/llm/components/prompt-generator-btn.tsx @@ -6,7 +6,7 @@ import cn from 'classnames' import { Generator } from '@/app/components/base/icons/src/vender/other' import { ActionButton } from '@/app/components/base/action-button' import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import type { GenRes } from '@/service/debug' import type { ModelConfig } from '@/app/components/workflow/types' import { useHooksStore } from '../../../hooks-store' @@ -42,7 +42,7 @@ const PromptGeneratorBtn: FC<Props> = ({ </ActionButton> {showAutomatic && ( <GetAutomaticResModal - mode={AppType.chat} + mode={AppModeEnum.CHAT} isShow={showAutomatic} onClose={showAutomaticFalse} onFinished={handleAutomaticRes} diff --git a/web/app/components/workflow/nodes/llm/default.ts b/web/app/components/workflow/nodes/llm/default.ts index a4ea0ef683..57033d26a1 100644 --- a/web/app/components/workflow/nodes/llm/default.ts +++ b/web/app/components/workflow/nodes/llm/default.ts @@ -1,4 +1,5 @@ // import { RETRIEVAL_OUTPUT_STRUCT } from '../../constants' +import { AppModeEnum } from '@/types/app' import { BlockEnum, EditionType } from '../../types' import { type NodeDefault, type PromptItem, PromptRole } from '../../types' import type { LLMNodeType } from './types' @@ -36,7 +37,7 @@ const nodeDefault: NodeDefault<LLMNodeType> = { model: { provider: '', name: '', - mode: 'chat', + mode: AppModeEnum.CHAT, completion_params: { temperature: 0.7, }, @@ -63,7 +64,7 @@ const nodeDefault: NodeDefault<LLMNodeType> = { errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.model`) }) if (!errorMessages && !payload.memory) { - const isChatModel = payload.model.mode === 'chat' + const isChatModel = payload.model.mode === AppModeEnum.CHAT const isPromptEmpty = isChatModel ? !(payload.prompt_template as PromptItem[]).some((t) => { if (t.edition_type === EditionType.jinja2) @@ -77,14 +78,14 @@ const nodeDefault: NodeDefault<LLMNodeType> = { } if (!errorMessages && !!payload.memory) { - const isChatModel = payload.model.mode === 'chat' + const isChatModel = payload.model.mode === AppModeEnum.CHAT // payload.memory.query_prompt_template not pass is default: {{#sys.query#}} if (isChatModel && !!payload.memory.query_prompt_template && !payload.memory.query_prompt_template.includes('{{#sys.query#}}')) errorMessages = t('workflow.nodes.llm.sysQueryInUser') } if (!errorMessages) { - const isChatModel = payload.model.mode === 'chat' + const isChatModel = payload.model.mode === AppModeEnum.CHAT const isShowVars = (() => { if (isChatModel) return (payload.prompt_template as PromptItem[]).some(item => item.edition_type === EditionType.jinja2) diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx index cd79b9f3d9..bb893b0da7 100644 --- a/web/app/components/workflow/nodes/llm/panel.tsx +++ b/web/app/components/workflow/nodes/llm/panel.tsx @@ -94,7 +94,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ } })() }, [inputs.model.completion_params]) - return ( <div className='mt-2'> <div className='space-y-4 px-4 pb-4'> @@ -106,7 +105,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({ popupClassName='!w-[387px]' isInWorkflow isAdvancedMode={true} - mode={model?.mode} provider={model?.provider} completionParams={model?.completion_params} modelId={model?.name} diff --git a/web/app/components/workflow/nodes/llm/types.ts b/web/app/components/workflow/nodes/llm/types.ts index 987fb75fef..70dc4d9cc7 100644 --- a/web/app/components/workflow/nodes/llm/types.ts +++ b/web/app/components/workflow/nodes/llm/types.ts @@ -30,6 +30,7 @@ export enum Type { arrayNumber = 'array[number]', arrayObject = 'array[object]', file = 'file', + enumType = 'enum', } export enum ArrayType { diff --git a/web/app/components/workflow/nodes/llm/use-config.ts b/web/app/components/workflow/nodes/llm/use-config.ts index c0608865b8..d9b811bb85 100644 --- a/web/app/components/workflow/nodes/llm/use-config.ts +++ b/web/app/components/workflow/nodes/llm/use-config.ts @@ -18,6 +18,7 @@ import { import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants' import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' +import { AppModeEnum } from '@/types/app' const useConfig = (id: string, payload: LLMNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() @@ -49,7 +50,7 @@ const useConfig = (id: string, payload: LLMNodeType) => { // model const model = inputs.model const modelMode = inputs.model?.mode - const isChatModel = modelMode === 'chat' + const isChatModel = modelMode === AppModeEnum.CHAT const isCompletionModel = !isChatModel @@ -134,7 +135,7 @@ const useConfig = (id: string, payload: LLMNodeType) => { draft.model.mode = model.mode! const isModeChange = model.mode !== inputRef.current.model.mode if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0) - appendDefaultPromptConfig(draft, defaultConfig, model.mode === 'chat') + appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT) }) setInputs(newInputs) setModelChanged(true) diff --git a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts index aaa12be0c2..8d539dfc15 100644 --- a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts @@ -12,6 +12,7 @@ import useConfigVision from '../../hooks/use-config-vision' import { noop } from 'lodash-es' import { findVariableWhenOnLLMVision } from '../utils' import useAvailableVarList from '../_base/hooks/use-available-var-list' +import { AppModeEnum } from '@/types/app' const i18nPrefix = 'workflow.nodes.llm' type Params = { @@ -56,7 +57,7 @@ const useSingleRunFormParams = ({ // model const model = inputs.model const modelMode = inputs.model?.mode - const isChatModel = modelMode === 'chat' + const isChatModel = modelMode === AppModeEnum.CHAT const { isVisionModel, } = useConfigVision(model, { diff --git a/web/app/components/workflow/nodes/llm/utils.ts b/web/app/components/workflow/nodes/llm/utils.ts index 10c287f86b..1652d511d0 100644 --- a/web/app/components/workflow/nodes/llm/utils.ts +++ b/web/app/components/workflow/nodes/llm/utils.ts @@ -9,8 +9,9 @@ export const checkNodeValid = (_payload: LLMNodeType) => { } export const getFieldType = (field: Field) => { - const { type, items } = field - if(field.schemaType === 'file') return Type.file + const { type, items, enum: enums } = field + if (field.schemaType === 'file') return Type.file + if (enums && enums.length > 0) return Type.enumType if (type !== Type.array || !items) return type diff --git a/web/app/components/workflow/nodes/loop/add-block.tsx b/web/app/components/workflow/nodes/loop/add-block.tsx index a9c1429269..9e2fa5b555 100644 --- a/web/app/components/workflow/nodes/loop/add-block.tsx +++ b/web/app/components/workflow/nodes/loop/add-block.tsx @@ -34,11 +34,11 @@ const AddBlock = ({ const { handleNodeAdd } = useNodesInteractions() const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, true) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { handleNodeAdd( { nodeType: type, - toolDefaultValue, + pluginDefaultValue, }, { prevNodeId: loopNodeData.start_node_id, diff --git a/web/app/components/workflow/nodes/loop/insert-block.tsx b/web/app/components/workflow/nodes/loop/insert-block.tsx index c4f4348d8e..66d51956ba 100644 --- a/web/app/components/workflow/nodes/loop/insert-block.tsx +++ b/web/app/components/workflow/nodes/loop/insert-block.tsx @@ -25,11 +25,11 @@ const InsertBlock = ({ const handleOpenChange = useCallback((v: boolean) => { setOpen(v) }, []) - const handleInsert = useCallback<OnSelectBlock>((nodeType, toolDefaultValue) => { + const handleInsert = useCallback<OnSelectBlock>((nodeType, pluginDefaultValue) => { handleNodeAdd( { nodeType, - toolDefaultValue, + pluginDefaultValue, }, { nextNodeId: startNodeId, diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx index 9392f28736..7b8354f6d5 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx @@ -9,7 +9,7 @@ import BlockSelector from '../../../../block-selector' import type { Param, ParamType } from '../../types' import cn from '@/utils/classnames' import type { - DataSourceDefaultValue, + PluginDefaultValue, ToolDefaultValue, } from '@/app/components/workflow/block-selector/types' import type { ToolParameter } from '@/app/components/tools/types' @@ -50,11 +50,11 @@ const ImportFromTool: FC<Props> = ({ const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() - const handleSelectTool = useCallback((_type: BlockEnum, toolInfo?: ToolDefaultValue | DataSourceDefaultValue) => { - if (!toolInfo || 'datasource_name' in toolInfo) + const handleSelectTool = useCallback((_type: BlockEnum, toolInfo?: PluginDefaultValue) => { + if (!toolInfo || 'datasource_name' in toolInfo || !('tool_name' in toolInfo)) return - const { provider_id, provider_type, tool_name } = toolInfo + const { provider_id, provider_type, tool_name } = toolInfo as ToolDefaultValue const currentTools = (() => { switch (provider_type) { case CollectionType.builtIn: diff --git a/web/app/components/workflow/nodes/parameter-extractor/default.ts b/web/app/components/workflow/nodes/parameter-extractor/default.ts index a65306249d..5d2010122d 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/default.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/default.ts @@ -3,6 +3,7 @@ import { type ParameterExtractorNodeType, ReasoningModeType } from './types' import { genNodeMetaData } from '@/app/components/workflow/utils' import { BlockEnum } from '@/app/components/workflow/types' import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types' +import { AppModeEnum } from '@/types/app' const i18nPrefix = 'workflow' const metaData = genNodeMetaData({ @@ -17,7 +18,7 @@ const nodeDefault: NodeDefault<ParameterExtractorNodeType> = { model: { provider: '', name: '', - mode: 'chat', + mode: AppModeEnum.CHAT, completion_params: { temperature: 0.7, }, diff --git a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx index a169217609..8faebfa547 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx @@ -67,7 +67,6 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({ popupClassName='!w-[387px]' isInWorkflow isAdvancedMode={true} - mode={model?.mode} provider={model?.provider} completionParams={model?.completion_params} modelId={model?.name} diff --git a/web/app/components/workflow/nodes/parameter-extractor/use-config.ts b/web/app/components/workflow/nodes/parameter-extractor/use-config.ts index 81dace1014..676d631a8a 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/use-config.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/use-config.ts @@ -17,6 +17,7 @@ import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constant import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import { supportFunctionCall } from '@/utils/tool-call' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' +import { AppModeEnum } from '@/types/app' const useConfig = (id: string, payload: ParameterExtractorNodeType) => { const { @@ -86,13 +87,13 @@ const useConfig = (id: string, payload: ParameterExtractorNodeType) => { const model = inputs.model || { provider: '', name: '', - mode: 'chat', + mode: AppModeEnum.CHAT, completion_params: { temperature: 0.7, }, } const modelMode = inputs.model?.mode - const isChatModel = modelMode === 'chat' + const isChatModel = modelMode === AppModeEnum.CHAT const isCompletionModel = !isChatModel const { @@ -133,7 +134,7 @@ const useConfig = (id: string, payload: ParameterExtractorNodeType) => { draft.model.mode = model.mode! const isModeChange = model.mode !== inputRef.current.model?.mode if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0) - appendDefaultPromptConfig(draft, defaultConfig, model.mode === 'chat') + appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT) }) setInputs(newInputs) setModelChanged(true) diff --git a/web/app/components/workflow/nodes/question-classifier/default.ts b/web/app/components/workflow/nodes/question-classifier/default.ts index d34c854916..90ae3fd586 100644 --- a/web/app/components/workflow/nodes/question-classifier/default.ts +++ b/web/app/components/workflow/nodes/question-classifier/default.ts @@ -3,6 +3,7 @@ import type { QuestionClassifierNodeType } from './types' import { genNodeMetaData } from '@/app/components/workflow/utils' import { BlockEnum } from '@/app/components/workflow/types' import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types' +import { AppModeEnum } from '@/types/app' const i18nPrefix = 'workflow' @@ -18,7 +19,7 @@ const nodeDefault: NodeDefault<QuestionClassifierNodeType> = { model: { provider: '', name: '', - mode: 'chat', + mode: AppModeEnum.CHAT, completion_params: { temperature: 0.7, }, diff --git a/web/app/components/workflow/nodes/question-classifier/panel.tsx b/web/app/components/workflow/nodes/question-classifier/panel.tsx index 8e27f5dceb..8b6bc533f2 100644 --- a/web/app/components/workflow/nodes/question-classifier/panel.tsx +++ b/web/app/components/workflow/nodes/question-classifier/panel.tsx @@ -56,7 +56,6 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({ popupClassName='!w-[387px]' isInWorkflow isAdvancedMode={true} - mode={model?.mode} provider={model?.provider} completionParams={model.completion_params} modelId={model.name} diff --git a/web/app/components/workflow/nodes/question-classifier/use-config.ts b/web/app/components/workflow/nodes/question-classifier/use-config.ts index dc197a079e..28a6fa0314 100644 --- a/web/app/components/workflow/nodes/question-classifier/use-config.ts +++ b/web/app/components/workflow/nodes/question-classifier/use-config.ts @@ -15,6 +15,7 @@ import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/com import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants' import { useUpdateNodeInternals } from 'reactflow' +import { AppModeEnum } from '@/types/app' const useConfig = (id: string, payload: QuestionClassifierNodeType) => { const updateNodeInternals = useUpdateNodeInternals() @@ -38,7 +39,7 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => { const model = inputs.model const modelMode = inputs.model?.mode - const isChatModel = modelMode === 'chat' + const isChatModel = modelMode === AppModeEnum.CHAT const { isVisionModel, diff --git a/web/app/components/workflow/nodes/start/default.ts b/web/app/components/workflow/nodes/start/default.ts index 3b98b57a73..60584b5144 100644 --- a/web/app/components/workflow/nodes/start/default.ts +++ b/web/app/components/workflow/nodes/start/default.ts @@ -7,10 +7,10 @@ const metaData = genNodeMetaData({ sort: 0.1, type: BlockEnum.Start, isStart: true, - isRequired: true, - isUndeletable: true, + isRequired: false, isSingleton: true, - isTypeFixed: true, + isTypeFixed: false, // support node type change for start node(user input) + helpLinkUri: 'user-input', }) const nodeDefault: NodeDefault<StartNodeType> = { metaData, diff --git a/web/app/components/workflow/nodes/start/panel.tsx b/web/app/components/workflow/nodes/start/panel.tsx index 0a1efd444f..a560bd2e63 100644 --- a/web/app/components/workflow/nodes/start/panel.tsx +++ b/web/app/components/workflow/nodes/start/panel.tsx @@ -62,7 +62,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ <VarItem readonly payload={{ - variable: 'sys.query', + variable: 'userinput.query', } as any} rightContent={ <div className='text-xs font-normal text-text-tertiary'> @@ -76,7 +76,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ readonly showLegacyBadge={!isChatMode} payload={{ - variable: 'sys.files', + variable: 'userinput.files', } as any} rightContent={ <div className='text-xs font-normal text-text-tertiary'> @@ -84,80 +84,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ </div> } /> - { - isChatMode && ( - <> - <VarItem - readonly - payload={{ - variable: 'sys.dialogue_count', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - Number - </div> - } - /> - <VarItem - readonly - payload={{ - variable: 'sys.conversation_id', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - String - </div> - } - /> - </> - ) - } - <VarItem - readonly - payload={{ - variable: 'sys.user_id', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - String - </div> - } - /> - <VarItem - readonly - payload={{ - variable: 'sys.app_id', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - String - </div> - } - /> - <VarItem - readonly - payload={{ - variable: 'sys.workflow_id', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - String - </div> - } - /> - <VarItem - readonly - payload={{ - variable: 'sys.workflow_run_id', - } as any} - rightContent={ - <div className='text-xs font-normal text-text-tertiary'> - String - </div> - } - /> </div> - </> </Field> </div> diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index ec35f9a60a..fa50727123 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -20,6 +20,7 @@ type MixedVariableTextInputProps = { onChange?: (text: string) => void showManageInputField?: boolean onManageInputField?: () => void + disableVariableInsertion?: boolean } const MixedVariableTextInput = ({ readOnly = false, @@ -29,6 +30,7 @@ const MixedVariableTextInput = ({ onChange, showManageInputField, onManageInputField, + disableVariableInsertion = false, }: MixedVariableTextInputProps) => { const { t } = useTranslation() const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) @@ -37,7 +39,7 @@ const MixedVariableTextInput = ({ <PromptEditor key={controlPromptEditorRerenderKey} wrapperClassName={cn( - 'w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1', + 'min-h-8 w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1', 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', 'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs', )} @@ -45,7 +47,7 @@ const MixedVariableTextInput = ({ editable={!readOnly} value={value} workflowVariableBlock={{ - show: true, + show: !disableVariableInsertion, variables: nodesOutputVars || [], workflowNodesMap: availableNodes.reduce((acc, node) => { acc[node.id] = { @@ -63,7 +65,7 @@ const MixedVariableTextInput = ({ showManageInputField, onManageInputField, }} - placeholder={<Placeholder />} + placeholder={<Placeholder disableVariableInsertion={disableVariableInsertion} />} onChange={onChange} /> ) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx index 75d4c91996..d6e0bbc059 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx @@ -6,7 +6,11 @@ import { $insertNodes } from 'lexical' import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node' import Badge from '@/app/components/base/badge' -const Placeholder = () => { +type PlaceholderProps = { + disableVariableInsertion?: boolean +} + +const Placeholder = ({ disableVariableInsertion = false }: PlaceholderProps) => { const { t } = useTranslation() const [editor] = useLexicalComposerContext() @@ -28,17 +32,21 @@ const Placeholder = () => { > <div className='flex grow items-center'> {t('workflow.nodes.tool.insertPlaceholder1')} - <div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div> - <div - className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary' - onMouseDown={((e) => { - e.preventDefault() - e.stopPropagation() - handleInsert('/') - })} - > - {t('workflow.nodes.tool.insertPlaceholder2')} - </div> + {(!disableVariableInsertion) && ( + <> + <div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div> + <div + className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary' + onMouseDown={((e) => { + e.preventDefault() + e.stopPropagation() + handleInsert('/') + })} + > + {t('workflow.nodes.tool.insertPlaceholder2')} + </div> + </> + )} </div> <Badge className='shrink-0' diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx index 747790ac58..ade29beddb 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/index.tsx @@ -18,6 +18,7 @@ type Props = { currentProvider?: ToolWithProvider showManageInputField?: boolean onManageInputField?: () => void + extraParams?: Record<string, any> } const ToolForm: FC<Props> = ({ @@ -31,6 +32,7 @@ const ToolForm: FC<Props> = ({ currentProvider, showManageInputField, onManageInputField, + extraParams, }) => { return ( <div className='space-y-1'> @@ -48,6 +50,8 @@ const ToolForm: FC<Props> = ({ currentProvider={currentProvider} showManageInputField={showManageInputField} onManageInputField={onManageInputField} + extraParams={extraParams} + providerType='tool' /> )) } diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx index c70a039b5b..567266abde 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx @@ -26,6 +26,8 @@ type Props = { currentProvider?: ToolWithProvider showManageInputField?: boolean onManageInputField?: () => void + extraParams?: Record<string, any> + providerType?: 'tool' | 'trigger' } const ToolFormItem: FC<Props> = ({ @@ -39,6 +41,8 @@ const ToolFormItem: FC<Props> = ({ currentProvider, showManageInputField, onManageInputField, + extraParams, + providerType = 'tool', }) => { const language = useLanguage() const { name, label, type, required, tooltip, input_schema } = schema @@ -95,6 +99,8 @@ const ToolFormItem: FC<Props> = ({ currentProvider={currentProvider} showManageInputField={showManageInputField} onManageInputField={onManageInputField} + extraParams={extraParams} + providerType={providerType} /> {isShowSchema && ( diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index 8cc3ec580d..6aa483e8b0 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -1,46 +1,90 @@ import type { FC } from 'react' -import React from 'react' -import type { ToolNodeType } from './types' +import React, { useEffect } from 'react' import type { NodeProps } from '@/app/components/workflow/types' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' +import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update' +import type { ToolNodeType } from './types' const Node: FC<NodeProps<ToolNodeType>> = ({ + id, data, }) => { const { tool_configurations, paramSchemas } = data const toolConfigs = Object.keys(tool_configurations || {}) + const { + isChecking, + isMissing, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } = useNodePluginInstallation(data) + const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier + const { handleNodeDataUpdate } = useNodeDataUpdate() + const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier) - if (!toolConfigs.length) + useEffect(() => { + if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim) + return + handleNodeDataUpdate({ + id, + data: { + _pluginInstallLocked: shouldLock, + _dimmed: shouldDim, + }, + }) + }, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock]) + + const hasConfigs = toolConfigs.length > 0 + + if (!showInstallButton && !hasConfigs) return null return ( - <div className='mb-1 px-3 py-1'> - <div className='space-y-0.5'> - {toolConfigs.map((key, index) => ( - <div key={index} className='flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary'> - <div title={key} className='max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary'> - {key} + <div className='relative mb-1 px-3 py-1'> + {showInstallButton && ( + <div className='pointer-events-auto absolute right-3 top-[-32px] z-40'> + <InstallPluginButton + size='small' + className='!font-medium !text-text-accent' + extraIdentifiers={[ + data.plugin_id, + data.provider_id, + data.provider_name, + ].filter(Boolean) as string[]} + uniqueIdentifier={uniqueIdentifier!} + onSuccess={onInstallSuccess} + /> + </div> + )} + {hasConfigs && ( + <div className='space-y-0.5' aria-disabled={shouldDim}> + {toolConfigs.map((key, index) => ( + <div key={index} className='flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary'> + <div title={key} className='max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary'> + {key} + </div> + {typeof tool_configurations[key].value === 'string' && ( + <div title={tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> + {paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key].value} + </div> + )} + {typeof tool_configurations[key].value === 'number' && ( + <div title={Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> + {Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} + </div> + )} + {typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.modelSelector && ( + <div title={tool_configurations[key].model} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> + {tool_configurations[key].model} + </div> + )} </div> - {typeof tool_configurations[key].value === 'string' && ( - <div title={tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> - {paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key].value} - </div> - )} - {typeof tool_configurations[key].value === 'number' && ( - <div title={Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> - {Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} - </div> - )} - {typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.modelSelector && ( - <div title={tool_configurations[key].model} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'> - {tool_configurations[key].model} - </div> - )} - </div> - - ))} - - </div> + ))} + </div> + )} </div> ) } diff --git a/web/app/components/workflow/nodes/tool/types.ts b/web/app/components/workflow/nodes/tool/types.ts index 8bed5076d3..6e6ef858dc 100644 --- a/web/app/components/workflow/nodes/tool/types.ts +++ b/web/app/components/workflow/nodes/tool/types.ts @@ -1,16 +1,10 @@ -import type { CollectionType } from '@/app/components/tools/types' -import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types' +import type { Collection, CollectionType } from '@/app/components/tools/types' +import type { CommonNodeType } from '@/app/components/workflow/types' +import type { ResourceVarInputs } from '../_base/types' -export enum VarType { - variable = 'variable', - constant = 'constant', - mixed = 'mixed', -} - -export type ToolVarInputs = Record<string, { - type: VarType - value?: string | ValueSelector | any -}> +// Use base types directly +export { VarKindType as VarType } from '../_base/types' +export type ToolVarInputs = ResourceVarInputs export type ToolNodeType = CommonNodeType & { provider_id: string @@ -26,4 +20,7 @@ export type ToolNodeType = CommonNodeType & { tool_description?: string is_team_authorization?: boolean params?: Record<string, any> + plugin_id?: string + provider_icon?: Collection['icon'] + plugin_unique_identifier?: string } diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx new file mode 100644 index 0000000000..93bf788c34 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx @@ -0,0 +1,57 @@ +'use client' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { Event } from '@/app/components/tools/types' +import type { FC } from 'react' +import type { PluginTriggerVarInputs } from '@/app/components/workflow/nodes/trigger-plugin/types' +import TriggerFormItem from './item' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' + +type Props = { + readOnly: boolean + nodeId: string + schema: CredentialFormSchema[] + value: PluginTriggerVarInputs + onChange: (value: PluginTriggerVarInputs) => void + onOpen?: (index: number) => void + inPanel?: boolean + currentEvent?: Event + currentProvider?: TriggerWithProvider + extraParams?: Record<string, any> + disableVariableInsertion?: boolean +} + +const TriggerForm: FC<Props> = ({ + readOnly, + nodeId, + schema, + value, + onChange, + inPanel, + currentEvent, + currentProvider, + extraParams, + disableVariableInsertion = false, +}) => { + return ( + <div className='space-y-1'> + { + schema.map((schema, index) => ( + <TriggerFormItem + key={index} + readOnly={readOnly} + nodeId={nodeId} + schema={schema} + value={value} + onChange={onChange} + inPanel={inPanel} + currentEvent={currentEvent} + currentProvider={currentProvider} + extraParams={extraParams} + disableVariableInsertion={disableVariableInsertion} + /> + )) + } + </div> + ) +} +export default TriggerForm diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx new file mode 100644 index 0000000000..678c12f02a --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx @@ -0,0 +1,112 @@ +'use client' +import type { FC } from 'react' +import { + RiBracesLine, +} from '@remixicon/react' +import type { PluginTriggerVarInputs } from '@/app/components/workflow/nodes/trigger-plugin/types' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import Button from '@/app/components/base/button' +import Tooltip from '@/app/components/base/tooltip' +import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item' +import { useBoolean } from 'ahooks' +import SchemaModal from '@/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal' +import type { Event } from '@/app/components/tools/types' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' + +type Props = { + readOnly: boolean + nodeId: string + schema: CredentialFormSchema + value: PluginTriggerVarInputs + onChange: (value: PluginTriggerVarInputs) => void + inPanel?: boolean + currentEvent?: Event + currentProvider?: TriggerWithProvider + extraParams?: Record<string, any> + disableVariableInsertion?: boolean +} + +const TriggerFormItem: FC<Props> = ({ + readOnly, + nodeId, + schema, + value, + onChange, + inPanel, + currentEvent, + currentProvider, + extraParams, + disableVariableInsertion = false, +}) => { + const language = useLanguage() + const { name, label, type, required, tooltip, input_schema } = schema + const showSchemaButton = type === FormTypeEnum.object || type === FormTypeEnum.array + const showDescription = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput + const [isShowSchema, { + setTrue: showSchema, + setFalse: hideSchema, + }] = useBoolean(false) + return ( + <div className='space-y-0.5 py-1'> + <div> + <div className='flex h-6 items-center'> + <div className='system-sm-medium text-text-secondary'>{label[language] || label.en_US}</div> + {required && ( + <div className='system-xs-regular ml-1 text-text-destructive-secondary'>*</div> + )} + {!showDescription && tooltip && ( + <Tooltip + popupContent={<div className='w-[200px]'> + {tooltip[language] || tooltip.en_US} + </div>} + triggerClassName='ml-1 w-4 h-4' + asChild={false} + /> + )} + {showSchemaButton && ( + <> + <div className='system-xs-regular ml-1 mr-0.5 text-text-quaternary'>·</div> + <Button + variant='ghost' + size='small' + onClick={showSchema} + className='system-xs-regular px-1 text-text-tertiary' + > + <RiBracesLine className='mr-1 size-3.5' /> + <span>JSON Schema</span> + </Button> + </> + )} + </div> + {showDescription && tooltip && ( + <div className='body-xs-regular pb-0.5 text-text-tertiary'>{tooltip[language] || tooltip.en_US}</div> + )} + </div> + <FormInputItem + readOnly={readOnly} + nodeId={nodeId} + schema={schema} + value={value} + onChange={onChange} + inPanel={inPanel} + currentTool={currentEvent} + currentProvider={currentProvider} + providerType='trigger' + extraParams={extraParams} + disableVariableInsertion={disableVariableInsertion} + /> + + {isShowSchema && ( + <SchemaModal + isShow + onClose={hideSchema} + rootName={name} + schema={input_schema!} + /> + )} + </div> + ) +} +export default TriggerFormItem diff --git a/web/app/components/workflow/nodes/trigger-plugin/default.ts b/web/app/components/workflow/nodes/trigger-plugin/default.ts new file mode 100644 index 0000000000..928534e07c --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/default.ts @@ -0,0 +1,297 @@ +import type { SchemaTypeDefinition } from '@/service/use-common' +import type { NodeDefault, Var } from '../../types' +import { BlockEnum, VarType } from '../../types' +import { genNodeMetaData } from '../../utils' +import { VarKindType } from '../_base/types' +import { type Field, type StructuredOutput, Type } from '../llm/types' +import type { PluginTriggerNodeType } from './types' + +const normalizeJsonSchemaType = (schema: any): string | undefined => { + if (!schema) return undefined + const { type, properties, items, oneOf, anyOf, allOf } = schema + + if (Array.isArray(type)) + return type.find((item: string | null) => item && item !== 'null') || type[0] + + if (typeof type === 'string') + return type + + const compositeCandidates = [oneOf, anyOf, allOf] + .filter((entry): entry is any[] => Array.isArray(entry)) + .flat() + + for (const candidate of compositeCandidates) { + const normalized = normalizeJsonSchemaType(candidate) + if (normalized) + return normalized + } + + if (properties) + return 'object' + + if (items) + return 'array' + + return undefined +} + +const pickItemSchema = (schema: any) => { + if (!schema || !schema.items) + return undefined + return Array.isArray(schema.items) ? schema.items[0] : schema.items +} + +const extractSchemaType = (schema: any, _schemaTypeDefinitions?: SchemaTypeDefinition[]): string | undefined => { + if (!schema) + return undefined + + const schemaTypeFromSchema = schema.schema_type || schema.schemaType + if (typeof schemaTypeFromSchema === 'string' && schemaTypeFromSchema.trim().length > 0) + return schemaTypeFromSchema + + return undefined +} + +const resolveVarType = ( + schema: any, + schemaTypeDefinitions?: SchemaTypeDefinition[], +): { type: VarType; schemaType?: string } => { + const schemaType = extractSchemaType(schema, schemaTypeDefinitions) + const normalizedType = normalizeJsonSchemaType(schema) + + switch (normalizedType) { + case 'string': + return { type: VarType.string, schemaType } + case 'number': + return { type: VarType.number, schemaType } + case 'integer': + return { type: VarType.integer, schemaType } + case 'boolean': + return { type: VarType.boolean, schemaType } + case 'object': + return { type: VarType.object, schemaType } + case 'array': { + const itemSchema = pickItemSchema(schema) + if (!itemSchema) + return { type: VarType.array, schemaType } + + const { type: itemType, schemaType: itemSchemaType } = resolveVarType(itemSchema, schemaTypeDefinitions) + const resolvedSchemaType = schemaType || itemSchemaType + + if (itemSchemaType === 'file') + return { type: VarType.arrayFile, schemaType: resolvedSchemaType } + + switch (itemType) { + case VarType.string: + return { type: VarType.arrayString, schemaType: resolvedSchemaType } + case VarType.number: + case VarType.integer: + return { type: VarType.arrayNumber, schemaType: resolvedSchemaType } + case VarType.boolean: + return { type: VarType.arrayBoolean, schemaType: resolvedSchemaType } + case VarType.object: + return { type: VarType.arrayObject, schemaType: resolvedSchemaType } + case VarType.file: + return { type: VarType.arrayFile, schemaType: resolvedSchemaType } + default: + return { type: VarType.array, schemaType: resolvedSchemaType } + } + } + default: + return { type: VarType.any, schemaType } + } +} + +const toFieldType = (normalizedType: string | undefined, schemaType?: string): Type => { + if (schemaType === 'file') + return normalizedType === 'array' ? Type.array : Type.file + + switch (normalizedType) { + case 'number': + case 'integer': + return Type.number + case 'boolean': + return Type.boolean + case 'object': + return Type.object + case 'array': + return Type.array + case 'string': + default: + return Type.string + } +} + +const toArrayItemType = (type: Type): Exclude<Type, Type.array> => { + if (type === Type.array) + return Type.object + return type as Exclude<Type, Type.array> +} + +const convertJsonSchemaToField = (schema: any, schemaTypeDefinitions?: SchemaTypeDefinition[]): Field => { + const schemaType = extractSchemaType(schema, schemaTypeDefinitions) + const normalizedType = normalizeJsonSchemaType(schema) + const fieldType = toFieldType(normalizedType, schemaType) + + const field: Field = { + type: fieldType, + } + + if (schema?.description) + field.description = schema.description + + if (schemaType) + field.schemaType = schemaType + + if (Array.isArray(schema?.enum)) + field.enum = schema.enum + + if (fieldType === Type.object) { + const properties = schema?.properties || {} + field.properties = Object.entries(properties).reduce((acc, [key, value]) => { + acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions) + return acc + }, {} as Record<string, Field>) + + const required = Array.isArray(schema?.required) ? schema.required.filter(Boolean) : undefined + field.required = required && required.length > 0 ? required : undefined + field.additionalProperties = false + } + + if (fieldType === Type.array) { + const itemSchema = pickItemSchema(schema) + if (itemSchema) { + const itemField = convertJsonSchemaToField(itemSchema, schemaTypeDefinitions) + const { type, ...rest } = itemField + field.items = { + ...rest, + type: toArrayItemType(type), + } + } + } + + return field +} + +const buildOutputVars = (schema: Record<string, any>, schemaTypeDefinitions?: SchemaTypeDefinition[]): Var[] => { + if (!schema || typeof schema !== 'object') + return [] + + const properties = schema.properties as Record<string, any> | undefined + if (!properties) + return [] + + return Object.entries(properties).map(([name, propertySchema]) => { + const { type, schemaType } = resolveVarType(propertySchema, schemaTypeDefinitions) + const normalizedType = normalizeJsonSchemaType(propertySchema) + + const varItem: Var = { + variable: name, + type, + des: propertySchema?.description, + ...(schemaType ? { schemaType } : {}), + } + + if (normalizedType === 'object') { + const childProperties = propertySchema?.properties + ? Object.entries(propertySchema.properties).reduce((acc, [key, value]) => { + acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions) + return acc + }, {} as Record<string, Field>) + : {} + + const required = Array.isArray(propertySchema?.required) ? propertySchema.required.filter(Boolean) : undefined + + varItem.children = { + schema: { + type: Type.object, + properties: childProperties, + required: required && required.length > 0 ? required : undefined, + additionalProperties: false, + }, + } as StructuredOutput + } + + return varItem + }) +} + +const metaData = genNodeMetaData({ + sort: 1, + type: BlockEnum.TriggerPlugin, + helpLinkUri: 'plugin-trigger', + isStart: true, +}) + +const nodeDefault: NodeDefault<PluginTriggerNodeType> = { + metaData, + defaultValue: { + plugin_id: '', + event_name: '', + event_parameters: {}, + // event_type: '', + config: {}, + }, + checkValid(payload: PluginTriggerNodeType, t: any, moreDataForCheckValid: { + triggerInputsSchema?: Array<{ + variable: string + label: string + required?: boolean + }> + isReadyForCheckValid?: boolean + } = {}) { + let errorMessage = '' + + if (!payload.subscription_id) + errorMessage = t('workflow.nodes.triggerPlugin.subscriptionRequired') + + const { + triggerInputsSchema = [], + isReadyForCheckValid = true, + } = moreDataForCheckValid || {} + + if (!errorMessage && isReadyForCheckValid) { + triggerInputsSchema.filter(field => field.required).forEach((field) => { + if (errorMessage) + return + + const rawParam = payload.event_parameters?.[field.variable] + ?? (payload.config as Record<string, any> | undefined)?.[field.variable] + if (!rawParam) { + errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label }) + return + } + + const targetParam = typeof rawParam === 'object' && rawParam !== null && 'type' in rawParam + ? rawParam as { type: VarKindType; value: any } + : { type: VarKindType.constant, value: rawParam } + + const { type, value } = targetParam + if (type === VarKindType.variable) { + if (!value || (Array.isArray(value) && value.length === 0)) + errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label }) + } + else { + if ( + value === undefined + || value === null + || value === '' + || (Array.isArray(value) && value.length === 0) + ) + errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label }) + } + }) + } + + return { + isValid: !errorMessage, + errorMessage, + } + }, + getOutputVars(payload, _allPluginInfoList, _ragVars, { schemaTypeDefinitions } = { schemaTypeDefinitions: [] }) { + const schema = payload.output_schema || {} + return buildOutputVars(schema, schemaTypeDefinitions) + }, +} + +export default nodeDefault diff --git a/web/app/components/workflow/nodes/trigger-plugin/hooks/use-trigger-auth-flow.ts b/web/app/components/workflow/nodes/trigger-plugin/hooks/use-trigger-auth-flow.ts new file mode 100644 index 0000000000..983b8512de --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/hooks/use-trigger-auth-flow.ts @@ -0,0 +1,162 @@ +import { useCallback, useState } from 'react' +import { + useBuildTriggerSubscription, + useCreateTriggerSubscriptionBuilder, + useUpdateTriggerSubscriptionBuilder, + useVerifyTriggerSubscriptionBuilder, +} from '@/service/use-triggers' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' + +// Helper function to serialize complex values to strings for backend encryption +const serializeFormValues = (values: Record<string, any>): Record<string, string> => { + const result: Record<string, string> = {} + + for (const [key, value] of Object.entries(values)) { + if (value === null || value === undefined) + result[key] = '' + else if (typeof value === 'object') + result[key] = JSON.stringify(value) + else + result[key] = String(value) + } + + return result +} + +export type AuthFlowStep = 'auth' | 'params' | 'complete' + +export type AuthFlowState = { + step: AuthFlowStep + builderId: string + isLoading: boolean + error: string | null +} + +export type AuthFlowActions = { + startAuth: () => Promise<void> + verifyAuth: (credentials: Record<string, any>) => Promise<void> + completeConfig: (parameters: Record<string, any>, properties?: Record<string, any>, name?: string) => Promise<void> + reset: () => void +} + +export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState & AuthFlowActions => { + const [step, setStep] = useState<AuthFlowStep>('auth') + const [builderId, setBuilderId] = useState<string>('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState<string | null>(null) + + const createBuilder = useCreateTriggerSubscriptionBuilder() + const updateBuilder = useUpdateTriggerSubscriptionBuilder() + const verifyBuilder = useVerifyTriggerSubscriptionBuilder() + const buildSubscription = useBuildTriggerSubscription() + + const startAuth = useCallback(async () => { + if (builderId) return // Prevent multiple calls if already started + + setIsLoading(true) + setError(null) + + try { + const response = await createBuilder.mutateAsync({ + provider: provider.name, + }) + setBuilderId(response.subscription_builder.id) + setStep('auth') + } + catch (err: any) { + setError(err.message || 'Failed to start authentication flow') + throw err + } + finally { + setIsLoading(false) + } + }, [provider.name, createBuilder, builderId]) + + const verifyAuth = useCallback(async (credentials: Record<string, any>) => { + if (!builderId) { + setError('No builder ID available') + return + } + + setIsLoading(true) + setError(null) + + try { + await updateBuilder.mutateAsync({ + provider: provider.name, + subscriptionBuilderId: builderId, + credentials: serializeFormValues(credentials), + }) + + await verifyBuilder.mutateAsync({ + provider: provider.name, + subscriptionBuilderId: builderId, + }) + + setStep('params') + } + catch (err: any) { + setError(err.message || 'Authentication verification failed') + throw err + } + finally { + setIsLoading(false) + } + }, [provider.name, builderId, updateBuilder, verifyBuilder]) + + const completeConfig = useCallback(async ( + parameters: Record<string, any>, + properties: Record<string, any> = {}, + name?: string, + ) => { + if (!builderId) { + setError('No builder ID available') + return + } + + setIsLoading(true) + setError(null) + + try { + await updateBuilder.mutateAsync({ + provider: provider.name, + subscriptionBuilderId: builderId, + parameters: serializeFormValues(parameters), + properties: serializeFormValues(properties), + name, + }) + + await buildSubscription.mutateAsync({ + provider: provider.name, + subscriptionBuilderId: builderId, + }) + + setStep('complete') + } + catch (err: any) { + setError(err.message || 'Configuration failed') + throw err + } + finally { + setIsLoading(false) + } + }, [provider.name, builderId, updateBuilder, buildSubscription]) + + const reset = useCallback(() => { + setStep('auth') + setBuilderId('') + setIsLoading(false) + setError(null) + }, []) + + return { + step, + builderId, + isLoading, + error, + startAuth, + verifyAuth, + completeConfig, + reset, + } +} diff --git a/web/app/components/workflow/nodes/trigger-plugin/node.tsx b/web/app/components/workflow/nodes/trigger-plugin/node.tsx new file mode 100644 index 0000000000..0eee4cb8b4 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/node.tsx @@ -0,0 +1,126 @@ +import NodeStatus, { NodeStatusEnum } from '@/app/components/base/node-status' +import type { NodeProps } from '@/app/components/workflow/types' +import type { FC } from 'react' +import React, { useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' +import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update' +import type { PluginTriggerNodeType } from './types' +import useConfig from './use-config' + +const formatConfigValue = (rawValue: any): string => { + if (rawValue === null || rawValue === undefined) + return '' + + if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') + return String(rawValue) + + if (Array.isArray(rawValue)) + return rawValue.join('.') + + if (typeof rawValue === 'object') { + const { value } = rawValue as { value?: any } + if (value === null || value === undefined) + return '' + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') + return String(value) + if (Array.isArray(value)) + return value.join('.') + try { + return JSON.stringify(value) + } + catch { + return '' + } + } + + return '' +} + +const Node: FC<NodeProps<PluginTriggerNodeType>> = ({ + id, + data, +}) => { + const { subscriptions } = useConfig(id, data) + const { config = {}, subscription_id } = data + const configKeys = Object.keys(config) + const { + isChecking, + isMissing, + uniqueIdentifier, + canInstall, + onInstallSuccess, + shouldDim, + } = useNodePluginInstallation(data) + const { handleNodeDataUpdate } = useNodeDataUpdate() + const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier + const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier) + + useEffect(() => { + if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim) + return + handleNodeDataUpdate({ + id, + data: { + _pluginInstallLocked: shouldLock, + _dimmed: shouldDim, + }, + }) + }, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock]) + + const { t } = useTranslation() + + const isValidSubscription = useMemo(() => { + return subscription_id && subscriptions?.some(sub => sub.id === subscription_id) + }, [subscription_id, subscriptions]) + + return ( + <div className="relative mb-1 px-3 py-1"> + {showInstallButton && ( + <div className="pointer-events-auto absolute right-3 top-[-32px] z-40"> + <InstallPluginButton + size="small" + extraIdentifiers={[ + data.plugin_id, + data.provider_id, + data.provider_name, + ].filter(Boolean) as string[]} + className="!font-medium !text-text-accent" + uniqueIdentifier={uniqueIdentifier!} + onSuccess={onInstallSuccess} + /> + </div> + )} + <div className="space-y-0.5" aria-disabled={shouldDim}> + {!isValidSubscription && <NodeStatus status={NodeStatusEnum.warning} message={t('pluginTrigger.node.status.warning')} />} + {isValidSubscription && configKeys.map((key, index) => ( + <div + key={index} + className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary" + > + <div + title={key} + className="max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary" + > + {key} + </div> + <div + title={formatConfigValue(config[key])} + className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary" + > + {(() => { + const displayValue = formatConfigValue(config[key]) + if (displayValue.includes('secret')) + return '********' + return displayValue + })()} + </div> + </div> + ))} + </div> + </div> + ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-plugin/panel.tsx b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx new file mode 100644 index 0000000000..9b4d8058b1 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx @@ -0,0 +1,94 @@ +import type { FC } from 'react' +import React from 'react' +import type { PluginTriggerNodeType } from './types' +import Split from '@/app/components/workflow/nodes/_base/components/split' +import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' +import type { NodePanelProps } from '@/app/components/workflow/types' +import useConfig from './use-config' +import TriggerForm from './components/trigger-form' +import StructureOutputItem from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show' +import { Type } from '../llm/types' +import { BlockEnum } from '@/app/components/workflow/types' + +const Panel: FC<NodePanelProps<PluginTriggerNodeType>> = ({ + id, + data, +}) => { + const { + readOnly, + triggerParameterSchema, + triggerParameterValue, + setTriggerParameterValue, + outputSchema, + hasObjectOutput, + currentProvider, + currentEvent, + subscriptionSelected, + } = useConfig(id, data) + const disableVariableInsertion = data.type === BlockEnum.TriggerPlugin + + // Convert output schema to VarItem format + const outputVars = Object.entries(outputSchema.properties || {}).map(([name, schema]: [string, any]) => ({ + name, + type: schema.type || 'string', + description: schema.description || '', + })) + + return ( + <div className='mt-2'> + {/* Dynamic Parameters Form - Only show when authenticated */} + {triggerParameterSchema.length > 0 && subscriptionSelected && ( + <> + <div className='px-4 pb-4'> + <TriggerForm + readOnly={readOnly} + nodeId={id} + schema={triggerParameterSchema as any} + value={triggerParameterValue} + onChange={setTriggerParameterValue} + currentProvider={currentProvider} + currentEvent={currentEvent} + disableVariableInsertion={disableVariableInsertion} + /> + </div> + <Split /> + </> + )} + + {/* Output Variables - Always show */} + <OutputVars> + <> + {outputVars.map(varItem => ( + <VarItem + key={varItem.name} + name={varItem.name} + type={varItem.type} + description={varItem.description} + isIndent={hasObjectOutput} + /> + ))} + {Object.entries(outputSchema.properties || {}).map(([name, schema]: [string, any]) => ( + <div key={name}> + {schema.type === 'object' ? ( + <StructureOutputItem + rootClassName='code-sm-semibold text-text-secondary' + payload={{ + schema: { + type: Type.object, + properties: { + [name]: schema, + }, + additionalProperties: false, + }, + }} + /> + ) : null} + </div> + ))} + </> + </OutputVars> + </div> + ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/trigger-plugin/types.ts b/web/app/components/workflow/nodes/trigger-plugin/types.ts new file mode 100644 index 0000000000..6dba97d795 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/types.ts @@ -0,0 +1,24 @@ +import type { CommonNodeType } from '@/app/components/workflow/types' +import type { CollectionType } from '@/app/components/tools/types' +import type { ResourceVarInputs } from '../_base/types' + +export type PluginTriggerNodeType = CommonNodeType & { + provider_id: string + provider_type: CollectionType + provider_name: string + event_name: string + event_label: string + event_parameters: PluginTriggerVarInputs + event_configurations: Record<string, any> + output_schema: Record<string, any> + parameters_schema?: Record<string, any>[] + version?: string + event_node_version?: string + plugin_id?: string + config?: Record<string, any> + plugin_unique_identifier?: string +} + +// Use base types directly +export { VarKindType as PluginTriggerVarType } from '../_base/types' +export type PluginTriggerVarInputs = ResourceVarInputs diff --git a/web/app/components/workflow/nodes/trigger-plugin/use-check-params.ts b/web/app/components/workflow/nodes/trigger-plugin/use-check-params.ts new file mode 100644 index 0000000000..16b763f11a --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/use-check-params.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react' +import type { PluginTriggerNodeType } from './types' +import { useAllTriggerPlugins } from '@/service/use-triggers' +import { useGetLanguage } from '@/context/i18n' +import { getTriggerCheckParams } from '@/app/components/workflow/utils/trigger' + +type Params = { + id: string + payload: PluginTriggerNodeType +} + +const useGetDataForCheckMore = ({ + payload, +}: Params) => { + const { data: triggerPlugins } = useAllTriggerPlugins() + const language = useGetLanguage() + + const getData = useCallback(() => { + return getTriggerCheckParams(payload, triggerPlugins, language) + }, [payload, triggerPlugins, language]) + + return { + getData, + } +} + +export default useGetDataForCheckMore diff --git a/web/app/components/workflow/nodes/trigger-plugin/use-config.ts b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts new file mode 100644 index 0000000000..cf66913e58 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { produce } from 'immer' +import type { PluginTriggerNodeType } from './types' +import type { PluginTriggerVarInputs } from './types' +import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' +import { + useAllTriggerPlugins, + useTriggerSubscriptions, +} from '@/service/use-triggers' +import { + getConfiguredValue, + toolParametersToFormSchemas, +} from '@/app/components/tools/utils/to-form-schema' +import type { InputVar } from '@/app/components/workflow/types' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' +import type { Event } from '@/app/components/tools/types' +import { VarKindType } from '../_base/types' + +const normalizeEventParameters = ( + params: PluginTriggerVarInputs | Record<string, unknown> | null | undefined, + { allowScalars = false }: { allowScalars?: boolean } = {}, +): PluginTriggerVarInputs => { + if (!params || typeof params !== 'object' || Array.isArray(params)) + return {} as PluginTriggerVarInputs + + return Object.entries(params).reduce((acc, [key, entry]) => { + if (!entry && entry !== 0 && entry !== false) + return acc + + if ( + typeof entry === 'object' + && !Array.isArray(entry) + && 'type' in entry + && 'value' in entry + ) { + const normalizedEntry = { ...(entry as PluginTriggerVarInputs[string]) } + if (normalizedEntry.type === VarKindType.mixed) + normalizedEntry.type = VarKindType.constant + acc[key] = normalizedEntry + return acc + } + + if (!allowScalars) + return acc + + if (typeof entry === 'string') { + acc[key] = { + type: VarKindType.constant, + value: entry, + } + return acc + } + + if (typeof entry === 'number' || typeof entry === 'boolean') { + acc[key] = { + type: VarKindType.constant, + value: entry, + } + return acc + } + + if (Array.isArray(entry) && entry.every(item => typeof item === 'string')) { + acc[key] = { + type: VarKindType.variable, + value: entry, + } + } + + return acc + }, {} as PluginTriggerVarInputs) +} + +const useConfig = (id: string, payload: PluginTriggerNodeType) => { + const { nodesReadOnly: readOnly } = useNodesReadOnly() + const { data: triggerPlugins = [] } = useAllTriggerPlugins() + + const { inputs, setInputs: doSetInputs } = useNodeCrud<PluginTriggerNodeType>( + id, + payload, + ) + + const { + provider_id, + provider_name, + event_name, + config = {}, + event_parameters: rawEventParameters = {}, + subscription_id, + } = inputs + + const event_parameters = useMemo( + () => normalizeEventParameters(rawEventParameters as PluginTriggerVarInputs), + [rawEventParameters], + ) + const legacy_config_parameters = useMemo( + () => normalizeEventParameters(config as PluginTriggerVarInputs, { allowScalars: true }), + [config], + ) + + const currentProvider = useMemo<TriggerWithProvider | undefined>(() => { + return triggerPlugins.find( + provider => + provider.name === provider_name + || provider.id === provider_id + || (provider_id && provider.plugin_id === provider_id), + ) + }, [triggerPlugins, provider_name, provider_id]) + + const { data: subscriptions = [] } = useTriggerSubscriptions(provider_id || '') + + const subscriptionSelected = useMemo(() => { + return subscriptions?.find(s => s.id === subscription_id) + }, [subscriptions, subscription_id]) + + const currentEvent = useMemo<Event | undefined>(() => { + return currentProvider?.events.find( + event => event.name === event_name, + ) + }, [currentProvider, event_name]) + + // Dynamic trigger parameters (from specific trigger.parameters) + const triggerSpecificParameterSchema = useMemo(() => { + if (!currentEvent) return [] + return toolParametersToFormSchemas(currentEvent.parameters) + }, [currentEvent]) + + // Combined parameter schema (subscription + trigger specific) + const triggerParameterSchema = useMemo(() => { + const schemaMap = new Map() + + triggerSpecificParameterSchema.forEach((schema) => { + schemaMap.set(schema.variable || schema.name, schema) + }) + + return Array.from(schemaMap.values()) + }, [triggerSpecificParameterSchema]) + + const triggerParameterValue = useMemo(() => { + if (!triggerParameterSchema.length) + return {} as PluginTriggerVarInputs + + const hasStoredParameters = event_parameters && Object.keys(event_parameters).length > 0 + const baseValue = hasStoredParameters ? event_parameters : legacy_config_parameters + + const configuredValue = getConfiguredValue(baseValue, triggerParameterSchema) as PluginTriggerVarInputs + return normalizeEventParameters(configuredValue) + }, [triggerParameterSchema, event_parameters, legacy_config_parameters]) + + useEffect(() => { + if (!triggerParameterSchema.length) + return + + if (event_parameters && Object.keys(event_parameters).length > 0) + return + + if (!triggerParameterValue || Object.keys(triggerParameterValue).length === 0) + return + + const newInputs = produce(inputs, (draft) => { + draft.event_parameters = triggerParameterValue + draft.config = triggerParameterValue + }) + doSetInputs(newInputs) + }, [ + doSetInputs, + event_parameters, + inputs, + triggerParameterSchema, + triggerParameterValue, + ]) + + const setTriggerParameterValue = useCallback( + (value: PluginTriggerVarInputs) => { + const sanitizedValue = normalizeEventParameters(value) + const newInputs = produce(inputs, (draft) => { + draft.event_parameters = sanitizedValue + draft.config = sanitizedValue + }) + doSetInputs(newInputs) + }, + [inputs, doSetInputs], + ) + + const setInputVar = useCallback( + (variable: InputVar, varDetail: InputVar) => { + const newInputs = produce(inputs, (draft) => { + const nextEventParameters = normalizeEventParameters({ + ...draft.event_parameters, + [variable.variable]: { + type: VarKindType.variable, + value: varDetail.variable, + }, + } as PluginTriggerVarInputs) + + draft.event_parameters = nextEventParameters + draft.config = nextEventParameters + }) + doSetInputs(newInputs) + }, + [inputs, doSetInputs], + ) + + // Get output schema + const outputSchema = useMemo(() => { + return currentEvent?.output_schema || {} + }, [currentEvent]) + + // Check if trigger has complex output structure + const hasObjectOutput = useMemo(() => { + const properties = outputSchema.properties || {} + return Object.values(properties).some( + (prop: any) => prop.type === 'object', + ) + }, [outputSchema]) + + return { + readOnly, + inputs, + currentProvider, + currentEvent, + triggerParameterSchema, + triggerParameterValue, + setTriggerParameterValue, + setInputVar, + outputSchema, + hasObjectOutput, + subscriptions, + subscriptionSelected, + } +} + +export default useConfig diff --git a/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts b/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts new file mode 100644 index 0000000000..c75ffc0a59 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts @@ -0,0 +1,308 @@ +import { deepSanitizeFormValues, findMissingRequiredField, sanitizeFormValues } from '../form-helpers' + +describe('Form Helpers', () => { + describe('sanitizeFormValues', () => { + it('should convert null values to empty strings', () => { + const input = { field1: null, field2: 'value', field3: undefined } + const result = sanitizeFormValues(input) + + expect(result).toEqual({ + field1: '', + field2: 'value', + field3: '', + }) + }) + + it('should convert undefined values to empty strings', () => { + const input = { field1: undefined, field2: 'test' } + const result = sanitizeFormValues(input) + + expect(result).toEqual({ + field1: '', + field2: 'test', + }) + }) + + it('should convert non-string values to strings', () => { + const input = { number: 123, boolean: true, string: 'test' } + const result = sanitizeFormValues(input) + + expect(result).toEqual({ + number: '123', + boolean: 'true', + string: 'test', + }) + }) + + it('should handle empty objects', () => { + const result = sanitizeFormValues({}) + expect(result).toEqual({}) + }) + + it('should handle objects with mixed value types', () => { + const input = { + null_field: null, + undefined_field: undefined, + zero: 0, + false_field: false, + empty_string: '', + valid_string: 'test', + } + const result = sanitizeFormValues(input) + + expect(result).toEqual({ + null_field: '', + undefined_field: '', + zero: '0', + false_field: 'false', + empty_string: '', + valid_string: 'test', + }) + }) + }) + + describe('deepSanitizeFormValues', () => { + it('should handle nested objects', () => { + const input = { + level1: { + field1: null, + field2: 'value', + level2: { + field3: undefined, + field4: 'nested', + }, + }, + simple: 'test', + } + const result = deepSanitizeFormValues(input) + + expect(result).toEqual({ + level1: { + field1: '', + field2: 'value', + level2: { + field3: '', + field4: 'nested', + }, + }, + simple: 'test', + }) + }) + + it('should handle arrays correctly', () => { + const input = { + array: [1, 2, 3], + nested: { + array: ['a', null, 'c'], + }, + } + const result = deepSanitizeFormValues(input) + + expect(result).toEqual({ + array: [1, 2, 3], + nested: { + array: ['a', null, 'c'], + }, + }) + }) + + it('should handle null and undefined at root level', () => { + const input = { + null_field: null, + undefined_field: undefined, + nested: { + null_nested: null, + }, + } + const result = deepSanitizeFormValues(input) + + expect(result).toEqual({ + null_field: '', + undefined_field: '', + nested: { + null_nested: '', + }, + }) + }) + + it('should handle deeply nested structures', () => { + const input = { + level1: { + level2: { + level3: { + field: null, + }, + }, + }, + } + const result = deepSanitizeFormValues(input) + + expect(result).toEqual({ + level1: { + level2: { + level3: { + field: '', + }, + }, + }, + }) + }) + + it('should preserve non-null values in nested structures', () => { + const input = { + config: { + client_id: 'valid_id', + client_secret: null, + options: { + timeout: 5000, + enabled: true, + message: undefined, + }, + }, + } + const result = deepSanitizeFormValues(input) + + expect(result).toEqual({ + config: { + client_id: 'valid_id', + client_secret: '', + options: { + timeout: 5000, + enabled: true, + message: '', + }, + }, + }) + }) + }) + + describe('findMissingRequiredField', () => { + const requiredFields = [ + { name: 'client_id', label: 'Client ID' }, + { name: 'client_secret', label: 'Client Secret' }, + { name: 'scope', label: 'Scope' }, + ] + + it('should return null when all required fields are present', () => { + const formData = { + client_id: 'test_id', + client_secret: 'test_secret', + scope: 'read', + optional_field: 'optional', + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toBeNull() + }) + + it('should return the first missing field', () => { + const formData = { + client_id: 'test_id', + scope: 'read', + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toEqual({ name: 'client_secret', label: 'Client Secret' }) + }) + + it('should treat empty strings as missing fields', () => { + const formData = { + client_id: '', + client_secret: 'test_secret', + scope: 'read', + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toEqual({ name: 'client_id', label: 'Client ID' }) + }) + + it('should treat null values as missing fields', () => { + const formData = { + client_id: 'test_id', + client_secret: null, + scope: 'read', + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toEqual({ name: 'client_secret', label: 'Client Secret' }) + }) + + it('should treat undefined values as missing fields', () => { + const formData = { + client_id: 'test_id', + client_secret: 'test_secret', + scope: undefined, + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toEqual({ name: 'scope', label: 'Scope' }) + }) + + it('should handle empty required fields array', () => { + const formData = { + client_id: 'test_id', + } + + const result = findMissingRequiredField(formData, []) + expect(result).toBeNull() + }) + + it('should handle empty form data', () => { + const result = findMissingRequiredField({}, requiredFields) + expect(result).toEqual({ name: 'client_id', label: 'Client ID' }) + }) + + it('should handle multilingual labels', () => { + const multilingualFields = [ + { name: 'field1', label: { en_US: 'Field 1 EN', zh_Hans: 'Field 1 CN' } }, + ] + const formData = {} + + const result = findMissingRequiredField(formData, multilingualFields) + expect(result).toEqual({ + name: 'field1', + label: { en_US: 'Field 1 EN', zh_Hans: 'Field 1 CN' }, + }) + }) + + it('should return null for form data with extra fields', () => { + const formData = { + client_id: 'test_id', + client_secret: 'test_secret', + scope: 'read', + extra_field1: 'extra1', + extra_field2: 'extra2', + } + + const result = findMissingRequiredField(formData, requiredFields) + expect(result).toBeNull() + }) + }) + + describe('Edge cases', () => { + it('should handle objects with non-string keys', () => { + const input = { [Symbol('test')]: 'value', regular: 'field' } as any + const result = sanitizeFormValues(input) + + expect(result.regular).toBe('field') + }) + + it('should handle objects with getter properties', () => { + const obj = {} + Object.defineProperty(obj, 'getter', { + get: () => 'computed_value', + enumerable: true, + }) + + const result = sanitizeFormValues(obj) + expect(result.getter).toBe('computed_value') + }) + + it('should handle circular references in deepSanitizeFormValues gracefully', () => { + const obj: any = { field: 'value' } + obj.circular = obj + + expect(() => deepSanitizeFormValues(obj)).not.toThrow() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts b/web/app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts new file mode 100644 index 0000000000..36090d9771 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts @@ -0,0 +1,55 @@ +/** + * Utility functions for form data handling in trigger plugin components + */ + +/** + * Sanitizes form values by converting null/undefined to empty strings + * This ensures React form inputs don't receive null values which can cause warnings + */ +export const sanitizeFormValues = (values: Record<string, any>): Record<string, string> => { + return Object.fromEntries( + Object.entries(values).map(([key, value]) => [ + key, + value === null || value === undefined ? '' : String(value), + ]), + ) +} + +/** + * Deep sanitizes form values while preserving nested objects structure + * Useful for complex form schemas with nested properties + */ +export const deepSanitizeFormValues = (values: Record<string, any>, visited = new WeakSet()): Record<string, any> => { + if (visited.has(values)) + return {} + + visited.add(values) + + const result: Record<string, any> = {} + + for (const [key, value] of Object.entries(values)) { + if (value === null || value === undefined) + result[key] = '' + else if (typeof value === 'object' && !Array.isArray(value)) + result[key] = deepSanitizeFormValues(value, visited) + else + result[key] = value + } + + return result +} + +/** + * Validates required fields in form data + * Returns the first missing required field or null if all are present + */ +export const findMissingRequiredField = ( + formData: Record<string, any>, + requiredFields: Array<{ name: string; label: any }>, +): { name: string; label: any } | null => { + for (const field of requiredFields) { + if (!formData[field.name] || formData[field.name] === '') + return field + } + return null +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx new file mode 100644 index 0000000000..d0de74a6ef --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx @@ -0,0 +1,38 @@ +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { SimpleSelect } from '@/app/components/base/select' +import type { ScheduleFrequency } from '../types' + +type FrequencySelectorProps = { + frequency: ScheduleFrequency + onChange: (frequency: ScheduleFrequency) => void +} + +const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => { + const { t } = useTranslation() + + const frequencies = useMemo(() => [ + { value: 'frequency-header', name: t('workflow.nodes.triggerSchedule.frequency.label'), isGroup: true }, + { value: 'hourly', name: t('workflow.nodes.triggerSchedule.frequency.hourly') }, + { value: 'daily', name: t('workflow.nodes.triggerSchedule.frequency.daily') }, + { value: 'weekly', name: t('workflow.nodes.triggerSchedule.frequency.weekly') }, + { value: 'monthly', name: t('workflow.nodes.triggerSchedule.frequency.monthly') }, + ], [t]) + + return ( + <SimpleSelect + key={`${frequency}-${frequencies[0]?.name}`} // Include translation in key to force re-render + items={frequencies} + defaultValue={frequency} + onSelect={item => onChange(item.value as ScheduleFrequency)} + placeholder={t('workflow.nodes.triggerSchedule.selectFrequency')} + className="w-full py-2" + wrapperClassName="h-auto" + optionWrapClassName="min-w-40" + notClearable={true} + allowSearch={false} + /> + ) +} + +export default FrequencySelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx new file mode 100644 index 0000000000..6dc88c85bf --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { RiCalendarLine, RiCodeLine } from '@remixicon/react' +import { SegmentedControl } from '@/app/components/base/segmented-control' +import type { ScheduleMode } from '../types' + +type ModeSwitcherProps = { + mode: ScheduleMode + onChange: (mode: ScheduleMode) => void +} + +const ModeSwitcher = ({ mode, onChange }: ModeSwitcherProps) => { + const { t } = useTranslation() + + const options = [ + { + Icon: RiCalendarLine, + text: t('workflow.nodes.triggerSchedule.mode.visual'), + value: 'visual' as const, + }, + { + Icon: RiCodeLine, + text: t('workflow.nodes.triggerSchedule.mode.cron'), + value: 'cron' as const, + }, + ] + + return ( + <SegmentedControl + options={options} + value={mode} + onChange={onChange} + /> + ) +} + +export default ModeSwitcher diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx new file mode 100644 index 0000000000..6ae5d2cf67 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Asterisk, CalendarCheckLine } from '@/app/components/base/icons/src/vender/workflow' +import type { ScheduleMode } from '../types' + +type ModeToggleProps = { + mode: ScheduleMode + onChange: (mode: ScheduleMode) => void +} + +const ModeToggle = ({ mode, onChange }: ModeToggleProps) => { + const { t } = useTranslation() + + const handleToggle = () => { + const newMode = mode === 'visual' ? 'cron' : 'visual' + onChange(newMode) + } + + const currentText = mode === 'visual' + ? t('workflow.nodes.triggerSchedule.useCronExpression') + : t('workflow.nodes.triggerSchedule.useVisualPicker') + + const currentIcon = mode === 'visual' ? Asterisk : CalendarCheckLine + + return ( + <button + type="button" + onClick={handleToggle} + className="flex cursor-pointer items-center gap-1 rounded-lg px-2 py-1 text-sm text-text-secondary hover:bg-state-base-hover" + > + {React.createElement(currentIcon, { className: 'w-4 h-4' })} + <span>{currentText}</span> + </button> + ) +} + +export default ModeToggle diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx new file mode 100644 index 0000000000..d7cce79328 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { RiQuestionLine } from '@remixicon/react' +import Tooltip from '@/app/components/base/tooltip' + +type MonthlyDaysSelectorProps = { + selectedDays: (number | 'last')[] + onChange: (days: (number | 'last')[]) => void +} + +const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProps) => { + const { t } = useTranslation() + + const handleDayClick = (day: number | 'last') => { + const current = selectedDays || [] + const newSelected = current.includes(day) + ? current.filter(d => d !== day) + : [...current, day] + // Ensure at least one day is selected (consistent with WeekdaySelector) + onChange(newSelected.length > 0 ? newSelected : [day]) + } + + const isDaySelected = (day: number | 'last') => selectedDays?.includes(day) || false + + const days = Array.from({ length: 31 }, (_, i) => i + 1) + const rows = [ + days.slice(0, 7), + days.slice(7, 14), + days.slice(14, 21), + days.slice(21, 28), + [29, 30, 31, 'last' as const], + ] + + return ( + <div className="space-y-2"> + <label className="mb-2 block text-xs font-medium text-text-tertiary"> + {t('workflow.nodes.triggerSchedule.days')} + </label> + + <div className="space-y-1.5"> + {rows.map((row, rowIndex) => ( + <div key={rowIndex} className="grid grid-cols-7 gap-1.5"> + {row.map(day => ( + <button + key={day} + type="button" + onClick={() => handleDayClick(day)} + className={`rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${ + day === 'last' ? 'col-span-2 min-w-0' : '' + } ${ + isDaySelected(day) + ? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary' + : 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary' + }`} + > + {day === 'last' ? ( + <div className="flex items-center justify-center gap-1"> + <span>{t('workflow.nodes.triggerSchedule.lastDay')}</span> + <Tooltip + popupContent={t('workflow.nodes.triggerSchedule.lastDayTooltip')} + > + <RiQuestionLine className="h-3 w-3 text-text-quaternary" /> + </Tooltip> + </div> + ) : ( + day + )} + </button> + ))} + {/* Fill empty cells in the last row (Last day takes 2 cols, so need 1 less) */} + {rowIndex === rows.length - 1 && Array.from({ length: 7 - row.length - 1 }, (_, i) => ( + <div key={`empty-${i}`} className="invisible"></div> + ))} + </div> + ))} + </div> + + {/* Warning message for day 31 - aligned with grid */} + {selectedDays?.includes(31) && ( + <div className="mt-1.5 grid grid-cols-7 gap-1.5"> + <div className="col-span-7 text-xs text-gray-500"> + {t('workflow.nodes.triggerSchedule.lastDayTooltip')} + </div> + </div> + )} + </div> + ) +} + +export default MonthlyDaysSelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx new file mode 100644 index 0000000000..02e85e2724 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { ScheduleTriggerNodeType } from '../types' +import { getFormattedExecutionTimes } from '../utils/execution-time-calculator' + +type NextExecutionTimesProps = { + data: ScheduleTriggerNodeType +} + +const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => { + const { t } = useTranslation() + + if (!data.frequency) + return null + + const executionTimes = getFormattedExecutionTimes(data, 5) + + if (executionTimes.length === 0) + return null + + return ( + <div className="space-y-2"> + <label className="block text-xs font-medium text-gray-500"> + {t('workflow.nodes.triggerSchedule.nextExecutionTimes')} + </label> + <div className="flex min-h-[80px] flex-col rounded-xl bg-components-input-bg-normal py-2"> + {executionTimes.map((time, index) => ( + <div key={index} className="flex items-baseline text-xs"> + <span className="w-6 select-none text-right font-mono font-normal leading-[150%] tracking-wider text-text-quaternary"> + {String(index + 1).padStart(2, '0')} + </span> + <span className="pl-2 pr-3 font-mono font-normal leading-[150%] tracking-wider text-text-secondary"> + {time} + </span> + </div> + ))} + </div> + </div> + ) +} + +export default NextExecutionTimes diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx new file mode 100644 index 0000000000..992a111d19 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import Slider from '@/app/components/base/slider' + +type OnMinuteSelectorProps = { + value?: number + onChange: (value: number) => void +} + +const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => { + const { t } = useTranslation() + + return ( + <div> + <label className="mb-2 block text-xs font-medium text-gray-500"> + {t('workflow.nodes.triggerSchedule.onMinute')} + </label> + <div className="relative flex h-8 items-center rounded-lg bg-components-input-bg-normal"> + <div className="flex h-full w-12 shrink-0 items-center justify-center text-[13px] text-components-input-text-filled"> + {value} + </div> + <div className="absolute left-12 top-0 h-full w-px bg-components-panel-bg"></div> + <div className="flex h-full grow items-center pl-4 pr-3"> + <Slider + className="w-full" + value={value} + min={0} + max={59} + step={1} + onChange={onChange} + /> + </div> + </div> + </div> + ) +} + +export default OnMinuteSelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx new file mode 100644 index 0000000000..348fd53454 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' + +type WeekdaySelectorProps = { + selectedDays: string[] + onChange: (days: string[]) => void +} + +const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => { + const { t } = useTranslation() + + const weekdays = [ + { key: 'sun', label: 'Sun' }, + { key: 'mon', label: 'Mon' }, + { key: 'tue', label: 'Tue' }, + { key: 'wed', label: 'Wed' }, + { key: 'thu', label: 'Thu' }, + { key: 'fri', label: 'Fri' }, + { key: 'sat', label: 'Sat' }, + ] + + const handleDaySelect = (dayKey: string) => { + const current = selectedDays || [] + const newSelected = current.includes(dayKey) + ? current.filter(d => d !== dayKey) + : [...current, dayKey] + onChange(newSelected.length > 0 ? newSelected : [dayKey]) + } + + const isDaySelected = (dayKey: string) => selectedDays.includes(dayKey) + + return ( + <div className="space-y-2"> + <label className="mb-2 block text-xs font-medium text-text-tertiary"> + {t('workflow.nodes.triggerSchedule.weekdays')} + </label> + <div className="flex gap-1.5"> + {weekdays.map(day => ( + <button + key={day.key} + type="button" + className={`flex-1 rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${ + isDaySelected(day.key) + ? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary' + : 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary' + }`} + onClick={() => handleDaySelect(day.key)} + > + {day.label} + </button> + ))} + </div> + </div> + ) +} + +export default WeekdaySelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/constants.ts b/web/app/components/workflow/nodes/trigger-schedule/constants.ts new file mode 100644 index 0000000000..ab6b8842bf --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/constants.ts @@ -0,0 +1,19 @@ +import type { ScheduleTriggerNodeType } from './types' + +export const getDefaultScheduleConfig = (): Partial<ScheduleTriggerNodeType> => ({ + mode: 'visual', + frequency: 'daily', + visual_config: { + time: '12:00 AM', + weekdays: ['sun'], + on_minute: 0, + monthly_days: [1], + }, +}) + +export const getDefaultVisualConfig = () => ({ + time: '12:00 AM', + weekdays: ['sun'], + on_minute: 0, + monthly_days: [1], +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/default.ts b/web/app/components/workflow/nodes/trigger-schedule/default.ts new file mode 100644 index 0000000000..69f93c33f4 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/default.ts @@ -0,0 +1,167 @@ +import { BlockEnum } from '../../types' +import type { NodeDefault } from '../../types' +import type { ScheduleTriggerNodeType } from './types' +import { isValidCronExpression } from './utils/cron-parser' +import { getNextExecutionTimes } from './utils/execution-time-calculator' +import { getDefaultScheduleConfig } from './constants' +import { genNodeMetaData } from '../../utils' + +const isValidTimeFormat = (time: string): boolean => { + const timeRegex = /^(0?\d|1[0-2]):[0-5]\d (AM|PM)$/ + if (!timeRegex.test(time)) return false + + const [timePart, period] = time.split(' ') + const [hour, minute] = timePart.split(':') + const hourNum = Number.parseInt(hour, 10) + const minuteNum = Number.parseInt(minute, 10) + + return hourNum >= 1 && hourNum <= 12 + && minuteNum >= 0 && minuteNum <= 59 + && ['AM', 'PM'].includes(period) +} + +const validateHourlyConfig = (config: any, t: any): string => { + if (config.on_minute === undefined || config.on_minute < 0 || config.on_minute > 59) + return t('workflow.nodes.triggerSchedule.invalidOnMinute') + + return '' +} + +const validateDailyConfig = (config: any, t: any): string => { + const i18nPrefix = 'workflow.errorMsg' + + if (!config.time) + return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.time') }) + + if (!isValidTimeFormat(config.time)) + return t('workflow.nodes.triggerSchedule.invalidTimeFormat') + + return '' +} + +const validateWeeklyConfig = (config: any, t: any): string => { + const dailyError = validateDailyConfig(config, t) + if (dailyError) return dailyError + + const i18nPrefix = 'workflow.errorMsg' + + if (!config.weekdays || config.weekdays.length === 0) + return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.weekdays') }) + + const validWeekdays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] + for (const day of config.weekdays) { + if (!validWeekdays.includes(day)) + return t('workflow.nodes.triggerSchedule.invalidWeekday', { weekday: day }) + } + + return '' +} + +const validateMonthlyConfig = (config: any, t: any): string => { + const dailyError = validateDailyConfig(config, t) + if (dailyError) return dailyError + + const i18nPrefix = 'workflow.errorMsg' + + const getMonthlyDays = (): (number | 'last')[] => { + if (Array.isArray(config.monthly_days) && config.monthly_days.length > 0) + return config.monthly_days + + return [] + } + + const monthlyDays = getMonthlyDays() + + if (monthlyDays.length === 0) + return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.monthlyDay') }) + + for (const day of monthlyDays) { + if (day !== 'last' && (typeof day !== 'number' || day < 1 || day > 31)) + return t('workflow.nodes.triggerSchedule.invalidMonthlyDay') + } + + return '' +} + +const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string => { + const i18nPrefix = 'workflow.errorMsg' + const { visual_config } = payload + + if (!visual_config) + return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.visualConfig') }) + + switch (payload.frequency) { + case 'hourly': + return validateHourlyConfig(visual_config, t) + case 'daily': + return validateDailyConfig(visual_config, t) + case 'weekly': + return validateWeeklyConfig(visual_config, t) + case 'monthly': + return validateMonthlyConfig(visual_config, t) + default: + return t('workflow.nodes.triggerSchedule.invalidFrequency') + } +} + +const metaData = genNodeMetaData({ + sort: 2, + type: BlockEnum.TriggerSchedule, + helpLinkUri: 'schedule-trigger', + isStart: true, +}) + +const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = { + metaData, + defaultValue: { + ...getDefaultScheduleConfig(), + cron_expression: '', + } as ScheduleTriggerNodeType, + checkValid(payload: ScheduleTriggerNodeType, t: any) { + const i18nPrefix = 'workflow.errorMsg' + let errorMessages = '' + if (!errorMessages && !payload.mode) + errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.mode') }) + + // Validate timezone format if provided (timezone will be auto-filled by use-config.ts if undefined) + if (!errorMessages && payload.timezone) { + try { + Intl.DateTimeFormat(undefined, { timeZone: payload.timezone }) + } + catch { + errorMessages = t('workflow.nodes.triggerSchedule.invalidTimezone') + } + } + if (!errorMessages) { + if (payload.mode === 'cron') { + if (!payload.cron_expression || payload.cron_expression.trim() === '') + errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.cronExpression') }) + else if (!isValidCronExpression(payload.cron_expression)) + errorMessages = t('workflow.nodes.triggerSchedule.invalidCronExpression') + } + else if (payload.mode === 'visual') { + if (!payload.frequency) + errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.frequency') }) + else + errorMessages = validateVisualConfig(payload, t) + } + } + if (!errorMessages) { + try { + const nextTimes = getNextExecutionTimes(payload, 1) + if (nextTimes.length === 0) + errorMessages = t('workflow.nodes.triggerSchedule.noValidExecutionTime') + } + catch { + errorMessages = t('workflow.nodes.triggerSchedule.executionTimeCalculationError') + } + } + + return { + isValid: !errorMessages, + errorMessage: errorMessages, + } + }, +} + +export default nodeDefault diff --git a/web/app/components/workflow/nodes/trigger-schedule/node.tsx b/web/app/components/workflow/nodes/trigger-schedule/node.tsx new file mode 100644 index 0000000000..9870ef211a --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/node.tsx @@ -0,0 +1,31 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { ScheduleTriggerNodeType } from './types' +import type { NodeProps } from '@/app/components/workflow/types' +import { getNextExecutionTime } from './utils/execution-time-calculator' + +const i18nPrefix = 'workflow.nodes.triggerSchedule' + +const Node: FC<NodeProps<ScheduleTriggerNodeType>> = ({ + data, +}) => { + const { t } = useTranslation() + + return ( + <div className="mb-1 px-3 py-1"> + <div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-text-tertiary"> + {t(`${i18nPrefix}.nextExecutionTime`)} + </div> + <div className="flex h-[26px] items-center rounded-md bg-workflow-block-parma-bg px-2 text-xs text-text-secondary"> + <div className="w-0 grow"> + <div className="truncate" title={getNextExecutionTime(data)}> + {getNextExecutionTime(data)} + </div> + </div> + </div> + </div> + ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx new file mode 100644 index 0000000000..2a7c661339 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx @@ -0,0 +1,146 @@ +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import type { ScheduleTriggerNodeType } from './types' +import Field from '@/app/components/workflow/nodes/_base/components/field' +import type { NodePanelProps } from '@/app/components/workflow/types' +import ModeToggle from './components/mode-toggle' +import FrequencySelector from './components/frequency-selector' +import WeekdaySelector from './components/weekday-selector' +import TimePicker from '@/app/components/base/date-and-time-picker/time-picker' +import NextExecutionTimes from './components/next-execution-times' +import MonthlyDaysSelector from './components/monthly-days-selector' +import OnMinuteSelector from './components/on-minute-selector' +import Input from '@/app/components/base/input' +import useConfig from './use-config' + +const i18nPrefix = 'workflow.nodes.triggerSchedule' + +const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({ + id, + data, +}) => { + const { t } = useTranslation() + const { + inputs, + setInputs, + handleModeChange, + handleFrequencyChange, + handleCronExpressionChange, + handleWeekdaysChange, + handleTimeChange, + handleOnMinuteChange, + } = useConfig(id, data) + + return ( + <div className='mt-2'> + <div className='space-y-4 px-4 pb-3 pt-2'> + <Field + title={t(`${i18nPrefix}.title`)} + operations={ + <ModeToggle + mode={inputs.mode} + onChange={handleModeChange} + /> + } + > + <div className="space-y-3"> + + {inputs.mode === 'visual' && ( + <div className="space-y-3"> + <div className="grid grid-cols-3 gap-3"> + <div> + <label className="mb-2 block text-xs font-medium text-gray-500"> + {t('workflow.nodes.triggerSchedule.frequencyLabel')} + </label> + <FrequencySelector + frequency={inputs.frequency || 'daily'} + onChange={handleFrequencyChange} + /> + </div> + <div className="col-span-2"> + {inputs.frequency === 'hourly' ? ( + <OnMinuteSelector + value={inputs.visual_config?.on_minute} + onChange={handleOnMinuteChange} + /> + ) : ( + <> + <label className="mb-2 block text-xs font-medium text-gray-500"> + {t('workflow.nodes.triggerSchedule.time')} + </label> + <TimePicker + notClearable={true} + timezone={inputs.timezone} + value={inputs.visual_config?.time || '12:00 AM'} + triggerFullWidth={true} + onChange={(time) => { + if (time) { + const timeString = time.format('h:mm A') + handleTimeChange(timeString) + } + }} + onClear={() => { + handleTimeChange('12:00 AM') + }} + placeholder={t('workflow.nodes.triggerSchedule.selectTime')} + showTimezone={true} + /> + </> + )} + </div> + </div> + + {inputs.frequency === 'weekly' && ( + <WeekdaySelector + selectedDays={inputs.visual_config?.weekdays || []} + onChange={handleWeekdaysChange} + /> + )} + + {inputs.frequency === 'monthly' && ( + <MonthlyDaysSelector + selectedDays={inputs.visual_config?.monthly_days || [1]} + onChange={(days) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + monthly_days: days, + }, + } + setInputs(newInputs) + }} + /> + )} + </div> + )} + + {inputs.mode === 'cron' && ( + <div className="space-y-2"> + <div> + <label className="mb-2 block text-xs font-medium text-gray-500"> + {t('workflow.nodes.triggerSchedule.cronExpression')} + </label> + <Input + value={inputs.cron_expression || ''} + onChange={e => handleCronExpressionChange(e.target.value)} + placeholder="0 0 * * *" + className="font-mono" + /> + </div> + </div> + )} + </div> + </Field> + + <div className="border-t border-divider-subtle"></div> + + <NextExecutionTimes data={inputs} /> + + </div> + </div> + ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/trigger-schedule/types.ts b/web/app/components/workflow/nodes/trigger-schedule/types.ts new file mode 100644 index 0000000000..3d82709199 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/types.ts @@ -0,0 +1,20 @@ +import type { CommonNodeType } from '@/app/components/workflow/types' + +export type ScheduleMode = 'visual' | 'cron' + +export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly' + +export type VisualConfig = { + time?: string + weekdays?: string[] + on_minute?: number + monthly_days?: (number | 'last')[] +} + +export type ScheduleTriggerNodeType = CommonNodeType & { + mode: ScheduleMode + frequency?: ScheduleFrequency + cron_expression?: string + visual_config?: VisualConfig + timezone?: string +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts new file mode 100644 index 0000000000..06e29ccd84 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts @@ -0,0 +1,110 @@ +import { useCallback, useMemo } from 'react' +import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types' +import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useNodesReadOnly } from '@/app/components/workflow/hooks' +import { useAppContext } from '@/context/app-context' +import { getDefaultVisualConfig } from './constants' + +const useConfig = (id: string, payload: ScheduleTriggerNodeType) => { + const { nodesReadOnly: readOnly } = useNodesReadOnly() + + const { userProfile } = useAppContext() + + const frontendPayload = useMemo(() => { + return { + ...payload, + mode: payload.mode || 'visual', + frequency: payload.frequency || 'daily', + timezone: payload.timezone || userProfile.timezone || 'UTC', + visual_config: { + ...getDefaultVisualConfig(), + ...payload.visual_config, + }, + } + }, [payload, userProfile.timezone]) + + const { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(id, frontendPayload) + + const handleModeChange = useCallback((mode: ScheduleMode) => { + const newInputs = { + ...inputs, + mode, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleFrequencyChange = useCallback((frequency: ScheduleFrequency) => { + const newInputs = { + ...inputs, + frequency, + visual_config: { + ...inputs.visual_config, + ...(frequency === 'hourly') && { + on_minute: inputs.visual_config?.on_minute ?? 0, + }, + }, + cron_expression: undefined, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleCronExpressionChange = useCallback((value: string) => { + const newInputs = { + ...inputs, + cron_expression: value, + frequency: undefined, + visual_config: undefined, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleWeekdaysChange = useCallback((weekdays: string[]) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + weekdays, + }, + cron_expression: undefined, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleTimeChange = useCallback((time: string) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + time, + }, + cron_expression: undefined, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + const handleOnMinuteChange = useCallback((on_minute: number) => { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + on_minute, + }, + cron_expression: undefined, + } + setInputs(newInputs) + }, [inputs, setInputs]) + + return { + readOnly, + inputs, + setInputs, + handleModeChange, + handleFrequencyChange, + handleCronExpressionChange, + handleWeekdaysChange, + handleTimeChange, + handleOnMinuteChange, + } +} + +export default useConfig diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts new file mode 100644 index 0000000000..90f65db0aa --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts @@ -0,0 +1,84 @@ +import { CronExpressionParser } from 'cron-parser' + +// Convert a UTC date from cron-parser to user timezone representation +// This ensures consistency with other execution time calculations +const convertToUserTimezoneRepresentation = (utcDate: Date, timezone: string): Date => { + // Get the time string in the target timezone + const userTimeStr = utcDate.toLocaleString('en-CA', { + timeZone: timezone, + hour12: false, + }) + const [dateStr, timeStr] = userTimeStr.split(', ') + const [year, month, day] = dateStr.split('-').map(Number) + const [hour, minute, second] = timeStr.split(':').map(Number) + + // Create a new Date object representing this time as "local" time + // This matches the behavior expected by the execution-time-calculator + return new Date(year, month - 1, day, hour, minute, second) +} + +/** + * Parse a cron expression and return the next 5 execution times + * + * @param cronExpression - Standard 5-field cron expression (minute hour day month dayOfWeek) + * @param timezone - IANA timezone identifier (e.g., 'UTC', 'America/New_York') + * @returns Array of Date objects representing the next 5 execution times + */ +export const parseCronExpression = (cronExpression: string, timezone: string = 'UTC'): Date[] => { + if (!cronExpression || cronExpression.trim() === '') + return [] + + const parts = cronExpression.trim().split(/\s+/) + + // Support both 5-field format and predefined expressions + if (parts.length !== 5 && !cronExpression.startsWith('@')) + return [] + + try { + // Parse the cron expression with timezone support + // Use the actual current time for cron-parser to handle properly + const interval = CronExpressionParser.parse(cronExpression, { + tz: timezone, + }) + + // Get the next 5 execution times using the take() method + const nextCronDates = interval.take(5) + + // Convert CronDate objects to Date objects and ensure they represent + // the time in user timezone (consistent with execution-time-calculator.ts) + return nextCronDates.map((cronDate) => { + const utcDate = cronDate.toDate() + return convertToUserTimezoneRepresentation(utcDate, timezone) + }) + } + catch { + // Return empty array if parsing fails + return [] + } +} + +/** + * Validate a cron expression format and syntax + * + * @param cronExpression - Standard 5-field cron expression to validate + * @returns boolean indicating if the cron expression is valid + */ +export const isValidCronExpression = (cronExpression: string): boolean => { + if (!cronExpression || cronExpression.trim() === '') + return false + + const parts = cronExpression.trim().split(/\s+/) + + // Support both 5-field format and predefined expressions + if (parts.length !== 5 && !cronExpression.startsWith('@')) + return false + + try { + // Use cron-parser to validate the expression + CronExpressionParser.parse(cronExpression) + return true + } + catch { + return false + } +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts new file mode 100644 index 0000000000..aef122ba25 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts @@ -0,0 +1,295 @@ +import type { ScheduleTriggerNodeType } from '../types' +import { isValidCronExpression, parseCronExpression } from './cron-parser' +import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs' + +const DEFAULT_TIMEZONE = 'UTC' + +const resolveTimezone = (timezone?: string): string => { + if (timezone) + return timezone + + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIMEZONE + } + catch { + return DEFAULT_TIMEZONE + } +} + +// Get current time completely in user timezone, no browser timezone involved +const getUserTimezoneCurrentTime = (timezone?: string): Date => { + const targetTimezone = resolveTimezone(timezone) + const now = new Date() + const userTimeStr = now.toLocaleString('en-CA', { + timeZone: targetTimezone, + hour12: false, + }) + const [dateStr, timeStr] = userTimeStr.split(', ') + const [year, month, day] = dateStr.split('-').map(Number) + const [hour, minute, second] = timeStr.split(':').map(Number) + return new Date(year, month - 1, day, hour, minute, second) +} + +// Format date that is already in user timezone, no timezone conversion +const formatUserTimezoneDate = (date: Date, timezone: string, includeWeekday: boolean = true, includeTimezone: boolean = true): string => { + const dateOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + } + + if (includeWeekday) + dateOptions.weekday = 'long' // Changed from 'short' to 'long' for full weekday name + + const timeOptions: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + hour12: true, + } + + const dateStr = date.toLocaleDateString('en-US', dateOptions) + const timeStr = date.toLocaleTimeString('en-US', timeOptions) + + if (includeTimezone) { + const timezoneOffset = convertTimezoneToOffsetStr(timezone) + return `${dateStr}, ${timeStr} (${timezoneOffset})` + } + + return `${dateStr}, ${timeStr}` +} + +// Helper function to get default datetime - consistent with base DatePicker +export const getDefaultDateTime = (): Date => { + const defaultDate = new Date(2024, 0, 2, 11, 30, 0, 0) + return defaultDate +} + +export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): Date[] => { + const timezone = resolveTimezone(data.timezone) + + if (data.mode === 'cron') { + if (!data.cron_expression || !isValidCronExpression(data.cron_expression)) + return [] + return parseCronExpression(data.cron_expression, timezone).slice(0, count) + } + + const times: Date[] = [] + const defaultTime = data.visual_config?.time || '12:00 AM' + + // Get "today" in user's timezone for display purposes + const now = new Date() + const userTodayStr = now.toLocaleDateString('en-CA', { timeZone: timezone }) + const [year, month, day] = userTodayStr.split('-').map(Number) + const userToday = new Date(year, month - 1, day, 0, 0, 0, 0) + + if (data.frequency === 'hourly') { + const onMinute = data.visual_config?.on_minute ?? 0 + + // Get current time completely in user timezone + const userCurrentTime = getUserTimezoneCurrentTime(timezone) + + let hour = userCurrentTime.getHours() + if (userCurrentTime.getMinutes() >= onMinute) + hour += 1 // Start from next hour if current minute has passed + + for (let i = 0; i < count; i++) { + const execution = new Date(userToday) + execution.setHours(hour + i, onMinute, 0, 0) + // Handle day overflow + if (hour + i >= 24) { + execution.setDate(userToday.getDate() + Math.floor((hour + i) / 24)) + execution.setHours((hour + i) % 24, onMinute, 0, 0) + } + times.push(execution) + } + } + else if (data.frequency === 'daily') { + const [time, period] = defaultTime.split(' ') + const [hour, minute] = time.split(':') + let displayHour = Number.parseInt(hour) + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 + + // Check if today's configured time has already passed + const todayExecution = new Date(userToday) + todayExecution.setHours(displayHour, Number.parseInt(minute), 0, 0) + + const userCurrentTime = getUserTimezoneCurrentTime(timezone) + + const startOffset = todayExecution <= userCurrentTime ? 1 : 0 + + for (let i = 0; i < count; i++) { + const execution = new Date(userToday) + execution.setDate(userToday.getDate() + startOffset + i) + execution.setHours(displayHour, Number.parseInt(minute), 0, 0) + times.push(execution) + } + } + else if (data.frequency === 'weekly') { + const selectedDays = data.visual_config?.weekdays || ['sun'] + const dayMap = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 } + + const [time, period] = defaultTime.split(' ') + const [hour, minute] = time.split(':') + let displayHour = Number.parseInt(hour) + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 + + // Get current time completely in user timezone + const userCurrentTime = getUserTimezoneCurrentTime(timezone) + + let executionCount = 0 + let weekOffset = 0 + + while (executionCount < count) { + let hasValidDays = false + + for (const selectedDay of selectedDays) { + if (executionCount >= count) break + + const targetDay = dayMap[selectedDay as keyof typeof dayMap] + if (targetDay === undefined) continue + + hasValidDays = true + + const currentDayOfWeek = userToday.getDay() + const daysUntilTarget = (targetDay - currentDayOfWeek + 7) % 7 + + // Check if today's configured time has already passed + const todayAtTargetTime = new Date(userToday) + todayAtTargetTime.setHours(displayHour, Number.parseInt(minute), 0, 0) + + let adjustedDays = daysUntilTarget + if (daysUntilTarget === 0 && todayAtTargetTime <= userCurrentTime) + adjustedDays = 7 + + const execution = new Date(userToday) + execution.setDate(userToday.getDate() + adjustedDays + (weekOffset * 7)) + execution.setHours(displayHour, Number.parseInt(minute), 0, 0) + + // Only add if execution time is in the future + if (execution > userCurrentTime) { + times.push(execution) + executionCount++ + } + } + + if (!hasValidDays) break + weekOffset++ + } + + times.sort((a, b) => a.getTime() - b.getTime()) + } + else if (data.frequency === 'monthly') { + const getSelectedDays = (): (number | 'last')[] => { + if (data.visual_config?.monthly_days && data.visual_config.monthly_days.length > 0) + return data.visual_config.monthly_days + + return [1] + } + + const selectedDays = [...new Set(getSelectedDays())] + const [time, period] = defaultTime.split(' ') + const [hour, minute] = time.split(':') + let displayHour = Number.parseInt(hour) + if (period === 'PM' && displayHour !== 12) displayHour += 12 + if (period === 'AM' && displayHour === 12) displayHour = 0 + + // Get current time completely in user timezone + const userCurrentTime = getUserTimezoneCurrentTime(timezone) + + let executionCount = 0 + let monthOffset = 0 + + while (executionCount < count) { + const targetMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1) + const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate() + + const monthlyExecutions: Date[] = [] + const processedDays = new Set<number>() + + for (const selectedDay of selectedDays) { + let targetDay: number + + if (selectedDay === 'last') { + targetDay = daysInMonth + } + else { + const dayNumber = selectedDay as number + if (dayNumber > daysInMonth) + continue + + targetDay = dayNumber + } + + if (processedDays.has(targetDay)) + continue + + processedDays.add(targetDay) + + const execution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0) + + // Only add if execution time is in the future + if (execution > userCurrentTime) + monthlyExecutions.push(execution) + } + + monthlyExecutions.sort((a, b) => a.getTime() - b.getTime()) + + for (const execution of monthlyExecutions) { + if (executionCount >= count) break + times.push(execution) + executionCount++ + } + + monthOffset++ + } + } + else { + for (let i = 0; i < count; i++) { + const execution = new Date(userToday) + execution.setDate(userToday.getDate() + i) + times.push(execution) + } + } + + return times +} + +export const formatExecutionTime = (date: Date, timezone: string | undefined, includeWeekday: boolean = true, includeTimezone: boolean = true): string => { + const resolvedTimezone = resolveTimezone(timezone) + return formatUserTimezoneDate(date, resolvedTimezone, includeWeekday, includeTimezone) +} + +export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => { + const timezone = resolveTimezone(data.timezone) + const times = getNextExecutionTimes(data, count) + + return times.map((date) => { + const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly' + return formatExecutionTime(date, timezone, includeWeekday, true) // Panel shows timezone + }) +} + +export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => { + const timezone = resolveTimezone(data.timezone) + + // Return placeholder for cron mode with empty or invalid expression + if (data.mode === 'cron') { + if (!data.cron_expression || !isValidCronExpression(data.cron_expression)) + return '--' + } + + // Get Date objects (not formatted strings) + const times = getNextExecutionTimes(data, 1) + if (times.length === 0) { + const userCurrentTime = getUserTimezoneCurrentTime(timezone) + const fallbackDate = new Date(userCurrentTime.getFullYear(), userCurrentTime.getMonth(), userCurrentTime.getDate(), 12, 0, 0, 0) + const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly' + return formatExecutionTime(fallbackDate, timezone, includeWeekday, false) // Node doesn't show timezone + } + + // Format the first execution time without timezone for node display + const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly' + return formatExecutionTime(times[0], timezone, includeWeekday, false) // Node doesn't show timezone +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts new file mode 100644 index 0000000000..1b7d374d33 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/integration.spec.ts @@ -0,0 +1,349 @@ +import { isValidCronExpression, parseCronExpression } from './cron-parser' +import { getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator' +import type { ScheduleTriggerNodeType } from '../types' + +// Comprehensive integration tests for cron-parser and execution-time-calculator compatibility +describe('cron-parser + execution-time-calculator integration', () => { + beforeAll(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-01-15T10:00:00Z')) + }) + + afterAll(() => { + jest.useRealTimers() + }) + + const createCronData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({ + id: 'test-cron', + type: 'schedule-trigger', + mode: 'cron', + frequency: 'daily', + timezone: 'UTC', + ...overrides, + }) + + describe('backward compatibility validation', () => { + it('maintains exact behavior for legacy cron expressions', () => { + const legacyExpressions = [ + '15 10 1 * *', // Monthly 1st at 10:15 + '0 0 * * 0', // Weekly Sunday midnight + '*/5 * * * *', // Every 5 minutes + '0 9-17 * * 1-5', // Business hours weekdays + '30 14 * * 1', // Monday 14:30 + '0 0 1,15 * *', // 1st and 15th midnight + ] + + legacyExpressions.forEach((expression) => { + // Test direct cron-parser usage + const directResult = parseCronExpression(expression, 'UTC') + expect(directResult).toHaveLength(5) + expect(isValidCronExpression(expression)).toBe(true) + + // Test through execution-time-calculator + const data = createCronData({ cron_expression: expression }) + const calculatorResult = getNextExecutionTimes(data, 5) + + expect(calculatorResult).toHaveLength(5) + + // Results should be identical + directResult.forEach((directDate, index) => { + const calcDate = calculatorResult[index] + expect(calcDate.getTime()).toBe(directDate.getTime()) + expect(calcDate.getHours()).toBe(directDate.getHours()) + expect(calcDate.getMinutes()).toBe(directDate.getMinutes()) + }) + }) + }) + + it('validates timezone handling consistency', () => { + const timezones = ['UTC', 'America/New_York', 'Asia/Tokyo', 'Europe/London'] + const expression = '0 12 * * *' // Daily noon + + timezones.forEach((timezone) => { + // Direct cron-parser call + const directResult = parseCronExpression(expression, timezone) + + // Through execution-time-calculator + const data = createCronData({ cron_expression: expression, timezone }) + const calculatorResult = getNextExecutionTimes(data, 5) + + expect(directResult).toHaveLength(5) + expect(calculatorResult).toHaveLength(5) + + // All results should show noon (12:00) in their respective timezone + directResult.forEach(date => expect(date.getHours()).toBe(12)) + calculatorResult.forEach(date => expect(date.getHours()).toBe(12)) + + // Cross-validation: results should be identical + directResult.forEach((directDate, index) => { + expect(calculatorResult[index].getTime()).toBe(directDate.getTime()) + }) + }) + }) + + it('error handling consistency', () => { + const invalidExpressions = [ + '', // Empty string + ' ', // Whitespace only + '60 10 1 * *', // Invalid minute + '15 25 1 * *', // Invalid hour + '15 10 32 * *', // Invalid day + '15 10 1 13 *', // Invalid month + '15 10 1', // Too few fields + '15 10 1 * * *', // Too many fields + 'invalid expression', // Completely invalid + ] + + invalidExpressions.forEach((expression) => { + // Direct cron-parser calls + expect(isValidCronExpression(expression)).toBe(false) + expect(parseCronExpression(expression, 'UTC')).toEqual([]) + + // Through execution-time-calculator + const data = createCronData({ cron_expression: expression }) + const result = getNextExecutionTimes(data, 5) + expect(result).toEqual([]) + + // getNextExecutionTime should return '--' for invalid cron + const timeString = getNextExecutionTime(data) + expect(timeString).toBe('--') + }) + }) + }) + + describe('enhanced features integration', () => { + it('month and day abbreviations work end-to-end', () => { + const enhancedExpressions = [ + { expr: '0 9 1 JAN *', month: 0, day: 1, hour: 9 }, // January 1st 9 AM + { expr: '0 15 * * MON', weekday: 1, hour: 15 }, // Monday 3 PM + { expr: '30 10 15 JUN,DEC *', month: [5, 11], day: 15, hour: 10, minute: 30 }, // Jun/Dec 15th + { expr: '0 12 * JAN-MAR *', month: [0, 1, 2], hour: 12 }, // Q1 noon + ] + + enhancedExpressions.forEach(({ expr, month, day, weekday, hour, minute = 0 }) => { + // Validate through both paths + expect(isValidCronExpression(expr)).toBe(true) + + const directResult = parseCronExpression(expr, 'UTC') + const data = createCronData({ cron_expression: expr }) + const calculatorResult = getNextExecutionTimes(data, 3) + + expect(directResult.length).toBeGreaterThan(0) + expect(calculatorResult.length).toBeGreaterThan(0) + + // Validate expected properties + const validateDate = (date: Date) => { + expect(date.getHours()).toBe(hour) + expect(date.getMinutes()).toBe(minute) + + if (month !== undefined) { + if (Array.isArray(month)) + expect(month).toContain(date.getMonth()) + else + expect(date.getMonth()).toBe(month) + } + + if (day !== undefined) + expect(date.getDate()).toBe(day) + + if (weekday !== undefined) + expect(date.getDay()).toBe(weekday) + } + + directResult.forEach(validateDate) + calculatorResult.forEach(validateDate) + }) + }) + + it('predefined expressions work through execution-time-calculator', () => { + const predefExpressions = [ + { expr: '@daily', hour: 0, minute: 0 }, + { expr: '@weekly', hour: 0, minute: 0, weekday: 0 }, // Sunday + { expr: '@monthly', hour: 0, minute: 0, day: 1 }, // 1st of month + { expr: '@yearly', hour: 0, minute: 0, month: 0, day: 1 }, // Jan 1st + ] + + predefExpressions.forEach(({ expr, hour, minute, weekday, day, month }) => { + expect(isValidCronExpression(expr)).toBe(true) + + const data = createCronData({ cron_expression: expr }) + const result = getNextExecutionTimes(data, 3) + + expect(result.length).toBeGreaterThan(0) + + result.forEach((date) => { + expect(date.getHours()).toBe(hour) + expect(date.getMinutes()).toBe(minute) + + if (weekday !== undefined) expect(date.getDay()).toBe(weekday) + if (day !== undefined) expect(date.getDate()).toBe(day) + if (month !== undefined) expect(date.getMonth()).toBe(month) + }) + }) + }) + + it('special characters integration', () => { + const specialExpressions = [ + '0 9 ? * 1', // ? wildcard for day + '0 12 * * 7', // Sunday as 7 + '0 15 L * *', // Last day of month + ] + + specialExpressions.forEach((expr) => { + // Should validate and parse successfully + expect(isValidCronExpression(expr)).toBe(true) + + const directResult = parseCronExpression(expr, 'UTC') + const data = createCronData({ cron_expression: expr }) + const calculatorResult = getNextExecutionTimes(data, 2) + + expect(directResult.length).toBeGreaterThan(0) + expect(calculatorResult.length).toBeGreaterThan(0) + + // Results should be consistent + expect(calculatorResult[0].getHours()).toBe(directResult[0].getHours()) + expect(calculatorResult[0].getMinutes()).toBe(directResult[0].getMinutes()) + }) + }) + }) + + describe('DST and timezone edge cases', () => { + it('handles DST transitions consistently', () => { + // Test around DST spring forward (March 2024) + jest.setSystemTime(new Date('2024-03-08T10:00:00Z')) + + const expression = '0 2 * * *' // 2 AM daily (problematic during DST) + const timezone = 'America/New_York' + + const directResult = parseCronExpression(expression, timezone) + const data = createCronData({ cron_expression: expression, timezone }) + const calculatorResult = getNextExecutionTimes(data, 5) + + expect(directResult.length).toBeGreaterThan(0) + expect(calculatorResult.length).toBeGreaterThan(0) + + // Both should handle DST gracefully + // During DST spring forward, 2 AM becomes 3 AM - this is correct behavior + directResult.forEach(date => expect([2, 3]).toContain(date.getHours())) + calculatorResult.forEach(date => expect([2, 3]).toContain(date.getHours())) + + // Results should be identical + directResult.forEach((directDate, index) => { + expect(calculatorResult[index].getTime()).toBe(directDate.getTime()) + }) + }) + + it('complex timezone scenarios', () => { + const scenarios = [ + { tz: 'Asia/Kolkata', expr: '30 14 * * *', expectedHour: 14, expectedMinute: 30 }, // UTC+5:30 + { tz: 'Australia/Adelaide', expr: '0 8 * * *', expectedHour: 8, expectedMinute: 0 }, // UTC+9:30/+10:30 + { tz: 'Pacific/Kiritimati', expr: '0 12 * * *', expectedHour: 12, expectedMinute: 0 }, // UTC+14 + ] + + scenarios.forEach(({ tz, expr, expectedHour, expectedMinute }) => { + const directResult = parseCronExpression(expr, tz) + const data = createCronData({ cron_expression: expr, timezone: tz }) + const calculatorResult = getNextExecutionTimes(data, 2) + + expect(directResult.length).toBeGreaterThan(0) + expect(calculatorResult.length).toBeGreaterThan(0) + + // Validate expected time + directResult.forEach((date) => { + expect(date.getHours()).toBe(expectedHour) + expect(date.getMinutes()).toBe(expectedMinute) + }) + + calculatorResult.forEach((date) => { + expect(date.getHours()).toBe(expectedHour) + expect(date.getMinutes()).toBe(expectedMinute) + }) + + // Cross-validate consistency + expect(calculatorResult[0].getTime()).toBe(directResult[0].getTime()) + }) + }) + }) + + describe('performance and reliability', () => { + it('handles high-frequency expressions efficiently', () => { + const highFreqExpressions = [ + '*/1 * * * *', // Every minute + '*/5 * * * *', // Every 5 minutes + '0,15,30,45 * * * *', // Every 15 minutes + ] + + highFreqExpressions.forEach((expr) => { + const start = performance.now() + + // Test both direct and through calculator + const directResult = parseCronExpression(expr, 'UTC') + const data = createCronData({ cron_expression: expr }) + const calculatorResult = getNextExecutionTimes(data, 5) + + const end = performance.now() + + expect(directResult).toHaveLength(5) + expect(calculatorResult).toHaveLength(5) + expect(end - start).toBeLessThan(100) // Should be fast + + // Results should be consistent + directResult.forEach((directDate, index) => { + expect(calculatorResult[index].getTime()).toBe(directDate.getTime()) + }) + }) + }) + + it('stress test with complex expressions', () => { + const complexExpressions = [ + '15,45 8-18 1,15 JAN-MAR MON-FRI', // Business hours, specific days, Q1, weekdays + '0 */2 ? * SUN#1,SUN#3', // First and third Sunday, every 2 hours + '30 9 L * *', // Last day of month, 9:30 AM + ] + + complexExpressions.forEach((expr) => { + if (isValidCronExpression(expr)) { + const directResult = parseCronExpression(expr, 'America/New_York') + const data = createCronData({ + cron_expression: expr, + timezone: 'America/New_York', + }) + const calculatorResult = getNextExecutionTimes(data, 3) + + expect(directResult.length).toBeGreaterThan(0) + expect(calculatorResult.length).toBeGreaterThan(0) + + // Validate consistency where results exist + const minLength = Math.min(directResult.length, calculatorResult.length) + for (let i = 0; i < minLength; i++) + expect(calculatorResult[i].getTime()).toBe(directResult[i].getTime()) + } + }) + }) + }) + + describe('format compatibility', () => { + it('getNextExecutionTime formatting consistency', () => { + const testCases = [ + { expr: '0 9 * * *', timezone: 'UTC' }, + { expr: '30 14 * * 1-5', timezone: 'America/New_York' }, + { expr: '@daily', timezone: 'Asia/Tokyo' }, + ] + + testCases.forEach(({ expr, timezone }) => { + const data = createCronData({ cron_expression: expr, timezone }) + const timeString = getNextExecutionTime(data) + + // Should return a formatted time string, not '--' + expect(timeString).not.toBe('--') + expect(typeof timeString).toBe('string') + expect(timeString.length).toBeGreaterThan(0) + + // Should contain expected format elements + expect(timeString).toMatch(/\d+:\d+/) // Time format + expect(timeString).toMatch(/AM|PM/) // 12-hour format + expect(timeString).toMatch(/\d{4}/) // Year + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx new file mode 100644 index 0000000000..235593d7f3 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx @@ -0,0 +1,297 @@ +'use client' +import type { FC, ReactNode } from 'react' +import React, { useCallback, useMemo } from 'react' +import { RiDeleteBinLine } from '@remixicon/react' +import Input from '@/app/components/base/input' +import Checkbox from '@/app/components/base/checkbox' +import { SimpleSelect } from '@/app/components/base/select' +import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var' +import cn from '@/utils/classnames' + +// Tiny utility to judge whether a cell value is effectively present +const isPresent = (v: unknown): boolean => { + if (typeof v === 'string') return v.trim() !== '' + return !(v === '' || v === null || v === undefined || v === false) +} +// Column configuration types for table components +export type ColumnType = 'input' | 'select' | 'switch' | 'custom' + +export type SelectOption = { + name: string + value: string +} + +export type ColumnConfig = { + key: string + title: string + type: ColumnType + width?: string // CSS class for width (e.g., 'w-1/2', 'w-[140px]') + placeholder?: string + options?: SelectOption[] // For select type + render?: (value: unknown, row: GenericTableRow, index: number, onChange: (value: unknown) => void) => ReactNode + required?: boolean +} + +export type GenericTableRow = { + [key: string]: unknown +} + +type GenericTableProps = { + title: string + columns: ColumnConfig[] + data: GenericTableRow[] + onChange: (data: GenericTableRow[]) => void + readonly?: boolean + placeholder?: string + emptyRowData: GenericTableRow // Template for new empty rows + className?: string + showHeader?: boolean // Whether to show column headers +} + +// Internal type for stable mapping between rendered rows and data indices +type DisplayRow = { + row: GenericTableRow + dataIndex: number | null // null indicates the trailing UI-only row + isVirtual: boolean // whether this row is the extra empty row for adding new items +} + +const GenericTable: FC<GenericTableProps> = ({ + title, + columns, + data, + onChange, + readonly = false, + placeholder, + emptyRowData, + className, + showHeader = false, +}) => { + // Build the rows to display while keeping a stable mapping to original data + const displayRows = useMemo<DisplayRow[]>(() => { + // Helper to check empty + const isEmptyRow = (r: GenericTableRow) => + Object.values(r).every(v => v === '' || v === null || v === undefined || v === false) + + if (readonly) + return data.map((r, i) => ({ row: r, dataIndex: i, isVirtual: false })) + + const hasData = data.length > 0 + const rows: DisplayRow[] = [] + + if (!hasData) { + // Initialize with exactly one empty row when there is no data + rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }) + return rows + } + + // Add configured rows, hide intermediate empty ones, keep mapping + data.forEach((r, i) => { + const isEmpty = isEmptyRow(r) + // Skip empty rows except the very last configured row + if (isEmpty && i < data.length - 1) + return + rows.push({ row: r, dataIndex: i, isVirtual: false }) + }) + + // If the last configured row has content, append a trailing empty row + const lastHasContent = !isEmptyRow(data[data.length - 1]) + if (lastHasContent) + rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true }) + + return rows + }, [data, emptyRowData, readonly]) + + const removeRow = useCallback((dataIndex: number) => { + if (readonly) return + if (dataIndex < 0 || dataIndex >= data.length) return // ignore virtual rows + const newData = data.filter((_, i) => i !== dataIndex) + onChange(newData) + }, [data, readonly, onChange]) + + const updateRow = useCallback((dataIndex: number | null, key: string, value: unknown) => { + if (readonly) return + + if (dataIndex !== null && dataIndex < data.length) { + // Editing existing configured row + const newData = [...data] + newData[dataIndex] = { ...newData[dataIndex], [key]: value } + onChange(newData) + return + } + + // Editing the trailing UI-only empty row: create a new configured row + const newRow = { ...emptyRowData, [key]: value } + const next = [...data, newRow] + onChange(next) + }, [data, emptyRowData, onChange, readonly]) + + // Determine the primary identifier column just once + const primaryKey = useMemo(() => ( + columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key' + ), [columns]) + + const renderCell = (column: ColumnConfig, row: GenericTableRow, dataIndex: number | null) => { + const value = row[column.key] + const handleChange = (newValue: unknown) => updateRow(dataIndex, column.key, newValue) + + switch (column.type) { + case 'input': + return ( + <Input + value={(value as string) || ''} + onChange={(e) => { + // Format variable names (replace spaces with underscores) + if (column.key === 'key' || column.key === 'name') + replaceSpaceWithUnderscoreInVarNameInput(e.target) + handleChange(e.target.value) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.currentTarget.blur() + } + }} + placeholder={column.placeholder} + disabled={readonly} + wrapperClassName="w-full min-w-0" + className={cn( + // Ghost/inline style: looks like plain text until focus/hover + 'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none', + 'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent', + 'system-sm-regular text-text-secondary placeholder:text-text-quaternary', + )} + /> + ) + + case 'select': + return ( + <SimpleSelect + items={column.options || []} + defaultValue={value as string | undefined} + onSelect={item => handleChange(item.value)} + disabled={readonly} + placeholder={column.placeholder} + hideChecked={false} + notClearable={true} + // wrapper provides compact height, trigger is transparent like text + wrapperClassName="h-6 w-full min-w-0" + className={cn( + 'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary', + 'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent', + )} + optionWrapClassName="w-26 min-w-26 z-[60] -ml-3" + /> + ) + + case 'switch': + return ( + <div className="flex h-7 items-center"> + <Checkbox + id={`${column.key}-${String(dataIndex ?? 'v')}`} + checked={Boolean(value)} + onCheck={() => handleChange(!value)} + disabled={readonly} + /> + </div> + ) + + case 'custom': + return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null + + default: + return null + } + } + + const renderTable = () => { + return ( + <div className="rounded-lg border border-divider-regular"> + {showHeader && ( + <div className="system-xs-medium-uppercase flex h-7 items-center leading-7 text-text-tertiary"> + {columns.map((column, index) => ( + <div + key={column.key} + className={cn( + 'h-full pl-3', + column.width && column.width.startsWith('w-') ? 'shrink-0' : 'flex-1', + column.width, + // Add right border except for last column + index < columns.length - 1 && 'border-r border-divider-regular', + )} + > + {column.title} + </div> + ))} + </div> + )} + <div className="divide-y divide-divider-subtle"> + {displayRows.map(({ row, dataIndex, isVirtual: _isVirtual }, renderIndex) => { + const rowKey = `row-${renderIndex}` + + // Check if primary identifier column has content + const primaryValue = row[primaryKey] + const hasContent = isPresent(primaryValue) + + return ( + <div + key={rowKey} + className={cn( + 'group relative flex border-t border-divider-regular', + hasContent ? 'hover:bg-state-destructive-hover' : 'hover:bg-state-base-hover', + )} + style={{ minHeight: '28px' }} + > + {columns.map((column, columnIndex) => ( + <div + key={column.key} + className={cn( + 'shrink-0 pl-3', + column.width, + // Add right border except for last column + columnIndex < columns.length - 1 && 'border-r border-divider-regular', + )} + > + {renderCell(column, row, dataIndex)} + </div> + ))} + {!readonly && dataIndex !== null && hasContent && ( + <div className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100"> + <button + type="button" + onClick={() => removeRow(dataIndex)} + className="p-1" + aria-label="Delete row" + > + <RiDeleteBinLine className="h-3.5 w-3.5 text-text-destructive" /> + </button> + </div> + )} + </div> + ) + })} + </div> + </div> + ) + } + + // Show placeholder only when readonly and there is no data configured + const showPlaceholder = readonly && data.length === 0 + + return ( + <div className={className}> + <div className="mb-3 flex items-center justify-between"> + <h4 className="system-sm-semibold-uppercase text-text-secondary">{title}</h4> + </div> + + {showPlaceholder ? ( + <div className="flex h-7 items-center justify-center rounded-lg border border-divider-regular bg-components-panel-bg text-xs font-normal leading-[18px] text-text-quaternary"> + {placeholder} + </div> + ) : ( + renderTable() + )} + </div> + ) +} + +export default React.memo(GenericTable) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx new file mode 100644 index 0000000000..25e3cd4137 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/header-table.tsx @@ -0,0 +1,78 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import GenericTable from './generic-table' +import type { ColumnConfig, GenericTableRow } from './generic-table' +import type { WebhookHeader } from '../types' + +type HeaderTableProps = { + readonly?: boolean + headers?: WebhookHeader[] + onChange: (headers: WebhookHeader[]) => void +} + +const HeaderTable: FC<HeaderTableProps> = ({ + readonly = false, + headers = [], + onChange, +}) => { + const { t } = useTranslation() + + // Define columns for header table - matching prototype design + const columns: ColumnConfig[] = [ + { + key: 'name', + title: t('workflow.nodes.triggerWebhook.varName'), + type: 'input', + width: 'flex-1', + placeholder: t('workflow.nodes.triggerWebhook.varNamePlaceholder'), + }, + { + key: 'required', + title: t('workflow.nodes.triggerWebhook.required'), + type: 'switch', + width: 'w-[88px]', + }, + ] + + // No default prefilled row; table initializes with one empty row + + // Empty row template for new rows + const emptyRowData: GenericTableRow = { + name: '', + required: false, + } + + // Convert WebhookHeader[] to GenericTableRow[] + const tableData: GenericTableRow[] = headers.map(header => ({ + name: header.name, + required: header.required, + })) + + // Handle data changes + const handleDataChange = (data: GenericTableRow[]) => { + const newHeaders: WebhookHeader[] = data + .filter(row => row.name && typeof row.name === 'string' && row.name.trim() !== '') + .map(row => ({ + name: (row.name as string) || '', + required: !!row.required, + })) + onChange(newHeaders) + } + + return ( + <GenericTable + title="Header Parameters" + columns={columns} + data={tableData} + onChange={handleDataChange} + readonly={readonly} + placeholder={t('workflow.nodes.triggerWebhook.noHeaders')} + emptyRowData={emptyRowData} + showHeader={true} + /> + ) +} + +export default React.memo(HeaderTable) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/paragraph-input.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/paragraph-input.tsx new file mode 100644 index 0000000000..f3946f5d3d --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/paragraph-input.tsx @@ -0,0 +1,57 @@ +'use client' +import type { FC } from 'react' +import React, { useRef } from 'react' +import cn from '@/utils/classnames' + +type ParagraphInputProps = { + value: string + onChange: (value: string) => void + placeholder?: string + disabled?: boolean + className?: string +} + +const ParagraphInput: FC<ParagraphInputProps> = ({ + value, + onChange, + placeholder, + disabled = false, + className, +}) => { + const textareaRef = useRef<HTMLTextAreaElement>(null) + + const lines = value ? value.split('\n') : [''] + const lineCount = Math.max(3, lines.length) + + return ( + <div className={cn('rounded-xl bg-components-input-bg-normal px-3 pb-2 pt-3', className)}> + <div className="relative"> + <div className="pointer-events-none absolute left-0 top-0 flex flex-col"> + {Array.from({ length: lineCount }, (_, index) => ( + <span + key={index} + className="flex h-[20px] select-none items-center font-mono text-xs leading-[20px] text-text-quaternary" + > + {String(index + 1).padStart(2, '0')} + </span> + ))} + </div> + <textarea + ref={textareaRef} + value={value} + onChange={e => onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className="w-full resize-none border-0 bg-transparent pl-6 font-mono text-xs leading-[20px] text-text-secondary outline-none placeholder:text-text-quaternary" + style={{ + minHeight: `${Math.max(3, lineCount) * 20}px`, + lineHeight: '20px', + }} + rows={Math.max(3, lineCount)} + /> + </div> + </div> + ) +} + +export default React.memo(ParagraphInput) diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx new file mode 100644 index 0000000000..bf030c4340 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx @@ -0,0 +1,112 @@ +'use client' +import type { FC } from 'react' +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import GenericTable from './generic-table' +import type { ColumnConfig, GenericTableRow } from './generic-table' +import type { WebhookParameter } from '../types' +import { createParameterTypeOptions, normalizeParameterType } from '../utils/parameter-type-utils' +import { VarType } from '@/app/components/workflow/types' + +type ParameterTableProps = { + title: string + parameters: WebhookParameter[] + onChange: (params: WebhookParameter[]) => void + readonly?: boolean + placeholder?: string + contentType?: string +} + +const ParameterTable: FC<ParameterTableProps> = ({ + title, + parameters, + onChange, + readonly, + placeholder, + contentType, +}) => { + const { t } = useTranslation() + + // Memoize typeOptions to prevent unnecessary re-renders that cause SimpleSelect state resets + const typeOptions = useMemo(() => + createParameterTypeOptions(contentType), + [contentType], + ) + + // Define columns based on component type - matching prototype design + const columns: ColumnConfig[] = [ + { + key: 'key', + title: t('workflow.nodes.triggerWebhook.varName'), + type: 'input', + width: 'flex-1', + placeholder: t('workflow.nodes.triggerWebhook.varNamePlaceholder'), + }, + { + key: 'type', + title: t('workflow.nodes.triggerWebhook.varType'), + type: 'select', + width: 'w-[120px]', + placeholder: t('workflow.nodes.triggerWebhook.varType'), + options: typeOptions, + }, + { + key: 'required', + title: t('workflow.nodes.triggerWebhook.required'), + type: 'switch', + width: 'w-[88px]', + }, + ] + + // Choose sensible default type for new rows according to content type + const defaultTypeValue: VarType = typeOptions[0]?.value || 'string' + + // Empty row template for new rows + const emptyRowData: GenericTableRow = { + key: '', + type: defaultTypeValue, + required: false, + } + + const tableData: GenericTableRow[] = parameters.map(param => ({ + key: param.name, + type: param.type, + required: param.required, + })) + + const handleDataChange = (data: GenericTableRow[]) => { + // For text/plain, enforce single text body semantics: keep only first non-empty row and force string type + // For application/octet-stream, enforce single file body semantics: keep only first non-empty row and force file type + const isTextPlain = (contentType || '').toLowerCase() === 'text/plain' + const isOctetStream = (contentType || '').toLowerCase() === 'application/octet-stream' + + const normalized = data + .filter(row => typeof row.key === 'string' && (row.key as string).trim() !== '') + .map(row => ({ + name: String(row.key), + type: isTextPlain ? VarType.string : isOctetStream ? VarType.file : normalizeParameterType((row.type as string)), + required: Boolean(row.required), + })) + + const newParams: WebhookParameter[] = (isTextPlain || isOctetStream) + ? normalized.slice(0, 1) + : normalized + + onChange(newParams) + } + + return ( + <GenericTable + title={title} + columns={columns} + data={tableData} + onChange={handleDataChange} + readonly={readonly} + placeholder={placeholder || t('workflow.nodes.triggerWebhook.noParameters')} + emptyRowData={emptyRowData} + showHeader={true} + /> + ) +} + +export default ParameterTable diff --git a/web/app/components/workflow/nodes/trigger-webhook/default.ts b/web/app/components/workflow/nodes/trigger-webhook/default.ts new file mode 100644 index 0000000000..5071a79913 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/default.ts @@ -0,0 +1,64 @@ +import { BlockEnum } from '../../types' +import type { NodeDefault } from '../../types' +import { genNodeMetaData } from '../../utils' +import type { WebhookTriggerNodeType } from './types' +import { isValidParameterType } from './utils/parameter-type-utils' +import { createWebhookRawVariable } from './utils/raw-variable' + +const metaData = genNodeMetaData({ + sort: 3, + type: BlockEnum.TriggerWebhook, + helpLinkUri: 'webhook-trigger', + isStart: true, +}) + +const nodeDefault: NodeDefault<WebhookTriggerNodeType> = { + metaData, + defaultValue: { + webhook_url: '', + method: 'POST', + content_type: 'application/json', + headers: [], + params: [], + body: [], + async_mode: true, + status_code: 200, + response_body: '', + variables: [createWebhookRawVariable()], + }, + checkValid(payload: WebhookTriggerNodeType, t: any) { + // Require webhook_url to be configured + if (!payload.webhook_url || payload.webhook_url.trim() === '') { + return { + isValid: false, + errorMessage: t('workflow.nodes.triggerWebhook.validation.webhookUrlRequired'), + } + } + + // Validate parameter types for params and body + const parametersWithTypes = [ + ...(payload.params || []), + ...(payload.body || []), + ] + + for (const param of parametersWithTypes) { + // Validate parameter type is valid + if (!isValidParameterType(param.type)) { + return { + isValid: false, + errorMessage: t('workflow.nodes.triggerWebhook.validation.invalidParameterType', { + name: param.name, + type: param.type, + }), + } + } + } + + return { + isValid: true, + errorMessage: '', + } + }, +} + +export default nodeDefault diff --git a/web/app/components/workflow/nodes/trigger-webhook/node.tsx b/web/app/components/workflow/nodes/trigger-webhook/node.tsx new file mode 100644 index 0000000000..40c3b441da --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/node.tsx @@ -0,0 +1,25 @@ +import type { FC } from 'react' +import React from 'react' +import type { WebhookTriggerNodeType } from './types' +import type { NodeProps } from '@/app/components/workflow/types' + +const Node: FC<NodeProps<WebhookTriggerNodeType>> = ({ + data, +}) => { + return ( + <div className="mb-1 px-3 py-1"> + <div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-text-tertiary"> + URL + </div> + <div className="flex h-[26px] items-center rounded-md bg-workflow-block-parma-bg px-2 text-xs text-text-secondary"> + <div className="w-0 grow"> + <div className="truncate" title={data.webhook_url || '--'}> + {data.webhook_url || '--'} + </div> + </div> + </div> + </div> + ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx new file mode 100644 index 0000000000..1de18bd806 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx @@ -0,0 +1,240 @@ +import type { FC } from 'react' +import React, { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import type { HttpMethod, WebhookTriggerNodeType } from './types' +import useConfig from './use-config' +import ParameterTable from './components/parameter-table' +import HeaderTable from './components/header-table' +import ParagraphInput from './components/paragraph-input' +import { OutputVariablesContent } from './utils/render-output-vars' +import Field from '@/app/components/workflow/nodes/_base/components/field' +import Split from '@/app/components/workflow/nodes/_base/components/split' +import OutputVars from '@/app/components/workflow/nodes/_base/components/output-vars' +import type { NodePanelProps } from '@/app/components/workflow/types' +import InputWithCopy from '@/app/components/base/input-with-copy' +import { InputNumber } from '@/app/components/base/input-number' +import { SimpleSelect } from '@/app/components/base/select' +import Toast from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' +import copy from 'copy-to-clipboard' +import { isPrivateOrLocalAddress } from '@/utils/urlValidation' + +const i18nPrefix = 'workflow.nodes.triggerWebhook' + +const HTTP_METHODS = [ + { name: 'GET', value: 'GET' }, + { name: 'POST', value: 'POST' }, + { name: 'PUT', value: 'PUT' }, + { name: 'DELETE', value: 'DELETE' }, + { name: 'PATCH', value: 'PATCH' }, + { name: 'HEAD', value: 'HEAD' }, +] + +const CONTENT_TYPES = [ + { name: 'application/json', value: 'application/json' }, + { name: 'application/x-www-form-urlencoded', value: 'application/x-www-form-urlencoded' }, + { name: 'text/plain', value: 'text/plain' }, + { name: 'application/octet-stream', value: 'application/octet-stream' }, + { name: 'multipart/form-data', value: 'multipart/form-data' }, +] + +const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({ + id, + data, +}) => { + const { t } = useTranslation() + const [debugUrlCopied, setDebugUrlCopied] = React.useState(false) + const [outputVarsCollapsed, setOutputVarsCollapsed] = useState(false) + const { + readOnly, + inputs, + handleMethodChange, + handleContentTypeChange, + handleHeadersChange, + handleParamsChange, + handleBodyChange, + handleStatusCodeChange, + handleStatusCodeBlur, + handleResponseBodyChange, + generateWebhookUrl, + } = useConfig(id, data) + + // Ensure we only attempt to generate URL once for a newly created node without url + const hasRequestedUrlRef = useRef(false) + useEffect(() => { + if (!readOnly && !inputs.webhook_url && !hasRequestedUrlRef.current) { + hasRequestedUrlRef.current = true + void generateWebhookUrl() + } + }, [readOnly, inputs.webhook_url, generateWebhookUrl]) + + return ( + <div className='mt-2'> + <div className='space-y-4 px-4 pb-3 pt-2'> + {/* Webhook URL Section */} + <Field title={t(`${i18nPrefix}.webhookUrl`)}> + <div className="space-y-1"> + <div className="flex gap-1" style={{ height: '32px' }}> + <div className="w-26 shrink-0"> + <SimpleSelect + items={HTTP_METHODS} + defaultValue={inputs.method} + onSelect={item => handleMethodChange(item.value as HttpMethod)} + disabled={readOnly} + className="h-8 pr-8 text-sm" + wrapperClassName="h-8" + optionWrapClassName="w-26 min-w-26 z-[5]" + allowSearch={false} + notClearable={true} + /> + </div> + <div className="flex-1" style={{ width: '284px' }}> + <InputWithCopy + value={inputs.webhook_url || ''} + placeholder={t(`${i18nPrefix}.webhookUrlPlaceholder`)} + readOnly + onCopy={() => { + Toast.notify({ + type: 'success', + message: t(`${i18nPrefix}.urlCopied`), + }) + }} + /> + </div> + </div> + {inputs.webhook_debug_url && ( + <div className="space-y-2"> + <Tooltip + popupContent={debugUrlCopied ? t(`${i18nPrefix}.debugUrlCopied`) : t(`${i18nPrefix}.debugUrlCopy`)} + popupClassName="system-xs-regular text-text-primary bg-components-tooltip-bg border border-components-panel-border shadow-lg backdrop-blur-sm rounded-md px-1.5 py-1" + position="top" + offset={{ mainAxis: -20 }} + needsDelay={true} + > + <div + className="flex cursor-pointer gap-1.5 rounded-lg px-1 py-1.5 transition-colors" + style={{ width: '368px', height: '38px' }} + onClick={() => { + copy(inputs.webhook_debug_url || '') + setDebugUrlCopied(true) + setTimeout(() => setDebugUrlCopied(false), 2000) + }} + > + <div className="mt-0.5 w-0.5 bg-divider-regular" style={{ height: '28px' }}></div> + <div className="flex-1" style={{ width: '352px', height: '32px' }}> + <div className="text-xs leading-4 text-text-tertiary"> + {t(`${i18nPrefix}.debugUrlTitle`)} + </div> + <div className="truncate text-xs leading-4 text-text-primary"> + {inputs.webhook_debug_url} + </div> + </div> + </div> + </Tooltip> + {isPrivateOrLocalAddress(inputs.webhook_debug_url) && ( + <div className="system-xs-regular mt-1 px-0 py-[2px] text-text-warning"> + {t(`${i18nPrefix}.debugUrlPrivateAddressWarning`)} + </div> + )} + </div> + )} + </div> + </Field> + + {/* Content Type */} + <Field title={t(`${i18nPrefix}.contentType`)}> + <div className="w-full"> + <SimpleSelect + items={CONTENT_TYPES} + defaultValue={inputs.content_type} + onSelect={item => handleContentTypeChange(item.value as string)} + disabled={readOnly} + className="h-8 text-sm" + wrapperClassName="h-8" + optionWrapClassName="min-w-48 z-[5]" + allowSearch={false} + notClearable={true} + /> + </div> + </Field> + + {/* Query Parameters */} + <ParameterTable + readonly={readOnly} + title="Query Parameters" + parameters={inputs.params} + onChange={handleParamsChange} + placeholder={t(`${i18nPrefix}.noQueryParameters`)} + /> + + {/* Header Parameters */} + <HeaderTable + readonly={readOnly} + headers={inputs.headers} + onChange={handleHeadersChange} + /> + + {/* Request Body Parameters */} + <ParameterTable + readonly={readOnly} + title="Request Body Parameters" + parameters={inputs.body} + onChange={handleBodyChange} + placeholder={t(`${i18nPrefix}.noBodyParameters`)} + contentType={inputs.content_type} + /> + + <Split /> + + {/* Response Configuration */} + <Field title={t(`${i18nPrefix}.responseConfiguration`)}> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <label className="system-sm-medium text-text-tertiary"> + {t(`${i18nPrefix}.statusCode`)} + </label> + <InputNumber + value={inputs.status_code} + onChange={(value) => { + handleStatusCodeChange(value || 200) + }} + disabled={readOnly} + wrapClassName="w-[120px]" + className="h-8" + defaultValue={200} + onBlur={() => { + handleStatusCodeBlur(inputs.status_code) + }} + /> + </div> + <div> + <label className="system-sm-medium mb-2 block text-text-tertiary"> + {t(`${i18nPrefix}.responseBody`)} + </label> + <ParagraphInput + value={inputs.response_body} + onChange={handleResponseBodyChange} + placeholder={t(`${i18nPrefix}.responseBodyPlaceholder`)} + disabled={readOnly} + /> + </div> + </div> + </Field> + </div> + + <Split /> + + <div className=''> + <OutputVars + collapsed={outputVarsCollapsed} + onCollapse={setOutputVarsCollapsed} + > + <OutputVariablesContent variables={inputs.variables} /> + </OutputVars> + </div> + </div> + ) +} + +export default Panel diff --git a/web/app/components/workflow/nodes/trigger-webhook/types.ts b/web/app/components/workflow/nodes/trigger-webhook/types.ts new file mode 100644 index 0000000000..d9632f20e1 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/types.ts @@ -0,0 +1,35 @@ +import type { CommonNodeType, VarType, Variable } from '@/app/components/workflow/types' + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' + +export type ArrayElementType = 'string' | 'number' | 'boolean' | 'object' + +export const getArrayElementType = (arrayType: `array[${ArrayElementType}]`): ArrayElementType => { + const match = arrayType.match(/^array\[(.+)\]$/) + return (match?.[1] as ArrayElementType) || 'string' +} + +export type WebhookParameter = { + name: string + type: VarType + required: boolean +} + +export type WebhookHeader = { + name: string + required: boolean +} + +export type WebhookTriggerNodeType = CommonNodeType & { + webhook_url?: string + webhook_debug_url?: string + method: HttpMethod + content_type: string + headers: WebhookHeader[] + params: WebhookParameter[] + body: WebhookParameter[] + async_mode: boolean + status_code: number + response_body: string + variables: Variable[] +} diff --git a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts new file mode 100644 index 0000000000..9b525ec758 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts @@ -0,0 +1,251 @@ +import { useCallback } from 'react' +import { produce } from 'immer' +import { useTranslation } from 'react-i18next' +import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types' + +import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' +import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useStore as useAppStore } from '@/app/components/app/store' +import { fetchWebhookUrl } from '@/service/apps' +import type { Variable } from '@/app/components/workflow/types' +import { VarType } from '@/app/components/workflow/types' +import Toast from '@/app/components/base/toast' +import { checkKeys, hasDuplicateStr } from '@/utils/var' +import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable' + +const useConfig = (id: string, payload: WebhookTriggerNodeType) => { + const { t } = useTranslation() + const { nodesReadOnly: readOnly } = useNodesReadOnly() + const { inputs, setInputs } = useNodeCrud<WebhookTriggerNodeType>(id, payload) + const appId = useAppStore.getState().appDetail?.id + const { isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow() + + const handleMethodChange = useCallback((method: HttpMethod) => { + setInputs(produce(inputs, (draft) => { + draft.method = method + })) + }, [inputs, setInputs]) + + const handleContentTypeChange = useCallback((contentType: string) => { + setInputs(produce(inputs, (draft) => { + const previousContentType = draft.content_type + draft.content_type = contentType + + // If the content type changes, reset body parameters and their variables, as the variable types might differ. + // However, we could consider retaining variables that are compatible with the new content type later. + if (previousContentType !== contentType) { + draft.body = [] + if (draft.variables) { + const bodyVariables = draft.variables.filter(v => v.label === 'body') + bodyVariables.forEach((v) => { + if (isVarUsedInNodes([id, v.variable])) + removeUsedVarInNodes([id, v.variable]) + }) + + draft.variables = draft.variables.filter(v => v.label !== 'body') + } + } + })) + }, [inputs, setInputs, id, isVarUsedInNodes, removeUsedVarInNodes]) + + const syncVariablesInDraft = useCallback(( + draft: WebhookTriggerNodeType, + newData: (WebhookParameter | WebhookHeader)[], + sourceType: 'param' | 'header' | 'body', + ) => { + if (!draft.variables) + draft.variables = [] + + const sanitizedEntries = newData.map(item => ({ + item, + sanitizedName: sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name, + })) + + const hasReservedConflict = sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME) + if (hasReservedConflict) { + Toast.notify({ + type: 'error', + message: t('appDebug.varKeyError.keyAlreadyExists', { + key: t('appDebug.variableConfig.varName'), + }), + }) + return false + } + const existingOtherVarNames = new Set( + draft.variables + .filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME) + .map(v => v.variable), + ) + + const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName)) + if (crossScopeConflict) { + Toast.notify({ + type: 'error', + message: t('appDebug.varKeyError.keyAlreadyExists', { + key: crossScopeConflict.sanitizedName, + }), + }) + return false + } + + if(hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) { + Toast.notify({ + type: 'error', + message: t('appDebug.varKeyError.keyAlreadyExists', { + key: t('appDebug.variableConfig.varName'), + }), + }) + return false + } + + for (const { sanitizedName } of sanitizedEntries) { + const { isValid, errorMessageKey } = checkKeys([sanitizedName], false) + if (!isValid) { + Toast.notify({ + type: 'error', + message: t(`appDebug.varKeyError.${errorMessageKey}`, { + key: t('appDebug.variableConfig.varName'), + }), + }) + return false + } + } + + // Create set of new variable names for this source + const newVarNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName)) + + // Find variables from current source that will be deleted and clean up references + draft.variables + .filter(v => v.label === sourceType && !newVarNames.has(v.variable)) + .forEach((v) => { + // Clean up references if variable is used in other nodes + if (isVarUsedInNodes([id, v.variable])) + removeUsedVarInNodes([id, v.variable]) + }) + + // Remove variables that no longer exist in newData for this specific source type + draft.variables = draft.variables.filter((v) => { + // Keep variables from other sources + if (v.label !== sourceType) return true + return newVarNames.has(v.variable) + }) + + // Add or update variables + sanitizedEntries.forEach(({ item, sanitizedName }) => { + const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName) + + const inputVarType = 'type' in item + ? item.type + : VarType.string // Default to string for headers + + const newVar: Variable = { + value_type: inputVarType, + label: sourceType, // Use sourceType as label to identify source + variable: sanitizedName, + value_selector: [], + required: item.required, + } + + if (existingVarIndex >= 0) + draft.variables[existingVarIndex] = newVar + else + draft.variables.push(newVar) + }) + return true + }, [t, id, isVarUsedInNodes, removeUsedVarInNodes]) + + const handleParamsChange = useCallback((params: WebhookParameter[]) => { + setInputs(produce(inputs, (draft) => { + draft.params = params + syncVariablesInDraft(draft, params, 'param') + })) + }, [inputs, setInputs, syncVariablesInDraft]) + + const handleHeadersChange = useCallback((headers: WebhookHeader[]) => { + setInputs(produce(inputs, (draft) => { + draft.headers = headers + syncVariablesInDraft(draft, headers, 'header') + })) + }, [inputs, setInputs, syncVariablesInDraft]) + + const handleBodyChange = useCallback((body: WebhookParameter[]) => { + setInputs(produce(inputs, (draft) => { + draft.body = body + syncVariablesInDraft(draft, body, 'body') + })) + }, [inputs, setInputs, syncVariablesInDraft]) + + const handleAsyncModeChange = useCallback((asyncMode: boolean) => { + setInputs(produce(inputs, (draft) => { + draft.async_mode = asyncMode + })) + }, [inputs, setInputs]) + + const handleStatusCodeChange = useCallback((statusCode: number) => { + setInputs(produce(inputs, (draft) => { + draft.status_code = statusCode + })) + }, [inputs, setInputs]) + + const handleStatusCodeBlur = useCallback((statusCode: number) => { + // Only clamp when user finishes editing (on blur) + const clampedStatusCode = Math.min(Math.max(statusCode, 200), 399) + + setInputs(produce(inputs, (draft) => { + draft.status_code = clampedStatusCode + })) + }, [inputs, setInputs]) + + const handleResponseBodyChange = useCallback((responseBody: string) => { + setInputs(produce(inputs, (draft) => { + draft.response_body = responseBody + })) + }, [inputs, setInputs]) + + const generateWebhookUrl = useCallback(async () => { + // Idempotency: if we already have a URL, just return it. + if (inputs.webhook_url && inputs.webhook_url.length > 0) + return + + if (!appId) + return + + try { + // Call backend to generate or fetch webhook url for this node + const response = await fetchWebhookUrl({ appId, nodeId: id }) + + const newInputs = produce(inputs, (draft) => { + draft.webhook_url = response.webhook_url + draft.webhook_debug_url = response.webhook_debug_url + }) + setInputs(newInputs) + } + catch (error: unknown) { + // Fallback to mock URL when API is not ready or request fails + // Keep the UI unblocked and allow users to proceed in local/dev environments. + console.error('Failed to generate webhook URL:', error) + const newInputs = produce(inputs, (draft) => { + draft.webhook_url = '' + }) + setInputs(newInputs) + } + }, [appId, id, inputs, setInputs]) + + return { + readOnly, + inputs, + setInputs, + handleMethodChange, + handleContentTypeChange, + handleHeadersChange, + handleParamsChange, + handleBodyChange, + handleAsyncModeChange, + handleStatusCodeChange, + handleStatusCodeBlur, + handleResponseBodyChange, + generateWebhookUrl, + } +} + +export default useConfig diff --git a/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts new file mode 100644 index 0000000000..10f61a5e22 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts @@ -0,0 +1,125 @@ +import { VarType } from '@/app/components/workflow/types' + +// Constants for better maintainability and reusability +const BASIC_TYPES = [VarType.string, VarType.number, VarType.boolean, VarType.object, VarType.file] as const +const ARRAY_ELEMENT_TYPES = [VarType.arrayString, VarType.arrayNumber, VarType.arrayBoolean, VarType.arrayObject] as const + +// Generate all valid parameter types programmatically +const VALID_PARAMETER_TYPES: readonly VarType[] = [ + ...BASIC_TYPES, + ...ARRAY_ELEMENT_TYPES, +] as const + +// Type display name mappings +const TYPE_DISPLAY_NAMES: Record<VarType, string> = { + [VarType.string]: 'String', + [VarType.number]: 'Number', + [VarType.boolean]: 'Boolean', + [VarType.object]: 'Object', + [VarType.file]: 'File', + [VarType.arrayString]: 'Array[String]', + [VarType.arrayNumber]: 'Array[Number]', + [VarType.arrayBoolean]: 'Array[Boolean]', + [VarType.arrayObject]: 'Array[Object]', + [VarType.secret]: 'Secret', + [VarType.array]: 'Array', + 'array[file]': 'Array[File]', + [VarType.any]: 'Any', + 'array[any]': 'Array[Any]', + [VarType.integer]: 'Integer', +} as const + +// Content type configurations +const CONTENT_TYPE_CONFIGS = { + 'application/json': { + supportedTypes: [...BASIC_TYPES.filter(t => t !== 'file'), ...ARRAY_ELEMENT_TYPES], + description: 'JSON supports all types including arrays', + }, + 'text/plain': { + supportedTypes: [VarType.string] as const, + description: 'Plain text only supports string', + }, + 'application/x-www-form-urlencoded': { + supportedTypes: [VarType.string, VarType.number, VarType.boolean] as const, + description: 'Form data supports basic types', + }, + 'application/octet-stream': { + supportedTypes: [VarType.file] as const, + description: 'octet-stream supports only binary data', + }, + 'multipart/form-data': { + supportedTypes: [VarType.string, VarType.number, VarType.boolean, VarType.file] as const, + description: 'Multipart supports basic types plus files', + }, +} as const + +/** + * Type guard to check if a string is a valid parameter type + */ +export const isValidParameterType = (type: string): type is VarType => { + return (VALID_PARAMETER_TYPES as readonly string[]).includes(type) +} + +export const normalizeParameterType = (input: string | undefined | null): VarType => { + if (!input || typeof input !== 'string') + return VarType.string + + const trimmed = input.trim().toLowerCase() + if (trimmed === 'array[string]') + return VarType.arrayString + else if (trimmed === 'array[number]') + return VarType.arrayNumber + else if (trimmed === 'array[boolean]') + return VarType.arrayBoolean + else if (trimmed === 'array[object]') + return VarType.arrayObject + else if (trimmed === 'array') + // Migrate legacy 'array' type to 'array[string]' + return VarType.arrayString + else if (trimmed === 'number') + return VarType.number + else if (trimmed === 'boolean') + return VarType.boolean + else if (trimmed === 'object') + return VarType.object + else if (trimmed === 'file') + return VarType.file + + return VarType.string +} + +/** + * Gets display name for parameter types in UI components + */ +export const getParameterTypeDisplayName = (type: VarType): string => { + return TYPE_DISPLAY_NAMES[type] +} + +/** + * Gets available parameter types based on content type + * Provides context-aware type filtering for different webhook content types + */ +export const getAvailableParameterTypes = (contentType?: string): VarType[] => { + if (!contentType) + return [VarType.string, VarType.number, VarType.boolean] + + const normalizedContentType = (contentType || '').toLowerCase() + const configKey = normalizedContentType in CONTENT_TYPE_CONFIGS + ? normalizedContentType as keyof typeof CONTENT_TYPE_CONFIGS + : 'application/json' + + const config = CONTENT_TYPE_CONFIGS[configKey] + return [...config.supportedTypes] +} + +/** + * Creates type options for UI select components + */ +export const createParameterTypeOptions = (contentType?: string) => { + const availableTypes = getAvailableParameterTypes(contentType) + + return availableTypes.map(type => ({ + name: getParameterTypeDisplayName(type), + value: type, + })) +} diff --git a/web/app/components/workflow/nodes/trigger-webhook/utils/raw-variable.ts b/web/app/components/workflow/nodes/trigger-webhook/utils/raw-variable.ts new file mode 100644 index 0000000000..2be7d4c65f --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/utils/raw-variable.ts @@ -0,0 +1,12 @@ +import { VarType, type Variable } from '@/app/components/workflow/types' + +export const WEBHOOK_RAW_VARIABLE_NAME = '_webhook_raw' +export const WEBHOOK_RAW_VARIABLE_LABEL = 'raw' + +export const createWebhookRawVariable = (): Variable => ({ + variable: WEBHOOK_RAW_VARIABLE_NAME, + label: WEBHOOK_RAW_VARIABLE_LABEL, + value_type: VarType.object, + value_selector: [], + required: true, +}) diff --git a/web/app/components/workflow/nodes/trigger-webhook/utils/render-output-vars.tsx b/web/app/components/workflow/nodes/trigger-webhook/utils/render-output-vars.tsx new file mode 100644 index 0000000000..0e9cb8a309 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/utils/render-output-vars.tsx @@ -0,0 +1,75 @@ +import type { FC } from 'react' +import React from 'react' +import type { Variable } from '@/app/components/workflow/types' + +type OutputVariablesContentProps = { + variables?: Variable[] +} + +// Define the display order for variable labels to match the table order in the UI +const LABEL_ORDER = { raw: 0, param: 1, header: 2, body: 3 } as const + +const getLabelPrefix = (label: string): string => { + const prefixMap: Record<string, string> = { + raw: 'payload', + param: 'query_params', + header: 'header_params', + body: 'req_body_params', + } + return prefixMap[label] || label +} + +type VarItemProps = { + prefix: string + name: string + type: string +} + +const VarItem: FC<VarItemProps> = ({ prefix, name, type }) => { + return ( + <div className='py-1'> + <div className='flex items-center leading-[18px]'> + <span className='code-sm-regular text-text-tertiary'>{prefix}.</span> + <span className='code-sm-semibold text-text-secondary'>{name}</span> + <span className='system-xs-regular ml-2 text-text-tertiary'>{type}</span> + </div> + </div> + ) +} + +export const OutputVariablesContent: FC<OutputVariablesContentProps> = ({ variables = [] }) => { + if (!variables || variables.length === 0) { + return ( + <div className="system-sm-regular py-2 text-text-tertiary"> + No output variables + </div> + ) + } + + // Sort variables by label to match the table display order: param → header → body + // Unknown labels are placed at the end (order value 999) + const sortedVariables = [...variables].sort((a, b) => { + const labelA = typeof a.label === 'string' ? a.label : '' + const labelB = typeof b.label === 'string' ? b.label : '' + return (LABEL_ORDER[labelA as keyof typeof LABEL_ORDER] || 999) + - (LABEL_ORDER[labelB as keyof typeof LABEL_ORDER] || 999) + }) + + return ( + <div> + {sortedVariables.map((variable, index) => { + const label = typeof variable.label === 'string' ? variable.label : '' + const varName = typeof variable.variable === 'string' ? variable.variable : '' + + return ( + <VarItem + key={`${label}-${varName}-${index}`} + prefix={getLabelPrefix(label)} + name={varName} + type={variable.value_type || 'string'} + /> + ) + })} + </div> + ) +} diff --git a/web/app/components/workflow/operator/add-block.tsx b/web/app/components/workflow/operator/add-block.tsx index 3c11dbac32..c40f2277bb 100644 --- a/web/app/components/workflow/operator/add-block.tsx +++ b/web/app/components/workflow/operator/add-block.tsx @@ -13,10 +13,12 @@ import { } from '../utils' import { useAvailableBlocks, + useIsChatMode, useNodesMetaData, useNodesReadOnly, usePanelInteractions, } from '../hooks' +import { useHooksStore } from '../hooks-store' import { useWorkflowStore } from '../store' import TipPopup from './tip-popup' import cn from '@/utils/classnames' @@ -27,6 +29,7 @@ import type { import { BlockEnum, } from '@/app/components/workflow/types' +import { FlowType } from '@/types/common' type AddBlockProps = { renderTrigger?: (open: boolean) => React.ReactNode @@ -39,11 +42,14 @@ const AddBlock = ({ const { t } = useTranslation() const store = useStoreApi() const workflowStore = useWorkflowStore() + const isChatMode = useIsChatMode() const { nodesReadOnly } = useNodesReadOnly() const { handlePaneContextmenuCancel } = usePanelInteractions() const [open, setOpen] = useState(false) const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false) const { nodesMap: nodesMetaDataMap } = useNodesMetaData() + const flowType = useHooksStore(s => s.configsMap?.flowType) + const showStartTab = flowType !== FlowType.ragPipeline && !isChatMode const handleOpenChange = useCallback((open: boolean) => { setOpen(open) @@ -51,7 +57,7 @@ const AddBlock = ({ handlePaneContextmenuCancel() }, [handlePaneContextmenuCancel]) - const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => { + const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => { const { getNodes, } = store.getState() @@ -65,7 +71,7 @@ const AddBlock = ({ data: { ...(defaultValue as any), title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title, - ...toolDefaultValue, + ...pluginDefaultValue, _isCandidate: true, }, position: { @@ -108,6 +114,7 @@ const AddBlock = ({ trigger={renderTrigger || renderTriggerElement} popupClassName='!min-w-[256px]' availableBlocksTypes={availableNextBlocks} + showStartTab={showStartTab} /> ) } diff --git a/web/app/components/workflow/operator/control.tsx b/web/app/components/workflow/operator/control.tsx index cfc32bbc30..7f1225de86 100644 --- a/web/app/components/workflow/operator/control.tsx +++ b/web/app/components/workflow/operator/control.tsx @@ -24,7 +24,7 @@ import { useStore } from '../store' import Divider from '../../base/divider' import AddBlock from './add-block' import TipPopup from './tip-popup' -import ExportImage from './export-image' +import MoreActions from './more-actions' import { useOperator } from './hooks' import cn from '@/utils/classnames' @@ -89,7 +89,6 @@ const Control = () => { </div> </TipPopup> <Divider className='my-1 w-3.5' /> - <ExportImage /> <TipPopup title={t('workflow.panel.organizeBlocks')} shortcuts={['ctrl', 'o']}> <div className={cn( @@ -114,6 +113,7 @@ const Control = () => { {!maximizeCanvas && <RiAspectRatioLine className='h-4 w-4' />} </div> </TipPopup> + <MoreActions /> </div> ) } diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx index 1100a7a905..b4fcf184a7 100644 --- a/web/app/components/workflow/operator/index.tsx +++ b/web/app/components/workflow/operator/index.tsx @@ -1,4 +1,5 @@ -import { memo, useEffect, useMemo, useRef } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' +import type { Node } from 'reactflow' import { MiniMap } from 'reactflow' import UndoRedo from '../header/undo-redo' import ZoomInOut from './zoom-in-out' @@ -24,6 +25,12 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { return Math.max((workflowCanvasWidth - rightPanelWidth), 400) }, [workflowCanvasWidth, rightPanelWidth]) + const getMiniMapNodeClassName = useCallback((node: Node) => { + return node.data?.selected + ? 'bg-workflow-minimap-block border-components-option-card-option-selected-border' + : 'bg-workflow-minimap-block' + }, []) + // update bottom panel height useEffect(() => { if (bottomPanelRef.current) { @@ -65,6 +72,8 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { height: 72, }} maskColor='var(--color-workflow-minimap-bg)' + nodeClassName={getMiniMapNodeClassName} + nodeStrokeWidth={3} className='!absolute !bottom-10 z-[9] !m-0 !h-[73px] !w-[103px] !rounded-lg !border-[0.5px] !border-divider-subtle !bg-background-default-subtle !shadow-md !shadow-shadow-shadow-5' /> diff --git a/web/app/components/workflow/operator/export-image.tsx b/web/app/components/workflow/operator/more-actions.tsx similarity index 87% rename from web/app/components/workflow/operator/export-image.tsx rename to web/app/components/workflow/operator/more-actions.tsx index 9b85847fd6..100df29560 100644 --- a/web/app/components/workflow/operator/export-image.tsx +++ b/web/app/components/workflow/operator/more-actions.tsx @@ -2,13 +2,15 @@ import type { FC } from 'react' import { memo, useCallback, + useMemo, useState, } from 'react' +import { useShallow } from 'zustand/react/shallow' import { useTranslation } from 'react-i18next' +import { RiExportLine, RiMoreFill } from '@remixicon/react' import { toJpeg, toPng, toSvg } from 'html-to-image' import { useNodesReadOnly } from '../hooks' import TipPopup from './tip-popup' -import { RiExportLine } from '@remixicon/react' import cn from '@/utils/classnames' import { PortalToFollowElem, @@ -18,8 +20,9 @@ import { import { getNodesBounds, useReactFlow } from 'reactflow' import ImagePreview from '@/app/components/base/image-uploader/image-preview' import { useStore } from '@/app/components/workflow/store' +import { useStore as useAppStore } from '@/app/components/app/store' -const ExportImage: FC = () => { +const MoreActions: FC = () => { const { t } = useTranslation() const { getNodesReadOnly } = useNodesReadOnly() const reactFlow = useReactFlow() @@ -29,6 +32,15 @@ const ExportImage: FC = () => { const [previewTitle, setPreviewTitle] = useState('') const knowledgeName = useStore(s => s.knowledgeName) const appName = useStore(s => s.appName) + const maximizeCanvas = useStore(s => s.maximizeCanvas) + const { appSidebarExpand } = useAppStore(useShallow(state => ({ + appSidebarExpand: state.appSidebarExpand, + }))) + + const crossAxisOffset = useMemo(() => { + if (maximizeCanvas) return 40 + return appSidebarExpand === 'expand' ? 188 : 40 + }, [appSidebarExpand, maximizeCanvas]) const handleExportImage = useCallback(async (type: 'png' | 'jpeg' | 'svg', currentWorkflow = false) => { if (!appName && !knowledgeName) @@ -53,14 +65,11 @@ const ExportImage: FC = () => { let dataUrl if (currentWorkflow) { - // Get all nodes and their bounds const nodes = reactFlow.getNodes() const nodesBounds = getNodesBounds(nodes) - // Save current viewport const currentViewport = reactFlow.getViewport() - // Calculate the required zoom to fit all nodes const viewportWidth = window.innerWidth const viewportHeight = window.innerHeight const zoom = Math.min( @@ -69,30 +78,25 @@ const ExportImage: FC = () => { 1, ) - // Calculate center position const centerX = nodesBounds.x + nodesBounds.width / 2 const centerY = nodesBounds.y + nodesBounds.height / 2 - // Set viewport to show all nodes reactFlow.setViewport({ x: viewportWidth / 2 - centerX * zoom, y: viewportHeight / 2 - centerY * zoom, zoom, }) - // Wait for the transition to complete await new Promise(resolve => setTimeout(resolve, 300)) - // Calculate actual content size with padding - const padding = 50 // More padding for better visualization + const padding = 50 const contentWidth = nodesBounds.width + padding * 2 const contentHeight = nodesBounds.height + padding * 2 - // Export with higher quality for whole workflow const exportOptions = { filter, - backgroundColor: '#1a1a1a', // Dark background to match previous style - pixelRatio: 2, // Higher resolution for better zoom + backgroundColor: '#1a1a1a', + pixelRatio: 2, width: contentWidth, height: contentHeight, style: { @@ -119,7 +123,6 @@ const ExportImage: FC = () => { filename += '-whole-workflow' - // Restore original viewport after a delay setTimeout(() => { reactFlow.setViewport(currentViewport) }, 500) @@ -142,11 +145,9 @@ const ExportImage: FC = () => { } if (currentWorkflow) { - // For whole workflow, show preview first setPreviewUrl(dataUrl) setPreviewTitle(`${filename}.${type}`) - // Also auto-download const link = document.createElement('a') link.href = dataUrl link.download = `${filename}.${type}` @@ -181,14 +182,14 @@ const ExportImage: FC = () => { <PortalToFollowElem open={open} onOpenChange={setOpen} - placement="top-start" + placement="bottom-end" offset={{ - mainAxis: 4, - crossAxis: -8, + mainAxis: -200, + crossAxis: crossAxisOffset, }} > <PortalToFollowElemTrigger> - <TipPopup title={t('workflow.common.exportImage')}> + <TipPopup title={t('workflow.common.moreActions')}> <div className={cn( 'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary', @@ -196,13 +197,17 @@ const ExportImage: FC = () => { )} onClick={handleTrigger} > - <RiExportLine className='h-4 w-4' /> + <RiMoreFill className='h-4 w-4' /> </div> </TipPopup> </PortalToFollowElemTrigger> <PortalToFollowElemContent className='z-10'> <div className='min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur text-text-secondary shadow-lg'> <div className='p-1'> + <div className='flex items-center gap-2 px-2 py-1 text-xs font-medium text-text-tertiary'> + <RiExportLine className='h-3 w-3' /> + {t('workflow.common.exportImage')} + </div> <div className='px-2 py-1 text-xs font-medium text-text-tertiary'> {t('workflow.common.currentView')} </div> @@ -264,4 +269,4 @@ const ExportImage: FC = () => { ) } -export default memo(ExportImage) +export default memo(MoreActions) diff --git a/web/app/components/workflow/panel/global-variable-panel/index.tsx b/web/app/components/workflow/panel/global-variable-panel/index.tsx index ad7996ab0c..a421a1605a 100644 --- a/web/app/components/workflow/panel/global-variable-panel/index.tsx +++ b/web/app/components/workflow/panel/global-variable-panel/index.tsx @@ -8,16 +8,53 @@ import Item from './item' import { useStore } from '@/app/components/workflow/store' import cn from '@/utils/classnames' +import { useTranslation } from 'react-i18next' +import { useIsChatMode } from '../../hooks' +import { isInWorkflowPage } from '../../constants' const Panel = () => { + const { t } = useTranslation() + const isChatMode = useIsChatMode() const setShowPanel = useStore(s => s.setShowGlobalVariablePanel) + const isWorkflowPage = isInWorkflowPage() const globalVariableList: GlobalVariable[] = [ - { + ...(isChatMode ? [{ name: 'conversation_id', - value_type: 'string', - description: 'conversation id', + value_type: 'string' as const, + description: t('workflow.globalVar.fieldsDescription.conversationId'), }, + { + name: 'dialog_count', + value_type: 'number' as const, + description: t('workflow.globalVar.fieldsDescription.dialogCount'), + }] : []), + { + name: 'user_id', + value_type: 'string', + description: t('workflow.globalVar.fieldsDescription.userId'), + }, + { + name: 'app_id', + value_type: 'string', + description: t('workflow.globalVar.fieldsDescription.appId'), + }, + { + name: 'workflow_id', + value_type: 'string', + description: t('workflow.globalVar.fieldsDescription.workflowId'), + }, + { + name: 'workflow_run_id', + value_type: 'string', + description: t('workflow.globalVar.fieldsDescription.workflowRunId'), + }, + // is workflow + ...((isWorkflowPage && !isChatMode) ? [{ + name: 'timestamp', + value_type: 'number' as const, + description: t('workflow.globalVar.fieldsDescription.triggerTimestamp'), + }] : []), ] return ( @@ -27,7 +64,7 @@ const Panel = () => { )} > <div className='system-xl-semibold flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary'> - Global Variables(Current not show) + {t('workflow.globalVar.title')} <div className='flex items-center'> <div className='flex h-6 w-6 cursor-pointer items-center justify-center' @@ -37,9 +74,9 @@ const Panel = () => { </div> </div> </div> - <div className='system-sm-regular shrink-0 px-4 py-1 text-text-tertiary'>...</div> + <div className='system-sm-regular shrink-0 px-4 py-1 text-text-tertiary'>{t('workflow.globalVar.description')}</div> - <div className='grow overflow-y-auto rounded-b-2xl px-4'> + <div className='mt-4 grow overflow-y-auto rounded-b-2xl px-4'> {globalVariableList.map(item => ( <Item key={item.name} diff --git a/web/app/components/workflow/panel/global-variable-panel/item.tsx b/web/app/components/workflow/panel/global-variable-panel/item.tsx index ddf9abe1d3..5185c1bead 100644 --- a/web/app/components/workflow/panel/global-variable-panel/item.tsx +++ b/web/app/components/workflow/panel/global-variable-panel/item.tsx @@ -1,6 +1,7 @@ import { memo } from 'react' import { capitalize } from 'lodash-es' -import { Env } from '@/app/components/base/icons/src/vender/line/others' +import { GlobalVariable as GlobalVariableIcon } from '@/app/components/base/icons/src/vender/line/others' + import type { GlobalVariable } from '@/app/components/workflow/types' import cn from '@/utils/classnames' @@ -17,12 +18,15 @@ const Item = ({ )}> <div className='flex items-center justify-between'> <div className='flex grow items-center gap-1'> - <Env className='h-4 w-4 text-util-colors-violet-violet-600' /> - <div className='system-sm-medium text-text-primary'>{payload.name}</div> + <GlobalVariableIcon className='h-4 w-4 text-util-colors-orange-orange-600' /> + <div className='system-sm-medium text-text-primary'> + <span className='text-text-tertiary'>sys.</span> + {payload.name} + </div> <div className='system-xs-medium text-text-tertiary'>{capitalize(payload.value_type)}</div> </div> </div> - <div className='system-xs-regular truncate text-text-tertiary'>{payload.description}</div> + <div className='system-xs-regular mt-1.5 truncate text-text-tertiary'>{payload.description}</div> </div> ) } diff --git a/web/app/components/workflow/panel/workflow-preview.tsx b/web/app/components/workflow/panel/workflow-preview.tsx index fdb1767df9..292a964b9e 100644 --- a/web/app/components/workflow/panel/workflow-preview.tsx +++ b/web/app/components/workflow/panel/workflow-preview.tsx @@ -31,6 +31,7 @@ const WorkflowPreview = () => { const { t } = useTranslation() const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions() const workflowRunningData = useStore(s => s.workflowRunningData) + const isListening = useStore(s => s.isListening) const showInputsPanel = useStore(s => s.showInputsPanel) const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) const panelWidth = useStore(s => s.previewPanelWidth) @@ -48,7 +49,16 @@ const WorkflowPreview = () => { }, [showDebugAndPreviewPanel, showInputsPanel]) useEffect(() => { - if ((workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded || workflowRunningData?.result.status === WorkflowRunningStatus.Failed) && !workflowRunningData.resultText && !workflowRunningData.result.files?.length) + if (isListening) + switchTab('DETAIL') + }, [isListening]) + + useEffect(() => { + const status = workflowRunningData?.result.status + if (!workflowRunningData) + return + + if ((status === WorkflowRunningStatus.Succeeded || status === WorkflowRunningStatus.Failed) && !workflowRunningData.resultText && !workflowRunningData.result.files?.length) switchTab('DETAIL') }, [workflowRunningData]) diff --git a/web/app/components/workflow/run/index.tsx b/web/app/components/workflow/run/index.tsx index 2e9ae392a6..5f6b07033d 100644 --- a/web/app/components/workflow/run/index.tsx +++ b/web/app/components/workflow/run/index.tsx @@ -5,6 +5,8 @@ import { useContext } from 'use-context-selector' import { useTranslation } from 'react-i18next' import OutputPanel from './output-panel' import ResultPanel from './result-panel' +import StatusPanel from './status' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' import TracingPanel from './tracing-panel' import cn from '@/utils/classnames' import { ToastContext } from '@/app/components/base/toast' @@ -12,6 +14,8 @@ import Loading from '@/app/components/base/loading' import { fetchRunDetail, fetchTracingList } from '@/service/log' import type { NodeTracing } from '@/types/workflow' import type { WorkflowRunDetailResponse } from '@/models/log' +import { useStore } from '../store' + export type RunProps = { hideResult?: boolean activeTab?: 'RESULT' | 'DETAIL' | 'TRACING' @@ -33,6 +37,7 @@ const RunPanel: FC<RunProps> = ({ const [loading, setLoading] = useState<boolean>(true) const [runDetail, setRunDetail] = useState<WorkflowRunDetailResponse>() const [list, setList] = useState<NodeTracing[]>([]) + const isListening = useStore(s => s.isListening) const executor = useMemo(() => { if (runDetail?.created_by_role === 'account') @@ -89,6 +94,11 @@ const RunPanel: FC<RunProps> = ({ await getTracingList() } + useEffect(() => { + if (isListening) + setCurrentTab('DETAIL') + }, [isListening]) + useEffect(() => { // fetch data if (runDetailUrl && tracingListUrl) @@ -166,6 +176,11 @@ const RunPanel: FC<RunProps> = ({ exceptionCounts={runDetail.exceptions_count} /> )} + {!loading && currentTab === 'DETAIL' && !runDetail && isListening && ( + <StatusPanel + status={WorkflowRunningStatus.Running} + /> + )} {!loading && currentTab === 'TRACING' && ( <TracingPanel className='bg-background-section-burn' diff --git a/web/app/components/workflow/run/status-container.tsx b/web/app/components/workflow/run/status-container.tsx index 47890da0b2..6837592c4e 100644 --- a/web/app/components/workflow/run/status-container.tsx +++ b/web/app/components/workflow/run/status-container.tsx @@ -14,6 +14,7 @@ const StatusContainer: FC<Props> = ({ children, }) => { const { theme } = useTheme() + return ( <div className={cn( diff --git a/web/app/components/workflow/run/status.tsx b/web/app/components/workflow/run/status.tsx index 5c533c9e5f..fa9559fcf8 100644 --- a/web/app/components/workflow/run/status.tsx +++ b/web/app/components/workflow/run/status.tsx @@ -5,6 +5,7 @@ import cn from '@/utils/classnames' import Indicator from '@/app/components/header/indicator' import StatusContainer from '@/app/components/workflow/run/status-container' import { useDocLink } from '@/context/i18n' +import { useStore } from '../store' type ResultProps = { status: string @@ -23,6 +24,7 @@ const StatusPanel: FC<ResultProps> = ({ }) => { const { t } = useTranslation() const docLink = useDocLink() + const isListening = useStore(s => s.isListening) return ( <StatusContainer status={status}> @@ -45,7 +47,7 @@ const StatusPanel: FC<ResultProps> = ({ {status === 'running' && ( <> <Indicator color={'blue'} /> - <span>Running</span> + <span>{isListening ? 'Listening' : 'Running'}</span> </> )} {status === 'succeeded' && ( diff --git a/web/app/components/workflow/run/utils/format-log/parallel/index.ts b/web/app/components/workflow/run/utils/format-log/parallel/index.ts index f5a1136e3f..22c96918e9 100644 --- a/web/app/components/workflow/run/utils/format-log/parallel/index.ts +++ b/web/app/components/workflow/run/utils/format-log/parallel/index.ts @@ -148,7 +148,7 @@ const format = (list: NodeTracing[], t: any, isPrint?: boolean): NodeTracing[] = return false const isParallelStartNode = node.parallelDetail?.isParallelStartNode - // eslint-disable-next-line sonarjs/prefer-single-boolean-return + if (!isParallelStartNode) return false diff --git a/web/app/components/workflow/shortcuts-name.tsx b/web/app/components/workflow/shortcuts-name.tsx index e7122c5ad5..9dd8c4bcd1 100644 --- a/web/app/components/workflow/shortcuts-name.tsx +++ b/web/app/components/workflow/shortcuts-name.tsx @@ -5,10 +5,12 @@ import cn from '@/utils/classnames' type ShortcutsNameProps = { keys: string[] className?: string + textColor?: 'default' | 'secondary' } const ShortcutsName = ({ keys, className, + textColor = 'default', }: ShortcutsNameProps) => { return ( <div className={cn( @@ -19,7 +21,10 @@ const ShortcutsName = ({ keys.map(key => ( <div key={key} - className='system-kbd flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray capitalize' + className={cn( + 'system-kbd flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray capitalize', + textColor === 'secondary' && 'text-text-tertiary', + )} > {getKeyboardKeyNameBySystem(key)} </div> diff --git a/web/app/components/workflow/store/__tests__/trigger-status.test.ts b/web/app/components/workflow/store/__tests__/trigger-status.test.ts new file mode 100644 index 0000000000..d7e1284487 --- /dev/null +++ b/web/app/components/workflow/store/__tests__/trigger-status.test.ts @@ -0,0 +1,293 @@ +import { act, renderHook } from '@testing-library/react' +import { useTriggerStatusStore } from '../trigger-status' +import type { EntryNodeStatus } from '../trigger-status' + +describe('useTriggerStatusStore', () => { + beforeEach(() => { + // Clear the store state before each test + const { result } = renderHook(() => useTriggerStatusStore()) + act(() => { + result.current.clearTriggerStatuses() + }) + }) + + describe('Initial State', () => { + it('should initialize with empty trigger statuses', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + expect(result.current.triggerStatuses).toEqual({}) + }) + + it('should return "disabled" for non-existent trigger status', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + const status = result.current.getTriggerStatus('non-existent-id') + expect(status).toBe('disabled') + }) + }) + + describe('setTriggerStatus', () => { + it('should set trigger status for a single node', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatus('node-1', 'enabled') + }) + + expect(result.current.triggerStatuses['node-1']).toBe('enabled') + expect(result.current.getTriggerStatus('node-1')).toBe('enabled') + }) + + it('should update existing trigger status', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + // Set initial status + act(() => { + result.current.setTriggerStatus('node-1', 'enabled') + }) + expect(result.current.getTriggerStatus('node-1')).toBe('enabled') + + // Update status + act(() => { + result.current.setTriggerStatus('node-1', 'disabled') + }) + expect(result.current.getTriggerStatus('node-1')).toBe('disabled') + }) + + it('should handle multiple nodes independently', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatus('node-1', 'enabled') + result.current.setTriggerStatus('node-2', 'disabled') + }) + + expect(result.current.getTriggerStatus('node-1')).toBe('enabled') + expect(result.current.getTriggerStatus('node-2')).toBe('disabled') + }) + }) + + describe('setTriggerStatuses', () => { + it('should set multiple trigger statuses at once', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + const statuses = { + 'node-1': 'enabled' as EntryNodeStatus, + 'node-2': 'disabled' as EntryNodeStatus, + 'node-3': 'enabled' as EntryNodeStatus, + } + + act(() => { + result.current.setTriggerStatuses(statuses) + }) + + expect(result.current.triggerStatuses).toEqual(statuses) + expect(result.current.getTriggerStatus('node-1')).toBe('enabled') + expect(result.current.getTriggerStatus('node-2')).toBe('disabled') + expect(result.current.getTriggerStatus('node-3')).toBe('enabled') + }) + + it('should replace existing statuses completely', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + // Set initial statuses + act(() => { + result.current.setTriggerStatuses({ + 'node-1': 'enabled', + 'node-2': 'disabled', + }) + }) + + // Replace with new statuses + act(() => { + result.current.setTriggerStatuses({ + 'node-3': 'enabled', + 'node-4': 'disabled', + }) + }) + + expect(result.current.triggerStatuses).toEqual({ + 'node-3': 'enabled', + 'node-4': 'disabled', + }) + expect(result.current.getTriggerStatus('node-1')).toBe('disabled') // default + expect(result.current.getTriggerStatus('node-2')).toBe('disabled') // default + }) + + it('should handle empty object', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + // Set some initial data + act(() => { + result.current.setTriggerStatus('node-1', 'enabled') + }) + + // Clear with empty object + act(() => { + result.current.setTriggerStatuses({}) + }) + + expect(result.current.triggerStatuses).toEqual({}) + expect(result.current.getTriggerStatus('node-1')).toBe('disabled') + }) + }) + + describe('getTriggerStatus', () => { + it('should return the correct status for existing nodes', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatuses({ + 'enabled-node': 'enabled', + 'disabled-node': 'disabled', + }) + }) + + expect(result.current.getTriggerStatus('enabled-node')).toBe('enabled') + expect(result.current.getTriggerStatus('disabled-node')).toBe('disabled') + }) + + it('should return "disabled" as default for non-existent nodes', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + expect(result.current.getTriggerStatus('non-existent')).toBe('disabled') + expect(result.current.getTriggerStatus('')).toBe('disabled') + expect(result.current.getTriggerStatus('undefined-node')).toBe('disabled') + }) + }) + + describe('clearTriggerStatuses', () => { + it('should clear all trigger statuses', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + // Set some statuses + act(() => { + result.current.setTriggerStatuses({ + 'node-1': 'enabled', + 'node-2': 'disabled', + 'node-3': 'enabled', + }) + }) + + expect(Object.keys(result.current.triggerStatuses)).toHaveLength(3) + + // Clear all + act(() => { + result.current.clearTriggerStatuses() + }) + + expect(result.current.triggerStatuses).toEqual({}) + expect(result.current.getTriggerStatus('node-1')).toBe('disabled') + expect(result.current.getTriggerStatus('node-2')).toBe('disabled') + expect(result.current.getTriggerStatus('node-3')).toBe('disabled') + }) + + it('should not throw when clearing empty statuses', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + expect(() => { + act(() => { + result.current.clearTriggerStatuses() + }) + }).not.toThrow() + + expect(result.current.triggerStatuses).toEqual({}) + }) + }) + + describe('Store Reactivity', () => { + it('should notify subscribers when status changes', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + const initialTriggerStatuses = result.current.triggerStatuses + + act(() => { + result.current.setTriggerStatus('reactive-node', 'enabled') + }) + + // The reference should change, indicating reactivity + expect(result.current.triggerStatuses).not.toBe(initialTriggerStatuses) + expect(result.current.triggerStatuses['reactive-node']).toBe('enabled') + }) + + it('should maintain immutability when updating statuses', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatus('node-1', 'enabled') + }) + + const firstSnapshot = result.current.triggerStatuses + + act(() => { + result.current.setTriggerStatus('node-2', 'disabled') + }) + + const secondSnapshot = result.current.triggerStatuses + + // References should be different (immutable updates) + expect(firstSnapshot).not.toBe(secondSnapshot) + // But the first node status should remain + expect(secondSnapshot['node-1']).toBe('enabled') + expect(secondSnapshot['node-2']).toBe('disabled') + }) + }) + + describe('Edge Cases', () => { + it('should handle rapid consecutive updates', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatus('rapid-node', 'enabled') + result.current.setTriggerStatus('rapid-node', 'disabled') + result.current.setTriggerStatus('rapid-node', 'enabled') + }) + + expect(result.current.getTriggerStatus('rapid-node')).toBe('enabled') + }) + + it('should handle setting the same status multiple times', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + act(() => { + result.current.setTriggerStatus('same-node', 'enabled') + }) + + const firstSnapshot = result.current.triggerStatuses + + act(() => { + result.current.setTriggerStatus('same-node', 'enabled') + }) + + const secondSnapshot = result.current.triggerStatuses + + expect(result.current.getTriggerStatus('same-node')).toBe('enabled') + // Should still create new reference (Zustand behavior) + expect(firstSnapshot).not.toBe(secondSnapshot) + }) + + it('should handle special node ID formats', () => { + const { result } = renderHook(() => useTriggerStatusStore()) + + const specialNodeIds = [ + 'node-with-dashes', + 'node_with_underscores', + 'nodeWithCamelCase', + 'node123', + 'node-123-abc', + ] + + act(() => { + specialNodeIds.forEach((nodeId, index) => { + const status = index % 2 === 0 ? 'enabled' : 'disabled' + result.current.setTriggerStatus(nodeId, status as EntryNodeStatus) + }) + }) + + specialNodeIds.forEach((nodeId, index) => { + const expectedStatus = index % 2 === 0 ? 'enabled' : 'disabled' + expect(result.current.getTriggerStatus(nodeId)).toBe(expectedStatus) + }) + }) + }) +}) diff --git a/web/app/components/workflow/store/index.ts b/web/app/components/workflow/store/index.ts index 61cd5773ce..5ca06d2ec3 100644 --- a/web/app/components/workflow/store/index.ts +++ b/web/app/components/workflow/store/index.ts @@ -1 +1,2 @@ export * from './workflow' +export * from './trigger-status' diff --git a/web/app/components/workflow/store/trigger-status.ts b/web/app/components/workflow/store/trigger-status.ts new file mode 100644 index 0000000000..2f472c79b9 --- /dev/null +++ b/web/app/components/workflow/store/trigger-status.ts @@ -0,0 +1,42 @@ +import { create } from 'zustand' +import { subscribeWithSelector } from 'zustand/middleware' + +export type EntryNodeStatus = 'enabled' | 'disabled' + +type TriggerStatusState = { + // Map of nodeId to trigger status + triggerStatuses: Record<string, EntryNodeStatus> + + // Actions + setTriggerStatus: (nodeId: string, status: EntryNodeStatus) => void + setTriggerStatuses: (statuses: Record<string, EntryNodeStatus>) => void + getTriggerStatus: (nodeId: string) => EntryNodeStatus + clearTriggerStatuses: () => void +} + +export const useTriggerStatusStore = create<TriggerStatusState>()( + subscribeWithSelector((set, get) => ({ + triggerStatuses: {}, + + setTriggerStatus: (nodeId: string, status: EntryNodeStatus) => { + set(state => ({ + triggerStatuses: { + ...state.triggerStatuses, + [nodeId]: status, + }, + })) + }, + + setTriggerStatuses: (statuses: Record<string, EntryNodeStatus>) => { + set({ triggerStatuses: statuses }) + }, + + getTriggerStatus: (nodeId: string): EntryNodeStatus => { + return get().triggerStatuses[nodeId] || 'disabled' + }, + + clearTriggerStatuses: () => { + set({ triggerStatuses: {} }) + }, + })), +) diff --git a/web/app/components/workflow/store/workflow/chat-variable-slice.ts b/web/app/components/workflow/store/workflow/chat-variable-slice.ts index 0d81446005..96fe8b00b8 100644 --- a/web/app/components/workflow/store/workflow/chat-variable-slice.ts +++ b/web/app/components/workflow/store/workflow/chat-variable-slice.ts @@ -20,7 +20,12 @@ export const createChatVariableSlice: StateCreator<ChatVariableSliceShape> = (se return ({ showChatVariablePanel: false, - setShowChatVariablePanel: showChatVariablePanel => set(() => ({ showChatVariablePanel })), + setShowChatVariablePanel: showChatVariablePanel => set(() => { + if (showChatVariablePanel) + return { ...hideAllPanel, showChatVariablePanel: true } + else + return { showChatVariablePanel: false } + }), showGlobalVariablePanel: false, setShowGlobalVariablePanel: showGlobalVariablePanel => set(() => { if (showGlobalVariablePanel) diff --git a/web/app/components/workflow/store/workflow/env-variable-slice.ts b/web/app/components/workflow/store/workflow/env-variable-slice.ts index de60e7dd5f..2ba6ce084a 100644 --- a/web/app/components/workflow/store/workflow/env-variable-slice.ts +++ b/web/app/components/workflow/store/workflow/env-variable-slice.ts @@ -10,11 +10,24 @@ export type EnvVariableSliceShape = { setEnvSecrets: (envSecrets: Record<string, string>) => void } -export const createEnvVariableSlice: StateCreator<EnvVariableSliceShape> = set => ({ - showEnvPanel: false, - setShowEnvPanel: showEnvPanel => set(() => ({ showEnvPanel })), - environmentVariables: [], - setEnvironmentVariables: environmentVariables => set(() => ({ environmentVariables })), - envSecrets: {}, - setEnvSecrets: envSecrets => set(() => ({ envSecrets })), -}) +export const createEnvVariableSlice: StateCreator<EnvVariableSliceShape> = (set) => { + const hideAllPanel = { + showDebugAndPreviewPanel: false, + showEnvPanel: false, + showChatVariablePanel: false, + showGlobalVariablePanel: false, + } + return ({ + showEnvPanel: false, + setShowEnvPanel: showEnvPanel => set(() => { + if (showEnvPanel) + return { ...hideAllPanel, showEnvPanel: true } + else + return { showEnvPanel: false } + }), + environmentVariables: [], + setEnvironmentVariables: environmentVariables => set(() => ({ environmentVariables })), + envSecrets: {}, + setEnvSecrets: envSecrets => set(() => ({ envSecrets })), + }) +} diff --git a/web/app/components/workflow/store/workflow/node-slice.ts b/web/app/components/workflow/store/workflow/node-slice.ts index 2068ee0ba1..3463fdee57 100644 --- a/web/app/components/workflow/store/workflow/node-slice.ts +++ b/web/app/components/workflow/store/workflow/node-slice.ts @@ -48,6 +48,11 @@ export type NodeSliceShape = { setLoopTimes: (loopTimes: number) => void iterParallelLogMap: Map<string, Map<string, NodeTracing[]>> setIterParallelLogMap: (iterParallelLogMap: Map<string, Map<string, NodeTracing[]>>) => void + pendingSingleRun?: { + nodeId: string + action: 'run' | 'stop' + } + setPendingSingleRun: (payload?: NodeSliceShape['pendingSingleRun']) => void } export const createNodeSlice: StateCreator<NodeSliceShape> = set => ({ @@ -73,4 +78,6 @@ export const createNodeSlice: StateCreator<NodeSliceShape> = set => ({ setLoopTimes: loopTimes => set(() => ({ loopTimes })), iterParallelLogMap: new Map<string, Map<string, NodeTracing[]>>(), setIterParallelLogMap: iterParallelLogMap => set(() => ({ iterParallelLogMap })), + pendingSingleRun: undefined, + setPendingSingleRun: payload => set(() => ({ pendingSingleRun: payload })), }) diff --git a/web/app/components/workflow/store/workflow/tool-slice.ts b/web/app/components/workflow/store/workflow/tool-slice.ts index c5180022fc..d5ff7743be 100644 --- a/web/app/components/workflow/store/workflow/tool-slice.ts +++ b/web/app/components/workflow/store/workflow/tool-slice.ts @@ -1,11 +1,24 @@ import type { StateCreator } from 'zustand' +import type { ToolWithProvider } from '../../types' export type ToolSliceShape = { toolPublished: boolean setToolPublished: (toolPublished: boolean) => void + lastPublishedHasUserInput: boolean + setLastPublishedHasUserInput: (hasUserInput: boolean) => void + buildInTools?: ToolWithProvider[] + customTools?: ToolWithProvider[] + workflowTools?: ToolWithProvider[] + mcpTools?: ToolWithProvider[] } export const createToolSlice: StateCreator<ToolSliceShape> = set => ({ toolPublished: false, setToolPublished: toolPublished => set(() => ({ toolPublished })), + lastPublishedHasUserInput: false, + setLastPublishedHasUserInput: hasUserInput => set(() => ({ lastPublishedHasUserInput: hasUserInput })), + buildInTools: undefined, + customTools: undefined, + workflowTools: undefined, + mcpTools: undefined, }) diff --git a/web/app/components/workflow/store/workflow/workflow-draft-slice.ts b/web/app/components/workflow/store/workflow/workflow-draft-slice.ts index a4048a9455..cae716dd52 100644 --- a/web/app/components/workflow/store/workflow/workflow-draft-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-draft-slice.ts @@ -21,6 +21,8 @@ export type WorkflowDraftSliceShape = { setSyncWorkflowDraftHash: (hash: string) => void isSyncingWorkflowDraft: boolean setIsSyncingWorkflowDraft: (isSyncingWorkflowDraft: boolean) => void + isWorkflowDataLoaded: boolean + setIsWorkflowDataLoaded: (loaded: boolean) => void } export const createWorkflowDraftSlice: StateCreator<WorkflowDraftSliceShape> = set => ({ @@ -33,4 +35,6 @@ export const createWorkflowDraftSlice: StateCreator<WorkflowDraftSliceShape> = s setSyncWorkflowDraftHash: syncWorkflowDraftHash => set(() => ({ syncWorkflowDraftHash })), isSyncingWorkflowDraft: false, setIsSyncingWorkflowDraft: isSyncingWorkflowDraft => set(() => ({ isSyncingWorkflowDraft })), + isWorkflowDataLoaded: false, + setIsWorkflowDataLoaded: loaded => set(() => ({ isWorkflowDataLoaded: loaded })), }) diff --git a/web/app/components/workflow/store/workflow/workflow-slice.ts b/web/app/components/workflow/store/workflow/workflow-slice.ts index 91dac42adb..35eeff07a7 100644 --- a/web/app/components/workflow/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-slice.ts @@ -1,6 +1,7 @@ import type { StateCreator } from 'zustand' import type { Node, + TriggerNodeType, WorkflowRunningData, } from '@/app/components/workflow/types' import type { FileUploadConfigResponse } from '@/models/common' @@ -13,6 +14,16 @@ type PreviewRunningData = WorkflowRunningData & { export type WorkflowSliceShape = { workflowRunningData?: PreviewRunningData setWorkflowRunningData: (workflowData: PreviewRunningData) => void + isListening: boolean + setIsListening: (listening: boolean) => void + listeningTriggerType: TriggerNodeType | null + setListeningTriggerType: (triggerType: TriggerNodeType | null) => void + listeningTriggerNodeId: string | null + setListeningTriggerNodeId: (nodeId: string | null) => void + listeningTriggerNodeIds: string[] + setListeningTriggerNodeIds: (nodeIds: string[]) => void + listeningTriggerIsAll: boolean + setListeningTriggerIsAll: (isAll: boolean) => void clipboardElements: Node[] setClipboardElements: (clipboardElements: Node[]) => void selection: null | { x1: number; y1: number; x2: number; y2: number } @@ -36,6 +47,16 @@ export type WorkflowSliceShape = { export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({ workflowRunningData: undefined, setWorkflowRunningData: workflowRunningData => set(() => ({ workflowRunningData })), + isListening: false, + setIsListening: listening => set(() => ({ isListening: listening })), + listeningTriggerType: null, + setListeningTriggerType: triggerType => set(() => ({ listeningTriggerType: triggerType })), + listeningTriggerNodeId: null, + setListeningTriggerNodeId: nodeId => set(() => ({ listeningTriggerNodeId: nodeId })), + listeningTriggerNodeIds: [], + setListeningTriggerNodeIds: nodeIds => set(() => ({ listeningTriggerNodeIds: nodeIds })), + listeningTriggerIsAll: false, + setListeningTriggerIsAll: isAll => set(() => ({ listeningTriggerIsAll: isAll })), clipboardElements: [], setClipboardElements: clipboardElements => set(() => ({ clipboardElements })), selection: null, diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 324443cfd1..5ae8d530a8 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -5,10 +5,7 @@ import type { XYPosition, } from 'reactflow' import type { Resolution, TransferMethod } from '@/types/app' -import type { - DataSourceDefaultValue, - ToolDefaultValue, -} from '@/app/components/workflow/block-selector/types' +import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types' import type { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import type { FileResponse, NodeTracing, PanelProps } from '@/types/workflow' import type { Collection, Tool } from '@/app/components/tools/types' @@ -50,6 +47,9 @@ export enum BlockEnum { DataSource = 'datasource', DataSourceEmpty = 'datasource-empty', KnowledgeBase = 'knowledge-index', + TriggerSchedule = 'trigger-schedule', + TriggerWebhook = 'trigger-webhook', + TriggerPlugin = 'trigger-plugin', } export enum ControlMode { @@ -103,9 +103,11 @@ export type CommonNodeType<T = {}> = { retry_config?: WorkflowRetryConfig default_value?: DefaultValueForm[] credential_id?: string + subscription_id?: string + provider_id?: string _dimmed?: boolean -} & T & Partial<Pick<ToolDefaultValue, 'provider_id' | 'provider_type' | 'provider_name' | 'tool_name'>> - & Partial<Pick<DataSourceDefaultValue, 'plugin_id' | 'provider_type' | 'provider_name' | 'datasource_name'>> + _pluginInstallLocked?: boolean +} & T & Partial<PluginDefaultValue> export type CommonEdgeType = { _hovering?: boolean @@ -176,7 +178,7 @@ export type ConversationVariable = { export type GlobalVariable = { name: string - value_type: 'string' | 'number' + value_type: 'string' | 'number' | 'integer' description: string } @@ -341,7 +343,7 @@ export type NodeDefault<T = {}> = { }) => Var[] } -export type OnSelectBlock = (type: BlockEnum, toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue) => void +export type OnSelectBlock = (type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => void export enum WorkflowRunningStatus { Waiting = 'waiting', @@ -359,6 +361,7 @@ export enum WorkflowVersion { export enum NodeRunningStatus { NotStart = 'not-start', Waiting = 'waiting', + Listening = 'listening', Running = 'running', Succeeded = 'succeeded', Failed = 'failed', @@ -372,7 +375,7 @@ export type OnNodeAdd = ( nodeType: BlockEnum sourceHandle?: string targetHandle?: string - toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue + pluginDefaultValue?: PluginDefaultValue }, oldNodesPayload: { prevNodeId?: string @@ -449,6 +452,7 @@ export type MoreInfo = { export type ToolWithProvider = Collection & { tools: Tool[] meta: PluginMeta + plugin_unique_identifier?: string } export type RAGRecommendedPlugins = { @@ -493,3 +497,23 @@ export enum VersionHistoryContextMenuOptions { export type ChildNodeTypeCount = { [key: string]: number; } + +export const TRIGGER_NODE_TYPES = [ + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, +] as const + +// Type-safe trigger node type extracted from TRIGGER_NODE_TYPES array +export type TriggerNodeType = typeof TRIGGER_NODE_TYPES[number] + +export function isTriggerNode(nodeType: BlockEnum): boolean { + return TRIGGER_NODE_TYPES.includes(nodeType as any) +} + +export type Block = { + classification?: string + type: BlockEnum + title: string + description?: string +} diff --git a/web/app/components/workflow/utils/node-navigation.ts b/web/app/components/workflow/utils/node-navigation.ts index 5522764949..57106ae6ee 100644 --- a/web/app/components/workflow/utils/node-navigation.ts +++ b/web/app/components/workflow/utils/node-navigation.ts @@ -97,13 +97,14 @@ export function setupScrollToNodeListener( const node = nodes.find(n => n.id === nodeId) if (node) { // Use ReactFlow's fitView API to scroll to the node - reactflow.fitView({ - nodes: [node], - padding: 0.2, - duration: 800, - minZoom: 0.5, - maxZoom: 1, - }) + const nodePosition = { x: node.position.x, y: node.position.y } + + // Calculate position to place node in top-left area + // Move the center point right and down to show node in top-left + const targetX = nodePosition.x + window.innerWidth * 0.25 + const targetY = nodePosition.y + window.innerHeight * 0.25 + + reactflow.setCenter(targetX, targetY, { zoom: 1, duration: 800 }) } } } diff --git a/web/app/components/workflow/utils/trigger.ts b/web/app/components/workflow/utils/trigger.ts new file mode 100644 index 0000000000..f6d197c69c --- /dev/null +++ b/web/app/components/workflow/utils/trigger.ts @@ -0,0 +1,52 @@ +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' +import type { PluginTriggerNodeType } from '@/app/components/workflow/nodes/trigger-plugin/types' + +export type TriggerCheckParams = { + triggerInputsSchema: Array<{ + variable: string + label: string + required?: boolean + }> + isReadyForCheckValid: boolean +} + +export const getTriggerCheckParams = ( + triggerData: PluginTriggerNodeType, + triggerProviders: TriggerWithProvider[] | undefined, + language: string, +): TriggerCheckParams => { + if (!triggerProviders) { + return { + triggerInputsSchema: [], + isReadyForCheckValid: false, + } + } + + const { + provider_id, + provider_name, + event_name, + } = triggerData + + const provider = triggerProviders.find(item => + item.name === provider_name + || item.id === provider_id + || (provider_id && item.plugin_id === provider_id), + ) + + const currentEvent = provider?.events.find(event => event.name === event_name) + + const triggerInputsSchema = (currentEvent?.parameters || []).map((parameter) => { + const label = parameter.label?.[language] || parameter.label?.en_US || parameter.name + return { + variable: parameter.name, + label, + required: parameter.required, + } + }) + + return { + triggerInputsSchema, + isReadyForCheckValid: true, + } +} diff --git a/web/app/components/workflow/utils/workflow-entry.ts b/web/app/components/workflow/utils/workflow-entry.ts new file mode 100644 index 0000000000..724a68a85b --- /dev/null +++ b/web/app/components/workflow/utils/workflow-entry.ts @@ -0,0 +1,26 @@ +import { BlockEnum, type Node, isTriggerNode } from '../types' + +/** + * Get the workflow entry node + * Priority: trigger nodes > start node + */ +export function getWorkflowEntryNode(nodes: Node[]): Node | undefined { + const triggerNode = nodes.find(node => isTriggerNode(node.data.type)) + if (triggerNode) return triggerNode + + return nodes.find(node => node.data.type === BlockEnum.Start) +} + +/** + * Check if a node type is a workflow entry node + */ +export function isWorkflowEntryNode(nodeType: BlockEnum): boolean { + return nodeType === BlockEnum.Start || isTriggerNode(nodeType) +} + +/** + * Check if workflow is in trigger mode + */ +export function isTriggerWorkflow(nodes: Node[]): boolean { + return nodes.some(node => isTriggerNode(node.data.type)) +} diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts index 48cb819086..14b1eb87d5 100644 --- a/web/app/components/workflow/utils/workflow.ts +++ b/web/app/components/workflow/utils/workflow.ts @@ -34,6 +34,9 @@ export const canRunBySingle = (nodeType: BlockEnum, isChildNode: boolean) => { || nodeType === BlockEnum.VariableAggregator || nodeType === BlockEnum.Assigner || nodeType === BlockEnum.DataSource + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin } export const isSupportCustomRunForm = (nodeType: BlockEnum) => { @@ -92,18 +95,29 @@ export const getNodesConnectedSourceOrTargetHandleIdsMap = (changes: ConnectedSo return nodesConnectedSourceOrTargetHandleIdsMap } -export const getValidTreeNodes = (startNode: Node, nodes: Node[], edges: Edge[]) => { - if (!startNode) { +export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => { + // Find all start nodes (Start and Trigger nodes) + const startNodes = nodes.filter(node => + node.data.type === BlockEnum.Start + || node.data.type === BlockEnum.TriggerSchedule + || node.data.type === BlockEnum.TriggerWebhook + || node.data.type === BlockEnum.TriggerPlugin, + ) + + if (startNodes.length === 0) { return { validNodes: [], maxDepth: 0, } } - const list: Node[] = [startNode] - let maxDepth = 1 + const list: Node[] = [] + let maxDepth = 0 const traverse = (root: Node, depth: number) => { + // Add the current node to the list + list.push(root) + if (depth > maxDepth) maxDepth = depth @@ -111,19 +125,19 @@ export const getValidTreeNodes = (startNode: Node, nodes: Node[], edges: Edge[]) if (outgoers.length) { outgoers.forEach((outgoer) => { - list.push(outgoer) + // Only traverse if we haven't processed this node yet (avoid cycles) + if (!list.find(n => n.id === outgoer.id)) { + if (outgoer.data.type === BlockEnum.Iteration) + list.push(...nodes.filter(node => node.parentId === outgoer.id)) + if (outgoer.data.type === BlockEnum.Loop) + list.push(...nodes.filter(node => node.parentId === outgoer.id)) - if (outgoer.data.type === BlockEnum.Iteration) - list.push(...nodes.filter(node => node.parentId === outgoer.id)) - if (outgoer.data.type === BlockEnum.Loop) - list.push(...nodes.filter(node => node.parentId === outgoer.id)) - - traverse(outgoer, depth + 1) + traverse(outgoer, depth + 1) + } }) } else { - list.push(root) - + // Leaf node - add iteration/loop children if any if (root.data.type === BlockEnum.Iteration) list.push(...nodes.filter(node => node.parentId === root.id)) if (root.data.type === BlockEnum.Loop) @@ -131,7 +145,11 @@ export const getValidTreeNodes = (startNode: Node, nodes: Node[], edges: Edge[]) } } - traverse(startNode, maxDepth) + // Start traversal from all start nodes + startNodes.forEach((startNode) => { + if (!list.find(n => n.id === startNode.id)) + traverse(startNode, 1) + }) return { validNodes: uniqBy(list, 'id'), diff --git a/web/app/components/workflow/variable-inspect/listening.tsx b/web/app/components/workflow/variable-inspect/listening.tsx new file mode 100644 index 0000000000..1f2577f150 --- /dev/null +++ b/web/app/components/workflow/variable-inspect/listening.tsx @@ -0,0 +1,219 @@ +import { type FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { type Node, useStoreApi } from 'reactflow' +import Button from '@/app/components/base/button' +import BlockIcon from '@/app/components/workflow/block-icon' +import { BlockEnum } from '@/app/components/workflow/types' +import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' +import { useStore } from '../store' +import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' +import type { TFunction } from 'i18next' +import { getNextExecutionTime } from '@/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator' +import type { ScheduleTriggerNodeType } from '@/app/components/workflow/nodes/trigger-schedule/types' +import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types' +import Tooltip from '@/app/components/base/tooltip' +import copy from 'copy-to-clipboard' + +const resolveListeningDescription = ( + message: string | undefined, + triggerNode: Node | undefined, + triggerType: BlockEnum, + t: TFunction, +): string => { + if (message) + return message + + if (triggerType === BlockEnum.TriggerSchedule) { + const scheduleData = triggerNode?.data as ScheduleTriggerNodeType | undefined + const nextTriggerTime = scheduleData ? getNextExecutionTime(scheduleData) : '' + return t('workflow.debug.variableInspect.listening.tipSchedule', { + nextTriggerTime: nextTriggerTime || t('workflow.debug.variableInspect.listening.defaultScheduleTime'), + }) + } + + if (triggerType === BlockEnum.TriggerPlugin) { + const pluginName = (triggerNode?.data as { provider_name?: string; title?: string })?.provider_name + || (triggerNode?.data as { title?: string })?.title + || t('workflow.debug.variableInspect.listening.defaultPluginName') + return t('workflow.debug.variableInspect.listening.tipPlugin', { pluginName }) + } + + if (triggerType === BlockEnum.TriggerWebhook) { + const nodeName = (triggerNode?.data as { title?: string })?.title || t('workflow.debug.variableInspect.listening.defaultNodeName') + return t('workflow.debug.variableInspect.listening.tip', { nodeName }) + } + + const nodeDescription = (triggerNode?.data as { desc?: string })?.desc + if (nodeDescription) + return nodeDescription + + return t('workflow.debug.variableInspect.listening.tipFallback') +} + +const resolveMultipleListeningDescription = ( + nodes: Node[], + t: TFunction, +): string => { + if (!nodes.length) + return t('workflow.debug.variableInspect.listening.tipFallback') + + const titles = nodes + .map(node => (node.data as { title?: string })?.title) + .filter((title): title is string => Boolean(title)) + + if (titles.length) + return t('workflow.debug.variableInspect.listening.tip', { nodeName: titles.join(', ') }) + + return t('workflow.debug.variableInspect.listening.tipFallback') +} + +export type ListeningProps = { + onStop: () => void + message?: string +} + +const Listening: FC<ListeningProps> = ({ + onStop, + message, +}) => { + const { t } = useTranslation() + const store = useStoreApi() + + // Get the current trigger type and node ID from store + const listeningTriggerType = useStore(s => s.listeningTriggerType) + const listeningTriggerNodeId = useStore(s => s.listeningTriggerNodeId) + const listeningTriggerNodeIds = useStore(s => s.listeningTriggerNodeIds) + const listeningTriggerIsAll = useStore(s => s.listeningTriggerIsAll) + + const getToolIcon = useGetToolIcon() + + // Get the trigger node data to extract icon information + const { getNodes } = store.getState() + const nodes = getNodes() + const triggerNode = listeningTriggerNodeId + ? nodes.find(node => node.id === listeningTriggerNodeId) + : undefined + const inferredTriggerType = (triggerNode?.data as { type?: BlockEnum })?.type + const triggerType = listeningTriggerType || inferredTriggerType || BlockEnum.TriggerWebhook + const webhookDebugUrl = triggerType === BlockEnum.TriggerWebhook + ? (triggerNode?.data as WebhookTriggerNodeType | undefined)?.webhook_debug_url + : undefined + const [debugUrlCopied, setDebugUrlCopied] = useState(false) + + useEffect(() => { + if (!debugUrlCopied) + return + + const timer = window.setTimeout(() => { + setDebugUrlCopied(false) + }, 2000) + + return () => { + window.clearTimeout(timer) + } + }, [debugUrlCopied]) + + let displayNodes: Node[] = [] + + if (listeningTriggerIsAll) { + if (listeningTriggerNodeIds.length > 0) { + displayNodes = nodes.filter(node => listeningTriggerNodeIds.includes(node.id)) + } + else { + displayNodes = nodes.filter((node) => { + const nodeType = (node.data as { type?: BlockEnum })?.type + return nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + }) + } + } + else if (triggerNode) { + displayNodes = [triggerNode] + } + + const iconsToRender = displayNodes.map((node) => { + const blockType = (node.data as { type?: BlockEnum })?.type || BlockEnum.TriggerWebhook + const icon = getToolIcon(node.data as any) + return { + key: node.id, + type: blockType, + toolIcon: icon, + } + }) + + if (iconsToRender.length === 0) { + iconsToRender.push({ + key: 'default', + type: listeningTriggerIsAll ? BlockEnum.TriggerWebhook : triggerType, + toolIcon: !listeningTriggerIsAll && triggerNode ? getToolIcon(triggerNode.data as any) : undefined, + }) + } + + const description = listeningTriggerIsAll + ? resolveMultipleListeningDescription(displayNodes, t) + : resolveListeningDescription(message, triggerNode, triggerType, t) + + return ( + <div className='flex h-full flex-col gap-4 rounded-xl bg-background-section p-8'> + <div className='flex flex-row flex-wrap items-center gap-3'> + {iconsToRender.map(icon => ( + <BlockIcon + key={icon.key} + type={icon.type} + toolIcon={icon.toolIcon} + size="md" + className="!h-10 !w-10 !rounded-xl [&_svg]:!h-7 [&_svg]:!w-7" + /> + ))} + </div> + <div className='flex flex-col gap-1'> + <div className='system-sm-semibold text-text-secondary'>{t('workflow.debug.variableInspect.listening.title')}</div> + <div className='system-xs-regular whitespace-pre-line text-text-tertiary'>{description}</div> + </div> + {webhookDebugUrl && ( + <div className='flex items-center gap-2'> + <div className='system-xs-regular shrink-0 whitespace-pre-line text-text-tertiary'> + {t('workflow.nodes.triggerWebhook.debugUrlTitle')} + </div> + <Tooltip + popupContent={debugUrlCopied + ? t('workflow.nodes.triggerWebhook.debugUrlCopied') + : t('workflow.nodes.triggerWebhook.debugUrlCopy')} + popupClassName="system-xs-regular text-text-primary bg-components-tooltip-bg border border-components-panel-border shadow-lg backdrop-blur-sm rounded-md px-1.5 py-1" + position="top" + offset={{ mainAxis: -4 }} + needsDelay={true} + > + <button + type='button' + aria-label={t('workflow.nodes.triggerWebhook.debugUrlCopy') || ''} + className={`inline-flex items-center rounded-[6px] border border-divider-regular bg-components-badge-white-to-dark px-1.5 py-[2px] font-mono text-[13px] leading-[18px] text-text-secondary transition-colors hover:bg-components-panel-on-panel-item-bg-hover focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-components-panel-border ${debugUrlCopied ? 'bg-components-panel-on-panel-item-bg-hover text-text-primary' : ''}`} + onClick={() => { + copy(webhookDebugUrl) + setDebugUrlCopied(true) + }} + > + <span className='whitespace-nowrap text-text-primary'> + {webhookDebugUrl} + </span> + </button> + </Tooltip> + </div> + )} + <div> + <Button + size='medium' + className='px-3' + variant='primary' + onClick={onStop} + > + <StopCircle className='mr-1 size-4' /> + {t('workflow.debug.variableInspect.listening.stopButton')} + </Button> + </div> + </div> + ) +} + +export default Listening diff --git a/web/app/components/workflow/variable-inspect/panel.tsx b/web/app/components/workflow/variable-inspect/panel.tsx index db0a6da8ab..c0ad4cd159 100644 --- a/web/app/components/workflow/variable-inspect/panel.tsx +++ b/web/app/components/workflow/variable-inspect/panel.tsx @@ -7,6 +7,7 @@ import { import { useStore } from '../store' import useCurrentVars from '../hooks/use-inspect-vars-crud' import Empty from './empty' +import Listening from './listening' import Left from './left' import Right from './right' import ActionButton from '@/app/components/base/action-button' @@ -16,6 +17,8 @@ import { VarInInspectType } from '@/types/workflow' import cn from '@/utils/classnames' import type { NodeProps } from '../types' import useMatchSchemaType from '../nodes/_base/components/variable/use-match-schema-type' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' export type currentVarType = { nodeId: string @@ -32,6 +35,7 @@ const Panel: FC = () => { const bottomPanelWidth = useStore(s => s.bottomPanelWidth) const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel) const [showLeftPanel, setShowLeftPanel] = useState(true) + const isListening = useStore(s => s.isListening) const environmentVariables = useStore(s => s.environmentVariables) const currentFocusNodeId = useStore(s => s.currentFocusNodeId) @@ -135,6 +139,11 @@ const Panel: FC = () => { }, [setCurrentFocusNodeId, setCurrentVarId]) const { isLoading, schemaTypeDefinitions } = useMatchSchemaType() + const { eventEmitter } = useEventEmitterContextContext() + + const handleStopListening = useCallback(() => { + eventEmitter?.emit({ type: EVENT_WORKFLOW_STOP } as any) + }, [eventEmitter]) useEffect(() => { if (currentFocusNodeId && currentVarId && !isLoading) { @@ -144,6 +153,24 @@ const Panel: FC = () => { } }, [currentFocusNodeId, currentVarId, nodesWithInspectVars, fetchInspectVarValue, schemaTypeDefinitions, isLoading]) + if (isListening) { + return ( + <div className={cn('flex h-full flex-col')}> + <div className='flex shrink-0 items-center justify-between pl-4 pr-2 pt-2'> + <div className='system-sm-semibold-uppercase text-text-primary'>{t('workflow.debug.variableInspect.title')}</div> + <ActionButton onClick={() => setShowVariableInspectPanel(false)}> + <RiCloseLine className='h-4 w-4' /> + </ActionButton> + </div> + <div className='grow p-2'> + <Listening + onStop={handleStopListening} + /> + </div> + </div> + ) + } + if (isEmpty) { return ( <div className={cn('flex h-full flex-col')}> diff --git a/web/app/components/workflow/variable-inspect/right.tsx b/web/app/components/workflow/variable-inspect/right.tsx index 4e38e66269..9627a7ea43 100644 --- a/web/app/components/workflow/variable-inspect/right.tsx +++ b/web/app/components/workflow/variable-inspect/right.tsx @@ -24,7 +24,7 @@ import useNodeInfo from '../nodes/_base/hooks/use-node-info' import { useBoolean } from 'ahooks' import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res' import GetCodeGeneratorResModal from '../../app/configuration/config/code-generator/get-code-generator-res' -import { AppType } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { useHooksStore } from '../hooks-store' import { useCallback, useMemo } from 'react' import { useNodesInteractions, useToolIcon } from '../hooks' @@ -282,7 +282,7 @@ const Right = ({ isCodeBlock ? <GetCodeGeneratorResModal isShow - mode={AppType.chat} + mode={AppModeEnum.CHAT} onClose={handleHidePromptGenerator} flowId={configsMap?.flowId || ''} nodeId={nodeId} @@ -291,7 +291,7 @@ const Right = ({ onFinished={handleUpdatePrompt} /> : <GetAutomaticResModal - mode={AppType.chat} + mode={AppModeEnum.CHAT} isShow onClose={handleHidePromptGenerator} onFinished={handleUpdatePrompt} diff --git a/web/app/components/workflow/workflow-preview/components/node-handle.tsx b/web/app/components/workflow/workflow-preview/components/node-handle.tsx index 4ff08354be..2211e3397f 100644 --- a/web/app/components/workflow/workflow-preview/components/node-handle.tsx +++ b/web/app/components/workflow/workflow-preview/components/node-handle.tsx @@ -34,7 +34,10 @@ export const NodeTargetHandle = memo(({ 'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', 'transition-all hover:scale-125', !connected && 'after:opacity-0', - data.type === BlockEnum.Start && 'opacity-0', + (data.type === BlockEnum.Start + || data.type === BlockEnum.TriggerWebhook + || data.type === BlockEnum.TriggerSchedule + || data.type === BlockEnum.TriggerPlugin) && 'opacity-0', handleClassName, )} > diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts index df3ee38795..9d45a5ee69 100644 --- a/web/app/education-apply/hooks.ts +++ b/web/app/education-apply/hooks.ts @@ -20,6 +20,7 @@ import timezone from 'dayjs/plugin/timezone' import { useAppContext } from '@/context/app-context' import { useRouter } from 'next/navigation' import { useProviderContext } from '@/context/provider-context' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' dayjs.extend(utc) dayjs.extend(timezone) @@ -155,7 +156,7 @@ export const useEducationInit = () => { useEffect(() => { if (educationVerifying === 'yes' || educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) { - setShowAccountSettingModal({ payload: 'billing' }) + setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') diff --git a/web/assets/search-menu.svg b/web/assets/search-menu.svg new file mode 100644 index 0000000000..8f7131c2ce --- /dev/null +++ b/web/assets/search-menu.svg @@ -0,0 +1,7 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M28.0049 16C28.0049 20.4183 24.4231 24 20.0049 24C15.5866 24 12.0049 20.4183 12.0049 16C12.0049 11.5817 15.5866 8 20.0049 8C24.4231 8 28.0049 11.5817 28.0049 16Z" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M4.00488 16H6.67155" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M4.00488 9.33301H8.00488" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M4.00488 22.667H8.00488" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M26 22L29.3333 25.3333" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/web/config/index.ts b/web/config/index.ts index 4e98182c0e..7b2b9e1084 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -421,6 +421,8 @@ export const ZENDESK_FIELD_IDS = { } export const APP_VERSION = pkg.version +export const IS_MARKETPLACE = globalThis.document?.body?.getAttribute('data-is-marketplace') === 'true' + export const RAG_PIPELINE_PREVIEW_CHUNK_NUM = 20 export const PROVIDER_WITH_PRESET_TONE = ['langgenius/openai/openai', 'langgenius/azure_openai/azure_openai'] diff --git a/web/context/debug-configuration.ts b/web/context/debug-configuration.ts index 1358940e39..5301835f12 100644 --- a/web/context/debug-configuration.ts +++ b/web/context/debug-configuration.ts @@ -22,6 +22,7 @@ import type { import type { ExternalDataTool } from '@/models/common' import type { DataSet } from '@/models/datasets' import type { VisionSettings } from '@/types/app' +import { AppModeEnum } from '@/types/app' import { ModelModeType, RETRIEVE_TYPE, Resolution, TransferMethod } from '@/types/app' import { ANNOTATION_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -32,7 +33,7 @@ type IDebugConfiguration = { appId: string isAPIKeySet: boolean isTrailFinished: boolean - mode: string + mode: AppModeEnum modelModeType: ModelModeType promptMode: PromptMode setPromptMode: (promptMode: PromptMode) => void @@ -111,7 +112,7 @@ const DebugConfigurationContext = createContext<IDebugConfiguration>({ appId: '', isAPIKeySet: false, isTrailFinished: false, - mode: '', + mode: AppModeEnum.CHAT, modelModeType: ModelModeType.chat, promptMode: PromptMode.simple, setPromptMode: noop, diff --git a/web/context/modal-context.tsx b/web/context/modal-context.tsx index 5baadc934b..e0228b8ca8 100644 --- a/web/context/modal-context.tsx +++ b/web/context/modal-context.tsx @@ -1,9 +1,9 @@ 'use client' import type { Dispatch, SetStateAction } from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { createContext, useContext, useContextSelector } from 'use-context-selector' -import { useRouter, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' import type { ConfigurationMethodEnum, Credential, @@ -12,8 +12,15 @@ import type { ModelProvider, } from '@/app/components/header/account-setting/model-provider-page/declarations' import { + EDUCATION_PRICING_SHOW_ACTION, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from '@/app/education-apply/constants' +import type { AccountSettingTab } from '@/app/components/header/account-setting/constants' +import { + ACCOUNT_SETTING_MODAL_ACTION, + DEFAULT_ACCOUNT_SETTING_TAB, + isValidAccountSettingTab, +} from '@/app/components/header/account-setting/constants' import type { ModerationConfig, PromptVariable } from '@/models/debug' import type { ApiBasedExtension, @@ -90,7 +97,7 @@ export type ModelModalType = { } export type ModalContextState = { - setShowAccountSettingModal: Dispatch<SetStateAction<ModalState<string> | null>> + setShowAccountSettingModal: Dispatch<SetStateAction<ModalState<AccountSettingTab> | null>> setShowApiBasedExtensionModal: Dispatch<SetStateAction<ModalState<ApiBasedExtension> | null>> setShowModerationSettingModal: Dispatch<SetStateAction<ModalState<ModerationConfig> | null>> setShowExternalDataToolModal: Dispatch<SetStateAction<ModalState<ExternalDataTool> | null>> @@ -107,6 +114,9 @@ export type ModalContextState = { setShowUpdatePluginModal: Dispatch<SetStateAction<ModalState<UpdatePluginPayload> | null>> setShowEducationExpireNoticeModal: Dispatch<SetStateAction<ModalState<ExpireNoticeModalPayloadProps> | null>> } +const PRICING_MODAL_QUERY_PARAM = 'pricing' +const PRICING_MODAL_QUERY_VALUE = 'open' + const ModalContext = createContext<ModalContextState>({ setShowAccountSettingModal: noop, setShowApiBasedExtensionModal: noop, @@ -135,7 +145,16 @@ type ModalContextProviderProps = { export const ModalContextProvider = ({ children, }: ModalContextProviderProps) => { - const [showAccountSettingModal, setShowAccountSettingModal] = useState<ModalState<string> | null>(null) + const searchParams = useSearchParams() + + const [showAccountSettingModal, setShowAccountSettingModal] = useState<ModalState<AccountSettingTab> | null>(() => { + if (searchParams.get('action') === ACCOUNT_SETTING_MODAL_ACTION) { + const tabParam = searchParams.get('tab') + const tab = isValidAccountSettingTab(tabParam) ? tabParam : DEFAULT_ACCOUNT_SETTING_TAB + return { payload: tab } + } + return null + }) const [showApiBasedExtensionModal, setShowApiBasedExtensionModal] = useState<ModalState<ApiBasedExtension> | null>(null) const [showModerationSettingModal, setShowModerationSettingModal] = useState<ModalState<ModerationConfig> | null>(null) const [showExternalDataToolModal, setShowExternalDataToolModal] = useState<ModalState<ExternalDataTool> | null>(null) @@ -150,9 +169,9 @@ export const ModalContextProvider = ({ const [showUpdatePluginModal, setShowUpdatePluginModal] = useState<ModalState<UpdatePluginPayload> | null>(null) const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState<ModalState<ExpireNoticeModalPayloadProps> | null>(null) - const searchParams = useSearchParams() - const router = useRouter() - const [showPricingModal, setShowPricingModal] = useState(searchParams.get('show-pricing') === '1') + const [showPricingModal, setShowPricingModal] = useState( + searchParams.get(PRICING_MODAL_QUERY_PARAM) === PRICING_MODAL_QUERY_VALUE, + ) const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false) const handleCancelAccountSettingModal = () => { const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) @@ -161,11 +180,54 @@ export const ModalContextProvider = ({ localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) removeSpecificQueryParam('action') + removeSpecificQueryParam('tab') setShowAccountSettingModal(null) if (showAccountSettingModal?.onCancelCallback) showAccountSettingModal?.onCancelCallback() } + const handleAccountSettingTabChange = useCallback((tab: AccountSettingTab) => { + setShowAccountSettingModal((prev) => { + if (!prev) + return { payload: tab } + if (prev.payload === tab) + return prev + return { ...prev, payload: tab } + }) + }, [setShowAccountSettingModal]) + + useEffect(() => { + if (typeof window === 'undefined') + return + const url = new URL(window.location.href) + if (!showAccountSettingModal?.payload) { + if (url.searchParams.get('action') !== ACCOUNT_SETTING_MODAL_ACTION) + return + url.searchParams.delete('action') + url.searchParams.delete('tab') + window.history.replaceState(null, '', url.toString()) + return + } + url.searchParams.set('action', ACCOUNT_SETTING_MODAL_ACTION) + url.searchParams.set('tab', showAccountSettingModal.payload) + window.history.replaceState(null, '', url.toString()) + }, [showAccountSettingModal]) + + useEffect(() => { + if (typeof window === 'undefined') + return + const url = new URL(window.location.href) + if (showPricingModal) { + url.searchParams.set(PRICING_MODAL_QUERY_PARAM, PRICING_MODAL_QUERY_VALUE) + } + else { + url.searchParams.delete(PRICING_MODAL_QUERY_PARAM) + if (url.searchParams.get('action') === EDUCATION_PRICING_SHOW_ACTION) + url.searchParams.delete('action') + } + window.history.replaceState(null, '', url.toString()) + }, [showPricingModal]) + const handleCancelModerationSettingModal = () => { setShowModerationSettingModal(null) if (showModerationSettingModal?.onCancelCallback) @@ -250,13 +312,21 @@ export const ModalContextProvider = ({ setShowOpeningModal(null) } + const handleShowPricingModal = useCallback(() => { + setShowPricingModal(true) + }, []) + + const handleCancelPricingModal = useCallback(() => { + setShowPricingModal(false) + }, []) + return ( <ModalContext.Provider value={{ setShowAccountSettingModal, setShowApiBasedExtensionModal, setShowModerationSettingModal, setShowExternalDataToolModal, - setShowPricingModal: () => setShowPricingModal(true), + setShowPricingModal: handleShowPricingModal, setShowAnnotationFullModal: () => setShowAnnotationFullModal(true), setShowModelModal, setShowExternalKnowledgeAPIModal, @@ -272,6 +342,7 @@ export const ModalContextProvider = ({ <AccountSetting activeTab={showAccountSettingModal.payload} onCancel={handleCancelAccountSettingModal} + onTabChange={handleAccountSettingTabChange} /> ) } @@ -307,12 +378,7 @@ export const ModalContextProvider = ({ { !!showPricingModal && ( - <Pricing onCancel={() => { - if (searchParams.get('show-pricing') === '1') - router.push(location.pathname, { forceOptimisticNavigation: true } as any) - removeSpecificQueryParam('action') - setShowPricingModal(false) - }} /> + <Pricing onCancel={handleCancelPricingModal} /> ) } diff --git a/web/context/provider-context.tsx b/web/context/provider-context.tsx index 755131c859..90233fbc21 100644 --- a/web/context/provider-context.tsx +++ b/web/context/provider-context.tsx @@ -17,7 +17,8 @@ import { } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Model, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { RETRIEVE_METHOD } from '@/types/app' -import { Plan, type UsagePlanInfo } from '@/app/components/billing/type' +import type { Plan } from '@/app/components/billing/type' +import type { UsagePlanInfo } from '@/app/components/billing/type' import { fetchCurrentPlanInfo } from '@/service/billing' import { parseCurrentPlan } from '@/app/components/billing/utils' import { defaultPlan } from '@/app/components/billing/config' @@ -70,23 +71,7 @@ const ProviderContext = createContext<ProviderContextState>({ textGenerationModelList: [], supportRetrievalMethods: [], isAPIKeySet: true, - plan: { - type: Plan.sandbox, - usage: { - vectorSpace: 32, - buildApps: 12, - teamMembers: 1, - annotatedResponse: 1, - documentsUploadQuota: 50, - }, - total: { - vectorSpace: 200, - buildApps: 50, - teamMembers: 1, - annotatedResponse: 10, - documentsUploadQuota: 500, - }, - }, + plan: defaultPlan, isFetchedPlan: false, enableBilling: false, onPlanInfoChanged: noop, diff --git a/web/hooks/use-oauth.ts b/web/hooks/use-oauth.ts index 9f21a476b3..34ed8bafb0 100644 --- a/web/hooks/use-oauth.ts +++ b/web/hooks/use-oauth.ts @@ -4,16 +4,38 @@ import { validateRedirectUrl } from '@/utils/urlValidation' export const useOAuthCallback = () => { useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const subscriptionId = urlParams.get('subscription_id') + const error = urlParams.get('error') + const errorDescription = urlParams.get('error_description') + if (window.opener) { - window.opener.postMessage({ - type: 'oauth_callback', - }, '*') + if (subscriptionId) { + window.opener.postMessage({ + type: 'oauth_callback', + success: true, + subscriptionId, + }, '*') + } + else if (error) { + window.opener.postMessage({ + type: 'oauth_callback', + success: false, + error, + errorDescription, + }, '*') + } + else { + window.opener.postMessage({ + type: 'oauth_callback', + }, '*') + } window.close() } }, []) } -export const openOAuthPopup = (url: string, callback: () => void) => { +export const openOAuthPopup = (url: string, callback: (data?: any) => void) => { const width = 600 const height = 600 const left = window.screenX + (window.outerWidth - width) / 2 @@ -29,10 +51,20 @@ export const openOAuthPopup = (url: string, callback: () => void) => { const handleMessage = (event: MessageEvent) => { if (event.data?.type === 'oauth_callback') { window.removeEventListener('message', handleMessage) - callback() + callback(event.data) } } window.addEventListener('message', handleMessage) + + // Fallback for window close detection + const checkClosed = setInterval(() => { + if (popup?.closed) { + clearInterval(checkClosed) + window.removeEventListener('message', handleMessage) + callback() + } + }, 1000) + return popup } diff --git a/web/i18n-config/i18next-config.ts b/web/i18n-config/i18next-config.ts index af04802e42..360d2afb29 100644 --- a/web/i18n-config/i18next-config.ts +++ b/web/i18n-config/i18next-config.ts @@ -38,6 +38,7 @@ const NAMESPACES = [ 'oauth', 'pipeline', 'plugin-tags', + 'plugin-trigger', 'plugin', 'register', 'run-log', diff --git a/web/i18n/de-DE/billing.ts b/web/i18n/de-DE/billing.ts index fc45f3889c..6601bbb179 100644 --- a/web/i18n/de-DE/billing.ts +++ b/web/i18n/de-DE/billing.ts @@ -83,7 +83,7 @@ const translation = { cloud: 'Cloud-Dienst', apiRateLimitTooltip: 'Die API-Datenbeschränkung gilt für alle Anfragen, die über die Dify-API gemacht werden, einschließlich Textgenerierung, Chat-Konversationen, Workflow-Ausführungen und Dokumentenverarbeitung.', getStarted: 'Loslegen', - apiRateLimitUnit: '{{count,number}}/Tag', + apiRateLimitUnit: '{{count,number}}/Monat', documentsTooltip: 'Vorgabe für die Anzahl der Dokumente, die aus der Wissensdatenquelle importiert werden.', apiRateLimit: 'API-Datenlimit', documents: '{{count,number}} Wissensdokumente', diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index 4353e5e10c..28aa8bdc19 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Veröffentlicht', publish: 'Veröffentlichen', update: 'Aktualisieren', - run: 'Ausführen', + run: 'Test ausführen', running: 'Wird ausgeführt', inRunMode: 'Im Ausführungsmodus', inPreview: 'In der Vorschau', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Ausführungsverlauf', goBackToEdit: 'Zurück zum Editor', conversationLog: 'Konversationsprotokoll', - features: 'Funktionen', debugAndPreview: 'Vorschau', restart: 'Neustarten', currentDraft: 'Aktueller Entwurf', @@ -91,10 +90,8 @@ const translation = { addParallelNode: 'Parallelen Knoten hinzufügen', parallel: 'PARALLEL', branch: 'ZWEIG', - featuresDocLink: 'Weitere Informationen', ImageUploadLegacyTip: 'Sie können jetzt Dateitypvariablen im Startformular erstellen. Wir werden die Funktion zum Hochladen von Bildern in Zukunft nicht mehr unterstützen.', fileUploadTip: 'Die Funktionen zum Hochladen von Bildern wurden auf das Hochladen von Dateien aktualisiert.', - featuresDescription: 'Verbessern Sie die Benutzererfahrung von Web-Apps', importWarning: 'Vorsicht', importWarningDetails: 'Der Unterschied zwischen den DSL-Versionen kann sich auf bestimmte Funktionen auswirken', openInExplore: 'In Explore öffnen', @@ -110,11 +107,12 @@ const translation = { exitVersions: 'Ausgangsversionen', exportPNG: 'Als PNG exportieren', addBlock: 'Knoten hinzufügen', - needEndNode: 'Der Endknoten muss hinzugefügt werden.', + needOutputNode: 'Der Ausgabeknoten muss hinzugefügt werden', needAnswerNode: 'Der Antwortknoten muss hinzugefügt werden.', tagBound: 'Anzahl der Apps, die dieses Tag verwenden', currentWorkflow: 'Aktueller Arbeitsablauf', currentView: 'Aktuelle Ansicht', + moreActions: 'Weitere Aktionen', }, env: { envPanelTitle: 'Umgebungsvariablen', @@ -139,6 +137,19 @@ const translation = { export: 'DSL mit geheimen Werten exportieren', }, }, + globalVar: { + title: 'Systemvariablen', + description: 'Systemvariablen sind globale Variablen, die von jedem Knoten ohne Verkabelung referenziert werden können, sofern der Typ passt, etwa Endnutzer-ID und Workflow-ID.', + fieldsDescription: { + conversationId: 'Konversations-ID', + dialogCount: 'Konversationsanzahl', + userId: 'Benutzer-ID', + triggerTimestamp: 'Zeitstempel des Anwendungsstarts', + appId: 'Anwendungs-ID', + workflowId: 'Workflow-ID', + workflowRunId: 'Workflow-Ausführungs-ID', + }, + }, chatVariable: { panelTitle: 'Gesprächsvariablen', panelDescription: 'Gesprächsvariablen werden verwendet, um interaktive Informationen zu speichern, die das LLM benötigt, einschließlich Gesprächsverlauf, hochgeladene Dateien und Benutzereinstellungen. Sie sind les- und schreibbar.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Start', - 'end': 'Ende', + 'end': 'Ausgabe', 'answer': 'Antwort', 'llm': 'LLM', 'knowledge-retrieval': 'Wissensabruf', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Definieren Sie die Anfangsparameter zum Starten eines Workflows', - 'end': 'Definieren Sie das Ende und den Ergebnistyp eines Workflows', + 'end': 'Definieren Sie die Ausgabe und den Ergebnistyp eines Workflows', 'answer': 'Definieren Sie den Antwortinhalt einer Chat-Konversation', 'llm': 'Große Sprachmodelle aufrufen, um Fragen zu beantworten oder natürliche Sprache zu verarbeiten', 'knowledge-retrieval': 'Ermöglicht das Abfragen von Textinhalten, die sich auf Benutzerfragen aus der Wissensdatenbank beziehen', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Benutzereingabefeld', - helpLink: 'Hilfelink', + helpLink: 'Hilfe', about: 'Über', createdBy: 'Erstellt von ', nextStep: 'Nächster Schritt', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Alle Probleme wurden gelöst', change: 'Ändern', optional: '(optional)', - moveToThisNode: 'Bewege zu diesem Knoten', selectNextStep: 'Nächsten Schritt auswählen', addNextStep: 'Fügen Sie den nächsten Schritt in diesem Arbeitsablauf hinzu.', organizeBlocks: 'Knoten organisieren', changeBlock: 'Knoten ändern', maximize: 'Maximiere die Leinwand', minimize: 'Vollbildmodus beenden', + scrollToSelectedNode: 'Zum ausgewählten Knoten scrollen', optional_and_hidden: '(optional & hidden)', }, nodes: { diff --git a/web/i18n/en-US/app-log.ts b/web/i18n/en-US/app-log.ts index e00e1cc675..7c5024a68f 100644 --- a/web/i18n/en-US/app-log.ts +++ b/web/i18n/en-US/app-log.ts @@ -18,8 +18,9 @@ const translation = { status: 'STATUS', runtime: 'RUN TIME', tokens: 'TOKENS', - user: 'End User or Account', + user: 'END USER OR ACCOUNT', version: 'VERSION', + triggered_from: 'TRIGGER BY', }, pagination: { previous: 'Prev', @@ -97,6 +98,15 @@ const translation = { iteration: 'Iteration', finalProcessing: 'Final Processing', }, + triggerBy: { + debugging: 'Debugging', + appRun: 'WebApp', + webhook: 'Webhook', + schedule: 'Schedule', + plugin: 'Plugin', + ragPipelineRun: 'RAG Pipeline', + ragPipelineDebugging: 'RAG Debugging', + }, } export default translation diff --git a/web/i18n/en-US/app-overview.ts b/web/i18n/en-US/app-overview.ts index feedc32e6b..4e88840b6d 100644 --- a/web/i18n/en-US/app-overview.ts +++ b/web/i18n/en-US/app-overview.ts @@ -30,6 +30,7 @@ const translation = { overview: { title: 'Overview', appInfo: { + title: 'Web App', explanation: 'Ready-to-use AI web app', accessibleAddress: 'Public URL', preview: 'Preview', @@ -37,6 +38,10 @@ const translation = { regenerate: 'Regenerate', regenerateNotice: 'Do you want to regenerate the public URL?', preUseReminder: 'Please enable web app before continuing.', + enableTooltip: { + description: 'To enable this feature, please add a User Input node to the canvas. (May already exist in draft, takes effect after publishing)', + learnMore: 'Learn more', + }, settings: { entry: 'Settings', title: 'Web App Settings', @@ -121,6 +126,14 @@ const translation = { accessibleAddress: 'Service API Endpoint', doc: 'API Reference', }, + triggerInfo: { + title: 'Triggers', + explanation: 'Workflow trigger management', + triggersAdded: '{{count}} Triggers added', + noTriggerAdded: 'No trigger added', + triggerStatusDescription: 'Trigger node status appears here. (May already exist in draft, takes effect after publishing)', + learnAboutTriggers: 'Learn about Triggers', + }, status: { running: 'In Service', disable: 'Disabled', diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index 7b3fead6e4..99bab2893c 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -254,6 +254,8 @@ const translation = { notSetDesc: 'Currently nobody can access the web app. Please set permissions.', }, noAccessPermission: 'No permission to access web app', + noUserInputNode: 'Missing user input node', + notPublishedYet: 'App is not published yet', maxActiveRequests: 'Max concurrent requests', maxActiveRequestsPlaceholder: 'Enter 0 for unlimited', maxActiveRequestsTip: 'Maximum number of concurrent active requests per app (0 for unlimited)', diff --git a/web/i18n/en-US/billing.ts b/web/i18n/en-US/billing.ts index 9169631281..0bd26c1075 100644 --- a/web/i18n/en-US/billing.ts +++ b/web/i18n/en-US/billing.ts @@ -7,6 +7,8 @@ const translation = { documentsUploadQuota: 'Documents Upload Quota', vectorSpace: 'Knowledge Data Storage', vectorSpaceTooltip: 'Documents with the High Quality indexing mode will consume Knowledge Data Storage resources. When Knowledge Data Storage reaches the limit, new documents will not be uploaded.', + triggerEvents: 'Trigger Events', + perMonth: 'per month', }, teamMembers: 'Team Members', upgradeBtn: { @@ -62,7 +64,7 @@ const translation = { documentsRequestQuota: '{{count,number}}/min Knowledge Request Rate Limit', documentsRequestQuotaTooltip: 'Specifies the total number of actions a workspace can perform per minute within the knowledge base, including dataset creation, deletion, updates, document uploads, modifications, archiving, and knowledge base queries. This metric is used to evaluate the performance of knowledge base requests. For example, if a Sandbox user performs 10 consecutive hit tests within one minute, their workspace will be temporarily restricted from performing the following actions for the next minute: dataset creation, deletion, updates, and document uploads or modifications. ', apiRateLimit: 'API Rate Limit', - apiRateLimitUnit: '{{count,number}}/day', + apiRateLimitUnit: '{{count,number}}/month', unlimitedApiRate: 'No API Rate Limit', apiRateLimitTooltip: 'API Rate Limit applies to all requests made through the Dify API, including text generation, chat conversations, workflow executions, and document processing.', documentProcessingPriority: ' Document Processing', @@ -72,6 +74,20 @@ const translation = { 'priority': 'Priority', 'top-priority': 'Top Priority', }, + triggerEvents: { + sandbox: '{{count,number}} Trigger Events', + professional: '{{count,number}} Trigger Events/month', + unlimited: 'Unlimited Trigger Events', + }, + workflowExecution: { + standard: 'Standard Workflow Execution', + faster: 'Faster Workflow Execution', + priority: 'Priority Workflow Execution', + }, + startNodes: { + limited: 'Up to {{count}} Start Nodes per Workflow', + unlimited: 'Unlimited Start Nodes per Workflow', + }, logsHistory: '{{days}} Log history', customTools: 'Custom Tools', unavailable: 'Unavailable', diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index bd0f27bd92..26c6ed89f2 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -29,6 +29,11 @@ const translation = { refresh: 'Restart', reset: 'Reset', search: 'Search', + noSearchResults: 'No {{content}} were found', + resetKeywords: 'Reset keywords', + selectCount: '{{count}} Selected', + searchCount: 'Find {{count}} {{content}}', + noSearchCount: '0 {{content}}', change: 'Change', remove: 'Remove', send: 'Send', @@ -41,6 +46,7 @@ const translation = { downloadFailed: 'Download failed. Please try again later.', viewDetails: 'View Details', delete: 'Delete', + now: 'Now', deleteApp: 'Delete App', settings: 'Settings', setup: 'Setup', @@ -79,7 +85,9 @@ const translation = { placeholder: { input: 'Please enter', select: 'Please select', + search: 'Search...', }, + noData: 'No data', label: { optional: '(optional)', }, @@ -174,7 +182,7 @@ const translation = { emailSupport: 'Email Support', workspace: 'Workspace', createWorkspace: 'Create Workspace', - helpCenter: 'Docs', + helpCenter: 'View Docs', support: 'Support', compliance: 'Compliance', forum: 'Forum', @@ -769,6 +777,12 @@ const translation = { supportedFormats: 'Supports PNG, JPG, JPEG, WEBP and GIF', }, you: 'You', + dynamicSelect: { + error: 'Loading options failed', + noData: 'No options available', + loading: 'Loading options...', + selected: '{{count}} selected', + }, } export default translation diff --git a/web/i18n/en-US/plugin-trigger.ts b/web/i18n/en-US/plugin-trigger.ts new file mode 100644 index 0000000000..aedd0c6225 --- /dev/null +++ b/web/i18n/en-US/plugin-trigger.ts @@ -0,0 +1,186 @@ +const translation = { + subscription: { + title: 'Subscriptions', + listNum: '{{num}} subscriptions', + empty: { + title: 'No subscriptions', + button: 'New subscription', + }, + createButton: { + oauth: 'New subscription with OAuth', + apiKey: 'New subscription with API Key', + manual: 'Paste URL to create a new subscription', + }, + createSuccess: 'Subscription created successfully', + createFailed: 'Failed to create subscription', + maxCount: 'Max {{num}} subscriptions', + selectPlaceholder: 'Select subscription', + noSubscriptionSelected: 'No subscription selected', + subscriptionRemoved: 'Subscription removed', + list: { + title: 'Subscriptions', + addButton: 'Add', + tip: 'Receive events via Subscription', + item: { + enabled: 'Enabled', + disabled: 'Disabled', + credentialType: { + api_key: 'API Key', + oauth2: 'OAuth', + unauthorized: 'Manual', + }, + actions: { + delete: 'Delete', + deleteConfirm: { + title: 'Delete {{name}}?', + success: 'Subscription {{name}} deleted successfully', + error: 'Failed to delete subscription {{name}}', + content: 'Once deleted, this subscription cannot be recovered. Please confirm.', + contentWithApps: 'The current subscription is referenced by {{count}} applications. Deleting it will cause the configured applications to stop receiving subscription events.', + confirm: 'Confirm Delete', + cancel: 'Cancel', + confirmInputWarning: 'Please enter the correct name to confirm.', + confirmInputPlaceholder: 'Enter "{{name}}" to confirm.', + confirmInputTip: 'Please enter “{{name}}” to confirm.', + }, + }, + status: { + active: 'Active', + inactive: 'Inactive', + }, + usedByNum: 'Used by {{num}} workflows', + noUsed: 'No workflow used', + }, + }, + addType: { + title: 'Add subscription', + description: 'Choose how you want to create your trigger subscription', + options: { + apikey: { + title: 'Create with API Key', + description: 'Automatically create subscription using API credentials', + }, + oauth: { + title: 'Create with OAuth', + description: 'Authorize with third-party platform to create subscription', + clientSettings: 'OAuth Client Settings', + clientTitle: 'OAuth Client', + default: 'Default', + custom: 'Custom', + }, + manual: { + title: 'Manual Setup', + description: 'Paste URL to create a new subscription', + tip: 'Configure URL on third-party platform manually', + }, + }, + }, + }, + modal: { + steps: { + verify: 'Verify', + configuration: 'Configuration', + }, + common: { + cancel: 'Cancel', + back: 'Back', + next: 'Next', + create: 'Create', + verify: 'Verify', + authorize: 'Authorize', + creating: 'Creating...', + verifying: 'Verifying...', + authorizing: 'Authorizing...', + }, + oauthRedirectInfo: 'As no system client secrets found for this tool provider, setup it manually is required, for redirect_uri, please use', + apiKey: { + title: 'Create with API Key', + verify: { + title: 'Verify Credentials', + description: 'Please provide your API credentials to verify access', + error: 'Credential verification failed. Please check your API key.', + success: 'Credentials verified successfully', + }, + configuration: { + title: 'Configure Subscription', + description: 'Set up your subscription parameters', + }, + }, + oauth: { + title: 'Create with OAuth', + authorization: { + title: 'OAuth Authorization', + description: 'Authorize Dify to access your account', + redirectUrl: 'Redirect URL', + redirectUrlHelp: 'Use this URL in your OAuth app configuration', + authorizeButton: 'Authorize with {{provider}}', + waitingAuth: 'Waiting for authorization...', + authSuccess: 'Authorization successful', + authFailed: 'Failed to get OAuth authorization information', + waitingJump: 'Authorized, waiting for jump', + }, + configuration: { + title: 'Configure Subscription', + description: 'Set up your subscription parameters after authorization', + success: 'OAuth configuration successful', + failed: 'OAuth configuration failed', + }, + remove: { + success: 'OAuth remove successful', + failed: 'OAuth remove failed', + }, + save: { + success: 'OAuth configuration saved successfully', + }, + }, + manual: { + title: 'Manual Setup', + description: 'Configure your webhook subscription manually', + logs: { + title: 'Request Logs', + request: 'Request', + loading: 'Awaiting request from {{pluginName}}...', + }, + }, + form: { + subscriptionName: { + label: 'Subscription Name', + placeholder: 'Enter subscription name', + required: 'Subscription name is required', + }, + callbackUrl: { + label: 'Callback URL', + description: 'This URL will receive webhook events', + tooltip: 'Provide a publicly accessible endpoint that can receive callback requests from the trigger provider.', + placeholder: 'Generating...', + privateAddressWarning: 'This URL appears to be an internal address, which may cause webhook requests to fail. You may change TRIGGER_URL to a public address.', + }, + }, + errors: { + createFailed: 'Failed to create subscription', + verifyFailed: 'Failed to verify credentials', + authFailed: 'Authorization failed', + networkError: 'Network error, please try again', + }, + }, + events: { + title: 'Available Events', + description: 'Events that this trigger plugin can subscribe to', + empty: 'No events available', + event: 'Event', + events: 'Events', + actionNum: '{{num}} {{event}} INCLUDED', + item: { + parameters: '{{count}} parameters', + noParameters: 'No parameters', + }, + output: 'Output', + }, + node: { + status: { + warning: 'Disconnect', + }, + }, +} + +export default translation diff --git a/web/i18n/en-US/plugin.ts b/web/i18n/en-US/plugin.ts index 18fc69c841..62a5f35c0b 100644 --- a/web/i18n/en-US/plugin.ts +++ b/web/i18n/en-US/plugin.ts @@ -8,6 +8,7 @@ const translation = { tools: 'Tools', agents: 'Agent Strategies', extensions: 'Extensions', + triggers: 'Triggers', bundles: 'Bundles', datasources: 'Data Sources', }, @@ -16,6 +17,7 @@ const translation = { tool: 'Tool', agent: 'Agent Strategy', extension: 'Extension', + trigger: 'Trigger', bundle: 'Bundle', datasource: 'Data Source', }, @@ -62,6 +64,7 @@ const translation = { checkUpdate: 'Check Update', viewDetail: 'View Detail', remove: 'Remove', + back: 'Back', }, actionNum: '{{num}} {{action}} INCLUDED', strategyNum: '{{num}} {{strategy}} INCLUDED', @@ -306,6 +309,12 @@ const translation = { connectedWorkspace: 'Connected Workspace', emptyAuth: 'Please configure authentication', }, + readmeInfo: { + title: 'README', + needHelpCheckReadme: 'Need help? Check the README.', + noReadmeAvailable: 'No README available', + failedToFetch: 'Failed to fetch README', + }, } export default translation diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index c59f4e9d6b..92a0b110c7 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -9,8 +9,11 @@ const translation = { publish: 'Publish', update: 'Update', publishUpdate: 'Publish Update', - run: 'Run', + run: 'Test Run', running: 'Running', + listening: 'Listening', + chooseStartNodeToRun: 'Choose the start node to run', + runAllTriggers: 'Run all triggers', inRunMode: 'In Run Mode', inPreview: 'In Preview', inPreviewMode: 'In Preview Mode', @@ -47,7 +50,8 @@ const translation = { needConnectTip: 'This step is not connected to anything', maxTreeDepth: 'Maximum limit of {{depth}} nodes per branch', needAdd: '{{node}} node must be added', - needEndNode: 'The End node must be added', + needOutputNode: 'The Output node must be added', + needStartNode: 'At least one start node must be added', needAnswerNode: 'The Answer node must be added', workflowProcess: 'Workflow Process', notRunning: 'Not running yet', @@ -77,12 +81,14 @@ const translation = { exportSVG: 'Export as SVG', currentView: 'Current View', currentWorkflow: 'Current Workflow', + moreActions: 'More Actions', model: 'Model', workflowAsTool: 'Workflow as Tool', configureRequired: 'Configure Required', configure: 'Configure', manageInTools: 'Manage in Tools', workflowAsToolTip: 'Tool reconfiguration is required after the workflow update.', + workflowAsToolDisabledHint: 'Publish the latest workflow and ensure a connected User Input node before configuring it as a tool.', viewDetailInTracingPanel: 'View details', syncingData: 'Syncing data, just a few seconds.', importDSL: 'Import DSL', @@ -140,6 +146,19 @@ const translation = { export: 'Export DSL with secret values ', }, }, + globalVar: { + title: 'System Variables', + description: 'System variables are global variables that can be referenced by any node without wiring when the type is correct, such as end-user ID and workflow ID.', + fieldsDescription: { + conversationId: 'Conversation ID', + dialogCount: 'Conversation Count', + userId: 'User ID', + triggerTimestamp: 'Application start timestamp', + appId: 'Application ID', + workflowId: 'Workflow ID', + workflowRunId: 'Workflow run ID', + }, + }, sidebar: { exportWarning: 'Export Current Saved Version', exportWarningDesc: 'This will export the current saved version of your workflow. If you have unsaved changes in the editor, please save them first by using the export option in the workflow canvas.', @@ -213,6 +232,16 @@ const translation = { invalidVariable: 'Invalid variable', noValidTool: '{{field}} no valid tool selected', toolParameterRequired: '{{field}}: parameter [{{param}}] is required', + startNodeRequired: 'Please add a start node first before {{operation}}', + }, + error: { + startNodeRequired: 'Please add a start node first before {{operation}}', + operations: { + connectingNodes: 'connecting nodes', + addingNodes: 'adding nodes', + modifyingWorkflow: 'modifying workflow', + updatingWorkflow: 'updating workflow', + }, }, singleRun: { testRun: 'Test Run', @@ -227,8 +256,11 @@ const translation = { }, tabs: { 'searchBlock': 'Search node', + 'start': 'Start', 'blocks': 'Nodes', 'searchTool': 'Search tool', + 'searchTrigger': 'Search triggers...', + 'allTriggers': 'All triggers', 'tools': 'Tools', 'allTool': 'All', 'plugin': 'Plugin', @@ -239,15 +271,28 @@ const translation = { 'transform': 'Transform', 'utilities': 'Utilities', 'noResult': 'No match found', + 'noPluginsFound': 'No plugins were found', + 'requestToCommunity': 'Requests to the community', 'agent': 'Agent Strategy', 'allAdded': 'All added', 'addAll': 'Add all', 'sources': 'Sources', 'searchDataSource': 'Search Data Source', + 'featuredTools': 'Featured', + 'showMoreFeatured': 'Show more', + 'showLessFeatured': 'Show less', + 'installed': 'Installed', + 'pluginByAuthor': 'By {{author}}', + 'usePlugin': 'Select tool', + 'hideActions': 'Hide tools', + 'noFeaturedPlugins': 'Discover more tools in Marketplace', + 'noFeaturedTriggers': 'Discover more triggers in Marketplace', + 'startDisabledTip': 'Trigger node and user input node are mutually exclusive.', }, blocks: { - 'start': 'Start', - 'end': 'End', + 'start': 'User Input', + 'originalStartNode': 'original start node', + 'end': 'Output', 'answer': 'Answer', 'llm': 'LLM', 'knowledge-retrieval': 'Knowledge Retrieval', @@ -270,10 +315,14 @@ const translation = { 'loop-end': 'Exit Loop', 'knowledge-index': 'Knowledge Base', 'datasource': 'Data Source', + 'trigger-schedule': 'Schedule Trigger', + 'trigger-webhook': 'Webhook Trigger', + 'trigger-plugin': 'Plugin Trigger', }, + customWebhook: 'Custom Webhook', blocksAbout: { 'start': 'Define the initial parameters for launching a workflow', - 'end': 'Define the end and result type of a workflow', + 'end': 'Define the output and result type of a workflow', 'answer': 'Define the reply content of a chat conversation', 'llm': 'Invoking large language models to answer questions or process natural language', 'knowledge-retrieval': 'Allows you to query text content related to user questions from the Knowledge', @@ -294,7 +343,11 @@ const translation = { 'agent': 'Invoking large language models to answer questions or process natural language', 'knowledge-index': 'Knowledge Base About', 'datasource': 'Data Source About', + 'trigger-schedule': 'Time-based workflow trigger that starts workflows on a schedule', + 'trigger-webhook': 'Webhook Trigger receives HTTP pushes from third-party systems to automatically trigger workflows.', + 'trigger-plugin': 'Third-party integration trigger that starts workflows from external platform events', }, + difyTeam: 'Dify Team', operator: { zoomIn: 'Zoom In', zoomOut: 'Zoom Out', @@ -324,22 +377,24 @@ const translation = { panel: { userInputField: 'User Input Field', changeBlock: 'Change Node', - helpLink: 'Help Link', + helpLink: 'View Docs', about: 'About', createdBy: 'Created By ', nextStep: 'Next Step', addNextStep: 'Add the next step in this workflow', selectNextStep: 'Select Next Step', runThisStep: 'Run this step', - moveToThisNode: 'Move to this node', checklist: 'Checklist', checklistTip: 'Make sure all issues are resolved before publishing', checklistResolved: 'All issues are resolved', + goTo: 'Go to', + startNode: 'Start Node', organizeBlocks: 'Organize nodes', change: 'Change', optional: '(optional)', maximize: 'Maximize Canvas', minimize: 'Exit Full Screen', + scrollToSelectedNode: 'Scroll to selected node', optional_and_hidden: '(optional & hidden)', }, nodes: { @@ -719,6 +774,50 @@ const translation = { json: 'tool generated json', }, }, + triggerPlugin: { + authorized: 'Authorized', + notConfigured: 'Not Configured', + notAuthorized: 'Not Authorized', + selectSubscription: 'Select Subscription', + availableSubscriptions: 'Available Subscriptions', + addSubscription: 'Add New Subscription', + removeSubscription: 'Remove Subscription', + subscriptionRemoved: 'Subscription removed successfully', + error: 'Error', + configuration: 'Configuration', + remove: 'Remove', + or: 'OR', + useOAuth: 'Use OAuth', + useApiKey: 'Use API Key', + authenticationFailed: 'Authentication failed', + authenticationSuccess: 'Authentication successful', + oauthConfigFailed: 'OAuth configuration failed', + configureOAuthClient: 'Configure OAuth Client', + oauthClientDescription: 'Configure OAuth client credentials to enable authentication', + oauthClientSaved: 'OAuth client configuration saved successfully', + configureApiKey: 'Configure API Key', + apiKeyDescription: 'Configure API key credentials for authentication', + apiKeyConfigured: 'API key configured successfully', + configurationFailed: 'Configuration failed', + failedToStart: 'Failed to start authentication flow', + credentialsVerified: 'Credentials verified successfully', + credentialVerificationFailed: 'Credential verification failed', + verifyAndContinue: 'Verify & Continue', + configureParameters: 'Configure Parameters', + parametersDescription: 'Configure trigger parameters and properties', + configurationComplete: 'Configuration Complete', + configurationCompleteDescription: 'Your trigger has been configured successfully', + configurationCompleteMessage: 'Your trigger configuration is now complete and ready to use.', + parameters: 'Parameters', + properties: 'Properties', + propertiesDescription: 'Additional configuration properties for this trigger', + noConfigurationRequired: 'No additional configuration required for this trigger.', + subscriptionName: 'Subscription Name', + subscriptionNameDescription: 'Enter a unique name for this trigger subscription', + subscriptionNamePlaceholder: 'Enter subscription name...', + subscriptionNameRequired: 'Subscription name is required', + subscriptionRequired: 'Subscription is required', + }, questionClassifiers: { model: 'model', inputVars: 'Input Variables', @@ -966,6 +1065,108 @@ const translation = { rerankingModelIsRequired: 'Reranking model is required', rerankingModelIsInvalid: 'Reranking model is invalid', }, + triggerSchedule: { + title: 'Schedule', + nodeTitle: 'Schedule Trigger', + notConfigured: 'Not configured', + useCronExpression: 'Use cron expression', + useVisualPicker: 'Use visual picker', + frequency: { + label: 'FREQUENCY', + hourly: 'Hourly', + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + }, + selectFrequency: 'Select frequency', + frequencyLabel: 'Frequency', + nextExecution: 'Next execution', + weekdays: 'Week days', + time: 'Time', + cronExpression: 'Cron expression', + nextExecutionTime: 'NEXT EXECUTION TIME', + nextExecutionTimes: 'Next 5 execution times', + startTime: 'Start Time', + executeNow: 'Execution now', + selectDateTime: 'Select Date & Time', + hours: 'Hours', + minutes: 'Minutes', + onMinute: 'On Minute', + days: 'Days', + lastDay: 'Last day', + lastDayTooltip: 'Not all months have 31 days. Use the \'last day\' option to select each month\'s final day.', + mode: 'Mode', + timezone: 'Timezone', + visualConfig: 'Visual Configuration', + monthlyDay: 'Monthly Day', + executionTime: 'Execution Time', + invalidTimezone: 'Invalid timezone', + invalidCronExpression: 'Invalid cron expression', + noValidExecutionTime: 'No valid execution time can be calculated', + executionTimeCalculationError: 'Failed to calculate execution times', + invalidFrequency: 'Invalid frequency', + invalidStartTime: 'Invalid start time', + startTimeMustBeFuture: 'Start time must be in the future', + invalidTimeFormat: 'Invalid time format (expected HH:MM AM/PM)', + invalidWeekday: 'Invalid weekday: {{weekday}}', + invalidMonthlyDay: 'Monthly day must be between 1-31 or "last"', + invalidOnMinute: 'On minute must be between 0-59', + invalidExecutionTime: 'Invalid execution time', + executionTimeMustBeFuture: 'Execution time must be in the future', + }, + triggerWebhook: { + title: 'Webhook Trigger', + nodeTitle: '🔗 Webhook Trigger', + configPlaceholder: 'Webhook trigger configuration will be implemented here', + webhookUrl: 'Webhook URL', + webhookUrlPlaceholder: 'Click generate to create webhook URL', + generate: 'Generate', + copy: 'Copy', + test: 'Test', + urlGenerated: 'Webhook URL generated successfully', + urlGenerationFailed: 'Failed to generate webhook URL', + urlCopied: 'URL copied to clipboard', + method: 'Method', + contentType: 'Content Type', + queryParameters: 'Query Parameters', + headerParameters: 'Header Parameters', + requestBodyParameters: 'Request Body Parameters', + parameterName: 'Variable name', + varName: 'Variable name', + varType: 'Type', + varNamePlaceholder: 'Enter variable name...', + required: 'Required', + addParameter: 'Add', + addHeader: 'Add', + noParameters: 'No parameters configured', + noQueryParameters: 'No query parameters configured', + noHeaders: 'No headers configured', + noBodyParameters: 'No body parameters configured', + debugUrlTitle: 'For test runs, always use this URL', + debugUrlCopy: 'Click to copy', + debugUrlCopied: 'Copied!', + debugUrlPrivateAddressWarning: 'This URL appears to be an internal address, which may cause webhook requests to fail. You may change TRIGGER_URL to a public address.', + errorHandling: 'Error Handling', + errorStrategy: 'Error Handling', + responseConfiguration: 'Response', + asyncMode: 'Async Mode', + statusCode: 'Status Code', + responseBody: 'Response Body', + responseBodyPlaceholder: 'Write your response body here', + headers: 'Headers', + validation: { + webhookUrlRequired: 'Webhook URL is required', + invalidParameterType: 'Invalid parameter type "{{type}}" for parameter "{{name}}"', + }, + }, + }, + triggerStatus: { + enabled: 'TRIGGER', + disabled: 'TRIGGER • DISABLED', + }, + entryNodeStatus: { + enabled: 'START', + disabled: 'START • DISABLED', }, tracing: { stopBy: 'Stop by {{user}}', @@ -1027,6 +1228,18 @@ const translation = { view: 'View log', edited: 'Edited', reset: 'Reset to last run value', + listening: { + title: 'Listening for events from triggers...', + tip: 'You can now simulate event triggers by sending test requests to HTTP {{nodeName}} endpoint or use it as a callback URL for live event debugging. All outputs can be viewed directly in the Variable Inspector.', + tipPlugin: 'Now you can create events in {{- pluginName}}, and retrieve outputs from these events in the Variable Inspector.', + tipSchedule: 'Listening for events from schedule triggers.\nNext scheduled run: {{nextTriggerTime}}', + tipFallback: 'Await incoming trigger events. Outputs will appear here.', + defaultNodeName: 'this trigger', + defaultPluginName: 'this plugin trigger', + defaultScheduleTime: 'Not configured', + selectedTriggers: 'selected triggers', + stopButton: 'Stop', + }, trigger: { normal: 'Variable Inspect', running: 'Caching running status', @@ -1052,6 +1265,22 @@ const translation = { noDependents: 'No dependents', }, }, + onboarding: { + title: 'Select a start node to begin', + description: 'Different start nodes have different capabilities. Don\'t worry, you can always change them later.', + userInputFull: 'User Input (original start node)', + userInputDescription: 'Start node that allows setting user input variables, with web app, service API, MCP server, and workflow as tool capabilities.', + trigger: 'Trigger', + triggerDescription: 'Triggers can serve as the start node of a workflow, such as scheduled tasks, custom webhooks, or integrations with other apps.', + back: 'Back', + learnMore: 'Learn more', + aboutStartNode: 'about start node.', + escTip: { + press: 'Press', + key: 'esc', + toDismiss: 'to dismiss', + }, + }, } export default translation diff --git a/web/i18n/es-ES/billing.ts b/web/i18n/es-ES/billing.ts index a8180e2d07..1632776e30 100644 --- a/web/i18n/es-ES/billing.ts +++ b/web/i18n/es-ES/billing.ts @@ -76,7 +76,7 @@ const translation = { priceTip: 'por espacio de trabajo/', teamMember_one: '{{count, número}} Miembro del Equipo', getStarted: 'Comenzar', - apiRateLimitUnit: '{{count, número}}/día', + apiRateLimitUnit: '{{count, número}}/mes', freeTrialTipSuffix: 'No se requiere tarjeta de crédito', unlimitedApiRate: 'Sin límite de tasa de API', apiRateLimit: 'Límite de tasa de API', diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index d7a6bef9e7..dd9519b68f 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publicado', publish: 'Publicar', update: 'Actualizar', - run: 'Ejecutar', + run: 'Ejecutar prueba', running: 'Ejecutando', inRunMode: 'En modo de ejecución', inPreview: 'En vista previa', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Historial de ejecución', goBackToEdit: 'Volver al editor', conversationLog: 'Registro de conversación', - features: 'Funcionalidades', debugAndPreview: 'Vista previa', restart: 'Reiniciar', currentDraft: 'Borrador actual', @@ -93,8 +92,6 @@ const translation = { branch: 'RAMA', fileUploadTip: 'Las funciones de carga de imágenes se han actualizado a la carga de archivos.', ImageUploadLegacyTip: 'Ahora puede crear variables de tipo de archivo en el formulario de inicio. Ya no admitiremos la función de carga de imágenes en el futuro.', - featuresDescription: 'Mejorar la experiencia del usuario de la aplicación web', - featuresDocLink: 'Aprende más', importWarning: 'Cautela', importWarningDetails: 'La diferencia de versión de DSL puede afectar a ciertas características', openInExplore: 'Abrir en Explorar', @@ -110,11 +107,12 @@ const translation = { publishUpdate: 'Publicar actualización', exportImage: 'Exportar imagen', needAnswerNode: 'Se debe agregar el nodo de respuesta', - needEndNode: 'Se debe agregar el nodo Final', + needOutputNode: 'Se debe agregar el nodo de Salida', addBlock: 'Agregar nodo', tagBound: 'Número de aplicaciones que utilizan esta etiqueta', currentView: 'Vista actual', currentWorkflow: 'Flujo de trabajo actual', + moreActions: 'Más acciones', }, env: { envPanelTitle: 'Variables de Entorno', @@ -139,6 +137,19 @@ const translation = { export: 'Exportar DSL con valores secretos', }, }, + globalVar: { + title: 'Variables del sistema', + description: 'Las variables del sistema son variables globales que cualquier nodo puede usar sin conexiones cuando el tipo es correcto, como el ID del usuario final y el ID del flujo de trabajo.', + fieldsDescription: { + conversationId: 'ID de la conversación', + dialogCount: 'Número de conversaciones', + userId: 'ID de usuario', + triggerTimestamp: 'Marca de tiempo de inicio de la aplicación', + appId: 'ID de la aplicación', + workflowId: 'ID del flujo de trabajo', + workflowRunId: 'ID de ejecución del flujo de trabajo', + }, + }, chatVariable: { panelTitle: 'Variables de Conversación', panelDescription: 'Las Variables de Conversación se utilizan para almacenar información interactiva que el LLM necesita recordar, incluyendo el historial de conversación, archivos subidos y preferencias del usuario. Son de lectura y escritura.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Inicio', - 'end': 'Fin', + 'end': 'Salida', 'answer': 'Respuesta', 'llm': 'LLM', 'knowledge-retrieval': 'Recuperación de conocimiento', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Define los parámetros iniciales para iniciar un flujo de trabajo', - 'end': 'Define el final y el tipo de resultado de un flujo de trabajo', + 'end': 'Define la salida y el tipo de resultado de un flujo de trabajo', 'answer': 'Define el contenido de respuesta de una conversación de chat', 'llm': 'Invoca modelos de lenguaje grandes para responder preguntas o procesar lenguaje natural', 'knowledge-retrieval': 'Te permite consultar contenido de texto relacionado con las preguntas de los usuarios desde el conocimiento', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Campo de entrada del usuario', - helpLink: 'Enlace de ayuda', + helpLink: 'Ayuda', about: 'Acerca de', createdBy: 'Creado por ', nextStep: 'Siguiente paso', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Se resolvieron todos los problemas', change: 'Cambiar', optional: '(opcional)', - moveToThisNode: 'Mueve a este nodo', organizeBlocks: 'Organizar nodos', addNextStep: 'Agrega el siguiente paso en este flujo de trabajo', changeBlock: 'Cambiar Nodo', selectNextStep: 'Seleccionar siguiente paso', maximize: 'Maximizar Canvas', minimize: 'Salir de pantalla completa', + scrollToSelectedNode: 'Desplácese hasta el nodo seleccionado', optional_and_hidden: '(opcional y oculto)', }, nodes: { diff --git a/web/i18n/fa-IR/billing.ts b/web/i18n/fa-IR/billing.ts index 3749036f3c..e5121bb65b 100644 --- a/web/i18n/fa-IR/billing.ts +++ b/web/i18n/fa-IR/billing.ts @@ -73,7 +73,7 @@ const translation = { }, ragAPIRequestTooltip: 'به تعداد درخواست‌های API که فقط قابلیت‌های پردازش پایگاه دانش Dify را فراخوانی می‌کنند اشاره دارد.', receiptInfo: 'فقط صاحب تیم و مدیر تیم می‌توانند اشتراک تهیه کنند و اطلاعات صورتحساب را مشاهده کنند', - apiRateLimitUnit: '{{count,number}}/روز', + apiRateLimitUnit: '{{count,number}}/ماه', cloud: 'سرویس ابری', documents: '{{count,number}} سندهای دانش', self: 'خود میزبان', diff --git a/web/i18n/fa-IR/workflow.ts b/web/i18n/fa-IR/workflow.ts index aba3a25010..e27b8934e2 100644 --- a/web/i18n/fa-IR/workflow.ts +++ b/web/i18n/fa-IR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'منتشر شده', publish: 'انتشار', update: 'به‌روزرسانی', - run: 'اجرا', + run: 'اجرای تست', running: 'در حال اجرا', inRunMode: 'در حالت اجرا', inPreview: 'در پیش‌نمایش', @@ -18,7 +18,6 @@ const translation = { runHistory: 'تاریخچه اجرا', goBackToEdit: 'بازگشت به ویرایشگر', conversationLog: 'گزارش مکالمات', - features: 'ویژگی‌ها', debugAndPreview: 'پیش‌نمایش', restart: 'راه‌اندازی مجدد', currentDraft: 'پیش‌نویس فعلی', @@ -91,8 +90,6 @@ const translation = { addParallelNode: 'افزودن گره موازی', parallel: 'موازی', branch: 'شاخه', - featuresDocLink: 'بیشتر بدانید', - featuresDescription: 'بهبود تجربه کاربری برنامه وب', ImageUploadLegacyTip: 'اکنون می توانید متغیرهای نوع فایل را در فرم شروع ایجاد کنید. ما دیگر از ویژگی آپلود تصویر در آینده پشتیبانی نخواهیم کرد.', fileUploadTip: 'ویژگی های آپلود تصویر برای آپلود فایل ارتقا یافته است.', importWarning: 'احتیاط', @@ -109,12 +106,13 @@ const translation = { exportImage: 'تصویر را صادر کنید', versionHistory: 'تاریخچه نسخه', publishUpdate: 'به‌روزرسانی منتشر کنید', - needEndNode: 'باید گره پایان اضافه شود', + needOutputNode: 'باید گره خروجی اضافه شود', needAnswerNode: 'باید گره پاسخ اضافه شود', addBlock: 'نود اضافه کنید', tagBound: 'تعداد برنامه‌هایی که از این برچسب استفاده می‌کنند', currentView: 'نمای فعلی', currentWorkflow: 'گردش کار فعلی', + moreActions: 'اقدامات بیشتر', }, env: { envPanelTitle: 'متغیرهای محیطی', @@ -139,6 +137,19 @@ const translation = { export: 'صادر کردن DSL با مقادیر مخفی', }, }, + globalVar: { + title: 'متغیرهای سیستمی', + description: 'متغیرهای سیستمی متغیرهای سراسری هستند که هر گره در صورت مطابقت نوع می‌تواند بدون سیم‌کشی از آن‌ها استفاده کند، مانند شناسه کاربر نهایی و شناسه گردش‌کار.', + fieldsDescription: { + conversationId: 'شناسه گفتگو', + dialogCount: 'تعداد گفتگو', + userId: 'شناسه کاربر', + triggerTimestamp: 'برچسب زمانی شروع اجرای برنامه', + appId: 'شناسه برنامه', + workflowId: 'شناسه گردش‌کار', + workflowRunId: 'شناسه اجرای گردش‌کار', + }, + }, chatVariable: { panelTitle: 'متغیرهای مکالمه', panelDescription: 'متغیرهای مکالمه برای ذخیره اطلاعات تعاملی که LLM نیاز به یادآوری دارد استفاده می‌شوند، از جمله تاریخچه مکالمه، فایل‌های آپلود شده و ترجیحات کاربر. آنها قابل خواندن و نوشتن هستند.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'شروع', - 'end': 'پایان', + 'end': 'خروجی', 'answer': 'پاسخ', 'llm': 'مدل زبان بزرگ', 'knowledge-retrieval': 'استخراج دانش', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'پارامترهای اولیه برای راه‌اندازی جریان کار را تعریف کنید', - 'end': 'پایان و نوع نتیجه یک جریان کار را تعریف کنید', + 'end': 'خروجی و نوع نتیجه یک جریان کار را تعریف کنید', 'answer': 'محتوای پاسخ مکالمه چت را تعریف کنید', 'llm': 'استفاده از مدل‌های زبان بزرگ برای پاسخ به سوالات یا پردازش زبان طبیعی', 'knowledge-retrieval': 'اجازه می‌دهد تا محتوای متنی مرتبط با سوالات کاربر از دانش استخراج شود', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'فیلد ورودی کاربر', - helpLink: 'لینک کمک', + helpLink: 'راهنما', about: 'درباره', createdBy: 'ساخته شده توسط', nextStep: 'مرحله بعدی', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'تمام مسائل حل شده‌اند', change: 'تغییر', optional: '(اختیاری)', - moveToThisNode: 'به این گره بروید', selectNextStep: 'گام بعدی را انتخاب کنید', changeBlock: 'تغییر گره', organizeBlocks: 'گره‌ها را سازماندهی کنید', addNextStep: 'مرحله بعدی را به این فرآیند اضافه کنید', minimize: 'خروج از حالت تمام صفحه', maximize: 'بیشینه‌سازی بوم', + scrollToSelectedNode: 'به گره انتخاب شده بروید', optional_and_hidden: '(اختیاری و پنهان)', }, nodes: { diff --git a/web/i18n/fr-FR/billing.ts b/web/i18n/fr-FR/billing.ts index a41eed7e23..9715a1e805 100644 --- a/web/i18n/fr-FR/billing.ts +++ b/web/i18n/fr-FR/billing.ts @@ -73,7 +73,7 @@ const translation = { ragAPIRequestTooltip: 'Fait référence au nombre d\'appels API invoquant uniquement les capacités de traitement de la base de connaissances de Dify.', receiptInfo: 'Seuls le propriétaire de l\'équipe et l\'administrateur de l\'équipe peuvent s\'abonner et consulter les informations de facturation', annotationQuota: 'Quota d’annotation', - apiRateLimitUnit: '{{count,number}}/jour', + apiRateLimitUnit: '{{count,number}}/mois', priceTip: 'par espace de travail/', freeTrialTipSuffix: 'Aucune carte de crédit requise', teamWorkspace: '{{count,number}} Espace de travail d\'équipe', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index f6c1899cac..c6405e0851 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publié', publish: 'Publier', update: 'Mettre à jour', - run: 'Exécuter', + run: 'Exécuter test', running: 'En cours d\'exécution', inRunMode: 'En mode exécution', inPreview: 'En aperçu', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Historique des exécutions', goBackToEdit: 'Retour à l\'éditeur', conversationLog: 'Journal de conversation', - features: 'Fonctionnalités', debugAndPreview: 'Aperçu', restart: 'Redémarrer', currentDraft: 'Brouillon actuel', @@ -91,10 +90,8 @@ const translation = { addParallelNode: 'Ajouter un nœud parallèle', parallel: 'PARALLÈLE', branch: 'BRANCHE', - featuresDocLink: 'Pour en savoir plus', ImageUploadLegacyTip: 'Vous pouvez désormais créer des variables de type de fichier dans le formulaire de démarrage. À l’avenir, nous ne prendrons plus en charge la fonctionnalité de téléchargement d’images.', fileUploadTip: 'Les fonctionnalités de téléchargement d’images ont été mises à niveau vers le téléchargement de fichiers.', - featuresDescription: 'Améliorer l’expérience utilisateur de l’application web', importWarning: 'Prudence', importWarningDetails: 'La différence de version DSL peut affecter certaines fonctionnalités', openInExplore: 'Ouvrir dans Explorer', @@ -109,12 +106,13 @@ const translation = { versionHistory: 'Historique des versions', exportImage: 'Exporter l\'image', exportJPEG: 'Exporter en JPEG', - needEndNode: 'Le nœud de fin doit être ajouté', + needOutputNode: 'Le nœud de sortie doit être ajouté', needAnswerNode: 'Le nœud de réponse doit être ajouté.', addBlock: 'Ajouter un nœud', tagBound: 'Nombre d\'applications utilisant cette étiquette', currentView: 'Vue actuelle', currentWorkflow: 'Flux de travail actuel', + moreActions: 'Plus d’actions', }, env: { envPanelTitle: 'Variables d\'Environnement', @@ -139,6 +137,19 @@ const translation = { export: 'Exporter les DSL avec des valeurs secrètes', }, }, + globalVar: { + title: 'Variables système', + description: 'Les variables système sont des variables globales que tout nœud peut référencer sans câblage lorsque le type correspond, comme l\'ID utilisateur final et l\'ID du workflow.', + fieldsDescription: { + conversationId: 'ID de conversation', + dialogCount: 'Nombre de conversations', + userId: 'ID utilisateur', + triggerTimestamp: 'Horodatage du lancement de l\'application', + appId: 'ID de l\'application', + workflowId: 'ID du workflow', + workflowRunId: 'ID d\'exécution du workflow', + }, + }, chatVariable: { panelTitle: 'Variables de Conversation', panelDescription: 'Les Variables de Conversation sont utilisées pour stocker des informations interactives dont le LLM a besoin de se souvenir, y compris l\'historique des conversations, les fichiers téléchargés et les préférences de l\'utilisateur. Elles sont en lecture-écriture.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Début', - 'end': 'Fin', + 'end': 'Sortie', 'answer': 'Réponse', 'llm': 'LLM', 'knowledge-retrieval': 'Récupération de connaissances', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Définir les paramètres initiaux pour lancer un flux de travail', - 'end': 'Définir la fin et le type de résultat d\'un flux de travail', + 'end': 'Définir la sortie et le type de résultat d\'un flux de travail', 'answer': 'Définir le contenu de la réponse d\'une conversation', 'llm': 'Inviter de grands modèles de langage pour répondre aux questions ou traiter le langage naturel', 'knowledge-retrieval': 'Permet de consulter le contenu textuel lié aux questions des utilisateurs à partir de la base de connaissances', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Champ de saisie de l\'utilisateur', - helpLink: 'Lien d\'aide', + helpLink: 'Aide', about: 'À propos', createdBy: 'Créé par', nextStep: 'Étape suivante', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Tous les problèmes ont été résolus', change: 'Modifier', optional: '(facultatif)', - moveToThisNode: 'Déplacer vers ce nœud', organizeBlocks: 'Organiser les nœuds', addNextStep: 'Ajoutez la prochaine étape dans ce flux de travail', selectNextStep: 'Sélectionner la prochaine étape', changeBlock: 'Changer de nœud', maximize: 'Maximiser le Canvas', minimize: 'Sortir du mode plein écran', + scrollToSelectedNode: 'Faites défiler jusqu’au nœud sélectionné', optional_and_hidden: '(optionnel et caché)', }, nodes: { diff --git a/web/i18n/hi-IN/billing.ts b/web/i18n/hi-IN/billing.ts index fbc6dffc7c..7164a13d6f 100644 --- a/web/i18n/hi-IN/billing.ts +++ b/web/i18n/hi-IN/billing.ts @@ -96,7 +96,7 @@ const translation = { freeTrialTip: '200 ओपनएआई कॉल्स का मुफ्त परीक्षण।', documents: '{{count,number}} ज्ञान दस्तावेज़', freeTrialTipSuffix: 'कोई क्रेडिट कार्ड की आवश्यकता नहीं है', - apiRateLimitUnit: '{{count,number}}/दिन', + apiRateLimitUnit: '{{count,number}}/माह', teamWorkspace: '{{count,number}} टीम कार्यक्षेत्र', apiRateLimitTooltip: 'Dify API के माध्यम से की गई सभी अनुरोधों पर API दर सीमा लागू होती है, जिसमें टेक्स्ट जनरेशन, चैट वार्तालाप, कार्यप्रवाह निष्पादन और दस्तावेज़ प्रसंस्करण शामिल हैं।', teamMember_one: '{{count,number}} टीम सदस्य', diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index 224f3acaeb..f739f64cf0 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'प्रकाशित', publish: 'प्रकाशित करें', update: 'अपडेट करें', - run: 'चलाएं', + run: 'परीक्षण चलाएं', running: 'चल रहा है', inRunMode: 'रन मोड में', inPreview: 'पूर्वावलोकन में', @@ -18,7 +18,6 @@ const translation = { runHistory: 'रन इतिहास', goBackToEdit: 'संपादक पर वापस जाएं', conversationLog: 'वार्तालाप लॉग', - features: 'विशेषताएं', debugAndPreview: 'पूर्वावलोकन', restart: 'पुनः आरंभ करें', currentDraft: 'वर्तमान ड्राफ्ट', @@ -94,8 +93,6 @@ const translation = { addParallelNode: 'समानांतर नोड जोड़ें', parallel: 'समानांतर', branch: 'शाखा', - featuresDocLink: 'और जानो', - featuresDescription: 'वेब ऐप उपयोगकर्ता अनुभव को बेहतर बनाएं', fileUploadTip: 'छवि अपलोड सुविधाओं को फ़ाइल अपलोड में अपग्रेड किया गया है।', ImageUploadLegacyTip: 'अब आप प्रारंभ प्रपत्र में फ़ाइल प्रकार चर बना सकते हैं। हम अब भविष्य में छवि अपलोड सुविधा का समर्थन नहीं करेंगे।', importWarning: 'सावधानी', @@ -114,10 +111,11 @@ const translation = { versionHistory: 'संस्करण इतिहास', needAnswerNode: 'उत्तर नोड जोड़ा जाना चाहिए', addBlock: 'नोड जोड़ें', - needEndNode: 'अंत नोड जोड़ा जाना चाहिए', + needOutputNode: 'आउटपुट नोड जोड़ा जाना चाहिए', tagBound: 'इस टैग का उपयोग करने वाले ऐप्स की संख्या', currentView: 'वर्तमान दृश्य', currentWorkflow: 'वर्तमान कार्यप्रवाह', + moreActions: 'अधिक क्रियाएँ', }, env: { envPanelTitle: 'पर्यावरण चर', @@ -142,6 +140,19 @@ const translation = { export: 'गुप्त मानों के साथ DSL निर्यात करें', }, }, + globalVar: { + title: 'सिस्टम वेरिएबल्स', + description: 'सिस्टम वेरिएबल्स वैश्विक वेरिएबल्स हैं जिन्हें सही प्रकार होने पर किसी भी नोड द्वारा बिना वायरिंग के संदर्भित किया जा सकता है, जैसे एंड-यूज़र ID और वर्कफ़्लो ID.', + fieldsDescription: { + conversationId: 'संवाद ID', + dialogCount: 'संवाद गणना', + userId: 'उपयोगकर्ता ID', + triggerTimestamp: 'एप्लिकेशन शुरू होने का टाइमस्टैम्प', + appId: 'एप्लिकेशन ID', + workflowId: 'वर्कफ़्लो ID', + workflowRunId: 'वर्कफ़्लो रन ID', + }, + }, chatVariable: { panelTitle: 'वार्तालाप चर', panelDescription: 'वार्तालाप चर का उपयोग इंटरैक्टिव जानकारी संग्रहित करने के लिए किया जाता है जिसे LLM को याद रखने की आवश्यकता होती है, जिसमें वार्तालाप इतिहास, अपलोड की गई फाइलें, उपयोगकर्ता प्राथमिकताएं शामिल हैं। वे पठनीय और लेखनीय हैं।', @@ -245,7 +256,7 @@ const translation = { }, blocks: { 'start': 'प्रारंभ', - 'end': 'समाप्त', + 'end': 'आउटपुट', 'answer': 'उत्तर', 'llm': 'एलएलएम', 'knowledge-retrieval': 'ज्ञान पुनर्प्राप्ति', @@ -271,7 +282,7 @@ const translation = { }, blocksAbout: { 'start': 'वर्कफ़्लो लॉन्च करने के लिए प्रारंभिक पैरामीटर को परिभाषित करें', - 'end': 'वर्कफ़्लो का अंत और परिणाम प्रकार परिभाषित करें', + 'end': 'वर्कफ़्लो का आउटपुट और परिणाम प्रकार परिभाषित करें', 'answer': 'चैट संवाद के उत्तर सामग्री को परिभाषित करें', 'llm': 'प्रश्नों के उत्तर देने या प्राकृतिक भाषा को संसाधित करने के लिए बड़े भाषा मॉडल को आमंत्रित करना', 'knowledge-retrieval': @@ -322,7 +333,7 @@ const translation = { }, panel: { userInputField: 'उपयोगकर्ता इनपुट फ़ील्ड', - helpLink: 'सहायता लिंक', + helpLink: 'सहायता', about: 'के बारे में', createdBy: 'द्वारा बनाया गया ', nextStep: 'अगला कदम', @@ -333,13 +344,13 @@ const translation = { checklistResolved: 'सभी समस्याएं हल हो गई हैं', change: 'बदलें', optional: '(वैकल्पिक)', - moveToThisNode: 'इस नोड पर जाएं', changeBlock: 'नोड बदलें', addNextStep: 'इस कार्यप्रवाह में अगला कदम जोड़ें', selectNextStep: 'अगला कदम चुनें', organizeBlocks: 'नोड्स का आयोजन करें', minimize: 'पूर्ण स्क्रीन से बाहर निकलें', maximize: 'कैनवास का अधिकतम लाभ उठाएँ', + scrollToSelectedNode: 'चुने गए नोड पर स्क्रॉल करें', optional_and_hidden: '(वैकल्पिक और छिपा हुआ)', }, nodes: { diff --git a/web/i18n/id-ID/workflow.ts b/web/i18n/id-ID/workflow.ts index 4ef6b2b832..506b17d925 100644 --- a/web/i18n/id-ID/workflow.ts +++ b/web/i18n/id-ID/workflow.ts @@ -90,7 +90,7 @@ const translation = { exportJPEG: 'Ekspor sebagai JPEG', addBlock: 'Tambahkan Node', processData: 'Proses Data', - needEndNode: 'Node Akhir harus ditambahkan', + needOutputNode: 'Node Output harus ditambahkan', manageInTools: 'Kelola di Alat', pointerMode: 'Mode Penunjuk', accessAPIReference: 'Referensi API Akses', @@ -137,6 +137,19 @@ const translation = { envPanelTitle: 'Variabel Lingkungan', envDescription: 'Variabel lingkungan dapat digunakan untuk menyimpan informasi pribadi dan kredensial. Mereka hanya baca dan dapat dipisahkan dari file DSL selama ekspor.', }, + globalVar: { + title: 'Variabel Sistem', + description: 'Variabel sistem adalah variabel global yang dapat dirujuk oleh node apa pun tanpa koneksi jika tipenya sesuai, seperti ID pengguna akhir dan ID alur kerja.', + fieldsDescription: { + conversationId: 'ID percakapan', + dialogCount: 'Jumlah percakapan', + userId: 'ID pengguna', + triggerTimestamp: 'Cap waktu saat aplikasi mulai berjalan', + appId: 'ID aplikasi', + workflowId: 'ID alur kerja', + workflowRunId: 'ID eksekusi alur kerja', + }, + }, chatVariable: { modal: { valuePlaceholder: 'Nilai default, biarkan kosong untuk tidak diatur', @@ -249,7 +262,7 @@ const translation = { 'answer': 'Jawaban', 'parameter-extractor': 'Ekstraktor Parameter', 'document-extractor': 'Ekstraktor Dokumen', - 'end': 'Ujung', + 'end': 'Keluaran', 'if-else': 'JIKA/LAIN', 'loop-start': 'Mulai Loop', 'variable-aggregator': 'Agregator Variabel', @@ -275,7 +288,7 @@ const translation = { 'variable-assigner': 'Agregatkan variabel multi-cabang menjadi satu variabel untuk konfigurasi terpadu simpul hilir.', 'loop': 'Jalankan perulangan logika hingga kondisi penghentian terpenuhi atau jumlah perulangan maksimum tercapai.', 'variable-aggregator': 'Agregatkan variabel multi-cabang menjadi satu variabel untuk konfigurasi terpadu simpul hilir.', - 'end': 'Menentukan jenis akhir dan hasil alur kerja', + 'end': 'Menentukan output dan jenis hasil alur kerja', 'list-operator': 'Digunakan untuk memfilter atau mengurutkan konten array.', 'datasource': 'Sumber Data Tentang', 'knowledge-index': 'Basis Pengetahuan Tentang', @@ -321,7 +334,7 @@ const translation = { userInputField: 'Bidang Input Pengguna', checklistResolved: 'Semua masalah terselesaikan', createdBy: 'Dibuat oleh', - helpLink: 'Tautan Bantuan', + helpLink: 'Docs', changeBlock: 'Ubah Node', runThisStep: 'Jalankan langkah ini', maximize: 'Maksimalkan Kanvas', diff --git a/web/i18n/it-IT/billing.ts b/web/i18n/it-IT/billing.ts index ef6b1943e3..fc5d67520b 100644 --- a/web/i18n/it-IT/billing.ts +++ b/web/i18n/it-IT/billing.ts @@ -88,7 +88,7 @@ const translation = { freeTrialTipPrefix: 'Iscriviti e ricevi un', teamMember_one: '{{count,number}} membro del team', documents: '{{count,number}} Documenti di Conoscenza', - apiRateLimitUnit: '{{count,number}}/giorno', + apiRateLimitUnit: '{{count,number}}/mese', documentsRequestQuota: '{{count,number}}/min Limite di richiesta di conoscenza', teamMember_other: '{{count,number}} membri del team', freeTrialTip: 'prova gratuita di 200 chiamate OpenAI.', diff --git a/web/i18n/it-IT/workflow.ts b/web/i18n/it-IT/workflow.ts index 314b8e0c52..b188bc3666 100644 --- a/web/i18n/it-IT/workflow.ts +++ b/web/i18n/it-IT/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Pubblicato', publish: 'Pubblica', update: 'Aggiorna', - run: 'Esegui', + run: 'Esegui test', running: 'In esecuzione', inRunMode: 'In modalità di esecuzione', inPreview: 'In anteprima', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Cronologia esecuzioni', goBackToEdit: 'Torna all\'editor', conversationLog: 'Registro conversazioni', - features: 'Caratteristiche', debugAndPreview: 'Anteprima', restart: 'Riavvia', currentDraft: 'Bozza corrente', @@ -95,8 +94,6 @@ const translation = { addParallelNode: 'Aggiungi nodo parallelo', parallel: 'PARALLELO', branch: 'RAMO', - featuresDocLink: 'Ulteriori informazioni', - featuresDescription: 'Migliora l\'esperienza utente dell\'app Web', fileUploadTip: 'Le funzioni di caricamento delle immagini sono state aggiornate al caricamento dei file.', ImageUploadLegacyTip: 'Ora è possibile creare variabili di tipo file nel modulo iniziale. In futuro non supporteremo più la funzione di caricamento delle immagini.', importWarning: 'Cautela', @@ -113,12 +110,13 @@ const translation = { exportImage: 'Esporta immagine', exportJPEG: 'Esporta come JPEG', exportPNG: 'Esporta come PNG', - needEndNode: 'Deve essere aggiunto il nodo finale', + needOutputNode: 'Deve essere aggiunto il nodo di uscita', addBlock: 'Aggiungi nodo', needAnswerNode: 'Deve essere aggiunto il nodo di risposta', tagBound: 'Numero di app che utilizzano questo tag', currentWorkflow: 'Flusso di lavoro corrente', currentView: 'Vista corrente', + moreActions: 'Altre azioni', }, env: { envPanelTitle: 'Variabili d\'Ambiente', @@ -143,6 +141,19 @@ const translation = { export: 'Esporta DSL con valori segreti', }, }, + globalVar: { + title: 'Variabili di sistema', + description: 'Le variabili di sistema sono variabili globali che possono essere richiamate da qualsiasi nodo senza collegamenti quando il tipo è corretto, come l\'ID dell\'utente finale e l\'ID del workflow.', + fieldsDescription: { + conversationId: 'ID conversazione', + dialogCount: 'Conteggio conversazioni', + userId: 'ID utente', + triggerTimestamp: 'Timestamp di avvio dell\'applicazione', + appId: 'ID applicazione', + workflowId: 'ID workflow', + workflowRunId: 'ID esecuzione workflow', + }, + }, chatVariable: { panelTitle: 'Variabili di Conversazione', panelDescription: 'Le Variabili di Conversazione sono utilizzate per memorizzare informazioni interattive che il LLM deve ricordare, inclusi la cronologia delle conversazioni, i file caricati e le preferenze dell\'utente. Sono in lettura e scrittura.', @@ -247,7 +258,7 @@ const translation = { }, blocks: { 'start': 'Inizio', - 'end': 'Fine', + 'end': 'Uscita', 'answer': 'Risposta', 'llm': 'LLM', 'knowledge-retrieval': 'Recupero Conoscenza', @@ -273,7 +284,7 @@ const translation = { }, blocksAbout: { 'start': 'Definisci i parametri iniziali per l\'avvio di un flusso di lavoro', - 'end': 'Definisci la fine e il tipo di risultato di un flusso di lavoro', + 'end': 'Definisci l\'uscita e il tipo di risultato di un flusso di lavoro', 'answer': 'Definisci il contenuto della risposta di una conversazione chat', 'llm': 'Invoca modelli di linguaggio di grandi dimensioni per rispondere a domande o elaborare il linguaggio naturale', 'knowledge-retrieval': @@ -325,7 +336,7 @@ const translation = { }, panel: { userInputField: 'Campo di Input Utente', - helpLink: 'Link di Aiuto', + helpLink: 'Aiuto', about: 'Informazioni', createdBy: 'Creato da ', nextStep: 'Prossimo Passo', @@ -336,13 +347,13 @@ const translation = { checklistResolved: 'Tutti i problemi sono risolti', change: 'Cambia', optional: '(opzionale)', - moveToThisNode: 'Sposta a questo nodo', changeBlock: 'Cambia Nodo', selectNextStep: 'Seleziona il prossimo passo', organizeBlocks: 'Organizzare i nodi', addNextStep: 'Aggiungi il prossimo passo in questo flusso di lavoro', minimize: 'Esci dalla modalità schermo intero', maximize: 'Massimizza Canvas', + scrollToSelectedNode: 'Scorri fino al nodo selezionato', optional_and_hidden: '(opzionale e nascosto)', }, nodes: { diff --git a/web/i18n/ja-JP/app-log.ts b/web/i18n/ja-JP/app-log.ts index 1ead075d14..aa23d8352d 100644 --- a/web/i18n/ja-JP/app-log.ts +++ b/web/i18n/ja-JP/app-log.ts @@ -20,6 +20,7 @@ const translation = { tokens: 'トークン', user: 'エンドユーザーまたはアカウント', version: 'バージョン', + triggered_from: 'トリガー方法', }, pagination: { previous: '前へ', @@ -97,6 +98,15 @@ const translation = { iteration: '反復', finalProcessing: '最終処理', }, + triggerBy: { + debugging: 'デバッグ', + appRun: 'ウェブアプリ', + webhook: 'Webhook', + schedule: 'スケジュール', + plugin: 'プラグイン', + ragPipelineRun: 'RAGパイプライン', + ragPipelineDebugging: 'RAGデバッグ', + }, } export default translation diff --git a/web/i18n/ja-JP/app-overview.ts b/web/i18n/ja-JP/app-overview.ts index 7c0378bc6b..ad1abb78fa 100644 --- a/web/i18n/ja-JP/app-overview.ts +++ b/web/i18n/ja-JP/app-overview.ts @@ -30,12 +30,17 @@ const translation = { overview: { title: '概要', appInfo: { + title: 'Web App', explanation: '使いやすい AI Web アプリ', accessibleAddress: '公開 URL', preview: 'プレビュー', regenerate: '再生成', regenerateNotice: '公開 URL を再生成しますか?', preUseReminder: '続行する前に Web アプリを有効にしてください。', + enableTooltip: { + description: 'この機能を有効にするには、キャンバスにユーザー入力ノードを追加してください。(下書きに既に存在する可能性があり、公開後に有効になります)', + learnMore: '詳細を見る', + }, settings: { entry: '設定', title: 'Web アプリの設定', @@ -121,6 +126,14 @@ const translation = { accessibleAddress: 'サービス API エンドポイント', doc: 'API リファレンス', }, + triggerInfo: { + title: 'トリガー', + explanation: 'ワークフロートリガー管理', + triggersAdded: '{{count}} 個のトリガーが追加されました', + noTriggerAdded: 'トリガーが追加されていません', + triggerStatusDescription: 'トリガーノードの状態がここに表示されます。(下書きに既に存在する可能性があり、公開後に有効になります)', + learnAboutTriggers: 'トリガーについて学ぶ', + }, status: { running: '稼働中', disable: '無効', diff --git a/web/i18n/ja-JP/billing.ts b/web/i18n/ja-JP/billing.ts index 6dbff60d5a..b679ae571a 100644 --- a/web/i18n/ja-JP/billing.ts +++ b/web/i18n/ja-JP/billing.ts @@ -7,6 +7,8 @@ const translation = { documentsUploadQuota: 'ドキュメント・アップロード・クォータ', vectorSpace: 'ナレッジベースのデータストレージ', vectorSpaceTooltip: '高品質インデックスモードのドキュメントは、ナレッジベースのデータストレージのリソースを消費します。ナレッジベースのデータストレージの上限に達すると、新しいドキュメントはアップロードされません。', + triggerEvents: 'トリガーイベント', + perMonth: '月あたり', }, upgradeBtn: { plain: 'プランをアップグレード', @@ -60,7 +62,7 @@ const translation = { documentsRequestQuota: '{{count,number}}/分のナレッジ リクエストのレート制限', documentsRequestQuotaTooltip: 'ナレッジベース内でワークスペースが 1 分間に実行できる操作の総数を示します。これには、データセットの作成、削除、更新、ドキュメントのアップロード、修正、アーカイブ、およびナレッジベースクエリが含まれます。この指標は、ナレッジベースリクエストのパフォーマンスを評価するために使用されます。例えば、Sandbox ユーザーが 1 分間に 10 回連続でヒットテストを実行した場合、そのワークスペースは次の 1 分間、データセットの作成、削除、更新、ドキュメントのアップロードや修正などの操作を一時的に実行できなくなります。', apiRateLimit: 'API レート制限', - apiRateLimitUnit: '{{count,number}}/日', + apiRateLimitUnit: '{{count,number}}/月', unlimitedApiRate: '無制限の API コール', apiRateLimitTooltip: 'API レート制限は、テキスト生成、チャットボット、ワークフロー、ドキュメント処理など、Dify API 経由のすべてのリクエストに適用されます。', documentProcessingPriority: '文書処理', diff --git a/web/i18n/ja-JP/common.ts b/web/i18n/ja-JP/common.ts index f3fd94de5c..53bb5301fe 100644 --- a/web/i18n/ja-JP/common.ts +++ b/web/i18n/ja-JP/common.ts @@ -66,6 +66,7 @@ const translation = { more: 'もっと', selectAll: 'すべて選択', deSelectAll: 'すべて選択解除', + now: '今', config: 'コンフィグ', yes: 'はい', no: 'いいえ', @@ -170,7 +171,7 @@ const translation = { emailSupport: 'サポート', workspace: 'ワークスペース', createWorkspace: 'ワークスペースを作成', - helpCenter: 'ヘルプ', + helpCenter: 'ドキュメントを見る', support: 'サポート', compliance: 'コンプライアンス', roadmap: 'ロードマップ', diff --git a/web/i18n/ja-JP/plugin-trigger.ts b/web/i18n/ja-JP/plugin-trigger.ts new file mode 100644 index 0000000000..a4f0a8c5df --- /dev/null +++ b/web/i18n/ja-JP/plugin-trigger.ts @@ -0,0 +1,216 @@ +const translation = { + subscription: { + title: 'サブスクリプション', + listNum: '{{num}} サブスクリプション', + empty: { + title: 'サブスクリプションがありません', + description: 'イベントの受信を開始するために最初のサブスクリプションを作成してください', + button: '新しいサブスクリプション', + }, + createButton: { + oauth: 'OAuth で新しいサブスクリプション', + apiKey: 'API キーで新しいサブスクリプション', + manual: 'URL を貼り付けて新しいサブスクリプションを作成', + }, + list: { + title: 'サブスクリプション', + addButton: '追加', + tip: 'サブスクリプション経由でイベントを受信', + item: { + enabled: '有効', + disabled: '無効', + credentialType: { + api_key: 'API キー', + oauth2: 'OAuth', + unauthorized: '手動', + }, + actions: { + delete: '削除', + deleteConfirm: { + title: 'サブスクリプションを削除', + content: '「{{name}}」を削除してもよろしいですか?', + contentWithApps: 'このサブスクリプションは {{count}} 個のアプリで使用されています。「{{name}}」を削除してもよろしいですか?', + confirm: '削除', + cancel: 'キャンセル', + confirmInputWarning: '確認するために正しい名前を入力してください。', + }, + }, + status: { + active: 'アクティブ', + inactive: '非アクティブ', + }, + usedByNum: '{{num}} ワークフローで使用中', + noUsed: 'ワークフローで使用されていません', + }, + }, + addType: { + title: 'サブスクリプションを追加', + description: 'トリガーサブスクリプションの作成方法を選択してください', + options: { + apiKey: { + title: 'API キー経由', + description: 'API 認証情報を使用してサブスクリプションを自動作成', + }, + oauth: { + title: 'OAuth 経由', + description: 'サードパーティプラットフォームで認証してサブスクリプションを作成', + custom: 'カスタム', + default: 'デフォルト', + clientSettings: 'OAuthクライアント設定', + clientTitle: 'OAuth クライアント', + }, + manual: { + title: '手動設定', + description: 'URL を貼り付けて新しいサブスクリプションを作成', + tip: 'サードパーティプラットフォームで URL を手動設定', + }, + apikey: { + title: 'APIキーで作成', + description: 'API資格情報を使用してサブスクリプションを自動的に作成する', + }, + }, + }, + subscriptionRemoved: 'サブスクリプションが解除されました', + createSuccess: 'サブスクリプションが正常に作成されました', + noSubscriptionSelected: 'サブスクリプションが選択されていません', + selectPlaceholder: 'サブスクリプションを選択', + createFailed: 'サブスクリプションの作成に失敗しました', + }, + modal: { + steps: { + verify: '検証', + configuration: '設定', + }, + common: { + cancel: 'キャンセル', + back: '戻る', + next: '次へ', + create: '作成', + verify: '検証', + authorize: '認証', + creating: '作成中...', + verifying: '検証中...', + authorizing: '認証中...', + }, + oauthRedirectInfo: 'このツールプロバイダーのシステムクライアントシークレットが見つからないため、手動設定が必要です。redirect_uri には以下を使用してください', + apiKey: { + title: 'API キーで作成', + verify: { + title: '認証情報を検証', + description: 'アクセスを検証するために API 認証情報を提供してください', + error: '認証情報の検証に失敗しました。API キーをご確認ください。', + success: '認証情報が正常に検証されました', + }, + configuration: { + title: 'サブスクリプションを設定', + description: 'サブスクリプションパラメータを設定', + }, + }, + oauth: { + title: 'OAuth で作成', + authorization: { + title: 'OAuth 認証', + description: 'Dify があなたのアカウントにアクセスすることを認証', + redirectUrl: 'リダイレクト URL', + redirectUrlHelp: 'OAuth アプリ設定でこの URL を使用', + authorizeButton: '{{provider}} で認証', + waitingAuth: '認証を待機中...', + authSuccess: '認証が成功しました', + authFailed: '認証に失敗しました', + waitingJump: '承認済み、ジャンプ待機中', + }, + configuration: { + title: 'サブスクリプションを設定', + description: '認証後にサブスクリプションパラメータを設定', + success: 'OAuth設定が成功しました', + failed: 'OAuthの設定に失敗しました', + }, + remove: { + success: 'OAuthの削除に成功しました', + failed: 'OAuthの削除に失敗しました', + }, + save: { + success: 'OAuth の設定が正常に保存されました', + }, + }, + manual: { + title: '手動設定', + description: 'Webhook サブスクリプションを手動で設定', + instruction: { + title: '設定手順', + step1: '1. 以下のコールバック URL をコピー', + step2: '2. サードパーティプラットフォームの Webhook 設定に移動', + step3: '3. コールバック URL を Webhook エンドポイントとして追加', + step4: '4. 受信したいイベントを設定', + step5: '5. イベントをトリガーして Webhook をテスト', + step6: '6. ここに戻って Webhook が動作していることを確認し、設定を完了', + }, + logs: { + title: 'リクエストログ', + description: '受信 Webhook リクエストを監視', + empty: 'まだリクエストを受信していません。Webhook 設定をテストしてください。', + status: { + success: '成功', + error: 'エラー', + }, + expandAll: 'すべて展開', + collapseAll: 'すべて折りたたむ', + timestamp: 'タイムスタンプ', + method: 'メソッド', + path: 'パス', + headers: 'ヘッダー', + body: 'ボディ', + response: 'レスポンス', + request: 'リクエスト', + }, + }, + form: { + subscriptionName: { + label: 'サブスクリプション名', + placeholder: 'サブスクリプション名を入力', + required: 'サブスクリプション名は必須です', + }, + callbackUrl: { + label: 'コールバック URL', + description: 'この URL で Webhook イベントを受信します', + copy: 'コピー', + copied: 'コピーしました!', + placeholder: '生成中...', + privateAddressWarning: 'このURLは内部アドレスのようで、Webhookリクエストが失敗する可能性があります。', + tooltip: 'トリガープロバイダーからのコールバックリクエストを受信できる、公開アクセス可能なエンドポイントを提供してください。', + }, + }, + errors: { + createFailed: 'サブスクリプションの作成に失敗しました', + verifyFailed: '認証情報の検証に失敗しました', + authFailed: '認証に失敗しました', + networkError: 'ネットワークエラーです。再試行してください', + }, + }, + events: { + title: '利用可能なイベント', + description: 'このトリガープラグインが購読できるイベント', + empty: '利用可能なイベントがありません', + event: 'イベント', + events: 'イベント', + actionNum: '{{num}} {{event}} が含まれています', + item: { + parameters: '{{count}} パラメータ', + noParameters: 'パラメータなし', + }, + output: '出力', + }, + provider: { + github: 'GitHub', + gitlab: 'GitLab', + notion: 'Notion', + webhook: 'Webhook', + }, + node: { + status: { + warning: '切断', + }, + }, +} + +export default translation diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 3320f5a89f..07241b8c4f 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -9,8 +9,10 @@ const translation = { publish: '公開する', update: '更新', publishUpdate: '更新を公開', - run: '実行', + run: 'テスト実行', running: '実行中', + chooseStartNodeToRun: '実行する開始ノードを選択', + runAllTriggers: 'すべてのトリガーを実行', inRunMode: '実行モード中', inPreview: 'プレビュー中', inPreviewMode: 'プレビューモード中', @@ -19,11 +21,8 @@ const translation = { runHistory: '実行履歴', goBackToEdit: '編集に戻る', conversationLog: '会話ログ', - features: '機能', - featuresDescription: 'Web アプリの操作性を向上させる機能', ImageUploadLegacyTip: '開始フォームでファイル型変数が作成可能になりました。画像アップロード機能は今後サポート終了となります。', fileUploadTip: '画像アップロード機能がファイルアップロードに拡張されました', - featuresDocLink: '詳細を見る', debugAndPreview: 'プレビュー', restart: '再起動', currentDraft: '現在の下書き', @@ -47,7 +46,8 @@ const translation = { needConnectTip: '接続されていないステップがあります', maxTreeDepth: '1 ブランチあたりの最大ノード数:{{depth}}', needAdd: '{{node}}ノードを追加する必要があります', - needEndNode: '終了ブロックを追加する必要があります', + needOutputNode: '出力ノードを追加する必要があります', + needStartNode: '少なくとも1つのスタートノードを追加する必要があります', needAnswerNode: '回答ブロックを追加する必要があります', workflowProcess: 'ワークフロー処理', notRunning: 'まだ実行されていません', @@ -83,6 +83,7 @@ const translation = { configure: '設定', manageInTools: 'ツールページで管理', workflowAsToolTip: 'ワークフロー更新後はツールの再設定が必要です', + workflowAsToolDisabledHint: '最新のワークフローを公開し、接続済みの User Input ノードを用意してからツールとして設定してください。', viewDetailInTracingPanel: '詳細を表示', syncingData: 'データ同期中。。。', importDSL: 'DSL をインポート', @@ -116,6 +117,7 @@ const translation = { loadMore: 'さらに読み込む', noHistory: '履歴がありません', tagBound: 'このタグを使用しているアプリの数', + moreActions: 'さらにアクション', }, env: { envPanelTitle: '環境変数', @@ -140,6 +142,19 @@ const translation = { export: 'シークレット値付きでエクスポート', }, }, + globalVar: { + title: 'システム変数', + description: 'システム変数は、タイプが適合していれば配線なしで任意のノードから参照できるグローバル変数です。エンドユーザーIDやワークフローIDなどが含まれます。', + fieldsDescription: { + conversationId: '会話ID', + dialogCount: '会話数', + userId: 'ユーザーID', + triggerTimestamp: 'アプリケーションの起動タイムスタンプ', + appId: 'アプリケーションID', + workflowId: 'ワークフローID', + workflowRunId: 'ワークフロー実行ID', + }, + }, sidebar: { exportWarning: '現在保存されているバージョンをエクスポート', exportWarningDesc: 'これは現在保存されているワークフローのバージョンをエクスポートします。エディターで未保存の変更がある場合は、まずワークフローキャンバスのエクスポートオプションを使用して保存してください。', @@ -213,6 +228,16 @@ const translation = { invalidVariable: '無効な変数です', noValidTool: '{{field}} に利用可能なツールがありません', toolParameterRequired: '{{field}}: パラメータ [{{param}}] は必須です', + startNodeRequired: '{{operation}}前に開始ノードを追加してください', + }, + error: { + startNodeRequired: '{{operation}}前に開始ノードを追加してください', + operations: { + connectingNodes: 'ノード接続', + addingNodes: 'ノード追加', + modifyingWorkflow: 'ワークフロー変更', + updatingWorkflow: 'ワークフロー更新', + }, }, singleRun: { testRun: 'テスト実行', @@ -229,7 +254,9 @@ const translation = { 'searchBlock': 'ブロック検索', 'blocks': 'ブロック', 'searchTool': 'ツール検索', + 'searchTrigger': 'トリガー検索...', 'tools': 'ツール', + 'allTriggers': 'すべてのトリガー', 'allTool': 'すべて', 'customTool': 'カスタム', 'workflowTool': 'ワークフロー', @@ -238,16 +265,23 @@ const translation = { 'transform': '変換', 'utilities': 'ツール', 'noResult': '該当なし', + 'noPluginsFound': 'プラグインが見つかりません', + 'requestToCommunity': 'コミュニティにリクエスト', 'plugin': 'プラグイン', 'agent': 'エージェント戦略', + 'noFeaturedPlugins': 'マーケットプレイスでさらにツールを見つける', + 'noFeaturedTriggers': 'マーケットプレイスでさらにトリガーを見つける', 'addAll': 'すべてを追加する', 'allAdded': 'すべて追加されました', 'searchDataSource': 'データソースを検索', 'sources': 'ソース', + 'start': '始める', + 'startDisabledTip': 'トリガーノードとユーザー入力ノードは互いに排他です。', }, blocks: { - 'start': '開始', - 'end': '終了', + 'start': 'ユーザー入力', + 'originalStartNode': '元の開始ノード', + 'end': '出力', 'answer': '回答', 'llm': 'LLM', 'knowledge-retrieval': '知識検索', @@ -270,10 +304,14 @@ const translation = { 'loop-end': 'ループ完了', 'knowledge-index': '知識ベース', 'datasource': 'データソース', + 'trigger-plugin': 'プラグイントリガー', + 'trigger-webhook': 'Webhook トリガー', + 'trigger-schedule': 'スケジュールトリガー', }, + customWebhook: 'カスタムWebhook', blocksAbout: { 'start': 'ワークフロー開始時の初期パラメータを定義します。', - 'end': 'ワークフローの終了条件と結果のタイプを定義します。', + 'end': 'ワークフローの出力と結果のタイプを定義します', 'answer': 'チャットダイアログの返答内容を定義します。', 'llm': '大規模言語モデルを呼び出して質問回答や自然言語処理を実行します。', 'knowledge-retrieval': 'ナレッジベースからユーザー質問に関連するテキストを検索します。', @@ -294,7 +332,11 @@ const translation = { 'agent': '大規模言語モデルを活用した質問応答や自然言語処理を実行します。', 'knowledge-index': '知識ベースについて', 'datasource': 'データソースについて', + 'trigger-schedule': 'スケジュールに基づいてワークフローを開始する時間ベースのトリガー', + 'trigger-webhook': 'Webhook トリガーは第三者システムからの HTTP プッシュを受信してワークフローを自動的に開始します。', + 'trigger-plugin': 'サードパーティ統合トリガー、外部プラットフォームのイベントによってワークフローを開始します', }, + difyTeam: 'Dify チーム', operator: { zoomIn: '拡大', zoomOut: '縮小', @@ -324,7 +366,7 @@ const translation = { panel: { userInputField: 'ユーザー入力欄', changeBlock: 'ノード変更', - helpLink: 'ヘルプリンク', + helpLink: 'ドキュメントを見る', about: '詳細', createdBy: '作成者', nextStep: '次のステップ', @@ -334,12 +376,14 @@ const translation = { checklist: 'チェックリスト', checklistTip: '公開前に全ての項目を確認してください', checklistResolved: '全てのチェックが完了しました', + goTo: '移動', + startNode: '開始ノード', organizeBlocks: 'ノード整理', change: '変更', optional: '(任意)', - moveToThisNode: 'このノードに移動する', maximize: 'キャンバスを最大化する', minimize: '全画面を終了する', + scrollToSelectedNode: '選択したノードまでスクロール', optional_and_hidden: '(オプションおよび非表示)', }, nodes: { @@ -964,6 +1008,137 @@ const translation = { embeddingModelIsInvalid: '埋め込みモデルが無効です', rerankingModelIsInvalid: 'リランキングモデルは無効です', }, + triggerSchedule: { + frequency: { + label: '頻度', + monthly: '毎月', + weekly: '毎週', + daily: '毎日', + hourly: '毎時', + }, + frequencyLabel: '頻度', + days: '日', + title: 'スケジュール', + minutes: '分', + time: '時刻', + useCronExpression: 'Cron 式を使用', + nextExecutionTimes: '次の5回の実行時刻', + nextExecution: '次回実行', + notConfigured: '未設定', + startTime: '開始時刻', + hours: '時間', + onMinute: '分', + executeNow: '今すぐ実行', + weekdays: '曜日', + selectDateTime: '日時を選択', + cronExpression: 'Cron 式', + selectFrequency: '頻度を選択', + lastDay: '月末', + nextExecutionTime: '次回実行時刻', + lastDayTooltip: 'すべての月に31日があるわけではありません。「月末」オプションを使用して各月の最終日を選択してください。', + useVisualPicker: 'ビジュアル設定を使用', + nodeTitle: 'スケジュールトリガー', + mode: 'モード', + timezone: 'タイムゾーン', + visualConfig: 'ビジュアル設定', + monthlyDay: '月の日', + executionTime: '実行時間', + invalidTimezone: '無効なタイムゾーン', + invalidCronExpression: '無効なCron式', + noValidExecutionTime: '有効な実行時間を計算できません', + executionTimeCalculationError: '実行時間の計算に失敗しました', + invalidFrequency: '無効な頻度', + invalidStartTime: '無効な開始時間', + startTimeMustBeFuture: '開始時間は未来の時間である必要があります', + invalidTimeFormat: '無効な時間形式(期待される形式:HH:MM AM/PM)', + invalidWeekday: '無効な曜日:{{weekday}}', + invalidMonthlyDay: '月の日は1-31の間または"last"である必要があります', + invalidOnMinute: '分は0-59の間である必要があります', + invalidExecutionTime: '無効な実行時間', + executionTimeMustBeFuture: '実行時間は未来の時間である必要があります', + }, + triggerWebhook: { + title: 'Webhook トリガー', + nodeTitle: '🔗 Webhook トリガー', + configPlaceholder: 'Webhook トリガーの設定がここに実装されます', + webhookUrl: 'Webhook URL', + webhookUrlPlaceholder: '生成をクリックして Webhook URL を作成', + generate: '生成', + copy: 'コピー', + test: 'テスト', + urlGenerated: 'Webhook URL を生成しました', + urlGenerationFailed: 'Webhook URL の生成に失敗しました', + urlCopied: 'URL をクリップボードにコピーしました', + method: 'メソッド', + contentType: 'コンテンツタイプ', + queryParameters: 'クエリパラメータ', + headerParameters: 'ヘッダーパラメータ', + requestBodyParameters: 'リクエストボディパラメータ', + parameterName: '変数名', + varName: '変数名', + varType: 'タイプ', + varNamePlaceholder: '変数名を入力...', + headerName: '変数名', + required: '必須', + addParameter: '追加', + addHeader: '追加', + noParameters: '設定されたパラメータはありません', + noQueryParameters: 'クエリパラメータは設定されていません', + noHeaders: 'ヘッダーは設定されていません', + noBodyParameters: 'ボディパラメータは設定されていません', + debugUrlTitle: 'テスト実行には、常にこのURLを使用してください', + debugUrlCopy: 'クリックしてコピー', + debugUrlCopied: 'コピーしました!', + errorHandling: 'エラー処理', + errorStrategy: 'エラー処理', + responseConfiguration: 'レスポンス', + asyncMode: '非同期モード', + statusCode: 'ステータスコード', + responseBody: 'レスポンスボディ', + responseBodyPlaceholder: 'ここにレスポンスボディを入力してください', + headers: 'ヘッダー', + validation: { + webhookUrlRequired: 'Webhook URLが必要です', + invalidParameterType: 'パラメータ"{{name}}"の無効なパラメータタイプ"{{type}}"です', + }, + }, + triggerPlugin: { + authorized: '認可された', + notConfigured: '設定されていません', + error: 'エラー', + configuration: '構成', + remove: '削除する', + or: 'または', + useOAuth: 'OAuth を使用', + useApiKey: 'API キーを使用', + authenticationFailed: '認証に失敗しました', + authenticationSuccess: '認証に成功しました', + oauthConfigFailed: 'OAuth 設定に失敗しました', + configureOAuthClient: 'OAuth クライアントを設定', + oauthClientDescription: '認証を有効にするために OAuth クライアント認証情報を設定してください', + oauthClientSaved: 'OAuth クライアント設定が正常に保存されました', + configureApiKey: 'API キーを設定', + apiKeyDescription: '認証のための API キー認証情報を設定してください', + apiKeyConfigured: 'API キーが正常に設定されました', + configurationFailed: '設定に失敗しました', + failedToStart: '認証フローの開始に失敗しました', + credentialsVerified: '認証情報が正常に検証されました', + credentialVerificationFailed: '認証情報の検証に失敗しました', + verifyAndContinue: '検証して続行', + configureParameters: 'パラメーターを設定', + parametersDescription: 'トリガーのパラメーターとプロパティを設定してください', + configurationComplete: '設定完了', + configurationCompleteDescription: 'トリガーが正常に設定されました', + configurationCompleteMessage: 'トリガーの設定が完了し、使用する準備ができました。', + parameters: 'パラメーター', + properties: 'プロパティ', + propertiesDescription: 'このトリガーの追加設定プロパティ', + noConfigurationRequired: 'このトリガーには追加の設定は必要ありません。', + subscriptionName: 'サブスクリプション名', + subscriptionNameDescription: 'このトリガーサブスクリプションの一意な名前を入力してください', + subscriptionNamePlaceholder: 'サブスクリプション名を入力...', + subscriptionNameRequired: 'サブスクリプション名は必須です', + }, }, tracing: { stopBy: '{{user}}によって停止', @@ -1008,6 +1183,18 @@ const translation = { description: '最後の実行の結果がここに表示されます', }, variableInspect: { + listening: { + title: 'トリガーからのイベントを待機中…', + tip: 'HTTP {{nodeName}} エンドポイントにテストリクエストを送信するか、ライブイベントデバッグ用のコールバック URL として利用してイベントトリガーをシミュレートできます。すべての出力は Variable Inspector で直接確認できます。', + tipPlugin: '{{- pluginName}} でイベントを作成し、これらのイベントの出力を Variable Inspector で取得できます。', + tipSchedule: 'スケジュールトリガーからのイベントを待機しています。\n次回の予定実行: {{nextTriggerTime}}', + tipFallback: 'トリガーイベントを待機しています。出力はここに表示されます。', + defaultNodeName: 'このトリガー', + defaultPluginName: 'このプラグイントリガー', + defaultScheduleTime: '未設定', + selectedTriggers: '選択したトリガー', + stopButton: '停止', + }, trigger: { clear: 'クリア', running: 'キャッシング実行状況', @@ -1050,6 +1237,30 @@ const translation = { lastRunInputsCopied: '前回の実行から{{count}}個の入力をコピーしました', lastOutput: '最後の出力', }, + triggerStatus: { + enabled: 'トリガー', + disabled: 'トリガー • 無効', + }, + entryNodeStatus: { + enabled: 'スタート', + disabled: '開始 • 無効', + }, + onboarding: { + title: '開始するには開始ノードを選択してください', + description: '異なる開始ノードには異なる機能があります。心配しないでください、いつでも変更できます。', + userInputFull: 'ユーザー入力(元の開始ノード)', + userInputDescription: 'ユーザー入力変数の設定を可能にする開始ノードで、Webアプリ、サービスAPI、MCPサーバー、およびツールとしてのワークフロー機能を持ちます。', + trigger: 'トリガー', + triggerDescription: 'トリガーは、スケジュールされたタスク、カスタムwebhook、または他のアプリとの統合など、ワークフローの開始ノードとして機能できます。', + back: '戻る', + learnMore: '詳細を見る', + aboutStartNode: '開始ノードについて。', + escTip: { + press: '', + key: 'esc', + toDismiss: 'キーで閉じる', + }, + }, } export default translation diff --git a/web/i18n/ko-KR/billing.ts b/web/i18n/ko-KR/billing.ts index c5f081d41b..112fa1bc63 100644 --- a/web/i18n/ko-KR/billing.ts +++ b/web/i18n/ko-KR/billing.ts @@ -88,7 +88,7 @@ const translation = { freeTrialTip: '200 회의 OpenAI 호출 무료 체험을 받으세요. ', annualBilling: '연간 청구', getStarted: '시작하기', - apiRateLimitUnit: '{{count,number}}/일', + apiRateLimitUnit: '{{count,number}}/월', freeTrialTipSuffix: '신용카드 없음', teamWorkspace: '{{count,number}} 팀 작업 공간', self: '자체 호스팅', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index 427452943a..e661b6b340 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: '게시됨', publish: '게시하기', update: '업데이트', - run: '실행', + run: '테스트 실행', running: '실행 중', inRunMode: '실행 모드', inPreview: '미리보기 중', @@ -18,7 +18,6 @@ const translation = { runHistory: '실행 기록', goBackToEdit: '편집기로 돌아가기', conversationLog: '대화 로그', - features: '기능', debugAndPreview: '미리보기', restart: '재시작', currentDraft: '현재 초안', @@ -93,9 +92,7 @@ const translation = { addParallelNode: '병렬 노드 추가', parallel: '병렬', branch: '브랜치', - featuresDocLink: '더 알아보세요', fileUploadTip: '이미지 업로드 기능이 파일 업로드로 업그레이드되었습니다.', - featuresDescription: '웹앱 사용자 경험 향상', ImageUploadLegacyTip: '이제 시작 양식에서 파일 형식 변수를 만들 수 있습니다. 앞으로 이미지 업로드 기능은 더 이상 지원되지 않습니다.', importWarning: '주의', @@ -115,10 +112,11 @@ const translation = { exportPNG: 'PNG 로 내보내기', addBlock: '노드 추가', needAnswerNode: '답변 노드를 추가해야 합니다.', - needEndNode: '종단 노드를 추가해야 합니다.', + needOutputNode: '출력 노드를 추가해야 합니다', tagBound: '이 태그를 사용하는 앱 수', currentView: '현재 보기', currentWorkflow: '현재 워크플로', + moreActions: '더 많은 작업', }, env: { envPanelTitle: '환경 변수', @@ -145,6 +143,19 @@ const translation = { export: '비밀 값이 포함된 DSL 내보내기', }, }, + globalVar: { + title: '시스템 변수', + description: '시스템 변수는 타입이 맞으면 배선 없이도 모든 노드에서 참조할 수 있는 전역 변수로, 엔드유저 ID와 워크플로 ID 등이 포함됩니다.', + fieldsDescription: { + conversationId: '대화 ID', + dialogCount: '대화 수', + userId: '사용자 ID', + triggerTimestamp: '애플리케이션 시작 타임스탬프', + appId: '애플리케이션 ID', + workflowId: '워크플로 ID', + workflowRunId: '워크플로 실행 ID', + }, + }, chatVariable: { panelTitle: '대화 변수', panelDescription: @@ -251,7 +262,7 @@ const translation = { }, blocks: { 'start': '시작', - 'end': '끝', + 'end': '출력', 'answer': '답변', 'llm': 'LLM', 'knowledge-retrieval': '지식 검색', @@ -277,7 +288,7 @@ const translation = { }, blocksAbout: { 'start': '워크플로우를 시작하기 위한 초기 매개변수를 정의합니다', - 'end': '워크플로우의 종료 및 결과 유형을 정의합니다', + 'end': '워크플로의 출력 및 결과 유형을 정의합니다', 'answer': '대화의 답변 내용을 정의합니다', 'llm': '질문에 답하거나 자연어를 처리하기 위해 대형 언어 모델을 호출합니다', 'knowledge-retrieval': @@ -332,7 +343,7 @@ const translation = { }, panel: { userInputField: '사용자 입력 필드', - helpLink: '도움말 링크', + helpLink: '도움말 센터', about: '정보', createdBy: '작성자 ', nextStep: '다음 단계', @@ -342,13 +353,13 @@ const translation = { checklistResolved: '모든 문제가 해결되었습니다', change: '변경', optional: '(선택사항)', - moveToThisNode: '이 노드로 이동', organizeBlocks: '노드 정리하기', selectNextStep: '다음 단계 선택', changeBlock: '노드 변경', addNextStep: '이 워크플로우에 다음 단계를 추가하세요.', minimize: '전체 화면 종료', maximize: '캔버스 전체 화면', + scrollToSelectedNode: '선택한 노드로 스크롤', optional_and_hidden: '(선택 사항 및 숨김)', }, nodes: { diff --git a/web/i18n/pl-PL/billing.ts b/web/i18n/pl-PL/billing.ts index cf0859468b..31aa337478 100644 --- a/web/i18n/pl-PL/billing.ts +++ b/web/i18n/pl-PL/billing.ts @@ -91,7 +91,7 @@ const translation = { freeTrialTipPrefix: 'Zarejestruj się i zdobądź', teamMember_other: '{{count,number}} członków zespołu', teamWorkspace: '{{count,number}} Zespół Workspace', - apiRateLimitUnit: '{{count,number}}/dzień', + apiRateLimitUnit: '{{count,number}}/miesiąc', cloud: 'Usługa chmurowa', teamMember_one: '{{count,number}} Członek zespołu', priceTip: 'na przestrzeń roboczą/', diff --git a/web/i18n/pl-PL/workflow.ts b/web/i18n/pl-PL/workflow.ts index 7c4d85e3ec..f30e9350f7 100644 --- a/web/i18n/pl-PL/workflow.ts +++ b/web/i18n/pl-PL/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Opublikowane', publish: 'Opublikuj', update: 'Aktualizuj', - run: 'Uruchom', + run: 'Uruchom test', running: 'Uruchamianie', inRunMode: 'W trybie uruchamiania', inPreview: 'W podglądzie', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Historia uruchomień', goBackToEdit: 'Wróć do edytora', conversationLog: 'Dziennik rozmów', - features: 'Funkcje', debugAndPreview: 'Podgląd', restart: 'Uruchom ponownie', currentDraft: 'Bieżący szkic', @@ -93,8 +92,6 @@ const translation = { branch: 'GAŁĄŹ', ImageUploadLegacyTip: 'Teraz można tworzyć zmienne typu pliku w formularzu startowym. W przyszłości nie będziemy już obsługiwać funkcji przesyłania obrazów.', fileUploadTip: 'Funkcje przesyłania obrazów zostały zaktualizowane do przesyłania plików.', - featuresDescription: 'Ulepszanie środowiska użytkownika aplikacji internetowej', - featuresDocLink: 'Dowiedz się więcej', importWarning: 'Ostrożność', importWarningDetails: 'Różnica w wersji DSL może mieć wpływ na niektóre funkcje', openInExplore: 'Otwieranie w obszarze Eksploruj', @@ -110,11 +107,12 @@ const translation = { exportPNG: 'Eksportuj jako PNG', publishUpdate: 'Opublikuj aktualizację', addBlock: 'Dodaj węzeł', - needEndNode: 'Należy dodać węzeł końcowy', + needOutputNode: 'Należy dodać węzeł wyjściowy', needAnswerNode: 'Węzeł odpowiedzi musi zostać dodany', tagBound: 'Liczba aplikacji korzystających z tego tagu', currentWorkflow: 'Bieżący przepływ pracy', currentView: 'Bieżący widok', + moreActions: 'Więcej akcji', }, env: { envPanelTitle: 'Zmienne Środowiskowe', @@ -139,6 +137,19 @@ const translation = { export: 'Eksportuj DSL z tajnymi wartościami', }, }, + globalVar: { + title: 'Zmienne systemowe', + description: 'Zmienne systemowe to zmienne globalne, do których może odwołać się każdy węzeł bez okablowania, jeśli typ jest zgodny, na przykład identyfikator użytkownika końcowego i identyfikator przepływu pracy.', + fieldsDescription: { + conversationId: 'ID konwersacji', + dialogCount: 'Liczba konwersacji', + userId: 'ID użytkownika', + triggerTimestamp: 'Znacznik czasu uruchomienia aplikacji', + appId: 'ID aplikacji', + workflowId: 'ID przepływu pracy', + workflowRunId: 'ID uruchomienia przepływu pracy', + }, + }, chatVariable: { panelTitle: 'Zmienne Konwersacji', panelDescription: 'Zmienne Konwersacji służą do przechowywania interaktywnych informacji, które LLM musi pamiętać, w tym historii konwersacji, przesłanych plików, preferencji użytkownika. Są one do odczytu i zapisu.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Start', - 'end': 'Koniec', + 'end': 'Wyjście', 'answer': 'Odpowiedź', 'llm': 'LLM', 'knowledge-retrieval': 'Wyszukiwanie wiedzy', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Zdefiniuj początkowe parametry uruchamiania przepływu pracy', - 'end': 'Zdefiniuj zakończenie i typ wyniku przepływu pracy', + 'end': 'Zdefiniuj wyjście i typ wyniku przepływu pracy', 'answer': 'Zdefiniuj treść odpowiedzi w rozmowie', 'llm': 'Wywołaj duże modele językowe do odpowiadania na pytania lub przetwarzania języka naturalnego', 'knowledge-retrieval': 'Pozwala na wyszukiwanie treści tekstowych związanych z pytaniami użytkowników z bazy wiedzy', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Pole wprowadzania użytkownika', - helpLink: 'Link do pomocy', + helpLink: 'Pomoc', about: 'O', createdBy: 'Stworzone przez ', nextStep: 'Następny krok', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Wszystkie problemy zostały rozwiązane', change: 'Zmień', optional: '(opcjonalne)', - moveToThisNode: 'Przenieś do tego węzła', selectNextStep: 'Wybierz następny krok', addNextStep: 'Dodaj następny krok w tym procesie roboczym', changeBlock: 'Zmień węzeł', organizeBlocks: 'Organizuj węzły', minimize: 'Wyjdź z trybu pełnoekranowego', maximize: 'Maksymalizuj płótno', + scrollToSelectedNode: 'Przewiń do wybranego węzła', optional_and_hidden: '(opcjonalne i ukryte)', }, nodes: { diff --git a/web/i18n/pt-BR/billing.ts b/web/i18n/pt-BR/billing.ts index e4ca0a064a..9e58b24af4 100644 --- a/web/i18n/pt-BR/billing.ts +++ b/web/i18n/pt-BR/billing.ts @@ -80,7 +80,7 @@ const translation = { documentsRequestQuota: '{{count,number}}/min Limite de Taxa de Solicitação de Conhecimento', cloud: 'Serviço de Nuvem', teamWorkspace: '{{count,number}} Espaço de Trabalho da Equipe', - apiRateLimitUnit: '{{count,number}}/dia', + apiRateLimitUnit: '{{count,number}}/mês', freeTrialTipSuffix: 'Nenhum cartão de crédito necessário', teamMember_other: '{{count,number}} Membros da Equipe', comparePlanAndFeatures: 'Compare planos e recursos', diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index bd5cf49ed7..265274c979 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publicado', publish: 'Publicar', update: 'Atualizar', - run: 'Executar', + run: 'Executar teste', running: 'Executando', inRunMode: 'No modo de execução', inPreview: 'Em visualização', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Histórico de execução', goBackToEdit: 'Voltar para o editor', conversationLog: 'Registro de conversa', - features: 'Recursos', debugAndPreview: 'Visualizar', restart: 'Reiniciar', currentDraft: 'Rascunho atual', @@ -91,8 +90,6 @@ const translation = { addParallelNode: 'Adicionar nó paralelo', parallel: 'PARALELO', branch: 'RAMIFICAÇÃO', - featuresDocLink: 'Saiba Mais', - featuresDescription: 'Melhore a experiência do usuário do aplicativo Web', ImageUploadLegacyTip: 'Agora você pode criar variáveis de tipo de arquivo no formulário inicial. Não daremos mais suporte ao recurso de upload de imagens no futuro.', fileUploadTip: 'Os recursos de upload de imagens foram atualizados para upload de arquivos.', importWarning: 'Cuidado', @@ -110,11 +107,12 @@ const translation = { exportSVG: 'Exportar como SVG', exportJPEG: 'Exportar como JPEG', addBlock: 'Adicionar Nó', - needEndNode: 'O nó de Fim deve ser adicionado', + needOutputNode: 'O nó de Saída deve ser adicionado', needAnswerNode: 'O nó de resposta deve ser adicionado', tagBound: 'Número de aplicativos usando esta tag', currentView: 'Visualização atual', currentWorkflow: 'Fluxo de trabalho atual', + moreActions: 'Mais ações', }, env: { envPanelTitle: 'Variáveis de Ambiente', @@ -139,6 +137,19 @@ const translation = { export: 'Exportar DSL com valores secretos', }, }, + globalVar: { + title: 'Variáveis do sistema', + description: 'Variáveis do sistema são variáveis globais que qualquer nó pode referenciar sem conexões quando o tipo está correto, como o ID do usuário final e o ID do fluxo de trabalho.', + fieldsDescription: { + conversationId: 'ID da conversa', + dialogCount: 'Contagem de conversas', + userId: 'ID do usuário', + triggerTimestamp: 'Carimbo de data/hora do início da aplicação', + appId: 'ID da aplicação', + workflowId: 'ID do fluxo de trabalho', + workflowRunId: 'ID da execução do fluxo de trabalho', + }, + }, chatVariable: { panelTitle: 'Variáveis de Conversação', panelDescription: 'As Variáveis de Conversação são usadas para armazenar informações interativas que o LLM precisa lembrar, incluindo histórico de conversas, arquivos carregados, preferências do usuário. Elas são de leitura e escrita.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Iniciar', - 'end': 'Fim', + 'end': 'Saída', 'answer': 'Resposta', 'llm': 'LLM', 'knowledge-retrieval': 'Recuperação de conhecimento', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Definir os parâmetros iniciais para iniciar um fluxo de trabalho', - 'end': 'Definir o fim e o tipo de resultado de um fluxo de trabalho', + 'end': 'Definir a saída e o tipo de resultado de um fluxo de trabalho', 'answer': 'Definir o conteúdo da resposta de uma conversa', 'llm': 'Invocar grandes modelos de linguagem para responder perguntas ou processar linguagem natural', 'knowledge-retrieval': 'Permite consultar conteúdo de texto relacionado a perguntas do usuário a partir da base de conhecimento', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Campo de entrada do usuário', - helpLink: 'Link de ajuda', + helpLink: 'Ajuda', about: 'Sobre', createdBy: 'Criado por ', nextStep: 'Próximo passo', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Todos os problemas foram resolvidos', change: 'Mudar', optional: '(opcional)', - moveToThisNode: 'Mova-se para este nó', changeBlock: 'Mudar Nó', addNextStep: 'Adicione o próximo passo neste fluxo de trabalho', organizeBlocks: 'Organizar nós', selectNextStep: 'Selecione o próximo passo', maximize: 'Maximize Canvas', minimize: 'Sair do Modo Tela Cheia', + scrollToSelectedNode: 'Role até o nó selecionado', optional_and_hidden: '(opcional & oculto)', }, nodes: { diff --git a/web/i18n/ro-RO/billing.ts b/web/i18n/ro-RO/billing.ts index 3f5577dc32..0d787bb661 100644 --- a/web/i18n/ro-RO/billing.ts +++ b/web/i18n/ro-RO/billing.ts @@ -82,7 +82,7 @@ const translation = { documentsTooltip: 'Cota pe numărul de documente importate din Sursele de Date de Cunoștințe.', getStarted: 'Întrebați-vă', cloud: 'Serviciu de cloud', - apiRateLimitUnit: '{{count,number}}/zi', + apiRateLimitUnit: '{{count,number}}/lună', comparePlanAndFeatures: 'Compară planurile și caracteristicile', documentsRequestQuota: '{{count,number}}/min Limita de rată a cererilor de cunoștințe', documents: '{{count,number}} Documente de Cunoaștere', diff --git a/web/i18n/ro-RO/workflow.ts b/web/i18n/ro-RO/workflow.ts index ffa1282380..8d55033929 100644 --- a/web/i18n/ro-RO/workflow.ts +++ b/web/i18n/ro-RO/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Publicat', publish: 'Publică', update: 'Actualizează', - run: 'Rulează', + run: 'Rulează test', running: 'Rulând', inRunMode: 'În modul de rulare', inPreview: 'În previzualizare', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Istoric rulări', goBackToEdit: 'Înapoi la editor', conversationLog: 'Jurnal conversație', - features: 'Funcționalități', debugAndPreview: 'Previzualizare', restart: 'Repornește', currentDraft: 'Schimbare curentă', @@ -91,8 +90,6 @@ const translation = { addParallelNode: 'Adăugare nod paralel', parallel: 'PARALEL', branch: 'RAMURĂ', - featuresDescription: 'Îmbunătățiți experiența utilizatorului aplicației web', - featuresDocLink: 'Află mai multe', fileUploadTip: 'Funcțiile de încărcare a imaginilor au fost actualizate la încărcarea fișierelor.', ImageUploadLegacyTip: 'Acum puteți crea variabile de tip de fișier în formularul de pornire. Nu vom mai accepta funcția de încărcare a imaginilor în viitor.', importWarning: 'Prudență', @@ -111,10 +108,11 @@ const translation = { exportJPEG: 'Exportă ca JPEG', addBlock: 'Adaugă nod', needAnswerNode: 'Nodul de răspuns trebuie adăugat', - needEndNode: 'Nodul de sfârșit trebuie adăugat', + needOutputNode: 'Nodul de ieșire trebuie adăugat', tagBound: 'Numărul de aplicații care folosesc acest tag', currentView: 'Vizualizare curentă', currentWorkflow: 'Flux de lucru curent', + moreActions: 'Mai multe acțiuni', }, env: { envPanelTitle: 'Variabile de Mediu', @@ -139,6 +137,19 @@ const translation = { export: 'Exportă DSL cu valori secrete', }, }, + globalVar: { + title: 'Variabile de sistem', + description: 'Variabilele de sistem sunt variabile globale care pot fi folosite de orice nod fără conexiuni dacă tipul este corect, precum ID-ul utilizatorului final și ID-ul fluxului de lucru.', + fieldsDescription: { + conversationId: 'ID conversație', + dialogCount: 'Număr conversații', + userId: 'ID utilizator', + triggerTimestamp: 'Marcaj temporal al pornirii aplicației', + appId: 'ID aplicație', + workflowId: 'ID flux de lucru', + workflowRunId: 'ID rulare flux de lucru', + }, + }, chatVariable: { panelTitle: 'Variabile de Conversație', panelDescription: 'Variabilele de Conversație sunt utilizate pentru a stoca informații interactive pe care LLM trebuie să le rețină, inclusiv istoricul conversației, fișiere încărcate, preferințele utilizatorului. Acestea sunt citibile și inscriptibile.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Începe', - 'end': 'Sfârșit', + 'end': 'Ieșire', 'answer': 'Răspuns', 'llm': 'LLM', 'knowledge-retrieval': 'Recuperare de cunoștințe', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Definiți parametrii inițiali pentru lansarea unui flux de lucru', - 'end': 'Definiți sfârșitul și tipul rezultatului unui flux de lucru', + 'end': 'Definiți ieșirea și tipul rezultatului unui flux de lucru', 'answer': 'Definiți conținutul răspunsului unei conversații', 'llm': 'Invocarea modelelor de limbaj mari pentru a răspunde la întrebări sau pentru a procesa limbajul natural', 'knowledge-retrieval': 'Permite interogarea conținutului textului legat de întrebările utilizatorului din baza de cunoștințe', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Câmp de introducere utilizator', - helpLink: 'Link de ajutor', + helpLink: 'Ajutor', about: 'Despre', createdBy: 'Creat de ', nextStep: 'Pasul următor', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Toate problemele au fost rezolvate', change: 'Schimbă', optional: '(opțional)', - moveToThisNode: 'Mutați la acest nod', organizeBlocks: 'Organizează nodurile', addNextStep: 'Adăugați următorul pas în acest flux de lucru', changeBlock: 'Schimbă nodul', selectNextStep: 'Selectați Pasul Următor', maximize: 'Maximize Canvas', minimize: 'Iesi din modul pe tot ecranul', + scrollToSelectedNode: 'Derulați la nodul selectat', optional_and_hidden: '(opțional și ascuns)', }, nodes: { diff --git a/web/i18n/ru-RU/billing.ts b/web/i18n/ru-RU/billing.ts index 7017f90cc2..1f3071a325 100644 --- a/web/i18n/ru-RU/billing.ts +++ b/web/i18n/ru-RU/billing.ts @@ -78,7 +78,7 @@ const translation = { apiRateLimit: 'Ограничение скорости API', self: 'Самостоятельно размещенный', teamMember_other: '{{count,number}} Члены команды', - apiRateLimitUnit: '{{count,number}}/день', + apiRateLimitUnit: '{{count,number}}/месяц', unlimitedApiRate: 'Нет ограничений на количество запросов к API', freeTrialTip: 'бесплатная пробная версия из 200 вызовов OpenAI.', freeTrialTipSuffix: 'Кредитная карта не требуется', diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index 78be03ba91..9d7c99acea 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Опубликовано', publish: 'Опубликовать', update: 'Обновить', - run: 'Запустить', + run: 'Тестовый запуск', running: 'Выполняется', inRunMode: 'В режиме выполнения', inPreview: 'В режиме предпросмотра', @@ -18,7 +18,6 @@ const translation = { runHistory: 'История запусков', goBackToEdit: 'Вернуться к редактору', conversationLog: 'Журнал разговоров', - features: 'Функции', debugAndPreview: 'Предпросмотр', restart: 'Перезапустить', currentDraft: 'Текущий черновик', @@ -91,9 +90,7 @@ const translation = { addParallelNode: 'Добавить параллельный узел', parallel: 'ПАРАЛЛЕЛЬНЫЙ', branch: 'ВЕТКА', - featuresDocLink: 'Подробнее', fileUploadTip: 'Функции загрузки изображений были обновлены до загрузки файлов.', - featuresDescription: 'Улучшение взаимодействия с пользователем веб-приложения', ImageUploadLegacyTip: 'Теперь вы можете создавать переменные типа файла в стартовой форме. В будущем мы больше не будем поддерживать функцию загрузки изображений.', importWarning: 'Осторожность', importWarningDetails: 'Разница в версии DSL может повлиять на некоторые функции', @@ -111,10 +108,11 @@ const translation = { publishUpdate: 'Опубликовать обновление', addBlock: 'Добавить узел', needAnswerNode: 'В узел ответа необходимо добавить', - needEndNode: 'Узел конца должен быть добавлен', + needOutputNode: 'Необходимо добавить узел вывода', tagBound: 'Количество приложений, использующих этот тег', currentView: 'Текущий вид', currentWorkflow: 'Текущий рабочий процесс', + moreActions: 'Больше действий', }, env: { envPanelTitle: 'Переменные среды', @@ -139,6 +137,19 @@ const translation = { export: 'Экспортировать DSL с секретными значениями ', }, }, + globalVar: { + title: 'Системные переменные', + description: 'Системные переменные — это глобальные переменные, к которым любой узел может обращаться без соединений при корректном типе, например идентификатор конечного пользователя и идентификатор рабочего процесса.', + fieldsDescription: { + conversationId: 'ID беседы', + dialogCount: 'Количество бесед', + userId: 'ID пользователя', + triggerTimestamp: 'Отметка времени запуска приложения', + appId: 'ID приложения', + workflowId: 'ID рабочего процесса', + workflowRunId: 'ID запуска рабочего процесса', + }, + }, chatVariable: { panelTitle: 'Переменные разговора', panelDescription: 'Переменные разговора используются для хранения интерактивной информации, которую LLM необходимо запомнить, включая историю разговоров, загруженные файлы, пользовательские настройки. Они доступны для чтения и записи. ', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Начало', - 'end': 'Конец', + 'end': 'Вывод', 'answer': 'Ответ', 'llm': 'LLM', 'knowledge-retrieval': 'Поиск знаний', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Определите начальные параметры для запуска рабочего процесса', - 'end': 'Определите конец и тип результата рабочего процесса', + 'end': 'Определите вывод и тип результата рабочего процесса', 'answer': 'Определите содержимое ответа в чате', 'llm': 'Вызов больших языковых моделей для ответа на вопросы или обработки естественного языка', 'knowledge-retrieval': 'Позволяет запрашивать текстовый контент, связанный с вопросами пользователей, из базы знаний', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Поле ввода пользователя', - helpLink: 'Ссылка на справку', + helpLink: 'Помощь', about: 'О программе', createdBy: 'Создано ', nextStep: 'Следующий шаг', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Все проблемы решены', change: 'Изменить', optional: '(необязательно)', - moveToThisNode: 'Перейдите к этому узлу', selectNextStep: 'Выберите следующий шаг', organizeBlocks: 'Организовать узлы', addNextStep: 'Добавьте следующий шаг в этот рабочий процесс', changeBlock: 'Изменить узел', minimize: 'Выйти из полноэкранного режима', maximize: 'Максимизировать холст', + scrollToSelectedNode: 'Прокрутите до выбранного узла', optional_and_hidden: '(необязательно и скрыто)', }, nodes: { diff --git a/web/i18n/sl-SI/billing.ts b/web/i18n/sl-SI/billing.ts index fb9d9ec435..ef8c767090 100644 --- a/web/i18n/sl-SI/billing.ts +++ b/web/i18n/sl-SI/billing.ts @@ -86,7 +86,7 @@ const translation = { teamMember_one: '{{count,number}} član ekipe', teamMember_other: '{{count,number}} Članov ekipe', documentsRequestQuota: '{{count,number}}/min Omejitev stopnje zahtev po znanju', - apiRateLimitUnit: '{{count,number}}/dan', + apiRateLimitUnit: '{{count,number}}/mesec', priceTip: 'na delovnem prostoru/', freeTrialTipPrefix: 'Prijavite se in prejmite', cloud: 'Oblačna storitev', diff --git a/web/i18n/sl-SI/workflow.ts b/web/i18n/sl-SI/workflow.ts index dbc4a75c43..fb1f709162 100644 --- a/web/i18n/sl-SI/workflow.ts +++ b/web/i18n/sl-SI/workflow.ts @@ -18,8 +18,7 @@ const translation = { }, versionHistory: 'Zgodovina različic', published: 'Objavljeno', - run: 'Teči', - featuresDocLink: 'Nauči se več', + run: 'Testni tek', notRunning: 'Še ne teče', exportImage: 'Izvozi sliko', openInExplore: 'Odpri v Raziskovanju', @@ -42,7 +41,7 @@ const translation = { inPreview: 'V predogledu', workflowAsToolTip: 'Zaradi posodobitve delovnega poteka je potrebna ponovna konfiguracija orodja.', variableNamePlaceholder: 'Ime spremenljivke', - needEndNode: 'Skrivnostna vozlišča je treba dodati.', + needOutputNode: 'Dodati je treba izhodiščno vozlišče', onFailure: 'O neuspehu', embedIntoSite: 'Vstavite v spletno stran', conversationLog: 'Pogovor Log', @@ -77,12 +76,10 @@ const translation = { fileUploadTip: 'Funkcije nalaganja slik so bile nadgrajene na nalaganje datotek.', backupCurrentDraft: 'Varnostno kopiraj trenutni osnutek', overwriteAndImport: 'Prepiši in uvozi', - features: 'Značilnosti', exportPNG: 'Izvozi kot PNG', chooseDSL: 'Izberi DSL datoteko', unpublished: 'Nepublikirano', pasteHere: 'Prilepite tukaj', - featuresDescription: 'Izboljšanje uporabniške izkušnje spletne aplikacije', exitVersions: 'Izhodne različice', editing: 'Urejanje', addFailureBranch: 'Dodaj neuspešno vejo', @@ -115,6 +112,7 @@ const translation = { tagBound: 'Število aplikacij, ki uporabljajo to oznako', currentView: 'Trenutni pogled', currentWorkflow: 'Trenutni potek dela', + moreActions: 'Več dejanj', }, env: { modal: { @@ -139,6 +137,19 @@ const translation = { envPanelButton: 'Dodaj spremenljivko', envDescription: 'Okoljske spremenljivke se lahko uporabljajo za shranjevanje zasebnih informacij in poverilnic. So samo za branje in jih je mogoče ločiti od DSL datoteke med izvozem.', }, + globalVar: { + title: 'Sistemske spremenljivke', + description: 'Sistemske spremenljivke so globalne spremenljivke, do katerih lahko vsako vozlišče dostopa brez povezovanja, če je tip pravilen, na primer ID končnega uporabnika in ID poteka dela.', + fieldsDescription: { + conversationId: 'ID pogovora', + dialogCount: 'Število pogovorov', + userId: 'ID uporabnika', + triggerTimestamp: 'Časovni žig začetka delovanja aplikacije', + appId: 'ID aplikacije', + workflowId: 'ID poteka dela', + workflowRunId: 'ID izvajanja poteka dela', + }, + }, chatVariable: { modal: { namePlaceholder: 'Ime spremenljivke', @@ -255,7 +266,7 @@ const translation = { 'code': 'Koda', 'template-transform': 'Predloga', 'answer': 'Odgovor', - 'end': 'Konec', + 'end': 'Izhod', 'iteration-start': 'Začetek iteracije', 'list-operator': 'Seznam operater', 'variable-aggregator': 'Spremenljivka agregator', @@ -275,7 +286,7 @@ const translation = { 'loop-end': 'Enakovredno „prekini“. Ta vozlišče nima konfiguracijskih elementov. Ko telo zanke doseže to vozlišče, zanka preneha.', 'document-extractor': 'Uporabljeno za razčlenitev prenesenih dokumentov v besedilno vsebino, ki jo je enostavno razumeti za LLM.', 'answer': 'Določi vsebino odgovora v pogovoru.', - 'end': 'Določite tip konca in rezultata delovnega toka', + 'end': 'Določite izhod in tip rezultata delovnega toka', 'knowledge-retrieval': 'Omogoča vam, da poizvedujete o besedilnih vsebinah, povezanih z vprašanji uporabnikov iz znanja.', 'http-request': 'Dovoli pošiljanje zahtevkov strežniku prek protokola HTTP', 'llm': 'Uporaba velikih jezikovnih modelov za odgovarjanje na vprašanja ali obdelavo naravnega jezika', @@ -324,10 +335,9 @@ const translation = { runThisStep: 'Izvedi ta korak', changeBlock: 'Spremeni vozlišče', addNextStep: 'Dodajte naslednji korak v ta delovni potek', - moveToThisNode: 'Premakni se na to vozlišče', checklistTip: 'Prepričajte se, da so vse težave rešene, preden objavite.', selectNextStep: 'Izberi naslednji korak', - helpLink: 'Pomočna povezava', + helpLink: 'Pomoč', checklist: 'Kontrolni seznam', checklistResolved: 'Vse težave so rešene', createdBy: 'Ustvarjeno z', @@ -335,6 +345,7 @@ const translation = { minimize: 'Izhod iz celotnega zaslona', maximize: 'Maksimiziraj platno', optional: '(neobvezno)', + scrollToSelectedNode: 'Pomaknite se do izbranega vozlišča', optional_and_hidden: '(neobvezno in skrito)', }, nodes: { diff --git a/web/i18n/th-TH/billing.ts b/web/i18n/th-TH/billing.ts index 461e4a8240..a3bd5b85bc 100644 --- a/web/i18n/th-TH/billing.ts +++ b/web/i18n/th-TH/billing.ts @@ -82,7 +82,7 @@ const translation = { teamMember_one: '{{count,number}} สมาชิกทีม', unlimitedApiRate: 'ไม่มีข้อจำกัดอัตราการเรียก API', self: 'โฮสต์ด้วยตัวเอง', - apiRateLimitUnit: '{{count,number}}/วัน', + apiRateLimitUnit: '{{count,number}}/เดือน', teamMember_other: '{{count,number}} สมาชิกทีม', teamWorkspace: '{{count,number}} ทีมทำงาน', priceTip: 'ต่อพื้นที่ทำงาน/', diff --git a/web/i18n/th-TH/workflow.ts b/web/i18n/th-TH/workflow.ts index 419b577a02..51e9b4d088 100644 --- a/web/i18n/th-TH/workflow.ts +++ b/web/i18n/th-TH/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'เผย แพร่', publish: 'ตีพิมพ์', update: 'อัพเดต', - run: 'วิ่ง', + run: 'ทดสอบการทำงาน', running: 'กำลัง เรียก ใช้', inRunMode: 'ในโหมดเรียกใช้', inPreview: 'ในการแสดงตัวอย่าง', @@ -18,11 +18,8 @@ const translation = { runHistory: 'ประวัติการวิ่ง', goBackToEdit: 'กลับไปที่ตัวแก้ไข', conversationLog: 'บันทึกการสนทนา', - features: 'หน้าตา', - featuresDescription: 'ปรับปรุงประสบการณ์ผู้ใช้เว็บแอป', ImageUploadLegacyTip: 'ตอนนี้คุณสามารถสร้างตัวแปรชนิดไฟล์ในฟอร์มเริ่มต้นได้แล้ว เราจะไม่รองรับฟีเจอร์การอัปโหลดรูปภาพอีกต่อไปในอนาคต', fileUploadTip: 'ฟีเจอร์การอัปโหลดรูปภาพได้รับการอัปเกรดเป็นการอัปโหลดไฟล์', - featuresDocLink: 'ศึกษาเพิ่มเติม', debugAndPreview: 'ดูตัวอย่าง', restart: 'เริ่มใหม่', currentDraft: 'ร่างปัจจุบัน', @@ -111,10 +108,11 @@ const translation = { exportSVG: 'ส่งออกเป็น SVG', needAnswerNode: 'ต้องเพิ่มโหนดคำตอบ', addBlock: 'เพิ่มโนด', - needEndNode: 'ต้องเพิ่มโหนดจบ', + needOutputNode: 'ต้องเพิ่มโหนดเอาต์พุต', tagBound: 'จำนวนแอปพลิเคชันที่ใช้แท็กนี้', currentWorkflow: 'เวิร์กโฟลว์ปัจจุบัน', currentView: 'ปัจจุบัน View', + moreActions: 'การดําเนินการเพิ่มเติม', }, env: { envPanelTitle: 'ตัวแปรสภาพแวดล้อม', @@ -139,6 +137,19 @@ const translation = { export: 'ส่งออก DSL ด้วยค่าลับ', }, }, + globalVar: { + title: 'ตัวแปรระบบ', + description: 'ตัวแปรระบบเป็นตัวแปรแบบโกลบอลที่โหนดใด ๆ สามารถอ้างอิงได้โดยไม่ต้องเดินสายเมื่อชนิดข้อมูลถูกต้อง เช่น รหัสผู้ใช้ปลายทางและรหัสเวิร์กโฟลว์', + fieldsDescription: { + conversationId: 'รหัสการสนทนา', + dialogCount: 'จำนวนการสนทนา', + userId: 'รหัสผู้ใช้', + triggerTimestamp: 'ตราประทับเวลาที่แอปเริ่มทำงาน', + appId: 'รหัสแอปพลิเคชัน', + workflowId: 'รหัสเวิร์กโฟลว์', + workflowRunId: 'รหัสการรันเวิร์กโฟลว์', + }, + }, chatVariable: { panelTitle: 'ตัวแปรการสนทนา', panelDescription: 'ตัวแปรการสนทนาใช้เพื่อจัดเก็บข้อมูลแบบโต้ตอบที่ LLM จําเป็นต้องจดจํา รวมถึงประวัติการสนทนา ไฟล์ที่อัปโหลด การตั้งค่าของผู้ใช้ พวกเขาอ่าน-เขียน', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'เริ่ม', - 'end': 'ปลาย', + 'end': 'เอาต์พุต', 'answer': 'ตอบ', 'llm': 'นิติศาสตราจารย์', 'knowledge-retrieval': 'การดึงความรู้', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'กําหนดพารามิเตอร์เริ่มต้นสําหรับการเปิดใช้เวิร์กโฟลว์', - 'end': 'กําหนดชนิดสิ้นสุดและผลลัพธ์ของเวิร์กโฟลว์', + 'end': 'กำหนดเอาต์พุตและประเภทผลลัพธ์ของเวิร์กโฟลว์', 'answer': 'กําหนดเนื้อหาการตอบกลับของการสนทนาแชท', 'llm': 'การเรียกใช้โมเดลภาษาขนาดใหญ่เพื่อตอบคําถามหรือประมวลผลภาษาธรรมชาติ', 'knowledge-retrieval': 'ช่วยให้คุณสามารถสอบถามเนื้อหาข้อความที่เกี่ยวข้องกับคําถามของผู้ใช้จากความรู้', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'ฟิลด์ป้อนข้อมูลของผู้ใช้', - helpLink: 'ลิงค์ช่วยเหลือ', + helpLink: 'วิธีใช้', about: 'ประมาณ', createdBy: 'สร้างโดย', nextStep: 'ขั้นตอนถัดไป', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'ปัญหาทั้งหมดได้รับการแก้ไขแล้ว', change: 'เปลี่ยน', optional: '(ไม่บังคับ)', - moveToThisNode: 'ย้ายไปที่โหนดนี้', organizeBlocks: 'จัดระเบียบโหนด', addNextStep: 'เพิ่มขั้นตอนถัดไปในกระบวนการทำงานนี้', changeBlock: 'เปลี่ยนโหนด', selectNextStep: 'เลือกขั้นตอนถัดไป', minimize: 'ออกจากโหมดเต็มหน้าจอ', maximize: 'เพิ่มประสิทธิภาพผ้าใบ', + scrollToSelectedNode: 'เลื่อนไปยังโหนดที่เลือก', optional_and_hidden: '(ตัวเลือก & ซ่อน)', }, nodes: { diff --git a/web/i18n/tr-TR/billing.ts b/web/i18n/tr-TR/billing.ts index 6d01d9dd32..93c54fd1ed 100644 --- a/web/i18n/tr-TR/billing.ts +++ b/web/i18n/tr-TR/billing.ts @@ -78,7 +78,7 @@ const translation = { freeTrialTipPrefix: 'Kaydolun ve bir', priceTip: 'iş alanı başına/', documentsRequestQuota: '{{count,number}}/dakika Bilgi İsteği Oran Limiti', - apiRateLimitUnit: '{{count,number}}/gün', + apiRateLimitUnit: '{{count,number}}/ay', documents: '{{count,number}} Bilgi Belgesi', comparePlanAndFeatures: 'Planları ve özellikleri karşılaştır', self: 'Kendi Barındırılan', diff --git a/web/i18n/tr-TR/workflow.ts b/web/i18n/tr-TR/workflow.ts index 930664ce57..df4f9c8093 100644 --- a/web/i18n/tr-TR/workflow.ts +++ b/web/i18n/tr-TR/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Yayınlandı', publish: 'Yayınla', update: 'Güncelle', - run: 'Çalıştır', + run: 'Test çalıştır', running: 'Çalışıyor', inRunMode: 'Çalıştırma Modunda', inPreview: 'Ön İzlemede', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Çalıştırma Geçmişi', goBackToEdit: 'Editöre geri dön', conversationLog: 'Konuşma Günlüğü', - features: 'Özellikler', debugAndPreview: 'Önizleme', restart: 'Yeniden Başlat', currentDraft: 'Geçerli Taslak', @@ -91,10 +90,8 @@ const translation = { disconnect: 'Ayırmak', parallel: 'PARALEL', branch: 'DAL', - featuresDocLink: 'Daha fazla bilgi edinin', fileUploadTip: 'Resim yükleme özellikleri, dosya yüklemeye yükseltildi.', ImageUploadLegacyTip: 'Artık başlangıç formunda dosya türü değişkenleri oluşturabilirsiniz. Gelecekte resim yükleme özelliğini artık desteklemeyeceğiz.', - featuresDescription: 'Web uygulaması kullanıcı deneyimini geliştirin', importWarningDetails: 'DSL sürüm farkı bazı özellikleri etkileyebilir', importWarning: 'Dikkat', openInExplore: 'Keşfet\'te Aç', @@ -111,10 +108,11 @@ const translation = { exportSVG: 'SVG olarak dışa aktar', addBlock: 'Düğüm Ekle', needAnswerNode: 'Cevap düğümü eklenmelidir.', - needEndNode: 'Son düğüm eklenmelidir', + needOutputNode: 'Çıktı düğümü eklenmelidir', tagBound: 'Bu etiketi kullanan uygulama sayısı', currentView: 'Geçerli Görünüm', currentWorkflow: 'Mevcut İş Akışı', + moreActions: 'Daha Fazla Eylem', }, env: { envPanelTitle: 'Çevre Değişkenleri', @@ -139,6 +137,19 @@ const translation = { export: 'Gizli değerlerle DSL\'yi dışa aktar', }, }, + globalVar: { + title: 'Sistem Değişkenleri', + description: 'Sistem değişkenleri, tipi uyumlu olduğunda herhangi bir düğümün bağlantı gerektirmeden başvurabileceği küresel değişkenlerdir; örneğin son kullanıcı kimliği ve iş akışı kimliği.', + fieldsDescription: { + conversationId: 'Konuşma Kimliği', + dialogCount: 'Konuşma Sayısı', + userId: 'Kullanıcı Kimliği', + triggerTimestamp: 'Uygulamanın çalışmaya başladığı zaman damgası', + appId: 'Uygulama Kimliği', + workflowId: 'İş Akışı Kimliği', + workflowRunId: 'İş akışı yürütme kimliği', + }, + }, chatVariable: { panelTitle: 'Konuşma Değişkenleri', panelDescription: 'Konuşma Değişkenleri, LLM\'nin hatırlaması gereken interaktif bilgileri (konuşma geçmişi, yüklenen dosyalar, kullanıcı tercihleri dahil) depolamak için kullanılır. Bunlar okunabilir ve yazılabilirdir.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Başlat', - 'end': 'Son', + 'end': 'Çıktı', 'answer': 'Yanıt', 'llm': 'LLM', 'knowledge-retrieval': 'Bilgi Geri Alımı', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Bir iş akışını başlatmak için başlangıç parametrelerini tanımlayın', - 'end': 'Bir iş akışının sonunu ve sonuç türünü tanımlayın', + 'end': 'Bir iş akışının çıktısını ve sonuç türünü tanımlayın', 'answer': 'Bir sohbet konuşmasının yanıt içeriğini tanımlayın', 'llm': 'Büyük dil modellerini soruları yanıtlamak veya doğal dili işlemek için çağırın', 'knowledge-retrieval': 'Kullanıcı sorularıyla ilgili metin içeriğini Bilgi\'den sorgulamanıza olanak tanır', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Kullanıcı Giriş Alanı', - helpLink: 'Yardım Linki', + helpLink: 'Yardım', about: 'Hakkında', createdBy: 'Oluşturan: ', nextStep: 'Sonraki Adım', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Tüm sorunlar çözüldü', change: 'Değiştir', optional: '(isteğe bağlı)', - moveToThisNode: 'Bu düğüme geç', changeBlock: 'Düğümü Değiştir', addNextStep: 'Bu iş akışına bir sonraki adımı ekleyin', organizeBlocks: 'Düğümleri düzenle', selectNextStep: 'Sonraki Adımı Seç', minimize: 'Tam Ekrandan Çık', maximize: 'Kanvası Maksimize Et', + scrollToSelectedNode: 'Seçili düğüme kaydırma', optional_and_hidden: '(isteğe bağlı ve gizli)', }, nodes: { diff --git a/web/i18n/uk-UA/billing.ts b/web/i18n/uk-UA/billing.ts index 03b743e4fe..e98b3e6091 100644 --- a/web/i18n/uk-UA/billing.ts +++ b/web/i18n/uk-UA/billing.ts @@ -84,7 +84,7 @@ const translation = { priceTip: 'за робочим простором/', unlimitedApiRate: 'Немає обмеження на швидкість API', freeTrialTipSuffix: 'Кредитна картка не потрібна', - apiRateLimitUnit: '{{count,number}}/день', + apiRateLimitUnit: '{{count,number}}/місяць', getStarted: 'Почати', freeTrialTip: 'безкоштовна пробна версія з 200 запитів до OpenAI.', documents: '{{count,number}} Документів знань', diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index 2f4f298204..95425f0a32 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Опубліковано', publish: 'Опублікувати', update: 'Оновити', - run: 'Запустити', + run: 'Тестовий запуск', running: 'Запущено', inRunMode: 'У режимі запуску', inPreview: 'У режимі попереднього перегляду', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Історія запусків', goBackToEdit: 'Повернутися до редактора', conversationLog: 'Журнал розмов', - features: 'Функції', debugAndPreview: 'Попередній перегляд', restart: 'Перезапустити', currentDraft: 'Поточний чернетка', @@ -91,8 +90,6 @@ const translation = { addParallelNode: 'Додати паралельний вузол', parallel: 'ПАРАЛЕЛЬНИЙ', branch: 'ГІЛКА', - featuresDocLink: 'Дізнатися більше', - featuresDescription: 'Покращення взаємодії з користувачем веб-додатку', fileUploadTip: 'Функції завантаження зображень були оновлені для завантаження файлів.', ImageUploadLegacyTip: 'Тепер ви можете створювати змінні типу файлу у початковій формі. У майбутньому ми більше не підтримуватимемо функцію завантаження зображень.', importWarning: 'Обережність', @@ -110,11 +107,12 @@ const translation = { exportSVG: 'Експортувати як SVG', exportJPEG: 'Експортувати як JPEG', addBlock: 'Додати вузол', - needEndNode: 'Необхідно додати кінцевий вузол', + needOutputNode: 'Необхідно додати вихідний вузол', needAnswerNode: 'Вузол Відповіді повинен бути доданий', tagBound: 'Кількість додатків, що використовують цей тег', currentView: 'Поточний вигляд', currentWorkflow: 'Поточний робочий процес', + moreActions: 'Більше дій', }, env: { envPanelTitle: 'Змінні середовища', @@ -139,6 +137,19 @@ const translation = { export: 'Експортувати DSL з секретними значеннями', }, }, + globalVar: { + title: 'Системні змінні', + description: 'Системні змінні — це глобальні змінні, до яких будь-який вузол може звертатися без з’єднання, якщо тип відповідає, наприклад ID кінцевого користувача та ID робочого процесу.', + fieldsDescription: { + conversationId: 'ID розмови', + dialogCount: 'Кількість розмов', + userId: 'ID користувача', + triggerTimestamp: 'Мітка часу запуску застосунку', + appId: 'ID застосунку', + workflowId: 'ID робочого процесу', + workflowRunId: 'ID запуску робочого процесу', + }, + }, chatVariable: { panelTitle: 'Змінні розмови', panelDescription: 'Змінні розмови використовуються для зберігання інтерактивної інформації, яку LLM повинен пам\'ятати, включаючи історію розмови, завантажені файли, вподобання користувача. Вони доступні для читання та запису.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Початок', - 'end': 'Кінець', + 'end': 'Вивід', 'answer': 'Відповідь', 'llm': 'LLM', 'knowledge-retrieval': 'Отримання знань', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Визначте початкові параметри для запуску робочого потоку', - 'end': 'Визначте кінець і тип результату робочого потоку', + 'end': 'Визначте вивід і тип результату робочого потоку', 'answer': 'Визначте зміст відповіді у чаті', 'llm': 'Виклик великих мовних моделей для відповіді на запитання або обробки природної мови', 'knowledge-retrieval': 'Дозволяє виконувати запити текстового вмісту, пов\'язаного із запитаннями користувача, з бази знань', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Поле введення користувача', - helpLink: 'Посилання на допомогу', + helpLink: 'Довідковий центр', about: 'Про', createdBy: 'Створено ', nextStep: 'Наступний крок', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Всі проблеми вирішені', change: 'Змінити', optional: '(необов\'язково)', - moveToThisNode: 'Перемістіть до цього вузла', organizeBlocks: 'Організуйте вузли', changeBlock: 'Змінити вузол', selectNextStep: 'Виберіть наступний крок', addNextStep: 'Додайте наступний крок у цей робочий процес', minimize: 'Вийти з повноекранного режиму', maximize: 'Максимізувати полотно', + scrollToSelectedNode: 'Прокрутіть до вибраного вузла', optional_and_hidden: '(необов\'язково & приховано)', }, nodes: { diff --git a/web/i18n/vi-VN/billing.ts b/web/i18n/vi-VN/billing.ts index 0166185e45..c6a7458164 100644 --- a/web/i18n/vi-VN/billing.ts +++ b/web/i18n/vi-VN/billing.ts @@ -90,7 +90,7 @@ const translation = { teamMember_other: '{{count,number}} thành viên trong nhóm', documents: '{{count,number}} Tài liệu Kiến thức', getStarted: 'Bắt đầu', - apiRateLimitUnit: '{{count,number}}/ngày', + apiRateLimitUnit: '{{count,number}}/tháng', freeTrialTipSuffix: 'Không cần thẻ tín dụng', documentsRequestQuotaTooltip: 'Chỉ định tổng số hành động mà một không gian làm việc có thể thực hiện mỗi phút trong cơ sở tri thức, bao gồm tạo mới tập dữ liệu, xóa, cập nhật, tải tài liệu lên, thay đổi, lưu trữ và truy vấn cơ sở tri thức. Chỉ số này được sử dụng để đánh giá hiệu suất của các yêu cầu cơ sở tri thức. Ví dụ, nếu một người dùng Sandbox thực hiện 10 lần kiểm tra liên tiếp trong một phút, không gian làm việc của họ sẽ bị hạn chế tạm thời không thực hiện các hành động sau trong phút tiếp theo: tạo mới tập dữ liệu, xóa, cập nhật và tải tài liệu lên hoặc thay đổi.', startBuilding: 'Bắt đầu xây dựng', diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index 4a3a720cb3..4fe45a8cc6 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: 'Đã xuất bản', publish: 'Xuất bản', update: 'Cập nhật', - run: 'Chạy', + run: 'Chạy thử nghiệm', running: 'Đang chạy', inRunMode: 'Chế độ chạy', inPreview: 'Trong chế độ xem trước', @@ -18,7 +18,6 @@ const translation = { runHistory: 'Lịch sử chạy', goBackToEdit: 'Quay lại trình chỉnh sửa', conversationLog: 'Nhật ký cuộc trò chuyện', - features: 'Tính năng', debugAndPreview: 'Xem trước', restart: 'Khởi động lại', currentDraft: 'Bản nháp hiện tại', @@ -91,9 +90,7 @@ const translation = { addParallelNode: 'Thêm nút song song', parallel: 'SONG SONG', branch: 'NHÁNH', - featuresDocLink: 'Tìm hiểu thêm', fileUploadTip: 'Các tính năng tải lên hình ảnh đã được nâng cấp để tải tệp lên.', - featuresDescription: 'Nâng cao trải nghiệm người dùng ứng dụng web', ImageUploadLegacyTip: 'Bây giờ bạn có thể tạo các biến loại tệp trong biểu mẫu bắt đầu. Chúng tôi sẽ không còn hỗ trợ tính năng tải lên hình ảnh trong tương lai.', importWarning: 'Thận trọng', importWarningDetails: 'Sự khác biệt về phiên bản DSL có thể ảnh hưởng đến một số tính năng nhất định', @@ -111,10 +108,11 @@ const translation = { exportJPEG: 'Xuất dưới dạng JPEG', needAnswerNode: 'Nút Trả lời phải được thêm vào', addBlock: 'Thêm Node', - needEndNode: 'Nút Kết thúc phải được thêm vào', + needOutputNode: 'Phải thêm nút Đầu ra', tagBound: 'Số lượng ứng dụng sử dụng thẻ này', currentWorkflow: 'Quy trình làm việc hiện tại', currentView: 'Hiện tại View', + moreActions: 'Hành động khác', }, env: { envPanelTitle: 'Biến Môi Trường', @@ -139,6 +137,19 @@ const translation = { export: 'Xuất DSL với giá trị bí mật', }, }, + globalVar: { + title: 'Biến hệ thống', + description: 'Biến hệ thống là biến toàn cục mà bất kỳ nút nào cũng có thể tham chiếu mà không cần nối dây khi kiểu dữ liệu phù hợp, chẳng hạn như ID người dùng cuối và ID quy trình làm việc.', + fieldsDescription: { + conversationId: 'ID cuộc trò chuyện', + dialogCount: 'Số lần trò chuyện', + userId: 'ID người dùng', + triggerTimestamp: 'Dấu thời gian ứng dụng bắt đầu chạy', + appId: 'ID ứng dụng', + workflowId: 'ID quy trình làm việc', + workflowRunId: 'ID lần chạy quy trình làm việc', + }, + }, chatVariable: { panelTitle: 'Biến Hội Thoại', panelDescription: 'Biến Hội Thoại được sử dụng để lưu trữ thông tin tương tác mà LLM cần ghi nhớ, bao gồm lịch sử hội thoại, tệp đã tải lên, tùy chọn người dùng. Chúng có thể đọc và ghi được.', @@ -242,7 +253,7 @@ const translation = { }, blocks: { 'start': 'Bắt đầu', - 'end': 'Kết thúc', + 'end': 'Đầu ra', 'answer': 'Trả lời', 'llm': 'LLM', 'knowledge-retrieval': 'Truy xuất kiến thức', @@ -268,7 +279,7 @@ const translation = { }, blocksAbout: { 'start': 'Định nghĩa các tham số ban đầu để khởi chạy quy trình làm việc', - 'end': 'Định nghĩa kết thúc và loại kết quả của quy trình làm việc', + 'end': 'Định nghĩa đầu ra và loại kết quả của quy trình làm việc', 'answer': 'Định nghĩa nội dung trả lời của cuộc trò chuyện', 'llm': 'Gọi các mô hình ngôn ngữ lớn để trả lời câu hỏi hoặc xử lý ngôn ngữ tự nhiên', 'knowledge-retrieval': 'Cho phép truy vấn nội dung văn bản liên quan đến câu hỏi của người dùng từ cơ sở kiến thức', @@ -311,7 +322,7 @@ const translation = { }, panel: { userInputField: 'Trường đầu vào của người dùng', - helpLink: 'Liên kết trợ giúp', + helpLink: 'Trung tâm trợ giúp', about: 'Giới thiệu', createdBy: 'Tạo bởi ', nextStep: 'Bước tiếp theo', @@ -321,13 +332,13 @@ const translation = { checklistResolved: 'Tất cả các vấn đề đã được giải quyết', change: 'Thay đổi', optional: '(tùy chọn)', - moveToThisNode: 'Di chuyển đến nút này', changeBlock: 'Thay đổi Node', selectNextStep: 'Chọn bước tiếp theo', organizeBlocks: 'Tổ chức các nút', addNextStep: 'Thêm bước tiếp theo trong quy trình này', maximize: 'Tối đa hóa Canvas', minimize: 'Thoát chế độ toàn màn hình', + scrollToSelectedNode: 'Cuộn đến nút đã chọn', optional_and_hidden: '(tùy chọn & ẩn)', }, nodes: { diff --git a/web/i18n/zh-Hans/app-log.ts b/web/i18n/zh-Hans/app-log.ts index 21505e28e6..629d584642 100644 --- a/web/i18n/zh-Hans/app-log.ts +++ b/web/i18n/zh-Hans/app-log.ts @@ -20,6 +20,7 @@ const translation = { tokens: 'TOKENS', user: '用户或账户', version: '版本', + triggered_from: '触发方式', }, pagination: { previous: '上一页', @@ -97,6 +98,15 @@ const translation = { iteration: '迭代', finalProcessing: '最终处理', }, + triggerBy: { + debugging: '调试', + appRun: '网页应用', + webhook: 'Webhook', + schedule: '定时任务', + plugin: '插件', + ragPipelineRun: 'RAG 流水线', + ragPipelineDebugging: 'RAG 调试', + }, } export default translation diff --git a/web/i18n/zh-Hans/app-overview.ts b/web/i18n/zh-Hans/app-overview.ts index a41a86975a..730240b9f7 100644 --- a/web/i18n/zh-Hans/app-overview.ts +++ b/web/i18n/zh-Hans/app-overview.ts @@ -30,6 +30,7 @@ const translation = { overview: { title: '概览', appInfo: { + title: 'Web App', explanation: '开箱即用的 AI web app', accessibleAddress: '公开访问 URL', preview: '预览', @@ -37,6 +38,10 @@ const translation = { regenerate: '重新生成', regenerateNotice: '您是否要重新生成公开访问 URL?', preUseReminder: '使用前请先打开开关', + enableTooltip: { + description: '要启用此功能,请在画布中添加用户输入节点。(草稿中可能已存在,发布后生效)', + learnMore: '了解更多', + }, settings: { entry: '设置', title: 'web app 设置', @@ -121,6 +126,14 @@ const translation = { accessibleAddress: 'API 访问凭据', doc: '查阅 API 文档', }, + triggerInfo: { + title: '触发器', + explanation: '工作流触发器管理', + triggersAdded: '已添加 {{count}} 个触发器', + noTriggerAdded: '未添加触发器', + triggerStatusDescription: '触发器节点状态显示在这里。(草稿中可能已存在,发布后生效)', + learnAboutTriggers: '了解触发器', + }, status: { running: '运行中', disable: '已停用', diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts index cdc2ba1ad7..f6eb3b2b1b 100644 --- a/web/i18n/zh-Hans/app.ts +++ b/web/i18n/zh-Hans/app.ts @@ -253,6 +253,8 @@ const translation = { notSetDesc: '当前任何人都无法访问 Web 应用。请设置访问权限。', }, noAccessPermission: '没有权限访问 web 应用', + noUserInputNode: '缺少用户输入节点', + notPublishedYet: '应用暂未发布', maxActiveRequests: '最大活跃请求数', maxActiveRequestsPlaceholder: '0 表示不限制', maxActiveRequestsTip: '当前应用的最大活跃请求数(0 表示不限制)', diff --git a/web/i18n/zh-Hans/billing.ts b/web/i18n/zh-Hans/billing.ts index 00a7dd909a..3c50abd01f 100644 --- a/web/i18n/zh-Hans/billing.ts +++ b/web/i18n/zh-Hans/billing.ts @@ -7,6 +7,8 @@ const translation = { documentsUploadQuota: '文档上传配额', vectorSpace: '知识库数据存储空间', vectorSpaceTooltip: '采用高质量索引模式的文档会消耗知识数据存储资源。当知识数据存储达到限制时,将不会上传新文档。', + triggerEvents: '触发事件', + perMonth: '每月', }, upgradeBtn: { plain: '查看套餐', @@ -61,7 +63,7 @@ const translation = { documentsRequestQuota: '{{count,number}}/分钟 知识库请求频率限制', documentsRequestQuotaTooltip: '指每分钟内,一个空间在知识库中可执行的操作总数,包括数据集的创建、删除、更新,文档的上传、修改、归档,以及知识库查询等,用于评估知识库请求的性能。例如,Sandbox 用户在 1 分钟内连续执行 10 次命中测试,其工作区将在接下来的 1 分钟内无法继续执行以下操作:数据集的创建、删除、更新,文档的上传、修改等操作。', apiRateLimit: 'API 请求频率限制', - apiRateLimitUnit: '{{count,number}} 次/天', + apiRateLimitUnit: '{{count,number}} 次/月', unlimitedApiRate: 'API 请求频率无限制', apiRateLimitTooltip: 'API 请求频率限制涵盖所有通过 Dify API 发起的调用,例如文本生成、聊天对话、工作流执行和文档处理等。', documentProcessingPriority: '文档处理', @@ -71,6 +73,20 @@ const translation = { 'priority': '优先', 'top-priority': '最高优先级', }, + triggerEvents: { + sandbox: '{{count,number}} 触发事件', + professional: '{{count,number}} 触发事件/月', + unlimited: '无限制触发事件', + }, + workflowExecution: { + standard: '标准工作流执行', + faster: '更快的工作流执行', + priority: '优先工作流执行', + }, + startNodes: { + limited: '每个工作流最多 {{count}} 个起始节点', + unlimited: '每个工作流无限制起始节点', + }, logsHistory: '{{days}}日志历史', customTools: '自定义工具', unavailable: '不可用', diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index 95c9e583c1..a9228a5a25 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -29,6 +29,11 @@ const translation = { refresh: '重新开始', reset: '重置', search: '搜索', + noSearchResults: '没有找到{{content}}', + resetKeywords: '重置关键词', + selectCount: '已选择 {{count}} 项', + searchCount: '找到 {{count}} 个 {{content}}', + noSearchCount: '0 个 {{content}}', change: '更改', remove: '移除', send: '发送', @@ -71,6 +76,7 @@ const translation = { more: '更多', selectAll: '全选', deSelectAll: '取消全选', + now: '现在', }, errorMsg: { fieldRequired: '{{field}} 为必填项', @@ -79,7 +85,9 @@ const translation = { placeholder: { input: '请输入', select: '请选择', + search: '搜索...', }, + noData: '暂无数据', label: { optional: '(可选)', }, @@ -173,7 +181,7 @@ const translation = { emailSupport: '邮件支持', workspace: '工作空间', createWorkspace: '创建工作空间', - helpCenter: '帮助文档', + helpCenter: '查看帮助文档', support: '支持', compliance: '合规', forum: '论坛', @@ -769,6 +777,12 @@ const translation = { title: '提供反馈', placeholder: '请描述发生了什么问题或我们可以如何改进...', }, + dynamicSelect: { + error: '加载选项失败', + noData: '没有可用的选项', + loading: '加载选项...', + selected: '已选择 {{count}} 项', + }, } export default translation diff --git a/web/i18n/zh-Hans/plugin-trigger.ts b/web/i18n/zh-Hans/plugin-trigger.ts new file mode 100644 index 0000000000..304cdd47bd --- /dev/null +++ b/web/i18n/zh-Hans/plugin-trigger.ts @@ -0,0 +1,186 @@ +const translation = { + subscription: { + title: '订阅', + listNum: '{{num}} 个订阅', + empty: { + title: '暂无订阅', + button: '新建订阅', + }, + createButton: { + oauth: '通过 OAuth 新建订阅', + apiKey: '通过 API Key 新建订阅', + manual: '粘贴 URL 以创建新订阅', + }, + createSuccess: '订阅创建成功', + createFailed: '订阅创建失败', + maxCount: '最多 {{num}} 个订阅', + selectPlaceholder: '选择订阅', + noSubscriptionSelected: '未选择订阅', + subscriptionRemoved: '订阅已移除', + list: { + title: '订阅列表', + addButton: '添加', + tip: '通过订阅接收事件', + item: { + enabled: '已启用', + disabled: '已禁用', + credentialType: { + api_key: 'API密钥', + oauth2: 'OAuth', + unauthorized: '手动', + }, + actions: { + delete: '删除', + deleteConfirm: { + title: '删除 {{name}}?', + success: '订阅 {{name}} 删除成功', + error: '订阅 {{name}} 删除失败', + content: '删除后,该订阅将无法恢复,请确认。', + contentWithApps: '该订阅正在被 {{count}} 个应用使用,删除它将导致这些应用停止接收订阅事件。', + confirm: '确认删除', + cancel: '取消', + confirmInputWarning: '请输入正确的名称确认。', + confirmInputPlaceholder: '输入 "{{name}}" 确认', + confirmInputTip: '请输入 “{{name}}” 确认:', + }, + }, + status: { + active: '活跃', + inactive: '非活跃', + }, + usedByNum: '被 {{num}} 个工作流使用', + noUsed: '未被工作流使用', + }, + }, + addType: { + title: '添加订阅', + description: '选择创建触发器订阅的方式', + options: { + apikey: { + title: '通过 API Key 创建', + description: '使用 API 凭据自动创建订阅', + }, + oauth: { + title: '通过 OAuth 创建', + description: '与第三方平台授权以创建订阅', + clientSettings: 'OAuth 客户端设置', + clientTitle: 'OAuth 客户端', + default: '默认', + custom: '自定义', + }, + manual: { + title: '手动设置', + description: '粘贴 URL 以创建新订阅', + tip: '手动配置 URL 到第三方平台', + }, + }, + }, + }, + modal: { + steps: { + verify: '验证', + configuration: '配置', + }, + common: { + cancel: '取消', + back: '返回', + next: '下一步', + create: '创建', + verify: '验证', + authorize: '授权', + creating: '创建中...', + verifying: '验证中...', + authorizing: '授权中...', + }, + oauthRedirectInfo: '由于未找到此工具提供方的系统客户端密钥,需要手动设置,对于 redirect_uri,请使用', + apiKey: { + title: '通过 API Key 创建', + verify: { + title: '验证凭据', + description: '请提供您的 API 凭据以验证访问权限', + error: '凭据验证失败,请检查您的 API 密钥。', + success: '凭据验证成功', + }, + configuration: { + title: '配置订阅', + description: '设置您的订阅参数', + }, + }, + oauth: { + title: '通过 OAuth 创建', + authorization: { + title: 'OAuth 授权', + description: '授权 Dify 访问您的账户', + redirectUrl: '重定向 URL', + redirectUrlHelp: '在您的 OAuth 应用配置中使用此 URL', + authorizeButton: '使用 {{provider}} 授权', + waitingAuth: '等待授权中...', + authSuccess: '授权成功', + authFailed: '获取 OAuth 授权信息失败', + waitingJump: '已授权,待跳转', + }, + configuration: { + title: '配置订阅', + description: '授权完成后设置您的订阅参数', + success: 'OAuth 配置成功', + failed: 'OAuth 配置失败', + }, + remove: { + success: 'OAuth 移除成功', + failed: 'OAuth 移除失败', + }, + save: { + success: 'OAuth 配置保存成功', + }, + }, + manual: { + title: '手动设置', + description: '手动配置您的 Webhook 订阅', + logs: { + title: '请求日志', + request: '请求', + loading: '等待 {{pluginName}} 的请求...', + }, + }, + form: { + subscriptionName: { + label: '订阅名称', + placeholder: '输入订阅名称', + required: '订阅名称为必填项', + }, + callbackUrl: { + label: '回调 URL', + description: '此 URL 将接收Webhook事件', + tooltip: '填写能被触发器提供方访问的公网地址,用于接收回调请求。', + placeholder: '生成中...', + privateAddressWarning: '此 URL 似乎是一个内部地址,可能会导致 Webhook 请求失败。', + }, + }, + errors: { + createFailed: '创建订阅失败', + verifyFailed: '验证凭据失败', + authFailed: '授权失败', + networkError: '网络错误,请重试', + }, + }, + events: { + title: '可用事件', + description: '此触发器插件可以订阅的事件', + empty: '没有可用事件', + event: '事件', + events: '事件', + actionNum: '包含 {{num}} 个 {{event}}', + item: { + parameters: '{{count}}个参数', + noParameters: '暂无参数', + }, + output: '输出', + }, + node: { + status: { + warning: '未连接', + }, + }, +} + +export default translation diff --git a/web/i18n/zh-Hans/plugin.ts b/web/i18n/zh-Hans/plugin.ts index adda0a3b8a..d648bccb85 100644 --- a/web/i18n/zh-Hans/plugin.ts +++ b/web/i18n/zh-Hans/plugin.ts @@ -8,6 +8,7 @@ const translation = { tools: '工具', agents: 'Agent 策略', extensions: '扩展', + triggers: '触发器', bundles: '插件集', datasources: '数据源', }, @@ -16,6 +17,7 @@ const translation = { tool: '工具', agent: 'Agent 策略', extension: '扩展', + trigger: '触发器', bundle: '插件集', datasource: '数据源', }, @@ -62,6 +64,7 @@ const translation = { checkUpdate: '检查更新', viewDetail: '查看详情', remove: '移除', + back: '返回', }, actionNum: '包含 {{num}} 个 {{action}}', strategyNum: '包含 {{num}} 个 {{strategy}}', @@ -77,7 +80,7 @@ const translation = { endpointModalDesc: '完成配置后可使用插件 API 端点提供的功能', serviceOk: '服务正常', disabled: '停用', - modelNum: '{{num}} 模型已包含', + modelNum: '包含 {{num}} 个模型', toolSelector: { title: '添加工具', toolSetting: '工具设置', @@ -306,6 +309,12 @@ const translation = { connectedWorkspace: '已连接的工作区', emptyAuth: '请配置凭据', }, + readmeInfo: { + title: 'README', + needHelpCheckReadme: '需要帮助?查看 README。', + noReadmeAvailable: 'README 文档不可用', + failedToFetch: '获取 README 文档失败', + }, } export default translation diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 2d869083b7..18e76caa64 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -9,8 +9,10 @@ const translation = { publish: '发布', update: '更新', publishUpdate: '发布更新', - run: '运行', + run: '测试运行', running: '运行中', + chooseStartNodeToRun: '选择启动节点进行运行', + runAllTriggers: '运行所有触发器', inRunMode: '在运行模式中', inPreview: '预览中', inPreviewMode: '预览中', @@ -46,7 +48,8 @@ const translation = { needConnectTip: '此节点尚未连接到其他节点', maxTreeDepth: '每个分支最大限制 {{depth}} 个节点', needAdd: '必须添加{{node}}节点', - needEndNode: '必须添加结束节点', + needOutputNode: '必须添加输出节点', + needStartNode: '必须添加至少一个开始节点', needAnswerNode: '必须添加直接回复节点', workflowProcess: '工作流', notRunning: '尚未运行', @@ -76,12 +79,14 @@ const translation = { exportSVG: '导出为 SVG', currentView: '当前视图', currentWorkflow: '整个工作流', + moreActions: '更多操作', model: '模型', workflowAsTool: '发布为工具', configureRequired: '需要进行配置', configure: '配置', manageInTools: '访问工具页', workflowAsToolTip: '工作流更新后需要重新配置工具参数', + workflowAsToolDisabledHint: '请先发布最新的工作流,并确保已连接的 User Input 节点后再配置为工具。', viewDetailInTracingPanel: '查看详细信息', syncingData: '同步数据中,只需几秒钟。', importDSL: '导入 DSL', @@ -140,6 +145,19 @@ const translation = { export: '导出包含 Secret 值的 DSL', }, }, + globalVar: { + title: '系统变量', + description: '系统变量是全局变量,在类型匹配时无需连线即可被任意节点引用,例如终端用户 ID 和工作流 ID。', + fieldsDescription: { + conversationId: '会话 ID', + dialogCount: '会话次数', + userId: '用户 ID', + triggerTimestamp: '应用开始运行的时间戳', + appId: '应用 ID', + workflowId: '工作流 ID', + workflowRunId: '工作流运行 ID', + }, + }, sidebar: { exportWarning: '导出当前已保存版本', exportWarningDesc: '这将导出您工作流的当前已保存版本。如果您在编辑器中有未保存的更改,请先使用工作流画布中的导出选项保存它们。', @@ -213,6 +231,16 @@ const translation = { invalidVariable: '无效的变量', noValidTool: '{{field}} 无可用工具', toolParameterRequired: '{{field}}: 参数 [{{param}}] 不能为空', + startNodeRequired: '请先添加开始节点,然后再{{operation}}', + }, + error: { + startNodeRequired: '请先添加开始节点,然后再{{operation}}', + operations: { + connectingNodes: '连接节点', + addingNodes: '添加节点', + modifyingWorkflow: '修改工作流', + updatingWorkflow: '更新工作流', + }, }, singleRun: { testRun: '测试运行', @@ -229,6 +257,8 @@ const translation = { 'searchBlock': '搜索节点', 'blocks': '节点', 'searchTool': '搜索工具', + 'searchTrigger': '搜索触发器...', + 'allTriggers': '全部触发器', 'tools': '工具', 'allTool': '全部', 'plugin': '插件', @@ -239,15 +269,29 @@ const translation = { 'transform': '转换', 'utilities': '工具', 'noResult': '未找到匹配项', + 'noPluginsFound': '未找到插件', + 'requestToCommunity': '向社区反馈', 'agent': 'Agent 策略', 'allAdded': '已添加全部', 'addAll': '添加全部', 'sources': '数据源', 'searchDataSource': '搜索数据源', + 'start': '开始', + 'featuredTools': '精选推荐', + 'showMoreFeatured': '查看更多', + 'showLessFeatured': '收起', + 'installed': '已安装', + 'pluginByAuthor': '来自 {{author}}', + 'usePlugin': '选择工具', + 'hideActions': '收起工具', + 'noFeaturedPlugins': '前往插件市场查看更多工具', + 'noFeaturedTriggers': '前往插件市场查看更多触发器', + 'startDisabledTip': '触发节点与用户输入节点互斥。', }, blocks: { - 'start': '开始', - 'end': '结束', + 'start': '用户输入', + 'originalStartNode': '原始开始节点', + 'end': '输出', 'answer': '直接回复', 'llm': 'LLM', 'knowledge-retrieval': '知识检索', @@ -270,10 +314,14 @@ const translation = { 'loop-end': '退出循环', 'knowledge-index': '知识库', 'datasource': '数据源', + 'trigger-webhook': 'Webhook 触发器', + 'trigger-schedule': '定时触发器', + 'trigger-plugin': '插件触发器', }, + customWebhook: '自定义 Webhook', blocksAbout: { 'start': '定义一个 workflow 流程启动的初始参数', - 'end': '定义一个 workflow 流程的结束和结果类型', + 'end': '定义一个 workflow 流程的输出和结果类型', 'answer': '定义一个聊天对话的回复内容', 'llm': '调用大语言模型回答问题或者对自然语言进行处理', 'knowledge-retrieval': '允许你从知识库中查询与用户问题相关的文本内容', @@ -294,7 +342,11 @@ const translation = { 'agent': '调用大型语言模型回答问题或处理自然语言', 'knowledge-index': '知识库节点', 'datasource': '数据源节点', + 'trigger-webhook': 'Webhook 触发器接收来自第三方系统的 HTTP 推送以自动触发工作流。', + 'trigger-schedule': '基于时间的工作流触发器,按计划启动工作流', + 'trigger-plugin': '从外部平台事件启动工作流的第三方集成触发器', }, + difyTeam: 'Dify 团队', operator: { zoomIn: '放大', zoomOut: '缩小', @@ -324,7 +376,7 @@ const translation = { panel: { userInputField: '用户输入字段', changeBlock: '更改节点', - helpLink: '帮助链接', + helpLink: '查看帮助文档', about: '关于', createdBy: '作者', nextStep: '下一步', @@ -334,12 +386,14 @@ const translation = { checklist: '检查清单', checklistTip: '发布前确保所有问题均已解决', checklistResolved: '所有问题均已解决', + goTo: '转到', + startNode: '开始节点', organizeBlocks: '整理节点', change: '更改', optional: '(选填)', - moveToThisNode: '定位至此节点', maximize: '最大化画布', minimize: '退出最大化', + scrollToSelectedNode: '滚动至选中节点', optional_and_hidden: '(选填 & 隐藏)', }, nodes: { @@ -966,6 +1020,138 @@ const translation = { rerankingModelIsRequired: 'Reranking 模型是必需的', rerankingModelIsInvalid: '无效的 Reranking 模型', }, + triggerSchedule: { + frequency: { + label: '频率', + monthly: '每月', + daily: '每日', + hourly: '每小时', + weekly: '每周', + }, + title: '定时触发', + nodeTitle: '定时触发器', + useCronExpression: '使用 Cron 表达式', + selectFrequency: '选择频率', + nextExecutionTimes: '接下来 5 次执行时间', + hours: '小时', + minutes: '分钟', + onMinute: '分钟', + cronExpression: 'Cron 表达式', + weekdays: '星期', + executeNow: '立即执行', + frequencyLabel: '频率', + nextExecution: '下次执行', + time: '时间', + lastDay: '最后一天', + startTime: '开始时间', + selectDateTime: '选择日期和时间', + lastDayTooltip: '并非所有月份都有 31 天。使用"最后一天"选项来选择每个月的最后一天。', + nextExecutionTime: '下次执行时间', + useVisualPicker: '使用可视化配置', + days: '天', + notConfigured: '未配置', + mode: '模式', + timezone: '时区', + visualConfig: '可视化配置', + monthlyDay: '月份日期', + executionTime: '执行时间', + invalidTimezone: '无效的时区', + invalidCronExpression: '无效的 Cron 表达式', + noValidExecutionTime: '无法计算有效的执行时间', + executionTimeCalculationError: '执行时间计算失败', + invalidFrequency: '无效的频率', + invalidStartTime: '无效的开始时间', + startTimeMustBeFuture: '开始时间必须是将来的时间', + invalidTimeFormat: '无效的时间格式(预期格式:HH:MM AM/PM)', + invalidWeekday: '无效的工作日:{{weekday}}', + invalidMonthlyDay: '月份日期必须在 1-31 之间或为"last"', + invalidOnMinute: '分钟必须在 0-59 之间', + invalidExecutionTime: '无效的执行时间', + executionTimeMustBeFuture: '执行时间必须是将来的时间', + }, + triggerWebhook: { + configPlaceholder: 'Webhook 触发器配置将在此处实现', + title: 'Webhook 触发器', + nodeTitle: '🔗 Webhook 触发器', + webhookUrl: 'Webhook URL', + webhookUrlPlaceholder: '点击生成以创建 webhook URL', + generate: '生成', + copy: '复制', + test: '测试', + urlGenerated: 'Webhook URL 生成成功', + urlGenerationFailed: '生成 Webhook URL 失败', + urlCopied: 'URL 已复制到剪贴板', + method: '方法', + contentType: '内容类型', + queryParameters: '查询参数', + headerParameters: 'Header 参数', + requestBodyParameters: '请求体参数', + parameterName: '变量名', + varName: '变量名', + varType: '类型', + varNamePlaceholder: '输入变量名...', + headerName: '变量名', + required: '必填', + addParameter: '添加', + addHeader: '添加', + noParameters: '未配置任何参数', + noQueryParameters: '未配置查询参数', + noHeaders: '未配置 Header', + noBodyParameters: '未配置请求体参数', + debugUrlTitle: '测试运行时,请始终使用此URL', + debugUrlCopy: '点击复制', + debugUrlCopied: '已复制!', + errorHandling: '错误处理', + errorStrategy: '错误处理', + responseConfiguration: '响应', + asyncMode: '异步模式', + statusCode: '状态码', + responseBody: '响应体', + responseBodyPlaceholder: '在此输入您的响应体', + headers: 'Headers', + validation: { + webhookUrlRequired: '需要提供Webhook URL', + invalidParameterType: '参数"{{name}}"的参数类型"{{type}}"无效', + }, + }, + triggerPlugin: { + authorized: '已授权', + notConfigured: '未配置', + error: '错误', + configuration: '配置', + remove: '移除', + or: '或', + useOAuth: '使用 OAuth', + useApiKey: '使用 API Key', + authenticationFailed: '身份验证失败', + authenticationSuccess: '身份验证成功', + oauthConfigFailed: 'OAuth 配置失败', + configureOAuthClient: '配置 OAuth 客户端', + oauthClientDescription: '配置 OAuth 客户端凭据以启用身份验证', + oauthClientSaved: 'OAuth 客户端配置保存成功', + configureApiKey: '配置 API Key', + apiKeyDescription: '配置 API key 凭据进行身份验证', + apiKeyConfigured: 'API key 配置成功', + configurationFailed: '配置失败', + failedToStart: '启动身份验证流程失败', + credentialsVerified: '凭据验证成功', + credentialVerificationFailed: '凭据验证失败', + verifyAndContinue: '验证并继续', + configureParameters: '配置参数', + parametersDescription: '配置触发器参数和属性', + configurationComplete: '配置完成', + configurationCompleteDescription: '您的触发器已成功配置', + configurationCompleteMessage: '您的触发器配置已完成,现在可以使用了。', + parameters: '参数', + properties: '属性', + propertiesDescription: '此触发器的额外配置属性', + noConfigurationRequired: '此触发器不需要额外配置。', + subscriptionName: '订阅名称', + subscriptionNameDescription: '为此触发器订阅输入一个唯一名称', + subscriptionNamePlaceholder: '输入订阅名称...', + subscriptionNameRequired: '订阅名称是必需的', + subscriptionRequired: '需要配置订阅', + }, }, tracing: { stopBy: '由{{user}}终止', @@ -1027,6 +1213,18 @@ const translation = { view: '查看记录', edited: '已编辑', reset: '还原至上一次运行', + listening: { + title: '正在监听触发器事件…', + tip: '您现在可以向 HTTP {{nodeName}} 端点发送测试请求以模拟事件触发,或将其用作实时事件调试的回调 URL。所有输出都可以在变量检查器中直接查看。', + tipPlugin: '现在您可以在 {{- pluginName}} 中创建事件,并在变量检查器中查看这些事件的输出。', + tipSchedule: '正在监听计划触发器事件。\n下一次计划运行时间:{{nextTriggerTime}}', + tipFallback: '正在等待触发器事件,输出结果将在此显示。', + defaultNodeName: '此触发器', + defaultPluginName: '此插件触发器', + defaultScheduleTime: '未设置', + selectedTriggers: '所选触发器', + stopButton: '停止', + }, trigger: { normal: '变量检查', running: '缓存中', @@ -1052,6 +1250,30 @@ const translation = { noDependents: '无被依赖', }, }, + triggerStatus: { + enabled: '触发器', + disabled: '触发器 • 已禁用', + }, + entryNodeStatus: { + enabled: '开始', + disabled: '开始 • 已禁用', + }, + onboarding: { + title: '选择开始节点来开始', + description: '不同的开始节点具有不同的功能。不用担心,您随时可以更改它们。', + userInputFull: '用户输入(原始开始节点)', + userInputDescription: '允许设置用户输入变量的开始节点,具有Web应用程序、服务API、MCP服务器和工作流即工具功能。', + trigger: '触发器', + triggerDescription: '触发器可以作为工作流的开始节点,例如定时任务、自定义webhook或与其他应用程序的集成。', + back: '返回', + learnMore: '了解更多', + aboutStartNode: '关于开始节点。', + escTip: { + press: '按', + key: 'esc', + toDismiss: '键关闭', + }, + }, } export default translation diff --git a/web/i18n/zh-Hant/billing.ts b/web/i18n/zh-Hant/billing.ts index f0f91a0966..38589179e7 100644 --- a/web/i18n/zh-Hant/billing.ts +++ b/web/i18n/zh-Hant/billing.ts @@ -74,7 +74,7 @@ const translation = { receiptInfo: '只有團隊所有者和團隊管理員才能訂閱和檢視賬單資訊', annotationQuota: '註釋配額', self: '自我主持', - apiRateLimitUnit: '{{count,number}}/天', + apiRateLimitUnit: '{{count,number}}/月', freeTrialTipPrefix: '註冊並獲得一個', annualBilling: '年度計費', freeTrialTipSuffix: '無需信用卡', diff --git a/web/i18n/zh-Hant/common.ts b/web/i18n/zh-Hant/common.ts index e5941c7d11..51cdcf8a0b 100644 --- a/web/i18n/zh-Hant/common.ts +++ b/web/i18n/zh-Hant/common.ts @@ -160,7 +160,8 @@ const translation = { emailSupport: '電子郵件支援', workspace: '工作空間', createWorkspace: '建立工作空間', - helpCenter: '幫助文件', + helpCenter: '查看幫助文件', + communityFeedback: '使用者反饋', roadmap: '路線圖', community: '社群', about: '關於', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 4bb5875d88..ce053d6e5b 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -8,7 +8,7 @@ const translation = { published: '已發佈', publish: '發佈', update: '更新', - run: '運行', + run: '測試運行', running: '運行中', inRunMode: '在運行模式中', inPreview: '預覽中', @@ -18,7 +18,6 @@ const translation = { runHistory: '運行歷史', goBackToEdit: '返回編輯模式', conversationLog: '對話記錄', - features: '功能', debugAndPreview: '預覽', restart: '重新開始', currentDraft: '當前草稿', @@ -38,6 +37,8 @@ const translation = { setVarValuePlaceholder: '設置變數值', needConnectTip: '此節點尚未連接到其他節點', maxTreeDepth: '每個分支最大限制 {{depth}} 個節點', + needAdd: '必須新增{{node}}節點', + needOutputNode: '必須新增輸出節點', needEndNode: '必須新增結束節點', needAnswerNode: '必須新增直接回覆節點', workflowProcess: '工作流', @@ -94,10 +95,8 @@ const translation = { addParallelNode: '新增並行節點', parallel: '並行', branch: '分支', - featuresDocLink: '瞭解更多資訊', fileUploadTip: '圖片上傳功能已升級為檔上傳。', ImageUploadLegacyTip: '現在,您可以在起始表單中創建檔案類型變數。我們將來不再支持圖片上傳功能。', - featuresDescription: '增強 Web 應用程式用戶體驗', importWarning: '謹慎', importWarningDetails: 'DSL 版本差異可能會影響某些功能', openInExplore: '在“探索”中打開', @@ -115,6 +114,7 @@ const translation = { tagBound: '使用此標籤的應用程式數量', currentView: '當前檢視', currentWorkflow: '當前工作流程', + moreActions: '更多動作', }, env: { envPanelTitle: '環境變數', @@ -139,6 +139,19 @@ const translation = { export: '導出帶有機密值的 DSL', }, }, + globalVar: { + title: '系統變數', + description: '系統變數是全域變數,在類型符合時可由任意節點在無需連線的情況下引用,例如終端使用者 ID 與工作流程 ID。', + fieldsDescription: { + conversationId: '對話 ID', + dialogCount: '對話次數', + userId: '使用者 ID', + triggerTimestamp: '應用程式開始運行的時間戳', + appId: '應用程式 ID', + workflowId: '工作流程 ID', + workflowRunId: '工作流程執行 ID', + }, + }, chatVariable: { panelTitle: '對話變數', panelDescription: '對話變數用於儲存 LLM 需要記住的互動資訊,包括對話歷史、上傳的檔案、使用者偏好等。這些變數可讀寫。', @@ -224,6 +237,8 @@ const translation = { 'searchBlock': '搜索節點', 'blocks': '節點', 'tools': '工具', + 'searchTrigger': '搜尋觸發器...', + 'allTriggers': '所有觸發器', 'allTool': '全部', 'customTool': '自定義', 'workflowTool': '工作流', @@ -239,10 +254,12 @@ const translation = { 'addAll': '全部新增', 'sources': '來源', 'searchDataSource': '搜尋資料來源', + 'noFeaturedPlugins': '前往 Marketplace 查看更多工具', + 'noFeaturedTriggers': '前往 Marketplace 查看更多觸發器', }, blocks: { 'start': '開始', - 'end': '結束', + 'end': '輸出', 'answer': '直接回覆', 'llm': 'LLM', 'knowledge-retrieval': '知識檢索', @@ -268,7 +285,7 @@ const translation = { }, blocksAbout: { 'start': '定義一個 workflow 流程啟動的參數', - 'end': '定義一個 workflow 流程的結束和結果類型', + 'end': '定義一個 workflow 流程的輸出和結果類型', 'answer': '定義一個聊天對話的回覆內容', 'llm': '調用大語言模型回答問題或者對自然語言進行處理', 'knowledge-retrieval': '允許你從知識庫中查詢與用戶問題相關的文本內容', @@ -312,7 +329,7 @@ const translation = { panel: { userInputField: '用戶輸入字段', changeBlock: '更改節點', - helpLink: '幫助連接', + helpLink: '查看幫助文件', about: '關於', createdBy: '作者', nextStep: '下一步', @@ -325,9 +342,9 @@ const translation = { organizeBlocks: '整理節點', change: '更改', optional: '(選擇性)', - moveToThisNode: '定位至此節點', minimize: '退出全螢幕', maximize: '最大化畫布', + scrollToSelectedNode: '捲動至選取的節點', optional_and_hidden: '(可選且隱藏)', }, nodes: { @@ -1006,6 +1023,18 @@ const translation = { description: '上次運行的結果將顯示在這裡', }, variableInspect: { + listening: { + title: '正在監聽觸發器事件…', + tip: '您現在可以向 HTTP {{nodeName}} 端點發送測試請求來模擬事件觸發,或將其作為即時事件除錯的回呼 URL。所有輸出都可在變數檢視器中直接查看。', + tipPlugin: '您現在可以在 {{- pluginName}} 中建立事件,並在變數檢視器中檢視這些事件的輸出。', + tipSchedule: '正在監聽排程觸發器事件。\n下一次排程執行時間:{{nextTriggerTime}}', + tipFallback: '正在等待觸發器事件,輸出會顯示在此處。', + defaultNodeName: '此觸發器', + defaultPluginName: '此插件觸發器', + defaultScheduleTime: '未設定', + selectedTriggers: '已選觸發器', + stopButton: '停止', + }, trigger: { cached: '查看快取的變數', stop: '停止跑步', diff --git a/web/models/app.ts b/web/models/app.ts index 454cc5d1e8..e0f31ff26e 100644 --- a/web/models/app.ts +++ b/web/models/app.ts @@ -1,5 +1,15 @@ -import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, TencentConfig, TracingProvider, WeaveConfig } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' -import type { App, AppMode, AppTemplate, SiteConfig } from '@/types/app' +import type { + AliyunConfig, + ArizeConfig, + LangFuseConfig, + LangSmithConfig, + OpikConfig, + PhoenixConfig, + TencentConfig, + TracingProvider, + WeaveConfig, +} from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' +import type { App, AppModeEnum, AppTemplate, SiteConfig } from '@/types/app' import type { Dependency } from '@/app/components/plugins/types' export enum DSLImportMode { @@ -27,7 +37,7 @@ export type AppDetailResponse = App export type DSLImportResponse = { id: string status: DSLImportStatus - app_mode: AppMode + app_mode: AppModeEnum app_id?: string current_dsl_version?: string imported_dsl_version?: string @@ -111,3 +121,12 @@ export type TracingConfig = { tracing_provider: TracingProvider tracing_config: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | TencentConfig } + +export type WebhookTriggerResponse = { + id: string + webhook_id: string + webhook_url: string + webhook_debug_url: string + node_id: string + created_at: string +} diff --git a/web/models/datasets.ts b/web/models/datasets.ts index 39313d68a3..eb7b7de4a2 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -1,5 +1,5 @@ import type { DataSourceNotionPage, DataSourceProvider } from './common' -import type { AppIconType, AppMode, RetrievalConfig, TransferMethod } from '@/types/app' +import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '@/types/app' import type { Tag } from '@/app/components/base/tag-management/constant' import type { IndexingType } from '@/app/components/datasets/create/step-two' import type { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types' @@ -662,7 +662,7 @@ export type ExternalKnowledgeBaseHitTestingResponse = { export type RelatedApp = { id: string name: string - mode: AppMode + mode: AppModeEnum icon_type: AppIconType | null icon: string icon_background: string diff --git a/web/models/explore.ts b/web/models/explore.ts index ad243e931e..fbbd01837a 100644 --- a/web/models/explore.ts +++ b/web/models/explore.ts @@ -1,7 +1,7 @@ -import type { AppIconType, AppMode } from '@/types/app' +import type { AppIconType, AppModeEnum } from '@/types/app' export type AppBasicInfo = { id: string - mode: AppMode + mode: AppModeEnum icon_type: AppIconType | null icon: string icon_background: string diff --git a/web/models/log.ts b/web/models/log.ts index eff46372d0..baa07a59c4 100644 --- a/web/models/log.ts +++ b/web/models/log.ts @@ -229,11 +229,38 @@ export type AnnotationsCountResponse = { count: number } +export enum WorkflowRunTriggeredFrom { + DEBUGGING = 'debugging', + APP_RUN = 'app-run', + RAG_PIPELINE_RUN = 'rag-pipeline-run', + RAG_PIPELINE_DEBUGGING = 'rag-pipeline-debugging', + WEBHOOK = 'webhook', + SCHEDULE = 'schedule', + PLUGIN = 'plugin', +} + +export type TriggerMetadata = { + type?: string + endpoint_id?: string + plugin_unique_identifier?: string + provider_id?: string + event_name?: string + icon_filename?: string + icon_dark_filename?: string + icon?: string | null + icon_dark?: string | null +} + +export type WorkflowLogDetails = { + trigger_metadata?: TriggerMetadata +} + export type WorkflowRunDetail = { id: string version: string status: 'running' | 'succeeded' | 'failed' | 'stopped' error?: string + triggered_from?: WorkflowRunTriggeredFrom elapsed_time: number total_tokens: number total_price: number @@ -255,6 +282,7 @@ export type EndUserInfo = { export type WorkflowAppLogDetail = { id: string workflow_run: WorkflowRunDetail + details?: WorkflowLogDetails created_from: 'service-api' | 'web-app' | 'explore' created_by_role: 'account' | 'end_user' created_by_account?: AccountInfo diff --git a/web/next.config.js b/web/next.config.js index c4f6fc87b6..212bed0a9c 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -1,4 +1,7 @@ const { codeInspectorPlugin } = require('code-inspector-plugin') + +const isDev = process.env.NODE_ENV === 'development' + const withPWA = require('next-pwa')({ dest: 'public', register: true, @@ -137,6 +140,9 @@ const nextConfig = { ] }, output: 'standalone', + compiler: { + removeConsole: isDev ? false : { exclude: ['warn', 'error'] }, + } } module.exports = withPWA(withBundleAnalyzer(withMDX(nextConfig))) diff --git a/web/package.json b/web/package.json index a43f57b5c5..7e5a84048b 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "dify-web", - "version": "1.9.2", + "version": "1.10.0-rc1", "private": true, "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8", "engines": { @@ -73,6 +73,7 @@ "classnames": "^2.5.1", "cmdk": "^1.1.1", "copy-to-clipboard": "^3.3.3", + "cron-parser": "^5.4.0", "dayjs": "^1.11.19", "decimal.js": "^10.6.0", "dompurify": "^3.3.0", @@ -197,6 +198,7 @@ "sass": "^1.93.2", "storybook": "9.1.13", "tailwindcss": "^3.4.18", + "ts-node": "^10.9.2", "typescript": "^5.9.3", "uglify-js": "^3.19.3" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 73df76a4e1..8e638ed2df 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -101,7 +101,7 @@ importers: version: 0.37.0 '@monaco-editor/react': specifier: ^4.7.0 - version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.7.0(monaco-editor@0.54.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@octokit/core': specifier: ^6.1.6 version: 6.1.6 @@ -147,6 +147,9 @@ importers: copy-to-clipboard: specifier: ^3.3.3 version: 3.3.3 + cron-parser: + specifier: ^5.4.0 + version: 5.4.0 dayjs: specifier: ^1.11.19 version: 1.11.19 @@ -345,7 +348,7 @@ importers: devDependencies: '@antfu/eslint-config': specifier: ^5.4.1 - version: 5.4.1(@eslint-react/eslint-plugin@1.53.1(eslint@9.38.0(jiti@1.21.7))(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3))(@next/eslint-plugin-next@15.5.4)(@vue/compiler-sfc@3.5.17)(eslint-plugin-react-hooks@5.2.0(eslint@9.38.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.24(eslint@9.38.0(jiti@1.21.7)))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + version: 5.4.1(@eslint-react/eslint-plugin@1.53.1(eslint@9.38.0(jiti@1.21.7))(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3))(@next/eslint-plugin-next@15.5.4)(@vue/compiler-sfc@3.5.22)(eslint-plugin-react-hooks@5.2.0(eslint@9.38.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.24(eslint@9.38.0(jiti@1.21.7)))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@babel/core': specifier: ^7.28.4 version: 7.28.4 @@ -492,7 +495,7 @@ importers: version: 29.7.0(@types/node@18.15.0)(ts-node@10.9.2(@types/node@18.15.0)(typescript@5.9.3)) knip: specifier: ^5.66.1 - version: 5.66.1(@types/node@18.15.0)(typescript@5.9.3) + version: 5.66.2(@types/node@18.15.0)(typescript@5.9.3) lint-staged: specifier: ^15.5.2 version: 15.5.2 @@ -514,6 +517,9 @@ importers: tailwindcss: specifier: ^3.4.18 version: 3.4.18(yaml@2.8.1) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@18.15.0)(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1302,11 +1308,11 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} - '@emnapi/core@1.5.0': - resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + '@emnapi/core@1.6.0': + resolution: {integrity: sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==} - '@emnapi/runtime@1.5.0': - resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} + '@emnapi/runtime@1.6.0': + resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -1558,8 +1564,8 @@ packages: resolution: {integrity: sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/markdown@7.4.0': - resolution: {integrity: sha512-VQykmMjBb4tQoJOXVWXa+oQbQeCZlE7W3rAsOpmtpKLvJd75saZZ04PVVs7+zgMDJGghd4/gyFV6YlvdJFaeNQ==} + '@eslint/markdown@7.4.1': + resolution: {integrity: sha512-fhcQcylVqgb7GLPr2+6hlDQXK4J3d/fPY6qzk9/i7IYtQkIr15NKI5Zg39Dv2cV/bn5J0Znm69rmu9vJI/7Tlw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -1570,10 +1576,6 @@ packages: resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.3.5': - resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -2295,98 +2297,98 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} - '@oxc-resolver/binding-android-arm-eabi@11.10.0': - resolution: {integrity: sha512-qvSSjeeBvYh3KlpMwDbLr0m/bmEfEzaAv2yW4RnYDGrsFVgTHlNc3WzQSji0+Bf2g3kLgyZ5pwylaJpS9baUIA==} + '@oxc-resolver/binding-android-arm-eabi@11.11.0': + resolution: {integrity: sha512-aN0UJg1xr0N1dADQ135z4p3bP9AYAUN1Ey2VvLMK6IwWYIJGWpKT+cr1l3AiyBeLK8QZyFDb4IDU8LHgjO9TDQ==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm64@11.10.0': - resolution: {integrity: sha512-rjiCqkhH1di5Sb/KpOmuC/1OCGZVDdUyVIxxPsmzkdgrTgS6Of5cwOHTBVNxXuVdlIMz0swN8wrmqUM9jspPAQ==} + '@oxc-resolver/binding-android-arm64@11.11.0': + resolution: {integrity: sha512-FckvvMclo8CSJqQjKpHueIIbKrg9L638NKWQTiJQaD8W9F61h8hTjF8+QFLlCHh6R9RcE5roVHdkkiBKHlB2Zw==} cpu: [arm64] os: [android] - '@oxc-resolver/binding-darwin-arm64@11.10.0': - resolution: {integrity: sha512-qr2+vw0BKxZVuaw3Ssbzfe0999FYs5BkKqezP8ocwYE9pJUC4hNlWUWhGLDxj0tBSjMEFvWQNF7IxCeZk6nzKw==} + '@oxc-resolver/binding-darwin-arm64@11.11.0': + resolution: {integrity: sha512-7ZcpgaXSBnwRHM1YR8Vazq7mCTtGdYRvM7k46CscA+oipCVqmI4LbW2wLsc6HVjqX+SM/KPOfFGoGjEgmQPFTQ==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.10.0': - resolution: {integrity: sha512-2XFEd89yVnnkk7u0LACdXsiHDN3rMthzcdSHj2VROaItiAW6qfKy+SJwLK94lYCVv9nFjxJUVHiVJUsKIn70tQ==} + '@oxc-resolver/binding-darwin-x64@11.11.0': + resolution: {integrity: sha512-Wsd1JWORokMmOKrR4t4jxpwYEWG11+AHWu9bdzjCO5EIyi0AuNpPIAEcEFCP9FNd0h8c+VUYbMRU/GooD2zOIg==} cpu: [x64] os: [darwin] - '@oxc-resolver/binding-freebsd-x64@11.10.0': - resolution: {integrity: sha512-EHapmlf+bg92Pf3+0E0nYSKQgQ5u2V++KXB0WTushFJSU+k6gXEL/P/y1QwKqzJ986Q14YWHh7IiT/nQvpaz4Q==} + '@oxc-resolver/binding-freebsd-x64@11.11.0': + resolution: {integrity: sha512-YX+W10kHrMouu/+Y+rqJdCWO3dFBKM1DIils30PHsmXWp1v+ZZvhibaST2BP6zrWkWquZ8pMmsObD6N10lLgiA==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.10.0': - resolution: {integrity: sha512-NhSAeelg0EU4ymM8XrUfGJL74jBHs2Q3WdVbXIve+ROge0UAB7yXpk40u7quIOmbyqAEUp/QPlhtEmWc+lWcPg==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.11.0': + resolution: {integrity: sha512-UAhlhVkW2ui98bClmEkDLKQz4XBSccxMahG7rMeX2RepS2QByAWxYFFThaNbHtBSB+B4Rc1hudkihq8grQkU3g==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.10.0': - resolution: {integrity: sha512-9rjZigo5/92O3jayjucIdhhq4eJBgf61K9UZZF1r1uoIhS4i0wz7W29gMWkCVYbwZAfkHxfmTn3zu8Vv34NvUQ==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.11.0': + resolution: {integrity: sha512-5pEliabSEiimXz/YyPxzyBST82q8PbM6BoEMS8kOyaDbEBuzTr7pWU1U0F7ILGBFjJmHaj3N7IAhQgeXdpdySg==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm64-gnu@11.10.0': - resolution: {integrity: sha512-73pz+sYfPfMzl8OVdjsWJXu5LO868LBpy8M/a/m4a7HUREwBz1/CK59ifxhbIkIeAv2ZkhwKiouFxsKmCsQRrw==} + '@oxc-resolver/binding-linux-arm64-gnu@11.11.0': + resolution: {integrity: sha512-CiyufPFIOJrW/HovAMGsH0AbV7BSCb0oE0KDtt7z1+e+qsDo7HRlTSnqE3JbNuhJRg3Cz/j7qEYzgGqco9SE4Q==} cpu: [arm64] os: [linux] - '@oxc-resolver/binding-linux-arm64-musl@11.10.0': - resolution: {integrity: sha512-s8AMNkiguFn2XJtnAaSHl+ak97Zwkq6biouUNuApDRZh34ckAjWxPTQRhUZLCFybNxgZtwVbglVQv0BJYieIXg==} + '@oxc-resolver/binding-linux-arm64-musl@11.11.0': + resolution: {integrity: sha512-w07MfGtDLZV0rISdXl2cGASxD/sRrrR93Qd4q27O2Hsky4MGbLw94trbzhmAkc7OKoJI0iDg1217i3jfxmVk1Q==} cpu: [arm64] os: [linux] - '@oxc-resolver/binding-linux-ppc64-gnu@11.10.0': - resolution: {integrity: sha512-70eHfsX9Xw+wGqmwFhlIxT/LhzGDlnI4ECQ7w0VLZsYpAUjRiQPUQCDKkfP65ikzHPSLeY8pARKVIc2gdC0HEA==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.11.0': + resolution: {integrity: sha512-gzM+ZfIjfcCofwX/m1eLCoTT+3T70QLWaKDOW5Hf3+ddLlxMEVRIQtUoRsp0e/VFanr7u7VKS57TxhkRubseNg==} cpu: [ppc64] os: [linux] - '@oxc-resolver/binding-linux-riscv64-gnu@11.10.0': - resolution: {integrity: sha512-geibi+L5hKmDwZ9iLEUzuvRG4o6gZWB8shlNBLiKnGtYD5SMAvCcJiHpz1Sf6ESm8laXjiIf6T/pTZZpaeStyw==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.11.0': + resolution: {integrity: sha512-oCR0ImJQhIwmqwNShsRT0tGIgKF5/H4nhtIEkQAQ9bLzMgjtRqIrZ3DtGHqd7w58zhXWfIZdyPNF9IrSm+J/fQ==} cpu: [riscv64] os: [linux] - '@oxc-resolver/binding-linux-riscv64-musl@11.10.0': - resolution: {integrity: sha512-oL1B0jGu9vYoQKyJiMvjtuxDzmV9P8M/xdu6wjUjvaGC/gIwvhILzlHgD3SMtFJJhzLVf4HPmYAF7BsLWvTugA==} + '@oxc-resolver/binding-linux-riscv64-musl@11.11.0': + resolution: {integrity: sha512-MjCEqsUzXMfWPfsEUX+UXttzXz6xiNU11r7sj00C5og/UCyqYw1OjrbC/B1f/dloDpTn0rd4xy6c/LTvVQl2tg==} cpu: [riscv64] os: [linux] - '@oxc-resolver/binding-linux-s390x-gnu@11.10.0': - resolution: {integrity: sha512-Sj6ooR4RZ+04SSc/iV7oK8C2TxoWzJbD5yirsF64ULFukTvQHz99ImjtwgauBUnR+3loyca3s6o8DiAmqHaxAw==} + '@oxc-resolver/binding-linux-s390x-gnu@11.11.0': + resolution: {integrity: sha512-4TaTX7gT3357vWQsTe3IfDtWyJNe0FejypQ4ngwxB3v1IVaW6KAUt0huSvx/tmj+YWxd3zzXdWd8AzW0jo6dpg==} cpu: [s390x] os: [linux] - '@oxc-resolver/binding-linux-x64-gnu@11.10.0': - resolution: {integrity: sha512-wH5nPRgIaEhuOD9M70NujV91FscboRkNf38wKAYiy9xuKeVsc43JzFqvmgxU1vXsKwUJBc/qMt4nFNluLXwVzw==} + '@oxc-resolver/binding-linux-x64-gnu@11.11.0': + resolution: {integrity: sha512-ch1o3+tBra9vmrgXqrufVmYnvRPFlyUb7JWs/VXndBmyNSuP2KP+guAUrC0fr2aSGoOQOasAiZza7MTFU7Vrxg==} cpu: [x64] os: [linux] - '@oxc-resolver/binding-linux-x64-musl@11.10.0': - resolution: {integrity: sha512-rDrv1Joh6hAidV/hixAA1+6keNr1aJA3rUU6VD8mqTedbUMV1CdQJ55f9UmQZn0nO35tQvwF0eLBNmumErCNLw==} + '@oxc-resolver/binding-linux-x64-musl@11.11.0': + resolution: {integrity: sha512-llTdl2gJAqXaGV7iV1w5BVlqXACcoT1YD3o840pCQx1ZmKKAAz7ydPnTjYVdkGImXNWPOIWJixHW0ryDm4Mx7w==} cpu: [x64] os: [linux] - '@oxc-resolver/binding-wasm32-wasi@11.10.0': - resolution: {integrity: sha512-VE+fuYPMqObhwEoLOUp9UgebrMFBBCuvCBY+auk+o3bFWOYXLpvCa5PzC4ttF7gOotQD/TWqbVWtfOh0CdBSHw==} + '@oxc-resolver/binding-wasm32-wasi@11.11.0': + resolution: {integrity: sha512-cROavohP0nX91NtIVVgOTugqoxlUSNxI9j7MD+B7fmD3gEFl8CVyTamR0/p6loDxLv51bQYTHRKn/ZYTd3ENzw==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-win32-arm64-msvc@11.10.0': - resolution: {integrity: sha512-M70Fr5P1SnQY4vm7ZTeodE27mDV6zqxLkQMHF4t43xt55dIFIlHiRTgCzykiI9ggan3M1YWffLeB97Q3X2yxSg==} + '@oxc-resolver/binding-win32-arm64-msvc@11.11.0': + resolution: {integrity: sha512-6amVs34yHmxE6Q3CtTPXnSvIYGqwQJ/lVVRYccLzg9smge3WJ1knyBV5jpKKayp0n316uPYzB4EgEbgcuRvrPw==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-ia32-msvc@11.10.0': - resolution: {integrity: sha512-UJfRwzXAAIduNJa0cZlwT8L8eAOSX85VfKQ0i0NCJWNjwFzjeeOpvd/vNXMd1jmYU22a8fulFX3k8AzdwI7wYw==} + '@oxc-resolver/binding-win32-ia32-msvc@11.11.0': + resolution: {integrity: sha512-v/IZ5s2/3auHUoi0t6Ea1CDsWxrE9BvgvbDcJ04QX+nEbmTBazWPZeLsH8vWkRAh8EUKCZHXxjQsPhEH5Yk5pQ==} cpu: [ia32] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@11.10.0': - resolution: {integrity: sha512-Q8gwXHjDeEokECEFCECkJW1OEOEgfFUGoLZs88jDpZ/QmdBklH/SbMLKJdYeIPztQ6HD069GAVPnP3WcXyHoUA==} + '@oxc-resolver/binding-win32-x64-msvc@11.11.0': + resolution: {integrity: sha512-qvm+IQ6r2q4HZitSV69O+OmvCD1y4pH7SbhR6lPwLsfZS5QRHS8V20VHxmG1jJzSPPw7S8Bb1rdNcxDSqc4bYA==} cpu: [x64] os: [win32] @@ -3082,8 +3084,8 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} - '@types/chai@5.2.2': - resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -3340,63 +3342,63 @@ packages: '@types/yargs@17.0.33': resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - '@typescript-eslint/eslint-plugin@8.46.1': - resolution: {integrity: sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==} + '@typescript-eslint/eslint-plugin@8.46.2': + resolution: {integrity: sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.46.1 + '@typescript-eslint/parser': ^8.46.2 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.46.1': - resolution: {integrity: sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==} + '@typescript-eslint/parser@8.46.2': + resolution: {integrity: sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.46.1': - resolution: {integrity: sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==} + '@typescript-eslint/project-service@8.46.2': + resolution: {integrity: sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.46.1': - resolution: {integrity: sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==} + '@typescript-eslint/scope-manager@8.46.2': + resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.46.1': - resolution: {integrity: sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==} + '@typescript-eslint/tsconfig-utils@8.46.2': + resolution: {integrity: sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.46.1': - resolution: {integrity: sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==} + '@typescript-eslint/type-utils@8.46.2': + resolution: {integrity: sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.46.1': - resolution: {integrity: sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==} + '@typescript-eslint/types@8.46.2': + resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.46.1': - resolution: {integrity: sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==} + '@typescript-eslint/typescript-estree@8.46.2': + resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.46.1': - resolution: {integrity: sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==} + '@typescript-eslint/utils@8.46.2': + resolution: {integrity: sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.46.1': - resolution: {integrity: sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==} + '@typescript-eslint/visitor-keys@8.46.2': + resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -3438,26 +3440,17 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vue/compiler-core@3.5.17': - resolution: {integrity: sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA==} - '@vue/compiler-core@3.5.22': resolution: {integrity: sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==} - '@vue/compiler-dom@3.5.17': - resolution: {integrity: sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ==} - '@vue/compiler-dom@3.5.22': resolution: {integrity: sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==} - '@vue/compiler-sfc@3.5.17': - resolution: {integrity: sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww==} + '@vue/compiler-sfc@3.5.22': + resolution: {integrity: sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==} - '@vue/compiler-ssr@3.5.17': - resolution: {integrity: sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ==} - - '@vue/shared@3.5.17': - resolution: {integrity: sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==} + '@vue/compiler-ssr@3.5.22': + resolution: {integrity: sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==} '@vue/shared@3.5.22': resolution: {integrity: sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==} @@ -4205,6 +4198,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron-parser@5.4.0: + resolution: {integrity: sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==} + engines: {node: '>=18'} + cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -4558,6 +4555,9 @@ packages: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} + dompurify@3.1.7: + resolution: {integrity: sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==} + dompurify@3.3.0: resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==} @@ -5948,8 +5948,8 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - knip@5.66.1: - resolution: {integrity: sha512-Ad3VUPIk9GZYovKuwKtGMheupek7IoPGaDEBAvnCYLKJXnwmqNLyXqMp+l5r3OOpFVjF7DdkFIZFVrXESDNylQ==} + knip@5.66.2: + resolution: {integrity: sha512-5wvsdc17C5bMxjuGfN9KVS/tW5KIvzP1RClfpTMdLYm8IXIsfWsiHlFkTvZIca9skwoVDyTyXmbRq4w1Poim+A==} engines: {node: '>=18.18.0'} hasBin: true peerDependencies: @@ -6087,6 +6087,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -6124,6 +6128,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + marked@15.0.12: resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} engines: {node: '>= 18'} @@ -6399,8 +6408,8 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - monaco-editor@0.52.2: - resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + monaco-editor@0.54.0: + resolution: {integrity: sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==} mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} @@ -6561,8 +6570,8 @@ packages: os-browserify@0.3.0: resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} - oxc-resolver@11.10.0: - resolution: {integrity: sha512-LNJkji0qsBvZ7+yze3S1qsWufZ3VBcyU1wAnC5bBP0QzHsKf4rrNhG5I4c0RIDQGKsKDpVWh8vhUAGE3cb53kA==} + oxc-resolver@11.11.0: + resolution: {integrity: sha512-vVeBJf77zBeqOA/LBCTO/pr0/ETHGSleCRsI5Kmsf2OsfB5opzhhZptt6VxkqjKWZH+eF1se88fYDG5DGRLjkg==} p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} @@ -6704,6 +6713,10 @@ packages: resolution: {integrity: sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA==} engines: {node: '>=0.12'} + pbkdf2@3.1.5: + resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==} + engines: {node: '>= 0.10'} + pdfjs-dist@4.4.168: resolution: {integrity: sha512-MbkAjpwka/dMHaCfQ75RY1FXX3IewBVu6NGZOcxerRFlaBiIkZmUoR0jotX5VUzYZEXAGzSFtknWs5xRKliXPA==} engines: {node: '>=18'} @@ -7321,8 +7334,8 @@ packages: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} hasBin: true @@ -8429,15 +8442,15 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@antfu/eslint-config@5.4.1(@eslint-react/eslint-plugin@1.53.1(eslint@9.38.0(jiti@1.21.7))(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3))(@next/eslint-plugin-next@15.5.4)(@vue/compiler-sfc@3.5.17)(eslint-plugin-react-hooks@5.2.0(eslint@9.38.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.24(eslint@9.38.0(jiti@1.21.7)))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': + '@antfu/eslint-config@5.4.1(@eslint-react/eslint-plugin@1.53.1(eslint@9.38.0(jiti@1.21.7))(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3))(@next/eslint-plugin-next@15.5.4)(@vue/compiler-sfc@3.5.22)(eslint-plugin-react-hooks@5.2.0(eslint@9.38.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.24(eslint@9.38.0(jiti@1.21.7)))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 0.11.0 '@eslint-community/eslint-plugin-eslint-comments': 4.5.0(eslint@9.38.0(jiti@1.21.7)) - '@eslint/markdown': 7.4.0 + '@eslint/markdown': 7.4.1 '@stylistic/eslint-plugin': 5.5.0(eslint@9.38.0(jiti@1.21.7)) - '@typescript-eslint/eslint-plugin': 8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@vitest/eslint-plugin': 1.3.23(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) ansis: 4.2.0 cac: 6.7.14 @@ -8457,10 +8470,10 @@ snapshots: eslint-plugin-regexp: 2.10.0(eslint@9.38.0(jiti@1.21.7)) eslint-plugin-toml: 0.12.0(eslint@9.38.0(jiti@1.21.7)) eslint-plugin-unicorn: 61.0.2(eslint@9.38.0(jiti@1.21.7)) - eslint-plugin-unused-imports: 4.3.0(@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7)) - eslint-plugin-vue: 10.5.1(@stylistic/eslint-plugin@5.5.0(eslint@9.38.0(jiti@1.21.7)))(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.38.0(jiti@1.21.7))) + eslint-plugin-unused-imports: 4.3.0(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7)) + eslint-plugin-vue: 10.5.1(@stylistic/eslint-plugin@5.5.0(eslint@9.38.0(jiti@1.21.7)))(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.38.0(jiti@1.21.7))) eslint-plugin-yml: 1.19.0(eslint@9.38.0(jiti@1.21.7)) - eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.17)(eslint@9.38.0(jiti@1.21.7)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.22)(eslint@9.38.0(jiti@1.21.7)) globals: 16.4.0 jsonc-eslint-parser: 2.4.1 local-pkg: 1.1.2 @@ -8569,7 +8582,7 @@ snapshots: '@babel/helper-plugin-utils': 7.27.1 debug: 4.4.3 lodash.debounce: 4.0.8 - resolve: 1.22.10 + resolve: 1.22.11 transitivePeerDependencies: - supports-color @@ -9408,17 +9421,16 @@ snapshots: '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 - optional: true '@discoveryjs/json-ext@0.5.7': {} - '@emnapi/core@1.5.0': + '@emnapi/core@1.6.0': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.5.0': + '@emnapi/runtime@1.6.0': dependencies: tslib: 2.8.1 optional: true @@ -9435,7 +9447,7 @@ snapshots: '@es-joy/jsdoccomment@0.50.2': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/types': 8.46.2 comment-parser: 1.4.1 esquery: 1.6.0 jsdoc-type-pratt-parser: 4.1.0 @@ -9443,7 +9455,7 @@ snapshots: '@es-joy/jsdoccomment@0.58.0': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/types': 8.46.2 comment-parser: 1.4.1 esquery: 1.6.0 jsdoc-type-pratt-parser: 5.4.0 @@ -9539,9 +9551,9 @@ snapshots: '@eslint-react/ast@1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-react/eff': 1.53.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) string-ts: 2.2.1 ts-pattern: 5.8.0 transitivePeerDependencies: @@ -9556,10 +9568,10 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) birecord: 0.1.1 ts-pattern: 5.8.0 transitivePeerDependencies: @@ -9574,10 +9586,10 @@ snapshots: '@eslint-react/eff': 1.53.1 '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) eslint-plugin-react-debug: 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint-plugin-react-dom: 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) @@ -9594,7 +9606,7 @@ snapshots: '@eslint-react/kit@1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-react/eff': 1.53.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) ts-pattern: 5.8.0 zod: 4.1.12 transitivePeerDependencies: @@ -9606,7 +9618,7 @@ snapshots: dependencies: '@eslint-react/eff': 1.53.1 '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) ts-pattern: 5.8.0 zod: 4.1.12 transitivePeerDependencies: @@ -9618,9 +9630,9 @@ snapshots: dependencies: '@eslint-react/ast': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/eff': 1.53.1 - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) string-ts: 2.2.1 ts-pattern: 5.8.0 transitivePeerDependencies: @@ -9670,10 +9682,10 @@ snapshots: '@eslint/js@9.38.0': {} - '@eslint/markdown@7.4.0': + '@eslint/markdown@7.4.1': dependencies: '@eslint/core': 0.16.0 - '@eslint/plugin-kit': 0.3.5 + '@eslint/plugin-kit': 0.3.4 github-slugger: 2.0.0 mdast-util-from-markdown: 2.0.2 mdast-util-frontmatter: 2.0.1 @@ -9691,11 +9703,6 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 - '@eslint/plugin-kit@0.3.5': - dependencies: - '@eslint/core': 0.15.2 - levn: 0.4.1 - '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -9926,12 +9933,12 @@ snapshots: '@img/sharp-wasm32@0.33.5': dependencies: - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.6.0 optional: true '@img/sharp-wasm32@0.34.4': dependencies: - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.6.0 optional: true '@img/sharp-win32-arm64@0.34.4': @@ -10164,7 +10171,6 @@ snapshots: dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - optional: true '@lexical/clipboard@0.36.2': dependencies: @@ -10425,17 +10431,17 @@ snapshots: dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + '@monaco-editor/react@4.7.0(monaco-editor@0.54.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@monaco-editor/loader': 1.5.0 - monaco-editor: 0.52.2 + monaco-editor: 0.54.0 react: 19.1.1 react-dom: 19.1.1(react@19.1.1) '@napi-rs/wasm-runtime@1.0.7': dependencies: - '@emnapi/core': 1.5.0 - '@emnapi/runtime': 1.5.0 + '@emnapi/core': 1.6.0 + '@emnapi/runtime': 1.6.0 '@tybys/wasm-util': 0.10.1 optional: true @@ -10588,63 +10594,63 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 - '@oxc-resolver/binding-android-arm-eabi@11.10.0': + '@oxc-resolver/binding-android-arm-eabi@11.11.0': optional: true - '@oxc-resolver/binding-android-arm64@11.10.0': + '@oxc-resolver/binding-android-arm64@11.11.0': optional: true - '@oxc-resolver/binding-darwin-arm64@11.10.0': + '@oxc-resolver/binding-darwin-arm64@11.11.0': optional: true - '@oxc-resolver/binding-darwin-x64@11.10.0': + '@oxc-resolver/binding-darwin-x64@11.11.0': optional: true - '@oxc-resolver/binding-freebsd-x64@11.10.0': + '@oxc-resolver/binding-freebsd-x64@11.11.0': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.10.0': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.11.0': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.10.0': + '@oxc-resolver/binding-linux-arm-musleabihf@11.11.0': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.10.0': + '@oxc-resolver/binding-linux-arm64-gnu@11.11.0': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.10.0': + '@oxc-resolver/binding-linux-arm64-musl@11.11.0': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.10.0': + '@oxc-resolver/binding-linux-ppc64-gnu@11.11.0': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.10.0': + '@oxc-resolver/binding-linux-riscv64-gnu@11.11.0': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.10.0': + '@oxc-resolver/binding-linux-riscv64-musl@11.11.0': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.10.0': + '@oxc-resolver/binding-linux-s390x-gnu@11.11.0': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.10.0': + '@oxc-resolver/binding-linux-x64-gnu@11.11.0': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.10.0': + '@oxc-resolver/binding-linux-x64-musl@11.11.0': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.10.0': + '@oxc-resolver/binding-wasm32-wasi@11.11.0': dependencies: '@napi-rs/wasm-runtime': 1.0.7 optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.10.0': + '@oxc-resolver/binding-win32-arm64-msvc@11.11.0': optional: true - '@oxc-resolver/binding-win32-ia32-msvc@11.10.0': + '@oxc-resolver/binding-win32-ia32-msvc@11.11.0': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.10.0': + '@oxc-resolver/binding-win32-x64-msvc@11.11.0': optional: true '@parcel/watcher-android-arm64@2.5.1': @@ -11026,7 +11032,7 @@ snapshots: builtin-modules: 3.3.0 deepmerge: 4.3.1 is-module: 1.0.0 - resolve: 1.22.10 + resolve: 1.22.11 rollup: 2.79.2 '@rollup/plugin-replace@2.4.2(rollup@2.79.2)': @@ -11232,7 +11238,7 @@ snapshots: react: 19.1.1 react-docgen: 7.1.1 react-dom: 19.1.1(react@19.1.1) - resolve: 1.22.10 + resolve: 1.22.11 semver: 7.7.3 storybook: 9.1.13(@testing-library/dom@10.4.1) tsconfig-paths: 4.2.0 @@ -11279,7 +11285,7 @@ snapshots: '@stylistic/eslint-plugin@5.5.0(eslint@9.38.0(jiti@1.21.7))': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@1.21.7)) - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/types': 8.46.2 eslint: 9.38.0(jiti@1.21.7) eslint-visitor-keys: 4.2.1 espree: 10.4.0 @@ -11395,17 +11401,13 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tsconfig/node10@1.0.11': - optional: true + '@tsconfig/node10@1.0.11': {} - '@tsconfig/node12@1.0.11': - optional: true + '@tsconfig/node12@1.0.11': {} - '@tsconfig/node14@1.0.3': - optional: true + '@tsconfig/node14@1.0.3': {} - '@tsconfig/node16@1.0.4': - optional: true + '@tsconfig/node16@1.0.4': {} '@tybys/wasm-util@0.10.1': dependencies: @@ -11442,9 +11444,10 @@ snapshots: '@types/node': 18.15.0 '@types/responselike': 1.0.3 - '@types/chai@5.2.2': + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 '@types/d3-array@3.2.2': {} @@ -11723,14 +11726,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.1 + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.2 eslint: 9.38.0(jiti@1.21.7) graphemer: 1.4.0 ignore: 7.0.5 @@ -11740,41 +11743,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.1 + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.2 debug: 4.4.3 eslint: 9.38.0(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.46.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.1(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.46.1': + '@typescript-eslint/scope-manager@8.46.2': dependencies: - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/visitor-keys': 8.46.1 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/visitor-keys': 8.46.2 - '@typescript-eslint/tsconfig-utils@8.46.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 eslint: 9.38.0(jiti@1.21.7) ts-api-utils: 2.1.0(typescript@5.9.3) @@ -11782,14 +11785,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.46.1': {} + '@typescript-eslint/types@8.46.2': {} - '@typescript-eslint/typescript-estree@8.46.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.46.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.46.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.1(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/visitor-keys': 8.46.1 + '@typescript-eslint/project-service': 8.46.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/visitor-keys': 8.46.2 debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 @@ -11800,28 +11803,28 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.46.1': + '@typescript-eslint/visitor-keys@8.46.2': dependencies: - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/types': 8.46.2 eslint-visitor-keys: 4.2.1 '@ungap/structured-clone@1.3.0': {} '@vitest/eslint-plugin@1.3.23(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) optionalDependencies: typescript: 5.9.3 @@ -11830,7 +11833,7 @@ snapshots: '@vitest/expect@3.2.4': dependencies: - '@types/chai': 5.2.2 + '@types/chai': 5.2.3 '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 @@ -11856,14 +11859,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vue/compiler-core@3.5.17': - dependencies: - '@babel/parser': 7.28.5 - '@vue/shared': 3.5.17 - entities: 4.5.0 - estree-walker: 2.0.2 - source-map-js: 1.2.1 - '@vue/compiler-core@3.5.22': dependencies: '@babel/parser': 7.28.4 @@ -11872,34 +11867,27 @@ snapshots: estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.17': - dependencies: - '@vue/compiler-core': 3.5.17 - '@vue/shared': 3.5.17 - '@vue/compiler-dom@3.5.22': dependencies: '@vue/compiler-core': 3.5.22 '@vue/shared': 3.5.22 - '@vue/compiler-sfc@3.5.17': + '@vue/compiler-sfc@3.5.22': dependencies: '@babel/parser': 7.28.5 - '@vue/compiler-core': 3.5.17 - '@vue/compiler-dom': 3.5.17 - '@vue/compiler-ssr': 3.5.17 - '@vue/shared': 3.5.17 + '@vue/compiler-core': 3.5.22 + '@vue/compiler-dom': 3.5.22 + '@vue/compiler-ssr': 3.5.22 + '@vue/shared': 3.5.22 estree-walker: 2.0.2 magic-string: 0.30.21 postcss: 8.5.6 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.17': + '@vue/compiler-ssr@3.5.22': dependencies: - '@vue/compiler-dom': 3.5.17 - '@vue/shared': 3.5.17 - - '@vue/shared@3.5.17': {} + '@vue/compiler-dom': 3.5.22 + '@vue/shared': 3.5.22 '@vue/shared@3.5.22': {} @@ -12085,8 +12073,7 @@ snapshots: are-docs-informative@0.0.2: {} - arg@4.1.3: - optional: true + arg@4.1.3: {} arg@5.0.2: {} @@ -12710,8 +12697,11 @@ snapshots: - supports-color - ts-node - create-require@1.1.1: - optional: true + create-require@1.1.1: {} + + cron-parser@5.4.0: + dependencies: + luxon: 3.7.2 cross-env@10.1.0: dependencies: @@ -13033,8 +13023,7 @@ snapshots: diff-sequences@29.6.3: {} - diff@4.0.2: - optional: true + diff@4.0.2: {} diffie-hellman@5.0.3: dependencies: @@ -13074,6 +13063,8 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.1.7: {} + dompurify@3.3.0: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -13275,7 +13266,7 @@ snapshots: eslint-plugin-import-lite@0.3.0(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@1.21.7)) - '@typescript-eslint/types': 8.46.1 + '@typescript-eslint/types': 8.46.2 eslint: 9.38.0(jiti@1.21.7) optionalDependencies: typescript: 5.9.3 @@ -13335,8 +13326,8 @@ snapshots: eslint-plugin-perfectionist@4.15.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) natural-orderby: 5.0.0 transitivePeerDependencies: @@ -13361,10 +13352,10 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) string-ts: 2.2.1 ts-pattern: 5.8.0 @@ -13381,9 +13372,9 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) compare-versions: 6.1.1 eslint: 9.38.0(jiti@1.21.7) string-ts: 2.2.1 @@ -13401,10 +13392,10 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) string-ts: 2.2.1 ts-pattern: 5.8.0 @@ -13425,10 +13416,10 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) string-ts: 2.2.1 ts-pattern: 5.8.0 @@ -13449,9 +13440,9 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) string-ts: 2.2.1 ts-pattern: 5.8.0 @@ -13468,10 +13459,10 @@ snapshots: '@eslint-react/kit': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/shared': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) '@eslint-react/var': 1.53.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.1 - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.1 - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.2 + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) compare-versions: 6.1.1 eslint: 9.38.0(jiti@1.21.7) is-immutable-type: 5.0.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) @@ -13510,7 +13501,7 @@ snapshots: eslint-plugin-storybook@9.1.13(eslint@9.38.0(jiti@1.21.7))(storybook@9.1.13(@testing-library/dom@10.4.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) storybook: 9.1.13(@testing-library/dom@10.4.1) transitivePeerDependencies: @@ -13555,13 +13546,13 @@ snapshots: semver: 7.7.3 strip-indent: 4.1.1 - eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7)): + eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7)): dependencies: eslint: 9.38.0(jiti@1.21.7) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) - eslint-plugin-vue@10.5.1(@stylistic/eslint-plugin@5.5.0(eslint@9.38.0(jiti@1.21.7)))(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.38.0(jiti@1.21.7))): + eslint-plugin-vue@10.5.1(@stylistic/eslint-plugin@5.5.0(eslint@9.38.0(jiti@1.21.7)))(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3))(eslint@9.38.0(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.38.0(jiti@1.21.7))): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@1.21.7)) eslint: 9.38.0(jiti@1.21.7) @@ -13573,7 +13564,7 @@ snapshots: xml-name-validator: 4.0.0 optionalDependencies: '@stylistic/eslint-plugin': 5.5.0(eslint@9.38.0(jiti@1.21.7)) - '@typescript-eslint/parser': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint-plugin-yml@1.19.0(eslint@9.38.0(jiti@1.21.7)): dependencies: @@ -13587,9 +13578,9 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.17)(eslint@9.38.0(jiti@1.21.7)): + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.22)(eslint@9.38.0(jiti@1.21.7)): dependencies: - '@vue/compiler-sfc': 3.5.17 + '@vue/compiler-sfc': 3.5.22 eslint: 9.38.0(jiti@1.21.7) eslint-scope@5.1.1: @@ -13615,7 +13606,7 @@ snapshots: '@eslint/core': 0.16.0 '@eslint/eslintrc': 3.3.1 '@eslint/js': 9.38.0 - '@eslint/plugin-kit': 0.3.5 + '@eslint/plugin-kit': 0.3.4 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -14396,7 +14387,7 @@ snapshots: is-immutable-type@5.0.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/type-utils': 8.46.1(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.46.2(eslint@9.38.0(jiti@1.21.7))(typescript@5.9.3) eslint: 9.38.0(jiti@1.21.7) ts-api-utils: 2.1.0(typescript@5.9.3) ts-declaration-location: 1.0.7(typescript@5.9.3) @@ -14670,7 +14661,7 @@ snapshots: jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) jest-util: 29.7.0 jest-validate: 29.7.0 - resolve: 1.22.10 + resolve: 1.22.11 resolve.exports: 2.0.3 slash: 3.0.0 @@ -14888,7 +14879,7 @@ snapshots: kleur@3.0.3: {} - knip@5.66.1(@types/node@18.15.0)(typescript@5.9.3): + knip@5.66.2(@types/node@18.15.0)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 '@types/node': 18.15.0 @@ -14897,7 +14888,7 @@ snapshots: jiti: 2.6.1 js-yaml: 4.1.0 minimist: 1.2.8 - oxc-resolver: 11.10.0 + oxc-resolver: 11.11.0 picocolors: 1.1.1 picomatch: 4.0.3 smol-toml: 1.4.2 @@ -15044,6 +15035,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + lz-string@1.5.0: {} magic-string@0.25.9: @@ -15072,8 +15065,7 @@ snapshots: dependencies: semver: 7.7.3 - make-error@1.3.6: - optional: true + make-error@1.3.6: {} makeerror@1.0.12: dependencies: @@ -15083,6 +15075,8 @@ snapshots: markdown-table@3.0.4: {} + marked@14.0.0: {} + marked@15.0.12: {} md5.js@1.3.5: @@ -15664,7 +15658,10 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 - monaco-editor@0.52.2: {} + monaco-editor@0.54.0: + dependencies: + dompurify: 3.1.7 + marked: 14.0.0 mrmime@2.0.1: {} @@ -15849,27 +15846,27 @@ snapshots: os-browserify@0.3.0: {} - oxc-resolver@11.10.0: + oxc-resolver@11.11.0: optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.10.0 - '@oxc-resolver/binding-android-arm64': 11.10.0 - '@oxc-resolver/binding-darwin-arm64': 11.10.0 - '@oxc-resolver/binding-darwin-x64': 11.10.0 - '@oxc-resolver/binding-freebsd-x64': 11.10.0 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.10.0 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.10.0 - '@oxc-resolver/binding-linux-arm64-gnu': 11.10.0 - '@oxc-resolver/binding-linux-arm64-musl': 11.10.0 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.10.0 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.10.0 - '@oxc-resolver/binding-linux-riscv64-musl': 11.10.0 - '@oxc-resolver/binding-linux-s390x-gnu': 11.10.0 - '@oxc-resolver/binding-linux-x64-gnu': 11.10.0 - '@oxc-resolver/binding-linux-x64-musl': 11.10.0 - '@oxc-resolver/binding-wasm32-wasi': 11.10.0 - '@oxc-resolver/binding-win32-arm64-msvc': 11.10.0 - '@oxc-resolver/binding-win32-ia32-msvc': 11.10.0 - '@oxc-resolver/binding-win32-x64-msvc': 11.10.0 + '@oxc-resolver/binding-android-arm-eabi': 11.11.0 + '@oxc-resolver/binding-android-arm64': 11.11.0 + '@oxc-resolver/binding-darwin-arm64': 11.11.0 + '@oxc-resolver/binding-darwin-x64': 11.11.0 + '@oxc-resolver/binding-freebsd-x64': 11.11.0 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.11.0 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.11.0 + '@oxc-resolver/binding-linux-arm64-gnu': 11.11.0 + '@oxc-resolver/binding-linux-arm64-musl': 11.11.0 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.11.0 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.11.0 + '@oxc-resolver/binding-linux-riscv64-musl': 11.11.0 + '@oxc-resolver/binding-linux-s390x-gnu': 11.11.0 + '@oxc-resolver/binding-linux-x64-gnu': 11.11.0 + '@oxc-resolver/binding-linux-x64-musl': 11.11.0 + '@oxc-resolver/binding-wasm32-wasi': 11.11.0 + '@oxc-resolver/binding-win32-arm64-msvc': 11.11.0 + '@oxc-resolver/binding-win32-ia32-msvc': 11.11.0 + '@oxc-resolver/binding-win32-x64-msvc': 11.11.0 p-cancelable@2.1.1: {} @@ -15923,7 +15920,7 @@ snapshots: asn1.js: 4.10.1 browserify-aes: 1.2.0 evp_bytestokey: 1.0.3 - pbkdf2: 3.1.3 + pbkdf2: 3.1.5 safe-buffer: 5.2.1 parse-entities@2.0.0: @@ -16010,6 +16007,15 @@ snapshots: sha.js: 2.4.12 to-buffer: 1.2.2 + pbkdf2@3.1.5: + dependencies: + create-hash: 1.2.0 + create-hmac: 1.1.7 + ripemd160: 2.0.3 + safe-buffer: 5.2.1 + sha.js: 2.4.12 + to-buffer: 1.2.2 + pdfjs-dist@4.4.168: optionalDependencies: canvas: 3.2.0 @@ -16082,7 +16088,7 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 read-cache: 1.0.0 - resolve: 1.22.10 + resolve: 1.22.11 postcss-js@4.1.0(postcss@8.5.6): dependencies: @@ -16304,7 +16310,7 @@ snapshots: '@types/doctrine': 0.0.9 '@types/resolve': 1.20.6 doctrine: 3.0.0 - resolve: 1.22.10 + resolve: 1.22.11 strip-indent: 4.1.1 transitivePeerDependencies: - supports-color @@ -16727,7 +16733,7 @@ snapshots: resolve.exports@2.0.3: {} - resolve@1.22.10: + resolve@1.22.11: dependencies: is-core-module: '@nolyfill/is-core-module@1.0.39' path-parse: 1.0.7 @@ -17195,7 +17201,7 @@ snapshots: postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.1) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 - resolve: 1.22.10 + resolve: 1.22.11 sucrase: 3.35.0 transitivePeerDependencies: - tsx @@ -17347,7 +17353,6 @@ snapshots: typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optional: true ts-pattern@5.8.0: {} @@ -17550,8 +17555,7 @@ snapshots: uuid@11.1.0: {} - v8-compile-cache-lib@3.0.1: - optional: true + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: dependencies: @@ -17899,8 +17903,7 @@ snapshots: dependencies: lib0: 0.2.114 - yn@3.1.1: - optional: true + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/web/service/apps.ts b/web/service/apps.ts index 5602f75791..b1124767ad 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -1,8 +1,8 @@ import type { Fetcher } from 'swr' import { del, get, patch, post, put } from './base' -import type { ApiKeysListResponse, AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppDetailResponse, AppListResponse, AppStatisticsResponse, AppTemplatesResponse, AppTokenCostsResponse, AppVoicesListResponse, CreateApiKeyResponse, DSLImportMode, DSLImportResponse, GenerationIntroductionResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, UpdateOpenAIKeyResponse, ValidateOpenAIKeyResponse, WorkflowDailyConversationsResponse } from '@/models/app' +import type { ApiKeysListResponse, AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppDetailResponse, AppListResponse, AppStatisticsResponse, AppTemplatesResponse, AppTokenCostsResponse, AppVoicesListResponse, CreateApiKeyResponse, DSLImportMode, DSLImportResponse, GenerationIntroductionResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, UpdateOpenAIKeyResponse, ValidateOpenAIKeyResponse, WebhookTriggerResponse, WorkflowDailyConversationsResponse } from '@/models/app' import type { CommonResponse } from '@/models/common' -import type { AppIconType, AppMode, ModelConfig } from '@/types/app' +import type { AppIconType, AppModeEnum, ModelConfig } from '@/types/app' import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' export const fetchAppList: Fetcher<AppListResponse, { url: string; params?: Record<string, any> }> = ({ url, params }) => { @@ -22,7 +22,7 @@ export const fetchAppTemplates: Fetcher<AppTemplatesResponse, { url: string }> = return get<AppTemplatesResponse>(url) } -export const createApp: Fetcher<AppDetailResponse, { name: string; icon_type?: AppIconType; icon?: string; icon_background?: string; mode: AppMode; description?: string; config?: ModelConfig }> = ({ name, icon_type, icon, icon_background, mode, description, config }) => { +export const createApp: Fetcher<AppDetailResponse, { name: string; icon_type?: AppIconType; icon?: string; icon_background?: string; mode: AppModeEnum; description?: string; config?: ModelConfig }> = ({ name, icon_type, icon, icon_background, mode, description, config }) => { return post<AppDetailResponse>('apps', { body: { name, icon_type, icon, icon_background, mode, description, model_config: config } }) } @@ -31,7 +31,7 @@ export const updateAppInfo: Fetcher<AppDetailResponse, { appID: string; name: st return put<AppDetailResponse>(`apps/${appID}`, { body }) } -export const copyApp: Fetcher<AppDetailResponse, { appID: string; name: string; icon_type: AppIconType; icon: string; icon_background?: string | null; mode: AppMode; description?: string }> = ({ appID, name, icon_type, icon, icon_background, mode, description }) => { +export const copyApp: Fetcher<AppDetailResponse, { appID: string; name: string; icon_type: AppIconType; icon: string; icon_background?: string | null; mode: AppModeEnum; description?: string }> = ({ appID, name, icon_type, icon, icon_background, mode, description }) => { return post<AppDetailResponse>(`apps/${appID}/copy`, { body: { name, icon_type, icon, icon_background, mode, description } }) } @@ -162,6 +162,11 @@ export const updateTracingStatus: Fetcher<CommonResponse, { appId: string; body: return post(`/apps/${appId}/trace`, { body }) } +// Webhook Trigger +export const fetchWebhookUrl: Fetcher<WebhookTriggerResponse, { appId: string; nodeId: string }> = ({ appId, nodeId }) => { + return get<WebhookTriggerResponse>(`apps/${appId}/workflows/triggers/webhook`, { params: { node_id: nodeId } }) +} + export const fetchTracingConfig: Fetcher<TracingConfig & { has_not_configured: true }, { appId: string; provider: TracingProvider }> = ({ appId, provider }) => { return get(`/apps/${appId}/trace-config`, { params: { diff --git a/web/service/base.ts b/web/service/base.ts index ccb48bd46b..e966fa74aa 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -155,7 +155,7 @@ export function format(text: string) { return res.replaceAll('\n', '<br/>').replaceAll('```', '') } -const handleStream = ( +export const handleStream = ( response: Response, onData: IOnData, onCompleted?: IOnCompleted, diff --git a/web/service/debug.ts b/web/service/debug.ts index fab2910c5e..3f3abda2d2 100644 --- a/web/service/debug.ts +++ b/web/service/debug.ts @@ -1,8 +1,9 @@ import { get, post, ssePost } from './base' import type { IOnCompleted, IOnData, IOnError, IOnFile, IOnMessageEnd, IOnMessageReplace, IOnThought } from './base' import type { ChatPromptConfig, CompletionPromptConfig } from '@/models/debug' -import type { ModelModeType } from '@/types/app' +import type { AppModeEnum, ModelModeType } from '@/types/app' import type { ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations' + export type BasicAppFirstRes = { prompt: string variables: string[] @@ -105,7 +106,7 @@ export const fetchPromptTemplate = ({ mode, modelName, hasSetDataSet, -}: { appMode: string; mode: ModelModeType; modelName: string; hasSetDataSet: boolean }) => { +}: { appMode: AppModeEnum; mode: ModelModeType; modelName: string; hasSetDataSet: boolean }) => { return get<Promise<{ chat_prompt_config: ChatPromptConfig; completion_prompt_config: CompletionPromptConfig; stop: [] }>>('/app/prompt-templates', { params: { app_mode: appMode, diff --git a/web/service/demo/index.tsx b/web/service/demo/index.tsx index aa02968549..5cbfa7c52a 100644 --- a/web/service/demo/index.tsx +++ b/web/service/demo/index.tsx @@ -4,6 +4,8 @@ import React from 'react' import useSWR, { useSWRConfig } from 'swr' import { createApp, fetchAppDetail, fetchAppList, getAppDailyConversations, getAppDailyEndUsers, updateAppApiStatus, updateAppModelConfig, updateAppRateLimit, updateAppSiteAccessToken, updateAppSiteConfig, updateAppSiteStatus } from '../apps' import Loading from '@/app/components/base/loading' +import { AppModeEnum } from '@/types/app' + const Service: FC = () => { const { data: appList, error: appListError } = useSWR({ url: '/apps', params: { page: 1 } }, fetchAppList) const { data: firstApp, error: appDetailError } = useSWR({ url: '/apps', id: '1' }, fetchAppDetail) @@ -21,7 +23,7 @@ const Service: FC = () => { const handleCreateApp = async () => { await createApp({ name: `new app${Math.round(Math.random() * 100)}`, - mode: 'chat', + mode: AppModeEnum.CHAT, }) // reload app list mutate({ url: '/apps', params: { page: 1 } }) diff --git a/web/service/fetch.ts b/web/service/fetch.ts index 8d663c902b..030549bdab 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -2,7 +2,7 @@ import type { AfterResponseHook, BeforeErrorHook, BeforeRequestHook, Hooks } fro import ky from 'ky' import type { IOtherOptions } from './base' import Toast from '@/app/components/base/toast' -import { API_PREFIX, APP_VERSION, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, MARKETPLACE_API_PREFIX, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' +import { API_PREFIX, APP_VERSION, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_MARKETPLACE, MARKETPLACE_API_PREFIX, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' import Cookies from 'js-cookie' import { getWebAppAccessToken, getWebAppPassport } from './webapp-auth' @@ -160,7 +160,7 @@ async function base<T>(url: string, options: FetchOptionType = {}, otherOptions: // ! For Marketplace API, help to filter tags added in new version if (isMarketplaceAPI) - (headers as any).set('X-Dify-Version', APP_VERSION) + (headers as any).set('X-Dify-Version', !IS_MARKETPLACE ? APP_VERSION : '999.0.0') const client = baseClient.extend({ hooks: { diff --git a/web/service/share.ts b/web/service/share.ts index b19dbc896d..df08f0f3d6 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -295,7 +295,8 @@ export const fetchAccessToken = async ({ userId, appCode }: { userId?: string, a if (accessToken) headers.append('Authorization', `Bearer ${accessToken}`) const params = new URLSearchParams() - userId && params.append('user_id', userId) + if (userId) + params.append('user_id', userId) const url = `/passport?${params.toString()}` return get<{ access_token: string }>(url, { headers }) as Promise<{ access_token: string }> } diff --git a/web/service/use-base.ts b/web/service/use-base.ts index 37af55a74a..b6445f4baf 100644 --- a/web/service/use-base.ts +++ b/web/service/use-base.ts @@ -3,9 +3,11 @@ import { useQueryClient, } from '@tanstack/react-query' -export const useInvalid = (key: QueryKey) => { +export const useInvalid = (key?: QueryKey) => { const queryClient = useQueryClient() return () => { + if (!key) + return queryClient.invalidateQueries( { queryKey: key, @@ -14,9 +16,11 @@ export const useInvalid = (key: QueryKey) => { } } -export const useReset = (key: QueryKey) => { +export const useReset = (key?: QueryKey) => { const queryClient = useQueryClient() return () => { + if (!key) + return queryClient.resetQueries( { queryKey: key, diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 5d2bc080d3..f6dbecaeba 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -19,7 +19,6 @@ import type { PluginDetail, PluginInfoFromMarketPlace, PluginTask, - PluginType, PluginsFromMarketplaceByInfoResponse, PluginsFromMarketplaceResponse, ReferenceSetting, @@ -28,7 +27,7 @@ import type { uploadGitHubResponse, } from '@/app/components/plugins/types' import { TaskStatus } from '@/app/components/plugins/types' -import { PluginType as PluginTypeEnum } from '@/app/components/plugins/types' +import { PluginCategoryEnum } from '@/app/components/plugins/types' import type { PluginsSearchParams, } from '@/app/components/plugins/marketplace/types' @@ -45,6 +44,7 @@ import useReferenceSetting from '@/app/components/plugins/plugin-page/use-refere import { uninstallPlugin } from '@/service/plugins' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import { cloneDeep } from 'lodash-es' +import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' const NAME_SPACE = 'plugins' @@ -68,6 +68,66 @@ export const useCheckInstalled = ({ }) } +const useRecommendedMarketplacePluginsKey = [NAME_SPACE, 'recommendedMarketplacePlugins'] +export const useRecommendedMarketplacePlugins = ({ + collection = '__recommended-plugins-tools', + enabled = true, + limit = 15, +}: { + collection?: string + enabled?: boolean + limit?: number +} = {}) => { + return useQuery<Plugin[]>({ + queryKey: [...useRecommendedMarketplacePluginsKey, collection, limit], + queryFn: async () => { + const response = await postMarketplace<{ data: { plugins: Plugin[] } }>( + `/collections/${collection}/plugins`, + { + body: { + limit, + }, + }, + ) + return response.data.plugins.map(plugin => getFormattedPlugin(plugin)) + }, + enabled, + staleTime: 60 * 1000, + }) +} + +export const useFeaturedToolsRecommendations = (enabled: boolean, limit = 15) => { + const { + data: plugins = [], + isLoading, + } = useRecommendedMarketplacePlugins({ + collection: '__recommended-plugins-tools', + enabled, + limit, + }) + + return { + plugins, + isLoading, + } +} + +export const useFeaturedTriggersRecommendations = (enabled: boolean, limit = 15) => { + const { + data: plugins = [], + isLoading, + } = useRecommendedMarketplacePlugins({ + collection: '__recommended-plugins-triggers', + enabled, + limit, + }) + + return { + plugins, + isLoading, + } +} + export const useInstalledPluginList = (disable?: boolean, pageSize = 100) => { const fetchPlugins = async ({ pageParam = 1 }) => { const response = await get<InstalledPluginListWithTotalResponse>( @@ -518,7 +578,7 @@ export const useFetchPluginsInMarketPlaceByInfo = (infos: Record<string, any>[]) } const usePluginTaskListKey = [NAME_SPACE, 'pluginTaskList'] -export const usePluginTaskList = (category?: PluginType) => { +export const usePluginTaskList = (category?: PluginCategoryEnum | string) => { const [initialized, setInitialized] = useState(false) const { canManagement, @@ -544,20 +604,20 @@ export const usePluginTaskList = (category?: PluginType) => { useEffect(() => { // After first fetch, refresh plugin list each time all tasks are done // Skip initialization period, because the query cache is not updated yet - if (initialized && !isRefetching) { - const lastData = cloneDeep(data) - const taskDone = lastData?.tasks.every(task => task.status === TaskStatus.success || task.status === TaskStatus.failed) - const taskAllFailed = lastData?.tasks.every(task => task.status === TaskStatus.failed) - if (taskDone) { - if (lastData?.tasks.length && !taskAllFailed) - refreshPluginList(category ? { category } as any : undefined, !category) - } - } - }, [isRefetching]) + if (!initialized || isRefetching) + return + + const lastData = cloneDeep(data) + const taskDone = lastData?.tasks.every(task => task.status === TaskStatus.success || task.status === TaskStatus.failed) + const taskAllFailed = lastData?.tasks.every(task => task.status === TaskStatus.failed) + if (taskDone && lastData?.tasks.length && !taskAllFailed) + refreshPluginList(category ? { category } as any : undefined, !category) + }, [initialized, isRefetching, data, category, refreshPluginList]) useEffect(() => { - setInitialized(true) - }, []) + if (isFetched && !initialized) + setInitialized(true) + }, [isFetched, initialized]) const handleRefetch = useCallback(() => { refetch() @@ -641,7 +701,7 @@ export const usePluginInfo = (providerName?: string) => { const name = parts[1] try { const response = await fetchPluginInfoFromMarketPlace({ org, name }) - return response.data.plugin.category === PluginTypeEnum.model ? response.data.plugin : null + return response.data.plugin.category === PluginCategoryEnum.model ? response.data.plugin : null } catch { return null @@ -651,7 +711,7 @@ export const usePluginInfo = (providerName?: string) => { }) } -export const useFetchDynamicOptions = (plugin_id: string, provider: string, action: string, parameter: string, provider_type: 'tool') => { +export const useFetchDynamicOptions = (plugin_id: string, provider: string, action: string, parameter: string, provider_type?: string, extra?: Record<string, any>) => { return useMutation({ mutationFn: () => get<{ options: FormOption[] }>('/workspaces/current/plugin/parameters/dynamic-options', { params: { @@ -660,7 +720,26 @@ export const useFetchDynamicOptions = (plugin_id: string, provider: string, acti action, parameter, provider_type, + ...extra, }, }), }) } + +export const usePluginReadme = ({ plugin_unique_identifier, language }: { plugin_unique_identifier: string, language?: string }) => { + return useQuery({ + queryKey: ['pluginReadme', plugin_unique_identifier, language], + queryFn: () => get<{ readme: string }>('/workspaces/current/plugin/readme', { params: { plugin_unique_identifier, language } }, { silent: true }), + enabled: !!plugin_unique_identifier, + retry: 0, + }) +} + +export const usePluginReadmeAsset = ({ file_name, plugin_unique_identifier }: { file_name?: string, plugin_unique_identifier?: string }) => { + const normalizedFileName = file_name?.replace(/(^\.\/_assets\/|^_assets\/)/, '') + return useQuery({ + queryKey: ['pluginReadmeAsset', plugin_unique_identifier, file_name], + queryFn: () => get<Blob>('/workspaces/current/plugin/asset', { params: { plugin_unique_identifier, file_name: normalizedFileName } }, { silent: true }), + enabled: !!plugin_unique_identifier && !!file_name && /(^\.\/_assets|^_assets)/.test(file_name), + }) +} diff --git a/web/service/use-tools.ts b/web/service/use-tools.ts index 306cb903df..ad483bea11 100644 --- a/web/service/use-tools.ts +++ b/web/service/use-tools.ts @@ -84,8 +84,9 @@ const useInvalidToolsKeyMap: Record<string, QueryKey> = { [CollectionType.workflow]: useAllWorkflowToolsKey, [CollectionType.mcp]: useAllMCPToolsKey, } -export const useInvalidToolsByType = (type: CollectionType | string) => { - return useInvalid(useInvalidToolsKeyMap[type]) +export const useInvalidToolsByType = (type?: CollectionType | string) => { + const queryKey = type ? useInvalidToolsKeyMap[type] : undefined + return useInvalid(queryKey) } export const useCreateMCP = () => { @@ -339,3 +340,53 @@ export const useRAGRecommendedPlugins = () => { export const useInvalidateRAGRecommendedPlugins = () => { return useInvalid(useRAGRecommendedPluginListKey) } + +// App Triggers API hooks +export type AppTrigger = { + id: string + trigger_type: 'trigger-webhook' | 'trigger-schedule' | 'trigger-plugin' + title: string + node_id: string + provider_name: string + icon: string + status: 'enabled' | 'disabled' | 'unauthorized' + created_at: string + updated_at: string +} + +export const useAppTriggers = (appId: string | undefined, options?: any) => { + return useQuery<{ data: AppTrigger[] }>({ + queryKey: [NAME_SPACE, 'app-triggers', appId], + queryFn: () => get<{ data: AppTrigger[] }>(`/apps/${appId}/triggers`), + enabled: !!appId, + ...options, // Merge additional options while maintaining backward compatibility + }) +} + +export const useInvalidateAppTriggers = () => { + const queryClient = useQueryClient() + return (appId: string) => { + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'app-triggers', appId], + }) + } +} + +export const useUpdateTriggerStatus = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'update-trigger-status'], + mutationFn: (payload: { + appId: string + triggerId: string + enableTrigger: boolean + }) => { + const { appId, triggerId, enableTrigger } = payload + return post<AppTrigger>(`/apps/${appId}/trigger-enable`, { + body: { + trigger_id: triggerId, + enable_trigger: enableTrigger, + }, + }) + }, + }) +} diff --git a/web/service/use-triggers.ts b/web/service/use-triggers.ts new file mode 100644 index 0000000000..cfb786e4a9 --- /dev/null +++ b/web/service/use-triggers.ts @@ -0,0 +1,320 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { del, get, post } from './base' +import type { + TriggerLogEntity, + TriggerOAuthClientParams, + TriggerOAuthConfig, + TriggerProviderApiEntity, + TriggerSubscription, + TriggerSubscriptionBuilder, + TriggerWithProvider, +} from '@/app/components/workflow/block-selector/types' +import { CollectionType } from '@/app/components/tools/types' +import { useInvalid } from './use-base' + +const NAME_SPACE = 'triggers' + +// Trigger Provider Service - Provider ID Format: plugin_id/provider_name + +// Convert backend API response to frontend ToolWithProvider format +const convertToTriggerWithProvider = (provider: TriggerProviderApiEntity): TriggerWithProvider => { + return { + // Collection fields + id: provider.plugin_id || provider.name, + name: provider.name, + author: provider.author, + description: provider.description, + icon: provider.icon || '', + label: provider.label, + type: CollectionType.trigger, + team_credentials: {}, + is_team_authorization: false, + allow_delete: false, + labels: provider.tags || [], + plugin_id: provider.plugin_id, + plugin_unique_identifier: provider.plugin_unique_identifier || '', + events: provider.events.map(event => ({ + name: event.name, + author: provider.author, + label: event.identity.label, + description: event.description, + parameters: event.parameters.map(param => ({ + name: param.name, + label: param.label, + human_description: param.description || param.label, + type: param.type, + form: param.type, + llm_description: JSON.stringify(param.description || {}), + required: param.required || false, + default: param.default || '', + options: param.options?.map(option => ({ + label: option.label, + value: option.value, + })) || [], + multiple: param.multiple || false, + })), + labels: provider.tags || [], + output_schema: event.output_schema || {}, + })), + + // Trigger-specific schema fields + subscription_constructor: provider.subscription_constructor, + subscription_schema: provider.subscription_schema, + supported_creation_methods: provider.supported_creation_methods, + + meta: { + version: '1.0', + }, + } +} + +export const useAllTriggerPlugins = (enabled = true) => { + return useQuery<TriggerWithProvider[]>({ + queryKey: [NAME_SPACE, 'all'], + queryFn: async () => { + const response = await get<TriggerProviderApiEntity[]>('/workspaces/current/triggers') + return response.map(convertToTriggerWithProvider) + }, + enabled, + staleTime: 0, + gcTime: 0, + }) +} + +export const useTriggerPluginsByType = (triggerType: string, enabled = true) => { + return useQuery<TriggerWithProvider[]>({ + queryKey: [NAME_SPACE, 'byType', triggerType], + queryFn: async () => { + const response = await get<TriggerProviderApiEntity[]>(`/workspaces/current/triggers?type=${triggerType}`) + return response.map(convertToTriggerWithProvider) + }, + enabled: enabled && !!triggerType, + }) +} + +export const useInvalidateAllTriggerPlugins = () => { + return useInvalid([NAME_SPACE, 'all']) +} + +// ===== Trigger Subscriptions Management ===== + +export const useTriggerProviderInfo = (provider: string, enabled = true) => { + return useQuery<TriggerProviderApiEntity>({ + queryKey: [NAME_SPACE, 'provider-info', provider], + queryFn: () => get<TriggerProviderApiEntity>(`/workspaces/current/trigger-provider/${provider}/info`), + enabled: enabled && !!provider, + staleTime: 0, + gcTime: 0, + }) +} + +export const useTriggerSubscriptions = (provider: string, enabled = true) => { + return useQuery<TriggerSubscription[]>({ + queryKey: [NAME_SPACE, 'list-subscriptions', provider], + queryFn: () => get<TriggerSubscription[]>(`/workspaces/current/trigger-provider/${provider}/subscriptions/list`), + enabled: enabled && !!provider, + }) +} + +export const useInvalidateTriggerSubscriptions = () => { + const queryClient = useQueryClient() + return (provider: string) => { + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'subscriptions', provider], + }) + } +} + +export const useCreateTriggerSubscriptionBuilder = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'create-subscription-builder'], + mutationFn: (payload: { + provider: string + credential_type?: string + }) => { + const { provider, ...body } = payload + return post<{ subscription_builder: TriggerSubscriptionBuilder }>( + `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/create`, + { body }, + ) + }, + }) +} + +export const useUpdateTriggerSubscriptionBuilder = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'update-subscription-builder'], + mutationFn: (payload: { + provider: string + subscriptionBuilderId: string + name?: string + properties?: Record<string, any> + parameters?: Record<string, any> + credentials?: Record<string, any> + }) => { + const { provider, subscriptionBuilderId, ...body } = payload + return post<TriggerSubscriptionBuilder>( + `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/update/${subscriptionBuilderId}`, + { body }, + ) + }, + }) +} + +export const useVerifyTriggerSubscriptionBuilder = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'verify-subscription-builder'], + mutationFn: (payload: { + provider: string + subscriptionBuilderId: string + credentials?: Record<string, any> + }) => { + const { provider, subscriptionBuilderId, ...body } = payload + return post<{ verified: boolean }>( + `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/verify/${subscriptionBuilderId}`, + { body }, + { silent: true }, + ) + }, + }) +} + +export type BuildTriggerSubscriptionPayload = { + provider: string + subscriptionBuilderId: string + name?: string + parameters?: Record<string, any> +} + +export const useBuildTriggerSubscription = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'build-subscription'], + mutationFn: (payload: BuildTriggerSubscriptionPayload) => { + const { provider, subscriptionBuilderId, ...body } = payload + return post( + `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/build/${subscriptionBuilderId}`, + { body }, + ) + }, + }) +} + +export const useDeleteTriggerSubscription = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'delete-subscription'], + mutationFn: (subscriptionId: string) => { + return post<{ result: string }>( + `/workspaces/current/trigger-provider/${subscriptionId}/subscriptions/delete`, + ) + }, + }) +} + +export const useTriggerSubscriptionBuilderLogs = ( + provider: string, + subscriptionBuilderId: string, + options: { + enabled?: boolean + refetchInterval?: number | false + } = {}, +) => { + const { enabled = true, refetchInterval = false } = options + + return useQuery<{ logs: TriggerLogEntity[] }>({ + queryKey: [NAME_SPACE, 'subscription-builder-logs', provider, subscriptionBuilderId], + queryFn: () => get( + `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/logs/${subscriptionBuilderId}`, + ), + enabled: enabled && !!provider && !!subscriptionBuilderId, + refetchInterval, + }) +} + +// ===== OAuth Management ===== +export const useTriggerOAuthConfig = (provider: string, enabled = true) => { + return useQuery<TriggerOAuthConfig>({ + queryKey: [NAME_SPACE, 'oauth-config', provider], + queryFn: () => get<TriggerOAuthConfig>(`/workspaces/current/trigger-provider/${provider}/oauth/client`), + enabled: enabled && !!provider, + }) +} + +export type ConfigureTriggerOAuthPayload = { + provider: string + client_params?: TriggerOAuthClientParams + enabled: boolean +} + +export const useConfigureTriggerOAuth = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'configure-oauth'], + mutationFn: (payload: ConfigureTriggerOAuthPayload) => { + const { provider, ...body } = payload + return post<{ result: string }>( + `/workspaces/current/trigger-provider/${provider}/oauth/client`, + { body }, + ) + }, + }) +} + +export const useDeleteTriggerOAuth = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'delete-oauth'], + mutationFn: (provider: string) => { + return del<{ result: string }>( + `/workspaces/current/trigger-provider/${provider}/oauth/client`, + ) + }, + }) +} + +export const useInitiateTriggerOAuth = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'initiate-oauth'], + mutationFn: (provider: string) => { + return get<{ authorization_url: string; subscription_builder: TriggerSubscriptionBuilder }>( + `/workspaces/current/trigger-provider/${provider}/subscriptions/oauth/authorize`, + {}, + { silent: true }, + ) + }, + }) +} + +// ===== Dynamic Options Support ===== +export const useTriggerPluginDynamicOptions = (payload: { + plugin_id: string + provider: string + action: string + parameter: string + credential_id: string + extra?: Record<string, any> +}, enabled = true) => { + return useQuery<{ options: Array<{ value: string; label: any }> }>({ + queryKey: [NAME_SPACE, 'dynamic-options', payload.plugin_id, payload.provider, payload.action, payload.parameter, payload.credential_id, payload.extra], + queryFn: () => get<{ options: Array<{ value: string; label: any }> }>( + '/workspaces/current/plugin/parameters/dynamic-options', + { + params: { + ...payload, + provider_type: 'trigger', // Add required provider_type parameter + }, + }, + { silent: true }, + ), + enabled: enabled && !!payload.plugin_id && !!payload.provider && !!payload.action && !!payload.parameter && !!payload.credential_id, + retry: 0, + }) +} + +// ===== Cache Invalidation Helpers ===== + +export const useInvalidateTriggerOAuthConfig = () => { + const queryClient = useQueryClient() + return (provider: string) => { + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'oauth-config', provider], + }) + } +} diff --git a/web/service/workflow-payload.ts b/web/service/workflow-payload.ts new file mode 100644 index 0000000000..b80c4a3731 --- /dev/null +++ b/web/service/workflow-payload.ts @@ -0,0 +1,152 @@ +import { produce } from 'immer' +import type { Edge, Node } from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import type { PluginTriggerNodeType } from '@/app/components/workflow/nodes/trigger-plugin/types' +import type { FetchWorkflowDraftResponse } from '@/types/workflow' + +export type TriggerPluginNodePayload = { + title: string + desc: string + plugin_id: string + provider_id: string + event_name: string + subscription_id: string + plugin_unique_identifier: string + event_parameters: Record<string, unknown> +} + +export type WorkflowDraftSyncParams = Pick< + FetchWorkflowDraftResponse, + 'graph' | 'features' | 'environment_variables' | 'conversation_variables' +> + +const removeTempProperties = (data: Record<string, unknown>): void => { + Object.keys(data).forEach((key) => { + if (key.startsWith('_')) + delete data[key] + }) +} + +type TriggerParameterSchema = Record<string, unknown> + +type TriggerPluginHydratePayload = (PluginTriggerNodeType & { + paramSchemas?: TriggerParameterSchema[] + parameters_schema?: TriggerParameterSchema[] +}) + +const sanitizeTriggerPluginNode = (node: Node<TriggerPluginNodePayload>): Node<TriggerPluginNodePayload> => { + const data = node.data + + if (!data || data.type !== BlockEnum.TriggerPlugin) + return node + + const sanitizedData: TriggerPluginNodePayload & { type: BlockEnum.TriggerPlugin } = { + type: BlockEnum.TriggerPlugin, + title: data.title ?? '', + desc: data.desc ?? '', + plugin_id: data.plugin_id ?? '', + provider_id: data.provider_id ?? '', + event_name: data.event_name ?? '', + subscription_id: data.subscription_id ?? '', + plugin_unique_identifier: data.plugin_unique_identifier ?? '', + event_parameters: (typeof data.event_parameters === 'object' && data.event_parameters !== null) + ? data.event_parameters as Record<string, unknown> + : {}, + } + + return { + ...node, + data: sanitizedData, + } +} + +export const sanitizeWorkflowDraftPayload = (params: WorkflowDraftSyncParams): WorkflowDraftSyncParams => { + const { graph } = params + + if (!graph?.nodes?.length) + return params + + const sanitizedNodes = graph.nodes.map(node => sanitizeTriggerPluginNode(node as Node<TriggerPluginNodePayload>)) + + return { + ...params, + graph: { + ...graph, + nodes: sanitizedNodes, + }, + } +} + +const isTriggerPluginNode = (node: Node): node is Node<TriggerPluginHydratePayload> => { + const data = node.data as unknown + + if (!data || typeof data !== 'object') + return false + + const payload = data as Partial<TriggerPluginHydratePayload> & { type?: BlockEnum } + + if (payload.type !== BlockEnum.TriggerPlugin) + return false + + return 'event_parameters' in payload +} + +const hydrateTriggerPluginNode = (node: Node): Node => { + if (!isTriggerPluginNode(node)) + return node + + const typedNode = node as Node<TriggerPluginHydratePayload> + const data = typedNode.data + const eventParameters = data.event_parameters ?? {} + const parametersSchema = data.parameters_schema ?? data.paramSchemas ?? [] + const config = data.config ?? eventParameters ?? {} + + const nextData: typeof data = { + ...data, + config, + paramSchemas: data.paramSchemas ?? parametersSchema, + parameters_schema: parametersSchema, + } + + return { + ...typedNode, + data: nextData, + } +} + +export const hydrateWorkflowDraftResponse = (draft: FetchWorkflowDraftResponse): FetchWorkflowDraftResponse => { + return produce(draft, (mutableDraft) => { + if (!mutableDraft?.graph) + return + + if (mutableDraft.graph.nodes) { + mutableDraft.graph.nodes = mutableDraft.graph.nodes + .filter((node: Node) => !node.data?._isTempNode) + .map((node: Node) => { + if (node.data) + removeTempProperties(node.data as Record<string, unknown>) + + return hydrateTriggerPluginNode(node) + }) + } + + if (mutableDraft.graph.edges) { + mutableDraft.graph.edges = mutableDraft.graph.edges + .filter((edge: Edge) => !edge.data?._isTemp) + .map((edge: Edge) => { + if (edge.data) + removeTempProperties(edge.data as Record<string, unknown>) + + return edge + }) + } + + if (mutableDraft.environment_variables) { + mutableDraft.environment_variables = mutableDraft.environment_variables.map(env => + env.value_type === 'secret' + ? { ...env, value: '[__HIDDEN__]' } + : env, + ) + } + }) +} diff --git a/web/themes/dark.css b/web/themes/dark.css index cd1a016f75..dae2add2b1 100644 --- a/web/themes/dark.css +++ b/web/themes/dark.css @@ -435,7 +435,7 @@ html[data-theme="dark"] { --color-workflow-block-bg: #27272b; --color-workflow-block-bg-transparent: rgb(39 39 43 / 0.96); --color-workflow-block-border-highlight: rgb(200 206 218 / 0.2); - --color-workflow-block-wrapper-bg-1: #27272b; + --color-workflow-block-wrapper-bg-1: #323236; --color-workflow-block-wrapper-bg-2: rgb(39 39 43 / 0.2); --color-workflow-canvas-workflow-dot-color: rgb(133 133 173 / 0.11); diff --git a/web/tsconfig.json b/web/tsconfig.json index 3b022e4708..1d03daa576 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -40,6 +40,11 @@ "app/components/develop/Prose.jsx" ], "exclude": [ - "node_modules" + "node_modules", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx", + "__tests__/**" ] } diff --git a/web/types/app.ts b/web/types/app.ts index 591bbf5e31..b7a7f6a48d 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -60,8 +60,14 @@ export type VariableInput = { /** * App modes */ -export const AppModes = ['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow'] as const -export type AppMode = typeof AppModes[number] +export enum AppModeEnum { + COMPLETION = 'completion', + WORKFLOW = 'workflow', + CHAT = 'chat', + ADVANCED_CHAT = 'advanced-chat', + AGENT_CHAT = 'agent-chat', +} +export const AppModes = [AppModeEnum.COMPLETION, AppModeEnum.WORKFLOW, AppModeEnum.CHAT, AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT] as const /** * Variable type @@ -339,7 +345,7 @@ export type App = { use_icon_as_answer_icon: boolean /** Mode */ - mode: AppMode + mode: AppModeEnum /** Enable web app */ enable_site: boolean /** Enable web API */ @@ -388,7 +394,7 @@ export type AppTemplate = { /** Description */ description: string /** Mode */ - mode: AppMode + mode: AppModeEnum /** Model */ model_config: ModelConfig } diff --git a/web/types/i18n.d.ts b/web/types/i18n.d.ts index a6ed8f0a1e..826fcc1613 100644 --- a/web/types/i18n.d.ts +++ b/web/types/i18n.d.ts @@ -27,6 +27,7 @@ type LoginMessages = typeof import('../i18n/en-US/login').default type OauthMessages = typeof import('../i18n/en-US/oauth').default type PipelineMessages = typeof import('../i18n/en-US/pipeline').default type PluginTagsMessages = typeof import('../i18n/en-US/plugin-tags').default +type PluginTriggerMessages = typeof import('../i18n/en-US/plugin-trigger').default type PluginMessages = typeof import('../i18n/en-US/plugin').default type RegisterMessages = typeof import('../i18n/en-US/register').default type RunLogMessages = typeof import('../i18n/en-US/run-log').default @@ -59,6 +60,7 @@ export type Messages = { oauth: OauthMessages; pipeline: PipelineMessages; pluginTags: PluginTagsMessages; + pluginTrigger: PluginTriggerMessages; plugin: PluginMessages; register: RegisterMessages; runLog: RunLogMessages; diff --git a/web/utils/app-redirection.ts b/web/utils/app-redirection.ts index dfecbd17d4..5ed8419e05 100644 --- a/web/utils/app-redirection.ts +++ b/web/utils/app-redirection.ts @@ -1,14 +1,14 @@ -import type { AppMode } from '@/types/app' +import { AppModeEnum } from '@/types/app' export const getRedirectionPath = ( isCurrentWorkspaceEditor: boolean, - app: { id: string, mode: AppMode }, + app: { id: string, mode: AppModeEnum }, ) => { if (!isCurrentWorkspaceEditor) { return `/app/${app.id}/overview` } else { - if (app.mode === 'workflow' || app.mode === 'advanced-chat') + if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) return `/app/${app.id}/workflow` else return `/app/${app.id}/configuration` @@ -17,7 +17,7 @@ export const getRedirectionPath = ( export const getRedirection = ( isCurrentWorkspaceEditor: boolean, - app: { id: string, mode: AppMode }, + app: { id: string, mode: AppModeEnum }, redirectionFunc: (href: string) => void, ) => { const redirectionPath = getRedirectionPath(isCurrentWorkspaceEditor, app) diff --git a/web/utils/error-parser.ts b/web/utils/error-parser.ts new file mode 100644 index 0000000000..311505521f --- /dev/null +++ b/web/utils/error-parser.ts @@ -0,0 +1,52 @@ +/** + * Parse plugin error message from nested error structure + * Extracts the real error message from PluginInvokeError JSON string + * + * @example + * Input: { message: "req_id: xxx PluginInvokeError: {\"message\":\"Bad credentials\"}" } + * Output: "Bad credentials" + * + * @param error - Error object (can be Response object or error with message property) + * @returns Promise<string> or string - Parsed error message + */ +export const parsePluginErrorMessage = async (error: any): Promise<string> => { + let rawMessage = '' + + // Handle Response object from fetch/ky + if (error instanceof Response) { + try { + const body = await error.clone().json() + rawMessage = body?.message || error.statusText || 'Unknown error' + } + catch { + rawMessage = error.statusText || 'Unknown error' + } + } + else { + rawMessage = error?.message || error?.toString() || 'Unknown error' + } + + console.log('rawMessage', rawMessage) + + // Try to extract nested JSON from PluginInvokeError + // Use greedy match .+ to capture the complete JSON object with nested braces + const pluginErrorPattern = /PluginInvokeError:\s*(\{.+\})/ + const match = rawMessage.match(pluginErrorPattern) + + if (match) { + try { + const errorData = JSON.parse(match[1]) + // Return the inner message if exists + if (errorData.message) + return errorData.message + // Fallback to error_type if message not available + if (errorData.error_type) + return errorData.error_type + } + catch (parseError) { + console.warn('Failed to parse plugin error JSON:', parseError) + } + } + + return rawMessage +} diff --git a/web/utils/urlValidation.ts b/web/utils/urlValidation.ts index abc15a1365..db6de5275a 100644 --- a/web/utils/urlValidation.ts +++ b/web/utils/urlValidation.ts @@ -21,3 +21,44 @@ export function validateRedirectUrl(url: string): void { throw new Error(`Invalid URL: ${url}`) } } + +/** + * Check if URL is a private/local network address or cloud debug URL + * @param url - The URL string to check + * @returns true if the URL is a private/local address or cloud debug URL + */ +export function isPrivateOrLocalAddress(url: string): boolean { + try { + const urlObj = new URL(url) + const hostname = urlObj.hostname.toLowerCase() + + // Check for localhost + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') + return true + + // Check for private IP ranges + const ipv4Regex = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/ + const ipv4Match = hostname.match(ipv4Regex) + if (ipv4Match) { + const [, a, b] = ipv4Match.map(Number) + // 10.0.0.0/8 + if (a === 10) + return true + // 172.16.0.0/12 + if (a === 172 && b >= 16 && b <= 31) + return true + // 192.168.0.0/16 + if (a === 192 && b === 168) + return true + // 169.254.0.0/16 (link-local) + if (a === 169 && b === 254) + return true + } + + // Check for .local domains + return hostname.endsWith('.local') + } + catch { + return false + } +}