diff --git a/api/controllers/common/human_input_v2_contracts.py b/api/controllers/common/human_input_v2_contracts.py index b3cbf70037a..562a7460d6b 100644 --- a/api/controllers/common/human_input_v2_contracts.py +++ b/api/controllers/common/human_input_v2_contracts.py @@ -134,6 +134,7 @@ class HumanInputContactSummary(BaseModel): id: ContactId = Field(description="Unique contact identifier.") name: str = Field(description="Display name shown in the contact directory.") + email: str | None = Field(default=None, description="Primary contact email if one exists.") avatar_url: str = Field(default="", description="URL of the contact's avatar.") created_at: Timestamp = Field(description="Timestamp when the contact was created.") @@ -279,12 +280,15 @@ class IMSyncRunResultCounts(ResponseModel): class IMSyncRun(ResponseModel): """One IM sync run snapshot. - The latest-only UI displays ``finished_at`` as the explicit sync time. The - transport contract intentionally does not expose a ``started_by`` actor. + The latest-only UI displays ``finished_at`` as the explicit sync time. + The initiator display name is resolved from the initiating Dify Account. """ id: IMSyncRunId = Field(description="Unique sync run identifier.") status: IMSyncRunStatus = Field(description="Current lifecycle state of the sync run.") + initiator_display_name: str = Field( + description="Current initiating Account name; empty for EE administrators or an unavailable Account." + ) started_at: Timestamp | None = Field( default=None, description="Unix timestamp in seconds when the sync run started." ) diff --git a/api/controllers/console/human_input_v2/contact.py b/api/controllers/console/human_input_v2/contact.py index db083c55fe8..1e6adcac0c7 100644 --- a/api/controllers/console/human_input_v2/contact.py +++ b/api/controllers/console/human_input_v2/contact.py @@ -76,10 +76,10 @@ from core.human_input_v2.im_integration import ( ContactIMBindingView, IMBindingCommandError, IMBindingCommandErrorCode, - IMSyncRun, SyncContactSnapshot, SyncResultFact, ) +from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator from core.human_input_v2.shared import ( AccountId, ContactId, @@ -123,6 +123,7 @@ from services.human_input_v2.im_contact_sync.service import ( IMSyncDispatchUnavailableError, IMSyncRevisionChangedError, IMSyncRunNotFoundError, + IMSyncRunSummary, ) from services.human_input_v2.node_data_migration import MigrationNode, NodeDataMigrationFailure @@ -237,10 +238,13 @@ def _im_application_error_response(error: _IMApplicationError) -> tuple[dict[str ) -def _sync_run_payload(run: IMSyncRun) -> dict[str, object]: +def _sync_run_payload(summary: IMSyncRunSummary) -> dict[str, object]: + """Serialize a persisted run with the currently resolved Account name.""" + run = summary.run return { "id": run.id, "status": run.status, + "initiator_display_name": summary.initiator_display_name, "started_at": run.started_at, "finished_at": run.finished_at, "error_message": run.error_message, @@ -272,11 +276,13 @@ def _avatar_url(avatar_file_id: str | None) -> str: def _sync_contact_payload(contact: SyncContactSnapshot, fallback_created_at) -> dict[str, object]: + """Serialize the frozen Contact profile without consulting current state.""" # Historical result snapshots predate the Contact creation-time field. created_at = contact.created_at if contact.created_at is not None else fallback_created_at return { "id": contact.contact_id, "name": contact.name, + "email": contact.email, "avatar_url": _avatar_url(contact.avatar_file_id), "created_at": created_at, } @@ -369,10 +375,12 @@ def _contact_option_payload(view: ContactWithIMBindings) -> dict[str, object]: def _contact_summary_payload(view: ContactWithIMBindings) -> dict[str, object]: + """Project one resolved Contact, including its available primary email.""" contact = view.contact return { "id": contact.id, "name": contact.name, + "email": contact.email, "avatar_url": _contact_avatar_url(contact), "created_at": contact.created_at, } @@ -655,10 +663,11 @@ class WorkspaceIMSyncRunsApi(Resource): @with_current_user @with_current_tenant_id def post(self, tenant_id: str, current_user: Account): + """Start or reuse a workspace run using the authenticated Account ID.""" try: run = build_im_contact_sync_application().sync_service.create_or_get_active_run( _workspace_scope(tenant_id), - AccountId(current_user.id), + AccountIMSyncInitiator(AccountId(current_user.id)), ) except ( IMChannelNotConfiguredError, @@ -675,7 +684,7 @@ class WorkspaceLatestIMSyncRunApi(Resource): @console_ns.doc( description=( "Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; " - "the response does not include started_by." + "initiator_display_name is resolved from the initiating Account." ) ) @console_ns.response(200, "Success", console_ns.models[GetLatestIMSyncRunResponse.__name__]) diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index ba669dc77ff..17baef71fe0 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -4,7 +4,7 @@ import contextvars import logging import threading import uuid -from collections.abc import Generator, Mapping +from collections.abc import Callable, Generator, Mapping from typing import TYPE_CHECKING, Any, Literal, overload from flask import Flask, current_app @@ -29,6 +29,7 @@ from core.app.apps.advanced_chat.generate_task_pipeline import ( MessageSnapshot, WorkflowSnapshot, ) +from core.app.apps.base_app_generator import noop_restore_file_access from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.apps.draft_variable_saver import DraftVariableSaverFactory from core.app.apps.exc import GenerateTaskStoppedError @@ -291,10 +292,15 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): graph_runtime_state: RuntimeState, pause_state_config: PauseStateLayerConfig | None = None, response_stream_filter: ResponseStreamFilter | None = None, + restore_file_access: Callable[[], None] = noop_restore_file_access, ): """ Resume a paused advanced chat execution. + ``restore_file_access`` restores host-owned grants inside the new + execution scope before worker contexts are copied. The default callback + leaves existing access rules unchanged. + ``trace_scope`` is transient and excluded from generate-entity serialization, so resumed executions create it again before persistence layers receive the entity. """ @@ -324,6 +330,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): pause_state_config=pause_state_config, graph_runtime_state=graph_runtime_state, response_stream_filter=response_stream_filter, + restore_file_access=restore_file_access, session=session, ) @@ -532,10 +539,14 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): pause_state_config: PauseStateLayerConfig | None = None, graph_runtime_state: RuntimeState | None = None, response_stream_filter: ResponseStreamFilter | None = None, + restore_file_access: Callable[[], None] = noop_restore_file_access, ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]: """ Generate App response. + On resume, HITL submissions must already be committed. Restore their + EndUser file grants before capturing worker and response contexts. + :param workflow: Workflow :param user: account or end user :param invoke_from: invoke from source @@ -552,6 +563,8 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): invoke_from=invoke_from, ): grant_file_default_access(application_generate_entity.app_config.variables) + # Restore grants before copying contexts for both workers and responses. + restore_file_access() is_first_conversation = conversation is None if conversation is not None and message is not None: diff --git a/api/core/app/apps/base_app_generator.py b/api/core/app/apps/base_app_generator.py index 4a23bb71af4..9ff2547fdf0 100644 --- a/api/core/app/apps/base_app_generator.py +++ b/api/core/app/apps/base_app_generator.py @@ -36,6 +36,10 @@ logger = logging.getLogger(__name__) _WORKER_THREAD_JOIN_TIMEOUT_SECONDS = 300 +def noop_restore_file_access() -> None: + """Leave the current file-access scope unchanged when no restoration is needed.""" + + @final class _DebuggerDraftVariableSaver: """Adapter that binds SQLAlchemy session setup outside the saver port.""" diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index e5223b9de35..7c72d236c90 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -4,7 +4,7 @@ import contextvars import logging import threading import uuid -from collections.abc import Generator, Mapping, Sequence +from collections.abc import Callable, Generator, Mapping, Sequence from typing import TYPE_CHECKING, Any, Literal, overload from flask import Flask, current_app @@ -15,7 +15,7 @@ from sqlalchemy.orm import Session, sessionmaker import contexts from configs import dify_config from core.app.app_config.features.file_upload.manager import FileUploadConfigManager -from core.app.apps.base_app_generator import BaseAppGenerator +from core.app.apps.base_app_generator import BaseAppGenerator, noop_restore_file_access from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.apps.draft_variable_saver import DraftVariableSaverFactory from core.app.apps.exc import GenerateTaskStoppedError @@ -300,10 +300,15 @@ class WorkflowAppGenerator(BaseAppGenerator): pause_state_config: PauseStateLayerConfig | None = None, variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER, response_stream_filter: ResponseStreamFilter | None = None, + restore_file_access: Callable[[], None] = noop_restore_file_access, ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]: """ Resume a paused workflow execution using the persisted runtime state. + ``restore_file_access`` restores host-owned grants inside the new + execution scope before worker contexts are copied. The default callback + leaves existing access rules unchanged. + ``trace_scope`` is transient and excluded from generate-entity serialization, so resumed executions create it again before persistence layers receive the entity. """ @@ -334,6 +339,7 @@ class WorkflowAppGenerator(BaseAppGenerator): graph_runtime_state=graph_runtime_state, pause_state_config=pause_state_config, response_stream_filter=response_stream_filter, + restore_file_access=restore_file_access, ) def _generate( @@ -353,10 +359,14 @@ class WorkflowAppGenerator(BaseAppGenerator): graph_runtime_state: RuntimeState | None = None, pause_state_config: PauseStateLayerConfig | None = None, response_stream_filter: ResponseStreamFilter | None = None, + restore_file_access: Callable[[], None] = noop_restore_file_access, ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]: """ Generate App response. + On resume, HITL submissions must already be committed. Restore their + EndUser file grants before capturing worker and response contexts. + :param app_model: App :param workflow: Workflow :param user: account or end user @@ -372,6 +382,8 @@ class WorkflowAppGenerator(BaseAppGenerator): invoke_from=invoke_from, ): grant_file_default_access(application_generate_entity.app_config.variables) + # Restore grants before copying contexts for both workers and responses. + restore_file_access() # init queue manager queue_manager = WorkflowAppQueueManager( task_id=application_generate_entity.task_id, diff --git a/api/core/human_input_v2/im_integration/ports.py b/api/core/human_input_v2/im_integration/ports.py index e0dba15c5b9..51b77631ee8 100644 --- a/api/core/human_input_v2/im_integration/ports.py +++ b/api/core/human_input_v2/im_integration/ports.py @@ -13,7 +13,6 @@ from pydantic import NaiveDatetime from core.human_input_v2.entities import IMProvider, IMSyncResultType from core.human_input_v2.shared import ( - AccountId, ContactId, DirectoryScope, IMIdentityId, @@ -24,6 +23,7 @@ from repositories.human_input_v2.im_channel_repository import IMChannelId from .sync_reconciliation import ReconciliationReasonCode from .sync_records import ( IMChannelRevision, + IMSyncInitiator, IMSyncRun, StaleRevision, SynchronizedIMIdentityPage, @@ -103,10 +103,10 @@ class IMSyncRepository(Protocol): *, organization_scope: DirectoryScope, sync_run_id: IMSyncRunId, - started_by_account_id: AccountId | None, + started_by: IMSyncInitiator, now: NaiveDatetime, ) -> ActiveRunDecision: - """Lock Channel and return at most one active run.""" + """Lock Channel and create or reuse a run, preserving its original initiator.""" ... def load_sync_run(self, sync_run_id: IMSyncRunId) -> IMSyncRun | None: diff --git a/api/core/human_input_v2/im_integration/sync_records.py b/api/core/human_input_v2/im_integration/sync_records.py index 835627af2ca..0733490cb9a 100644 --- a/api/core/human_input_v2/im_integration/sync_records.py +++ b/api/core/human_input_v2/im_integration/sync_records.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, replace +from typing import NewType from pydantic import NaiveDatetime @@ -127,9 +128,33 @@ class SynchronizedIMIdentityPage: total: int +SysUserId = NewType("SysUserId", str) + + +@dataclass(frozen=True, slots=True) +class AccountIMSyncInitiator: + """A Dify Account that initiated a CE/SaaS synchronization.""" + + account_id: AccountId + + +@dataclass(frozen=True, slots=True) +class SysUserIMSyncInitiator: + """An EE administrator identified by the upstream sys_users.id UUID.""" + + sys_user_id: SysUserId + + +type IMSyncInitiator = AccountIMSyncInitiator | SysUserIMSyncInitiator + + @dataclass(frozen=True, slots=True) class IMSyncRun: - """Independent sync aggregate that captures one complete Channel revision.""" + """Capture one Channel revision and either an Account or EE administrator. + + Every run has exactly one initiating identity, represented by one of the + two variants. Missing initiators are not valid synchronization state. + """ id: IMSyncRunId channel_revision: IMChannelRevision @@ -140,7 +165,7 @@ class IMSyncRun: failed_count: int removed_count: int skipped_count: int - started_by_account_id: AccountId | None + started_by: IMSyncInitiator started_at: NaiveDatetime | None finished_at: NaiveDatetime | None error_code: str | None @@ -155,9 +180,10 @@ class IMSyncRun: sync_run_id: IMSyncRunId, channel_revision: IMChannelRevision, provider: IMProvider, - started_by_account_id: AccountId | None, + started_by: IMSyncInitiator, now: NaiveDatetime, ) -> IMSyncRun: + """Create a queued run from one trusted initiating identity, without I/O.""" return cls( id=sync_run_id, channel_revision=channel_revision, @@ -168,7 +194,7 @@ class IMSyncRun: failed_count=0, removed_count=0, skipped_count=0, - started_by_account_id=started_by_account_id, + started_by=started_by, started_at=None, finished_at=None, error_code=None, diff --git a/api/migrations/versions/2026_11_09_1200-6b7e2a9c4d10_add_im_sync_sys_user_id.py b/api/migrations/versions/2026_11_09_1200-6b7e2a9c4d10_add_im_sync_sys_user_id.py new file mode 100644 index 00000000000..5db4cf5a3c1 --- /dev/null +++ b/api/migrations/versions/2026_11_09_1200-6b7e2a9c4d10_add_im_sync_sys_user_id.py @@ -0,0 +1,42 @@ +"""Add the upstream EE synchronization initiator identity. + +Revision ID: 6b7e2a9c4d10 +Revises: 53c10126d551 +Create Date: 2026-11-09 12:00:00 + +""" + +import sqlalchemy as sa +from alembic import op + +from models.types import StringUUID + +revision = "6b7e2a9c4d10" +down_revision = "53c10126d551" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add the EE UUID and require exactly one initiating identity per run.""" + with op.batch_alter_table("human_input_im_sync_runs") as batch_op: + batch_op.add_column( + sa.Column( + "started_by_sys_user_id", + StringUUID, + nullable=True, + comment="Logical reference to upstream EE sys_users.id; mutually exclusive with started_by_account_id.", + ) + ) + batch_op.create_check_constraint( + "initiator_exclusive", + "(started_by_account_id IS NOT NULL AND started_by_sys_user_id IS NULL) OR " + "(started_by_account_id IS NULL AND started_by_sys_user_id IS NOT NULL)", + ) + + +def downgrade() -> None: + """Remove the EE initiator reference while preserving existing Account IDs.""" + with op.batch_alter_table("human_input_im_sync_runs") as batch_op: + batch_op.drop_constraint("initiator_exclusive", type_="check") + batch_op.drop_column("started_by_sys_user_id") diff --git a/api/models/human_input_v2.py b/api/models/human_input_v2.py index 557f0077979..f267a50ac62 100644 --- a/api/models/human_input_v2.py +++ b/api/models/human_input_v2.py @@ -768,6 +768,11 @@ class HumanInputIMSyncRun(DefaultFieldsDCMixin, TypeBase): __tablename__ = "human_input_im_sync_runs" __table_args__ = ( sa.CheckConstraint("channel_config_version > 0", name="captured_version_positive"), + sa.CheckConstraint( + "(started_by_account_id IS NOT NULL AND started_by_sys_user_id IS NULL) OR " + "(started_by_account_id IS NULL AND started_by_sys_user_id IS NOT NULL)", + name="initiator_exclusive", + ), sa.CheckConstraint( "added_count >= 0 AND not_matched_count >= 0 AND failed_count >= 0 AND removed_count >= 0 " "AND skipped_count >= 0", @@ -836,7 +841,13 @@ class HumanInputIMSyncRun(DefaultFieldsDCMixin, TypeBase): StringUUID, nullable=True, default=None, - comment="Logical foreign key to accounts.id for the administrator who started this run.", + comment="Logical accounts.id reference for CE/SaaS initiators; null only for EE sys_user initiators.", + ) + started_by_sys_user_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment="Logical reference to upstream EE sys_users.id; mutually exclusive with started_by_account_id.", ) started_at: Mapped[datetime | None] = mapped_column( sa.DateTime, diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index b8f3d495e6e..2c8b2f6032a 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -15220,6 +15220,8 @@ Set or reset the IM override for a contact. This endpoint is used to override th | default | Error | **application/json**: [ErrorResponse](#errorresponse) | ### [POST] /workspaces/current/human-input/v2/im-sync-runs +**Start or reuse a workspace run using the authenticated Account ID** + #### Responses | Code | Description | Schema | @@ -15231,7 +15233,7 @@ Set or reset the IM override for a contact. This endpoint is used to override th | default | Error | **application/json**: [ErrorResponse](#errorresponse) | ### [GET] /workspaces/current/human-input/v2/im-sync-runs/latest -Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; the response does not include started_by. +Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; initiator_display_name is resolved from the initiating Account. #### Responses @@ -26777,6 +26779,7 @@ A trimmed version of `HumanInputContact` that only includes the fields needed fo | ---- | ---- | ----------- | -------- | | avatar_url | string | URL of the contact's avatar. | No | | created_at | integer | Timestamp when the contact was created. | Yes | +| email | string | Primary contact email if one exists. | No | | id | string | Unique contact identifier. | Yes | | name | string | Display name shown in the contact directory. | Yes | @@ -27139,8 +27142,8 @@ Stable reconciliation bucket for one synchronized directory entry. One IM sync run snapshot. -The latest-only UI displays ``finished_at`` as the explicit sync time. The -transport contract intentionally does not expose a ``started_by`` actor. +The latest-only UI displays ``finished_at`` as the explicit sync time. +The initiator display name is resolved from the initiating Dify Account. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -27149,6 +27152,7 @@ transport contract intentionally does not expose a ``started_by`` actor. | error_message | string | Terminal error message. Present only when the sync run status is `failed`. | No | | finished_at | integer | Unix timestamp in seconds when the sync run finished. This is the sync time displayed by the latest-only UI and is None while the run is unfinished. | No | | id | string | Unique sync run identifier. | Yes | +| initiator_display_name | string | Current initiating Account name; empty for EE administrators or an unavailable Account. | Yes | | provider | [IMProvider](#improvider) | IM provider associated with the sync run. | Yes | | result_counts | [IMSyncRunResultCounts](#imsyncrunresultcounts) | Aggregate reconciliation counts for the current run snapshot. | Yes | | started_at | integer | Unix timestamp in seconds when the sync run started. | No | diff --git a/api/repositories/human_input_v2/form_repository.py b/api/repositories/human_input_v2/form_repository.py index 918bf7b2b1e..2378f1a1b1e 100644 --- a/api/repositories/human_input_v2/form_repository.py +++ b/api/repositories/human_input_v2/form_repository.py @@ -122,6 +122,14 @@ class FormRepository(Protocol): """ ... + def get_submitted_forms(self, *, workflow_run_id: str) -> tuple[Form, ...]: + """Read accepted runtime submissions for one run in the bound tenant and app. + + Exclude waiting, timed-out, expired, and delivery-test forms. Return + detached snapshots without modifying forms or their submissions. + """ + ... + def create_form(self, params: FormCreateParams) -> Form: """Create an unsubmitted form with generated ID and timestamps. diff --git a/api/repositories/human_input_v2/im_integration/mappers.py b/api/repositories/human_input_v2/im_integration/mappers.py index b455d441c3a..2f6dd04f96b 100644 --- a/api/repositories/human_input_v2/im_integration/mappers.py +++ b/api/repositories/human_input_v2/im_integration/mappers.py @@ -1,6 +1,7 @@ """Explicit bidirectional mappings for IM domain values and ORM records.""" from datetime import datetime +from typing import assert_never from pydantic import NaiveDatetime @@ -18,6 +19,12 @@ from core.human_input_v2.im_integration import ( SyncResultFact, ) from core.human_input_v2.im_integration.adapters.entities import ProviderUserId +from core.human_input_v2.im_integration.sync_records import ( + AccountIMSyncInitiator, + IMSyncInitiator, + SysUserId, + SysUserIMSyncInitiator, +) from core.human_input_v2.shared import ( AccountId, ContactId, @@ -48,6 +55,19 @@ def _timestamp(value: datetime) -> NaiveDatetime: return ensure_naive_utc(value) +def _sync_initiator_from_record(record: HumanInputIMSyncRun) -> IMSyncInitiator: + """Restore the identity guaranteed by the database's exactly-one constraint.""" + account_id = record.started_by_account_id + sys_user_id = record.started_by_sys_user_id + if account_id is not None: + if sys_user_id is not None: + raise AssertionError("IM sync run must contain exactly one initiating identity") + return AccountIMSyncInitiator(AccountId(account_id)) + if sys_user_id is None: + raise AssertionError("IM sync run must contain exactly one initiating identity") + return SysUserIMSyncInitiator(SysUserId(sys_user_id)) + + def sync_run_from_record(record: HumanInputIMSyncRun) -> IMSyncRun: """Map one sync run record into its independent aggregate.""" @@ -61,9 +81,7 @@ def sync_run_from_record(record: HumanInputIMSyncRun) -> IMSyncRun: failed_count=record.failed_count, removed_count=record.removed_count, skipped_count=record.skipped_count, - started_by_account_id=( - AccountId(record.started_by_account_id) if record.started_by_account_id is not None else None - ), + started_by=_sync_initiator_from_record(record), started_at=_timestamp(record.started_at) if record.started_at is not None else None, finished_at=_timestamp(record.finished_at) if record.finished_at is not None else None, error_code=record.error_code, @@ -74,7 +92,17 @@ def sync_run_from_record(record: HumanInputIMSyncRun) -> IMSyncRun: def sync_run_to_record(run: IMSyncRun) -> HumanInputIMSyncRun: - """Map one sync aggregate into a detached record.""" + """Map either initiating identity into exactly one column of a detached record.""" + + match run.started_by: + case AccountIMSyncInitiator(account_id): + started_by_account_id = str(account_id) + started_by_sys_user_id = None + case SysUserIMSyncInitiator(sys_user_id): + started_by_account_id = None + started_by_sys_user_id = str(sys_user_id) + case unexpected: + assert_never(unexpected) record = HumanInputIMSyncRun( channel_id=run.channel_revision.channel_id, @@ -86,7 +114,8 @@ def sync_run_to_record(run: IMSyncRun) -> HumanInputIMSyncRun: failed_count=run.failed_count, removed_count=run.removed_count, skipped_count=run.skipped_count, - started_by_account_id=str(run.started_by_account_id) if run.started_by_account_id is not None else None, + started_by_account_id=started_by_account_id, + started_by_sys_user_id=started_by_sys_user_id, started_at=run.started_at if run.started_at is not None else None, finished_at=run.finished_at if run.finished_at is not None else None, error_code=run.error_code, diff --git a/api/repositories/human_input_v2/im_integration/repository.py b/api/repositories/human_input_v2/im_integration/repository.py index 3858659a4a6..6aac4276448 100644 --- a/api/repositories/human_input_v2/im_integration/repository.py +++ b/api/repositories/human_input_v2/im_integration/repository.py @@ -19,7 +19,8 @@ from core.human_input_v2.im_integration import ( SyncResultFact, SyncResultPage, ) -from core.human_input_v2.shared import AccountId, IMSyncResultId, IMSyncRunId +from core.human_input_v2.im_integration.sync_records import IMSyncInitiator +from core.human_input_v2.shared import IMSyncResultId, IMSyncRunId from libs.uuid_utils import uuidv7 from models.human_input_v2 import HumanInputIMChannel, HumanInputIMSyncResult, HumanInputIMSyncRun from repositories.human_input_v2.im_binding_repository import IMBindingRepository @@ -42,9 +43,14 @@ class SQLAlchemyIMControlPlaneRepository: self, *, sync_run_id: IMSyncRunId, - started_by_account_id: AccountId | None, + started_by: IMSyncInitiator, now: NaiveDatetime, ) -> ActiveRunDecision: + """Persist one initiating identity or return the unchanged active run. + + The caller owns the transaction. The Channel lock serializes creation; + a stale revision returns without creating a run. + """ revision = IMChannelRevision( str(self._channel.id), self._channel.config_version, @@ -78,7 +84,7 @@ class SQLAlchemyIMControlPlaneRepository: sync_run_id=sync_run_id, channel_revision=revision, provider=self._channel.provider, - started_by_account_id=started_by_account_id, + started_by=started_by, now=now, ) record = sync_run_to_record(run) diff --git a/api/repositories/human_input_v2/sqlalchemy_form_repository.py b/api/repositories/human_input_v2/sqlalchemy_form_repository.py index 3ff7816670f..c1a37fdb59c 100644 --- a/api/repositories/human_input_v2/sqlalchemy_form_repository.py +++ b/api/repositories/human_input_v2/sqlalchemy_form_repository.py @@ -210,6 +210,18 @@ class SQLAlchemyFormRepository(FormRepository): ) return tuple(_form_from_record(record) for record in records) + @override + def get_submitted_forms(self, *, workflow_run_id: str) -> tuple[Form, ...]: + """Return only accepted runtime forms in the bound owner scope without writes.""" + records = self._session.scalars( + self._query().where( + HumanInputForm.workflow_run_id == workflow_run_id, + HumanInputForm.form_kind == HumanInputFormKind.RUNTIME, + HumanInputForm.status == HumanInputFormStatus.SUBMITTED, + ) + ) + return tuple(_form_from_record(record) for record in records) + @override def create_form(self, params: FormCreateParams) -> Form: execution_ids = (params.workflow_run_id, params.node_execution_id) diff --git a/api/services/human_input_v2/im_contact_sync/composition.py b/api/services/human_input_v2/im_contact_sync/composition.py index 87073b9d423..636723a99c9 100644 --- a/api/services/human_input_v2/im_contact_sync/composition.py +++ b/api/services/human_input_v2/im_contact_sync/composition.py @@ -13,6 +13,7 @@ from core.human_input_v2.im_integration.adapters.factory import build_im_provide from core.human_input_v2.shared import DeploymentScope, DirectoryScope, IMSyncRunId, TenantId, WorkspaceScope from extensions.ext_database import db from extensions.ext_key_provider import key_provider_manager +from repositories.account.repository import SQLAlchemyAccountRepository from repositories.human_input_v2.contact import Contact, ContactQuery, ContactType from repositories.human_input_v2.im_channel_repository import IMChannel from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository @@ -103,7 +104,9 @@ def build_im_contact_sync_application( deployment_adapter_factory: IMChannelAdapterFactory | None = None, ) -> IMContactSyncApplication: sessions = session_maker or sessionmaker(bind=db.engine, expire_on_commit=False) - sync_service = IMSyncService(sessions, _resolve_channel, _dispatch) + sync_service = IMSyncService( + sessions, _resolve_channel, _dispatch, account_query=SQLAlchemyAccountRepository(sessions) + ) worker = IMContactSyncWorker( sync_service, _reconciliation_factory( @@ -137,7 +140,7 @@ def build_im_contact_sync_worker( def build_im_sync_service(*, session_maker: sessionmaker[Session] | None = None) -> IMSyncService: sessions = session_maker or sessionmaker(bind=db.engine, expire_on_commit=False) - return IMSyncService(sessions, _resolve_channel, _dispatch) + return IMSyncService(sessions, _resolve_channel, _dispatch, account_query=SQLAlchemyAccountRepository(sessions)) def _resolve_channel(session: Session, owner_scope: DirectoryScope) -> IMChannel | None: diff --git a/api/services/human_input_v2/im_contact_sync/coordinator.py b/api/services/human_input_v2/im_contact_sync/coordinator.py index c0ae40bae0f..5f4badcd411 100644 --- a/api/services/human_input_v2/im_contact_sync/coordinator.py +++ b/api/services/human_input_v2/im_contact_sync/coordinator.py @@ -39,6 +39,7 @@ from core.human_input_v2.im_integration import ( ) from core.human_input_v2.im_integration.adapters import IMProviderAdapter from core.human_input_v2.im_integration.adapters.entities import DirectoryEntry, DirectoryReadFailure, ProviderUserId +from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator from core.human_input_v2.shared import ( AccountId, ContactId, @@ -236,7 +237,7 @@ class IMChannelReconciliationService: # tenant only for Workspace Channels. if self._tenant_id is None or not any(entry.avatar is not None for entry in entries): return {} - created_by = run.started_by_account_id + created_by = run.started_by.account_id if isinstance(run.started_by, AccountIMSyncInitiator) else None if created_by is None: with self._session_factory() as session: owner_id = session.scalar( diff --git a/api/services/human_input_v2/im_contact_sync/service.py b/api/services/human_input_v2/im_contact_sync/service.py index 25207f7423d..3df6183c447 100644 --- a/api/services/human_input_v2/im_contact_sync/service.py +++ b/api/services/human_input_v2/im_contact_sync/service.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from collections.abc import Callable +from dataclasses import dataclass from pydantic import NaiveDatetime from sqlalchemy.orm import Session, sessionmaker @@ -15,7 +16,8 @@ from core.human_input_v2.im_integration import ( SynchronizedIMIdentityPage, SyncResultPage, ) -from core.human_input_v2.shared import AccountId, DirectoryScope, IMSyncRunId +from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator, IMSyncInitiator +from core.human_input_v2.shared import DirectoryScope, IMSyncRunId from libs.datetime_utils import naive_utc_now from libs.uuid_utils import uuidv7 from models.human_input_v2 import HumanInputIMSyncRun @@ -23,6 +25,7 @@ from repositories.human_input_v2.im_channel_repository import IMChannel from repositories.human_input_v2.im_integration import SQLAlchemyIMControlPlaneRepository from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository +from services.account_ports import AccountSnapshotQuery logger = logging.getLogger(__name__) @@ -47,6 +50,18 @@ type IMSyncRunDispatcher = Callable[[IMSyncRunId, DirectoryScope], None] type IMChannelResolver = Callable[[Session, DirectoryScope], IMChannel | None] +@dataclass(frozen=True, slots=True) +class IMSyncRunSummary: + """A persisted run with its currently resolvable Dify Account name. + + The name is empty for EE sys_users and deleted Accounts. CE/SaaS never + resolves EE administrator identities. + """ + + run: IMSyncRun + initiator_display_name: str + + class IMSyncService: """Own Session boundaries around Channel-bound synchronization persistence.""" @@ -56,25 +71,34 @@ class IMSyncService: channel_resolver: IMChannelResolver, dispatcher: IMSyncRunDispatcher, *, + account_query: AccountSnapshotQuery, clock: Callable[[], NaiveDatetime] = naive_utc_now, run_id_factory: Callable[[], IMSyncRunId] = lambda: IMSyncRunId(str(uuidv7())), ) -> None: self._session_factory = session_factory self._channel_resolver = channel_resolver self._dispatcher = dispatcher + self._account_query = account_query self._clock = clock self._run_id_factory = run_id_factory def create_or_get_active_run( self, owner_scope: DirectoryScope, - started_by_account_id: AccountId | None, - ) -> IMSyncRun: + started_by: IMSyncInitiator, + ) -> IMSyncRunSummary: + """Create or reuse a run from one authenticated initiating identity. + + CE/SaaS supplies an Account ID; EE supplies a sys_users.id. Reusing an + active run preserves its original initiator. Commit precedes dispatch; + dispatch failure leaves the queued run available for retry. The returned + display name is resolved from the Account after persistence, never stored. + """ with self._session_factory() as session, session.begin(): channel = self._require_channel(session, owner_scope) decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run( sync_run_id=self._run_id_factory(), - started_by_account_id=started_by_account_id, + started_by=started_by, now=self._clock(), ) if decision.kind is ActiveRunDecisionKind.STALE_REVISION: @@ -89,9 +113,14 @@ class IMSyncService: raise IMSyncDispatchUnavailableError( "IM synchronization dispatch is temporarily unavailable" ) from error - return decision.run + return self._summarize_run(decision.run) - def get_latest_run(self, owner_scope: DirectoryScope) -> IMSyncRun: + def get_latest_run(self, owner_scope: DirectoryScope) -> IMSyncRunSummary: + """Read the current Channel's latest run and resolve its Account name.""" + return self._summarize_run(self._load_latest_run(owner_scope)) + + def _load_latest_run(self, owner_scope: DirectoryScope) -> IMSyncRun: + """Load the current Channel's latest run or raise the existing not-found error.""" with self._session_factory() as session: channel = self._require_channel(session, owner_scope) run = SQLAlchemyIMControlPlaneRepository(session, channel).load_latest_sync_run() @@ -107,7 +136,7 @@ class IMSyncService: page: int, limit: int, ) -> SyncResultPage: - latest_run = self.get_latest_run(owner_scope) + latest_run = self._load_latest_run(owner_scope) with self._session_factory() as session: channel = self._require_channel(session, owner_scope) return SQLAlchemyIMControlPlaneRepository(session, channel).page_sync_results( @@ -152,6 +181,13 @@ class IMSyncService: raise IMChannelNotConfiguredError("Owner has no IM Channel") return channel + def _summarize_run(self, run: IMSyncRun) -> IMSyncRunSummary: + """Resolve only Account initiators; EE names stay empty.""" + if not isinstance(run.started_by, AccountIMSyncInitiator): + return IMSyncRunSummary(run, "") + account = self._account_query.get(str(run.started_by.account_id)) + return IMSyncRunSummary(run, account.name if account is not None else "") + __all__ = [ "IMChannelNotConfiguredError", diff --git a/api/services/human_input_v2/submission_service.py b/api/services/human_input_v2/submission_service.py index 626065410cf..25304d70bb7 100644 --- a/api/services/human_input_v2/submission_service.py +++ b/api/services/human_input_v2/submission_service.py @@ -11,14 +11,16 @@ from pydantic import JsonValue, TypeAdapter from sqlalchemy.orm import Session, sessionmaker from core.app.file_access import DatabaseFileAccessController +from core.app.file_access.scope import get_current_file_access_scope, grant_upload_file_access from core.human_input_v2.entities import IMProvider from core.human_input_v2.resolved_form import FileInput, FileListInput, MarkdownFragment, ParagraphInput, SelectInput from core.human_input_v2.shared.values import AccountId, TenantId, WorkspaceScope +from core.workflow.file_reference import resolve_file_record_id from core.workflow.nodes.human_input.enums import HumanInputFormStatus from factories.file_factory import build_from_mapping from factories.human_input_v2_form_presentation import refresh_form_file_urls from graphon.enums import WorkflowExecutionStatus -from graphon.file import FileUploadConfig +from graphon.file import FileTransferMethod, FileUploadConfig from graphon.variables.factory import build_segment from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment, StringSegment from libs.datetime_utils import naive_utc_now @@ -144,6 +146,41 @@ class HumanInputSubmissionService: def __init__(self, *, session_factory: sessionmaker[Session]) -> None: self._session_factory = session_factory + def grant_submitted_file_access(self, *, app_id: str, workflow_run_id: str) -> None: + """Restore accepted HITL file grants inside the initiating EndUser's scope. + + The caller supplies its authorized app and run before worker contexts + are captured. Grant only persisted submissions in the current tenant, + including earlier HITL steps across repeated pauses. Keep persistent + file ownership and all unrelated grants unchanged. Account and absent + scopes require no grants and perform no reads; read failures propagate + without changing the scope. + """ + scope = get_current_file_access_scope() + if scope is None or not scope.requires_user_ownership: + return + + with self._session_factory() as session: + forms = SQLAlchemyFormRepository(session, TenantId(scope.tenant_id), app_id).get_submitted_forms( + workflow_run_id=workflow_run_id + ) + file_ids: set[str] = set() + for form in forms: + assert form.submission is not None, "Submitted form must contain its accepted submission" + for segment in form.submission.normalized_submission_data.values(): + if isinstance(segment, FileSegment): + files = [segment.value] + elif isinstance(segment, ArrayFileSegment): + files = segment.value + else: + files = [] + for file in files: + if file.transfer_method in {FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL}: + file_id = resolve_file_record_id(file.reference) + if file_id is not None: + file_ids.add(file_id) + grant_upload_file_access(file_ids) + def get_service_api_form( self, *, actor: ServiceAPIFormActor, form_token: str, ip_address: str | None = None ) -> Form: diff --git a/api/tasks/app_generate/workflow_execute_task.py b/api/tasks/app_generate/workflow_execute_task.py index 6b47a01beab..963e0e996c2 100644 --- a/api/tasks/app_generate/workflow_execute_task.py +++ b/api/tasks/app_generate/workflow_execute_task.py @@ -40,6 +40,7 @@ from models.workflow import Workflow, WorkflowNodeExecutionTriggeredFrom, Workfl from repositories.factory import DifyAPIRepositoryFactory from repositories.sqlalchemy_api_workflow_run_repository import WorkflowRunNotPausedError from services.app_task_service import AppTaskService +from services.human_input_v2.submission_service import HumanInputSubmissionService from services.workflow_execution_identity import build_workflow_execution_identity from services.workflow_run_agg import WorkflowRunAgg @@ -715,6 +716,11 @@ def _resume_advanced_chat( graph_runtime_state=graph_runtime_state, pause_state_config=pause_state_config, response_stream_filter=response_stream_filter, + restore_file_access=partial( + HumanInputSubmissionService(session_factory=session_factory).grant_submitted_file_access, + app_id=app_model.id, + workflow_run_id=workflow_run_id, + ), session=session, ) except Exception: @@ -782,6 +788,11 @@ def _resume_workflow( workflow_node_execution_repository=workflow_node_execution_repository, pause_state_config=pause_state_config, response_stream_filter=response_stream_filter, + restore_file_access=partial( + HumanInputSubmissionService(session_factory=session_factory).grant_submitted_file_access, + app_id=app_model.id, + workflow_run_id=workflow_run_id, + ), ) except Exception: logger.exception("Failed to resume workflow execution for workflow run %s", workflow_run_id) diff --git a/api/tasks/async_workflow_tasks.py b/api/tasks/async_workflow_tasks.py index a9e3890e915..63616064c99 100644 --- a/api/tasks/async_workflow_tasks.py +++ b/api/tasks/async_workflow_tasks.py @@ -7,6 +7,7 @@ with appropriate retry policies and error handling. import logging from datetime import UTC, datetime +from functools import partial from typing import Any from uuid import UUID @@ -30,6 +31,7 @@ from models.workflow import Workflow, WorkflowNodeExecutionTriggeredFrom, Workfl from repositories.factory import DifyAPIRepositoryFactory from services.app_task_service import AppTaskService from services.errors.app import WorkflowNotFoundError +from services.human_input_v2.submission_service import HumanInputSubmissionService from services.trigger.execution_layer import TriggerPostLayer from services.workflow.entities import ( WorkflowResumeTaskData, @@ -184,6 +186,11 @@ def resume_workflow_execution(task_data_dict: dict[str, Any]) -> None: graph_engine_layers=graph_engine_layers, pause_state_config=pause_config, response_stream_filter=response_stream_filter, + restore_file_access=partial( + HumanInputSubmissionService(session_factory=session_factory).grant_submitted_file_access, + app_id=app_model.id, + workflow_run_id=workflow_run.id, + ), ) workflow_run_repo.delete_workflow_pause(pause_entity) diff --git a/api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_api.py b/api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_api.py index 5fde53b991a..6d61c26d585 100644 --- a/api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_api.py +++ b/api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_api.py @@ -23,6 +23,7 @@ from core.human_input_v2.im_integration import ( SyncIdentitySnapshot, SyncResultFact, ) +from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator from core.human_input_v2.shared import ( AccountId, ContactId, @@ -275,7 +276,7 @@ def test_latest_result_http_projection_reads_every_persisted_bucket( sync_run_id=IMSyncRunId("00000000-0000-0000-0000-000000000501"), channel_revision=IMChannelRevision(str(channel.id), channel.config_version), provider=channel.provider, - started_by_account_id=AccountId(account.id), + started_by=AccountIMSyncInitiator(AccountId(account.id)), now=_NOW, ), status=IMSyncRunStatus.SUCCEEDED, diff --git a/api/tests/integration_tests/repositories/human_input_v2/test_im_control_plane_concurrency.py b/api/tests/integration_tests/repositories/human_input_v2/test_im_control_plane_concurrency.py index a4a1ad7f5ba..47b3ede3a3a 100644 --- a/api/tests/integration_tests/repositories/human_input_v2/test_im_control_plane_concurrency.py +++ b/api/tests/integration_tests/repositories/human_input_v2/test_im_control_plane_concurrency.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, sessionmaker from core.human_input_v2.entities import IMProvider from core.human_input_v2.im_integration import ActiveRunDecisionKind +from core.human_input_v2.im_integration.sync_records import SysUserId, SysUserIMSyncInitiator from core.human_input_v2.shared import ContactId, IMIdentityId, IMSyncRunId from extensions.ext_database import db from libs.datetime_utils import naive_utc_now @@ -99,7 +100,7 @@ def test_concurrent_sync_triggers_create_at_most_one_active_run(flask_req_ctx: o with session_maker.begin() as session: decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run( sync_run_id=IMSyncRunId(str(uuidv7())), - started_by_account_id=None, + started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")), now=naive_utc_now(), ) return decision.kind diff --git a/api/tests/integration_tests/repositories/human_input_v2/test_stale_im_sync_runs.py b/api/tests/integration_tests/repositories/human_input_v2/test_stale_im_sync_runs.py index 7e5cc2cfd06..c63b38bac80 100644 --- a/api/tests/integration_tests/repositories/human_input_v2/test_stale_im_sync_runs.py +++ b/api/tests/integration_tests/repositories/human_input_v2/test_stale_im_sync_runs.py @@ -30,6 +30,7 @@ from core.human_input_v2.im_integration.adapters import ( IMWebhookHandler, ProviderUserId, ) +from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator, SysUserId, SysUserIMSyncInitiator from core.human_input_v2.shared import AccountId, ContactId, DirectoryScope, IMSyncRunId, TenantId, WorkspaceScope from libs.datetime_utils import naive_utc_now from models.human_input_v2 import ( @@ -41,6 +42,7 @@ from models.human_input_v2 import ( HumanInputIMSyncResult, HumanInputIMSyncRun, ) +from repositories.account.repository import SQLAlchemyAccountRepository from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials from repositories.human_input_v2.contact import Contact from repositories.human_input_v2.im_channel_repository import IMChannel, IMChannelId, IMChannelStatus, WebhookId @@ -151,7 +153,12 @@ class _SyncContext: return WorkspaceIMChannelReader(session, self.scope.id).get() def service(self) -> IMSyncService: - return IMSyncService(self.sessions, self.resolve_channel, lambda _run, _scope: None) + return IMSyncService( + self.sessions, + self.resolve_channel, + lambda _run, _scope: None, + account_query=SQLAlchemyAccountRepository(self.sessions), + ) def worker(self, directory: _Directory | None = None) -> IMContactSyncWorker: def reconciliation(scope: DirectoryScope) -> IMChannelReconciliationService: @@ -201,7 +208,7 @@ def sync_context(sync_engine: Engine) -> _SyncContext: def test_revision_rotation_retires_old_run_and_new_worker_succeeds( sync_context: _SyncContext, status: IMSyncRunStatus, recovery: str ) -> None: - old = sync_context.service().create_or_get_active_run(sync_context.scope, None) + old = sync_context.service().create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)).run with sync_context.sessions.begin() as session: session.get_one(HumanInputIMSyncRun, str(old.id)).status = status sync_context.rotate() @@ -209,7 +216,9 @@ def test_revision_rotation_retires_old_run_and_new_worker_succeeds( terminal = sync_context.worker().execute(old.id, sync_context.scope) assert terminal.status is IMSyncRunStatus.FAILED assert terminal.error_code == "stale_revision" - current = sync_context.service().create_or_get_active_run(sync_context.scope, None) + current = ( + sync_context.service().create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)).run + ) assert current.id != old.id assert current.channel_revision.config_version == 2 assert current.status is IMSyncRunStatus.QUEUED @@ -233,14 +242,18 @@ def test_revision_rotation_retires_old_run_and_new_worker_succeeds( def test_concurrent_old_workers_and_triggers_leave_one_new_active_run(sync_context: _SyncContext) -> None: - old = sync_context.service().create_or_get_active_run(sync_context.scope, None) + old = sync_context.service().create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)).run sync_context.rotate() barrier = Barrier(4) def execute(trigger: bool) -> IMSyncRun: barrier.wait(timeout=10) if trigger: - return sync_context.service().create_or_get_active_run(sync_context.scope, None) + return ( + sync_context.service() + .create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)) + .run + ) return sync_context.worker().execute(old.id, sync_context.scope) with ThreadPoolExecutor(max_workers=4) as executor: @@ -267,14 +280,18 @@ def test_concurrent_old_workers_and_triggers_leave_one_new_active_run(sync_conte def test_late_old_apply_cannot_overwrite_new_revision_state(sync_context: _SyncContext) -> None: - old = sync_context.service().create_or_get_active_run(sync_context.scope, None) + old = sync_context.service().create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)).run entered, resume = Event(), Event() with ThreadPoolExecutor(max_workers=1) as executor: pending = executor.submit(sync_context.worker(_Directory(entered, resume)).execute, old.id, sync_context.scope) try: assert entered.wait(timeout=10) sync_context.rotate() - current = sync_context.service().create_or_get_active_run(sync_context.scope, None) + current = ( + sync_context.service() + .create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)) + .run + ) assert sync_context.worker().execute(current.id, sync_context.scope).status is IMSyncRunStatus.SUCCEEDED finally: resume.set() @@ -296,7 +313,7 @@ def test_late_old_apply_cannot_overwrite_new_revision_state(sync_context: _SyncC def test_stale_completion_is_scoped_current_and_caller_rollback_owned(sync_context: _SyncContext) -> None: - old = sync_context.service().create_or_get_active_run(sync_context.scope, None) + old = sync_context.service().create_or_get_active_run(sync_context.scope, AccountIMSyncInitiator(_ACCOUNT_ID)).run current = sync_context.rotate() with sync_context.sessions() as session: assert ( @@ -307,7 +324,9 @@ def test_stale_completion_is_scoped_current_and_caller_rollback_owned(sync_conte ) with sync_context.sessions() as session: replacement = SQLAlchemyIMControlPlaneRepository(session, current).create_or_get_active_run( - sync_run_id=IMSyncRunId(str(uuid4())), started_by_account_id=None, now=naive_utc_now() + sync_run_id=IMSyncRunId(str(uuid4())), + started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")), + now=naive_utc_now(), ) assert replacement.run is not None assert replacement.run.id != old.id diff --git a/api/tests/unit_tests/controllers/common/test_human_input_v2_contracts.py b/api/tests/unit_tests/controllers/common/test_human_input_v2_contracts.py index 6168adf7d5e..fc5c67b4294 100644 --- a/api/tests/unit_tests/controllers/common/test_human_input_v2_contracts.py +++ b/api/tests/unit_tests/controllers/common/test_human_input_v2_contracts.py @@ -39,6 +39,7 @@ def test_sync_run_exposes_captured_revision() -> None: "provider": "feishu", "channel_id": "channel-id", "channel_config_version": 4, + "initiator_display_name": "Sync Admin", } ) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py b/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py index 3d60c4c6c79..2c51914054c 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py @@ -7,7 +7,8 @@ from dataclasses import replace from datetime import datetime from importlib import import_module from inspect import unwrap -from typing import Never, override +from typing import override +from unittest.mock import create_autospec import pytest from flask import Flask @@ -50,10 +51,12 @@ from core.human_input_v2.im_integration import ( SyncResultFact, SyncResultPage, ) +from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator, IMSyncInitiator from core.human_input_v2.shared import ( AccountId, ContactId, DeploymentScope, + DirectoryScope, IMBindingId, IMIdentityId, IMSyncResultId, @@ -77,6 +80,7 @@ from services.human_input_v2.im_contact_sync.service import ( IMSyncDispatchUnavailableError, IMSyncRevisionChangedError, IMSyncRunNotFoundError, + IMSyncRunSummary, ) from tests.helpers.unit.model_factories import make_account @@ -87,7 +91,7 @@ _RUN = IMSyncRun.create( sync_run_id=IMSyncRunId("run-1"), channel_revision=IMChannelRevision("channel-1", 3), provider=IMProvider.FEISHU, - started_by_account_id=AccountId("account-1"), + started_by=AccountIMSyncInitiator(AccountId("account-1")), now=_NOW, ) _RESULT = SyncResultFact( @@ -117,39 +121,39 @@ _RESULT = SyncResultFact( class _SyncService: def create_or_get_active_run( self, - organization_scope: WorkspaceScope, - started_by_account_id: AccountId, - ) -> IMSyncRun: - assert organization_scope == WorkspaceScope(id=TenantId("workspace-1")) - assert started_by_account_id == AccountId("account-1") - return _RUN + owner_scope: DirectoryScope, + started_by: IMSyncInitiator, + ) -> IMSyncRunSummary: + assert owner_scope == WorkspaceScope(id=TenantId("workspace-1")) + assert started_by == AccountIMSyncInitiator(AccountId("account-1")) + return IMSyncRunSummary(_RUN, "Editor") - def get_latest_run(self, organization_scope: WorkspaceScope) -> IMSyncRun: - assert organization_scope == WorkspaceScope(id=TenantId("workspace-1")) - return _RUN + def get_latest_run(self, owner_scope: DirectoryScope) -> IMSyncRunSummary: + assert owner_scope == WorkspaceScope(id=TenantId("workspace-1")) + return IMSyncRunSummary(_RUN, "Editor") def list_latest_results( self, - organization_scope: WorkspaceScope, + owner_scope: DirectoryScope, result_type: IMSyncResultType, *, page: int, limit: int, ) -> SyncResultPage: - assert organization_scope == WorkspaceScope(id=TenantId("workspace-1")) + assert owner_scope == WorkspaceScope(id=TenantId("workspace-1")) assert result_type is IMSyncResultType.NOT_MATCHED assert (page, limit) == (1, 20) return SyncResultPage((_RESULT,), page=page, limit=limit, total=1) def search_identities( self, - organization_scope: WorkspaceScope, + owner_scope: DirectoryScope, *, keyword: str | None, page: int, limit: int, ) -> SynchronizedIMIdentityPage: - assert organization_scope == WorkspaceScope(id=TenantId("workspace-1")) + assert owner_scope == WorkspaceScope(id=TenantId("workspace-1")) assert (keyword, page, limit) == ("provider-user", 1, 20) return SynchronizedIMIdentityPage( ( @@ -188,26 +192,42 @@ def _contact_view(scope: IMBindingScope) -> ContactIMBindingView: class _BindingService: - def set_organization_binding(self, **kwargs: object) -> ContactIMBindingView: - assert kwargs == { - "organization_scope": WorkspaceScope(id=TenantId("workspace-1")), - "tenant_id": TenantId("workspace-1"), - "contact_id": ContactId("00000000-0000-0000-0000-000000000001"), - "identity_id": IMIdentityId("identity-1"), - "bound_by_account_id": AccountId("account-1"), - } + def set_organization_binding( + self, + *, + organization_scope: DirectoryScope, + tenant_id: TenantId, + contact_id: ContactId, + identity_id: IMIdentityId, + bound_by_account_id: AccountId | None, + ) -> ContactIMBindingView: + assert organization_scope == WorkspaceScope(id=TenantId("workspace-1")) + assert tenant_id == TenantId("workspace-1") + assert contact_id == ContactId("00000000-0000-0000-0000-000000000001") + assert identity_id == IMIdentityId("identity-1") + assert bound_by_account_id == AccountId("account-1") return _contact_view(IMBindingScope.ORGANIZATION) - def delete_organization_binding(self, **kwargs: object) -> None: - assert kwargs == { - "organization_scope": WorkspaceScope(id=TenantId("workspace-1")), - "contact_id": ContactId("00000000-0000-0000-0000-000000000001"), - } + def delete_organization_binding(self, *, organization_scope: DirectoryScope, contact_id: ContactId) -> None: + assert organization_scope == WorkspaceScope(id=TenantId("workspace-1")) + assert contact_id == ContactId("00000000-0000-0000-0000-000000000001") - def set_workspace_override(self, **_kwargs: object) -> ContactIMBindingView: + def set_workspace_override( + self, + *, + organization_scope: DirectoryScope, + tenant_id: TenantId, + contact_id: ContactId, + identity_id: IMIdentityId, + bound_by_account_id: AccountId | None, + ) -> ContactIMBindingView: + del organization_scope, tenant_id, contact_id, identity_id, bound_by_account_id return _contact_view(IMBindingScope.WORKSPACE) - def reset_workspace_override(self, **_kwargs: object) -> ContactIMBindingView: + def reset_workspace_override( + self, *, organization_scope: DirectoryScope, tenant_id: TenantId, contact_id: ContactId + ) -> ContactIMBindingView: + del organization_scope, tenant_id, contact_id return _contact_view(IMBindingScope.ORGANIZATION) @@ -265,6 +285,7 @@ def test_sync_command_and_queries_map_transport_neutral_results( "provider": "feishu", "channel_id": "channel-1", "channel_config_version": 3, + "initiator_display_name": "Editor", } } ) @@ -310,11 +331,11 @@ def test_sync_dispatch_unavailable_has_stable_retryable_http_mapping( ) -> None: error = IMSyncDispatchUnavailableError("IM synchronization dispatch is temporarily unavailable") - class SyncService: - def create_or_get_active_run(self, _scope: object, _account_id: object) -> Never: - raise error - - monkeypatch.setattr(application.sync_service, "create_or_get_active_run", SyncService().create_or_get_active_run) + monkeypatch.setattr( + application.sync_service, + "create_or_get_active_run", + create_autospec(application.sync_service.create_or_get_active_run, side_effect=error), + ) with app.test_request_context(method="POST"): response, status = unwrap(WorkspaceIMSyncRunsApi.post)( @@ -346,22 +367,21 @@ def test_write_unavailable_has_stable_retryable_http_mapping( ) error = IMWriteUnavailableError("IM write is temporarily unavailable") - class SyncService: - def create_or_get_active_run(self, _scope: object, _account_id: object) -> Never: - raise error - - class BindingService: - def set_organization_binding(self, **_kwargs: object) -> Never: - raise error - - def set_workspace_override(self, **_kwargs: object) -> Never: - raise error - - monkeypatch.setattr(application.sync_service, "create_or_get_active_run", SyncService().create_or_get_active_run) monkeypatch.setattr( - application.binding_service, "set_organization_binding", BindingService().set_organization_binding + application.sync_service, + "create_or_get_active_run", + create_autospec(application.sync_service.create_or_get_active_run, side_effect=error), + ) + monkeypatch.setattr( + application.binding_service, + "set_organization_binding", + create_autospec(application.binding_service.set_organization_binding, side_effect=error), + ) + monkeypatch.setattr( + application.binding_service, + "set_workspace_override", + create_autospec(application.binding_service.set_workspace_override, side_effect=error), ) - monkeypatch.setattr(application.binding_service, "set_workspace_override", BindingService().set_workspace_override) account = make_account(account_id="account-1", name="Editor", email="editor@example.com") contact_id = "00000000-0000-0000-0000-000000000001" if command == "sync_create": @@ -399,7 +419,7 @@ def test_write_unavailable_has_stable_retryable_http_mapping( contact_snapshot=SyncContactSnapshot( ContactId("contact-1"), "Reviewer", - None, + "snapshot@example.com", None, _CONTACT_CREATED_AT, ), @@ -410,7 +430,7 @@ def test_write_unavailable_has_stable_retryable_http_mapping( contact_id=ContactId("contact-1"), binding_id=IMBindingId("binding-1"), removal_reason=IMSyncRemovalReason.NOT_PRESENT_IN_DIRECTORY, - contact_snapshot=SyncContactSnapshot(ContactId("contact-1"), "Reviewer", None, None), + contact_snapshot=SyncContactSnapshot(ContactId("contact-1"), "Reviewer", "snapshot@example.com", None), identity_snapshot=SyncIdentitySnapshot( IMIdentityId("identity-1"), IMProvider.FEISHU, @@ -435,10 +455,11 @@ def test_latest_results_map_every_discriminated_bucket( monkeypatch: pytest.MonkeyPatch, sync_result: SyncResultFact, ) -> None: - class SyncService: + class SyncService(_SyncService): + @override def list_latest_results( self, - _scope: object, + owner_scope: DirectoryScope, result_type: IMSyncResultType, *, page: int, @@ -461,6 +482,8 @@ def test_latest_results_map_every_discriminated_bucket( assert result_payload["type"] == sync_result.result_type.value if sync_result.result_type in (IMSyncResultType.ADDED, IMSyncResultType.SKIPPED, IMSyncResultType.REMOVED): assert "type" not in result_payload["contact"] + assert sync_result.contact_snapshot is not None + assert result_payload["contact"]["email"] == sync_result.contact_snapshot.email if sync_result.result_type is IMSyncResultType.ADDED: assert result_payload["contact"]["created_at"] == 1752127200 if sync_result.result_type is IMSyncResultType.REMOVED: @@ -569,25 +592,50 @@ def test_enterprise_override_and_identity_search_use_the_deployment_directory( config_overrides: Callable[..., None], ) -> None: config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) - scopes = [] + scopes: list[DirectoryScope] = [] class BindingService(_BindingService): @override - def set_workspace_override(self, **kwargs: object) -> ContactIMBindingView: - scopes.append(kwargs["organization_scope"]) - return super().set_workspace_override(**kwargs) + def set_workspace_override( + self, + *, + organization_scope: DirectoryScope, + tenant_id: TenantId, + contact_id: ContactId, + identity_id: IMIdentityId, + bound_by_account_id: AccountId | None, + ) -> ContactIMBindingView: + scopes.append(organization_scope) + return super().set_workspace_override( + organization_scope=organization_scope, + tenant_id=tenant_id, + contact_id=contact_id, + identity_id=identity_id, + bound_by_account_id=bound_by_account_id, + ) @override - def reset_workspace_override(self, **kwargs: object) -> ContactIMBindingView: - scopes.append(kwargs["organization_scope"]) - return super().reset_workspace_override(**kwargs) + def reset_workspace_override( + self, *, organization_scope: DirectoryScope, tenant_id: TenantId, contact_id: ContactId + ) -> ContactIMBindingView: + scopes.append(organization_scope) + return super().reset_workspace_override( + organization_scope=organization_scope, + tenant_id=tenant_id, + contact_id=contact_id, + ) class SyncService(_SyncService): @override def search_identities( - self, organization_scope: WorkspaceScope, **_kwargs: object + self, + owner_scope: DirectoryScope, + *, + keyword: str | None, + page: int, + limit: int, ) -> SynchronizedIMIdentityPage: - scopes.append(organization_scope) + scopes.append(owner_scope) return SynchronizedIMIdentityPage((), page=1, limit=20, total=0) monkeypatch.setattr(application.binding_service, "set_workspace_override", BindingService().set_workspace_override) @@ -638,13 +686,10 @@ def test_expected_application_errors_have_stable_http_mapping( expected_code: str, ) -> None: if isinstance(error, IMBindingCommandError): - - class BindingService: - def set_organization_binding(self, **_kwargs: object) -> Never: - raise error - monkeypatch.setattr( - application.binding_service, "set_organization_binding", BindingService().set_organization_binding + application.binding_service, + "set_organization_binding", + create_autospec(application.binding_service.set_organization_binding, side_effect=error), ) handler = unwrap(WorkspaceContactIMBindingsApi.put) request_context = app.test_request_context(method="PUT", json={"identity_id": "identity-1"}) @@ -655,13 +700,10 @@ def test_expected_application_errors_have_stable_http_mapping( "00000000-0000-0000-0000-000000000001", ) elif isinstance(error, IMSyncRevisionChangedError): - - class SyncService: - def create_or_get_active_run(self, _scope: object, _account_id: object) -> Never: - raise error - monkeypatch.setattr( - application.sync_service, "create_or_get_active_run", SyncService().create_or_get_active_run + application.sync_service, + "create_or_get_active_run", + create_autospec(application.sync_service.create_or_get_active_run, side_effect=error), ) handler = unwrap(WorkspaceIMSyncRunsApi.post) request_context = app.test_request_context(method="POST") @@ -671,12 +713,11 @@ def test_expected_application_errors_have_stable_http_mapping( make_account(account_id="account-1", name="Editor", email="editor@example.com"), ) else: - - class SyncService: - def get_latest_run(self, _scope: object) -> Never: - raise error - - monkeypatch.setattr(application.sync_service, "get_latest_run", SyncService().get_latest_run) + monkeypatch.setattr( + application.sync_service, + "get_latest_run", + create_autospec(application.sync_service.get_latest_run, side_effect=error), + ) handler = unwrap(WorkspaceLatestIMSyncRunApi.get) request_context = app.test_request_context(method="GET") handler_args = (WorkspaceLatestIMSyncRunApi(), "workspace-1") @@ -823,7 +864,7 @@ def test_contact_read_handlers_preserve_current_and_editor_safe_projections( assert options["data"][0]["im_providers"] == ["feishu"] assert batch_options["data"] == options["data"] assert "im_bindings" not in options["data"][0] - assert "email" not in batch["data"][0] + assert batch["data"][0]["email"] == "reviewer@example.com" @pytest.mark.parametrize("provider", list(IMProvider)) diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py index ed8a48ee0c4..bfc0db4e76e 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py @@ -5,7 +5,9 @@ import logging import time from collections.abc import Mapping from contextlib import contextmanager +from datetime import timedelta from decimal import Decimal +from functools import partial from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock @@ -18,7 +20,7 @@ from pytest_mock import MockerFixture from sqlalchemy import Engine as _app_generator_Engine from sqlalchemy import event as sqlalchemy_event from sqlalchemy.engine import Engine as _advanced_chat_app_generator_Engine -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker import core.app.apps.advanced_chat.app_generator as app_generator_module import core.workflow.nodes.human_input.entities # noqa: F401 @@ -31,6 +33,7 @@ from core.app.apps.advanced_chat.generate_task_pipeline import ( MessageSnapshot, WorkflowSnapshot, ) +from core.app.apps.base_app_generator import noop_restore_file_access from core.app.apps.exc import GenerateTaskStoppedError from core.app.apps.workflow import app_generator as wf_app_gen_module from core.app.entities.app_invoke_entities import ( @@ -43,7 +46,10 @@ from core.app.file_access import DatabaseFileAccessController from core.app.task_pipeline import message_cycle_manager from core.app.task_pipeline.message_cycle_manager import MessageCycleManager from core.app.workflow.file_runtime import DifyWorkflowFileRuntime +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import RecipientId, TenantId from core.workflow import node_factory as node_factory_module +from core.workflow.file_reference import build_file_reference from core.workflow.node_factory import DifyNodeFactory from core.workflow.system_variables import build_system_variables from graphon.engine import Engine as _pause_resume_Engine @@ -59,6 +65,7 @@ from graphon.entities import WorkflowStartReason from graphon.entities.base_node_data import BaseNodeData, RetryConfig from graphon.entities.pause_reason import SchedulingPause from graphon.enums import BuiltinNodeTypes, NodeType, WorkflowNodeExecutionStatus +from graphon.file import File, FileTransferMethod, FileType from graphon.graph import Graph from graphon.node_events import NodeRunResult, PauseRequestedEvent from graphon.nodes.base.entities import OutputVariableEntity @@ -67,6 +74,7 @@ from graphon.nodes.end.entities import EndNodeData from graphon.nodes.start.entities import StartNodeData from graphon.runtime import RuntimeState, VariablePool from graphon.variables.input_entities import VariableEntity +from graphon.variables.segments import FileSegment from libs.datetime_utils import naive_utc_now from models import Account as _advanced_chat_app_generator_Account from models import Workflow as _advanced_chat_app_generator_Workflow @@ -77,7 +85,10 @@ from models.model import App, AppMode, Conversation, EndUser, Message from models.workflow import Workflow as _app_generator_Workflow from models.workflow import Workflow as _pause_resume_Workflow from models.workflow import WorkflowType +from repositories.human_input_v2.form_repository import Form, FormCreateParams +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository from services.errors.conversation import ConversationNotExistsError +from services.human_input_v2.submission_service import HumanInputSubmissionService from services.workflow_run_agg import WorkflowRunAgg from tests.helpers.unit.config_override import apply_config_overrides from tests.helpers.unit.model_factories import make_upload_file @@ -671,9 +682,15 @@ class TestAdvancedChatAppGeneratorInternals: assert isinstance(captured["conversation"], ConversationSnapshot) assert isinstance(captured["message"], MessageSnapshot) + @pytest.mark.parametrize("resuming", [False, True], ids=["file-default", "submitted-hitl-file"]) def test_generate_internal_flow_with_existing_records_skips_init( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: _app_generator_Engine - ): + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: _app_generator_Engine, + resuming: bool, + sqlite_session_factory: sessionmaker[Session], + ) -> None: generator = AdvancedChatAppGenerator(execution_driver=WorkflowRunAgg.run) generator._dialogue_count = 0 app_config = self._build_app_config() @@ -688,6 +705,40 @@ class TestAdvancedChatAppGeneratorInternals: } ) ] + if resuming: + app_config.variables = [] + now = naive_utc_now() + repository = SQLAlchemyFormRepository(sqlite_session, TenantId(app_config.tenant_id), app_config.app_id) + form = repository.create_form( + FormCreateParams( + workflow_run_id="run-id", + node_execution_id="approval-execution", + resolved_form=ResolvedForm(title="Approval", parts=(), actions=(), legacy_form_content=""), + expiration_time=now + timedelta(hours=1), + global_timeout_deadline=now + timedelta(days=1), + ) + ) + accepted = repository.submit_form( + form.id, + submitted_by_recipient_id=RecipientId("recipient"), + submitted_by_delivery_id="delivery", + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={ + "file": FileSegment( + value=File( + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + reference=build_file_reference(record_id=default_file.id), + filename=default_file.name, + extension=".txt", + mime_type=default_file.mime_type, + size=default_file.size, + ) + ) + }, + ) + assert isinstance(accepted, Form) file_runtime = DifyWorkflowFileRuntime(file_access_controller=DatabaseFileAccessController()) monkeypatch.setattr(file_runtime, "_secret_key", lambda: b"test-key") @@ -780,6 +831,16 @@ class TestAdvancedChatAppGeneratorInternals: conversation=conversation, message=message, stream=False, + graph_runtime_state=_build_runtime_state("run-id") if resuming else None, + restore_file_access=( + partial( + HumanInputSubmissionService(session_factory=sqlite_session_factory).grant_submitted_file_access, + app_id=app_config.app_id, + workflow_run_id="run-id", + ) + if resuming + else noop_restore_file_access + ), ) assert response == {"raw": True} diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator.py index 1b8896b29d2..51cc7dc942e 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator.py @@ -3,6 +3,8 @@ from __future__ import annotations import contextlib import contextvars import json +from datetime import timedelta +from functools import partial from types import SimpleNamespace from unittest.mock import MagicMock, Mock @@ -31,22 +33,31 @@ from core.app.layers.pause_state_persist_layer import ( _WorkflowGenerateEntityWrapper, ) from core.app.workflow.file_runtime import DifyWorkflowFileRuntime +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import RecipientId, TenantId from core.repositories import SQLAlchemyWorkflowExecutionRepository, SQLAlchemyWorkflowNodeExecutionRepository from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository from core.tools.tool_file_manager import ToolFileManager from core.workflow.file_reference import build_file_reference from core.workflow.node_runtime import resolve_dify_run_context from core.workflow.system_variables import SystemVariableKey, get_system_text +from graphon.engine_events import GraphRunSucceededEvent from graphon.enums import WorkflowExecutionStatus +from graphon.file import File, FileTransferMethod, FileType from graphon.runtime import RuntimeState, VariablePool from graphon.variable_loader import DUMMY_VARIABLE_LOADER from graphon.variables.input_entities import VariableEntity +from graphon.variables.segments import ArrayFileSegment, FileSegment +from libs.datetime_utils import naive_utc_now from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom from models.model import App, AppMode, EndUser from models.snippet import CustomizedSnippet from models.workflow import Workflow, WorkflowKind, WorkflowNodeExecutionTriggeredFrom, WorkflowRun, WorkflowType from repositories.entities.workflow_execution import WorkflowExecutionIdentity +from repositories.human_input_v2.form_repository import Form, FormCreateParams +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository from services.file_request_service import FileRequestService +from services.human_input_v2.submission_service import HumanInputSubmissionService from services.workflow_run_agg import WorkflowRunAgg from tests.helpers.unit.model_factories import make_account, make_upload_file, make_workflow @@ -195,6 +206,140 @@ class TestWorkflowAppGeneratorCases: def disable_enterprise_telemetry(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("core.telemetry.gateway.is_enterprise_telemetry_enabled", lambda: False) + @staticmethod + @pytest.mark.parametrize("file_list", [False, True], ids=["single-file", "file-list"]) + @pytest.mark.parametrize("invoke_from", [InvokeFrom.WEB_APP, InvokeFrom.SERVICE_API]) + def test_resumed_end_user_can_output_accepted_hitl_files( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + file_list: bool, + invoke_from: InvokeFrom, + ) -> None: + app, workflow, end_user = _persist_generator_rows(sqlite_session) + uploaded = make_upload_file(tenant_id=app.tenant_id, created_by="approver") + unrelated = make_upload_file(tenant_id=app.tenant_id, created_by="approver") + workflow.graph = json.dumps( + { + "nodes": [ + {"id": "start", "data": {"type": "start", "title": "Start", "variables": []}}, + { + "id": "end", + "data": { + "type": "end", + "title": "End", + "outputs": [{"variable": "result", "value_selector": ["approval", "file"]}], + }, + }, + ], + "edges": [{"source": "start", "target": "end"}], + } + ) + sqlite_session.add_all( + [ + uploaded, + unrelated, + WorkflowRun( + id="run", + tenant_id=app.tenant_id, + app_id=app.id, + workflow_id=workflow.id, + type=workflow.type, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version=workflow.version, + graph=workflow.graph, + inputs="{}", + status=WorkflowExecutionStatus.PAUSED, + created_by_role=CreatorUserRole.END_USER, + created_by=end_user.id, + ), + ] + ) + sqlite_session.commit() + file = File( + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.LOCAL_FILE, + reference=build_file_reference(record_id=uploaded.id), + filename=uploaded.name, + extension=".txt", + mime_type=uploaded.mime_type, + size=uploaded.size, + ) + submitted = ArrayFileSegment(value=[file]) if file_list else FileSegment(value=file) + now = naive_utc_now() + with sqlite_session_factory.begin() as session: + repository = SQLAlchemyFormRepository(session, TenantId(app.tenant_id), app.id) + form = repository.create_form( + FormCreateParams( + workflow_run_id="run", + node_execution_id="approval-execution", + resolved_form=ResolvedForm(title="Approval", parts=(), actions=(), legacy_form_content=""), + expiration_time=now + timedelta(hours=1), + global_timeout_deadline=now + timedelta(days=1), + ) + ) + accepted = repository.submit_form( + form.id, + submitted_by_recipient_id=RecipientId("recipient"), + submitted_by_delivery_id="delivery", + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={"file": submitted}, + ) + assert isinstance(accepted, Form) + state = RuntimeState(workflow_id=workflow.id, variable_pool=VariablePool(), start_at=0) + state.variable_pool.add(["approval", "file"], submitted) + state.variable_pool.add(["sys", "workflow_run_id"], "run") + state.variable_pool.add(["sys", "app_id"], app.id) + file_runtime = DifyWorkflowFileRuntime(file_access_controller=DatabaseFileAccessController()) + monkeypatch.setattr(file_runtime, "_secret_key", lambda: b"test-key") + completed: list[GraphRunSucceededEvent] = [] + + def execute( + runner: WorkflowBasedAppRunner, + _pause_config: PauseStateLayerConfig | None, + _executions: WorkflowExecutionRepository, + _nodes: WorkflowNodeExecutionRepository, + ) -> None: + prepared = runner.prepare() + assert prepared is not None + events = list(prepared.entry.run()) + completed.extend(event for event in events if isinstance(event, GraphRunSucceededEvent)) + + def respond(**_kwargs: object) -> dict[str, object]: + assert file_runtime.resolve_upload_file_uri(upload_file_id=uploaded.id).startswith( + f"/files/{uploaded.id}/file-preview?" + ) + with pytest.raises(ValueError, match="not found"): + file_runtime.resolve_upload_file_uri(upload_file_id=unrelated.id) + return {} + + generator = WorkflowAppGenerator(execution_driver=execute) + monkeypatch.setattr(generator, "_handle_response", respond) + monkeypatch.setattr( + _workflow_app_generator.WorkflowAppGenerateResponseConverter, + "convert", + lambda response, invoke_from: response, + ) + execution_repository, node_repository = _repositories(sqlite_session_factory, app, end_user) + generator.resume( + app_model=app, + workflow=workflow, + user=end_user, + application_generate_entity=_generate_entity(app, workflow, end_user, invoke_from=invoke_from), + graph_runtime_state=state, + workflow_execution_repository=execution_repository, + workflow_node_execution_repository=node_repository, + restore_file_access=partial( + HumanInputSubmissionService(session_factory=sqlite_session_factory).grant_submitted_file_access, + app_id=app.id, + workflow_run_id="run", + ), + ) + + assert len(completed) == 1 + assert completed[0].outputs["result"] == submitted.value + @staticmethod def test_worker_executes_and_persists_a_workflow_through_the_injected_driver( app: Flask, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session] diff --git a/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation.py b/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation.py index d0747d8d3a4..984c70df3d7 100644 --- a/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation.py +++ b/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation.py @@ -28,6 +28,7 @@ from core.human_input_v2.im_integration import ( SyncReconciler, ) from core.human_input_v2.im_integration.adapters import DirectoryEntry, ProviderUserId +from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator from core.human_input_v2.shared import ( AccountId, ContactId, @@ -114,7 +115,7 @@ def test_sync_run_captures_complete_channel_revision() -> None: sync_run_id=IMSyncRunId("run-1"), channel_revision=_RUN.channel_revision, provider=IMProvider.FEISHU, - started_by_account_id=AccountId("account-1"), + started_by=AccountIMSyncInitiator(AccountId("account-1")), now=_NOW, ) diff --git a/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_im_sync_queries.py b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_im_sync_queries.py index 8ba8fe1ad4f..4174b1704fb 100644 --- a/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_im_sync_queries.py +++ b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_im_sync_queries.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from core.human_input_v2.entities import IMIdentityBindingStatus, IMProvider, IMSyncResultType from core.human_input_v2.im_integration import IMChannelRevision, IMSyncRun, SyncResultFact +from core.human_input_v2.im_integration.sync_records import SysUserId, SysUserIMSyncInitiator from core.human_input_v2.shared import ContactId, IMIdentityId, IMSyncResultId, IMSyncRunId from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials from repositories.human_input_v2.im_binding_repository import IMBindingAssignment @@ -61,7 +62,7 @@ def _run(run_id: IMSyncRunId, created_at: datetime) -> IMSyncRun: sync_run_id=run_id, channel_revision=IMChannelRevision(str(_CHANNEL_ID), 1), provider=IMProvider.FEISHU, - started_by_account_id=None, + started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")), now=created_at, ) diff --git a/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_mappers.py b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_mappers.py index 25a4f2b0dae..2a51ef6bdff 100644 --- a/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_mappers.py +++ b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_mappers.py @@ -1,5 +1,6 @@ """Bidirectional mapping tests for every IM Control Plane record.""" +from dataclasses import replace from datetime import datetime import pytest @@ -23,6 +24,12 @@ from core.human_input_v2.im_integration import ( SyncResultFact, ) from core.human_input_v2.im_integration.adapters import ProviderUserId +from core.human_input_v2.im_integration.sync_records import ( + AccountIMSyncInitiator, + IMSyncInitiator, + SysUserId, + SysUserIMSyncInitiator, +) from core.human_input_v2.shared import ( AccountId, ContactId, @@ -52,7 +59,7 @@ def _run() -> IMSyncRun: sync_run_id=IMSyncRunId("run-1"), channel_revision=IMChannelRevision(str(_CHANNEL_ID), 1), provider=IMProvider.FEISHU, - started_by_account_id=AccountId("account-1"), + started_by=AccountIMSyncInitiator(AccountId("account-1")), now=_NOW, ) @@ -94,12 +101,30 @@ def _result() -> SyncResultFact: ) -def test_sync_run_mapping_round_trips_captured_revision_and_counts() -> None: - run = _run() +@pytest.mark.parametrize( + "started_by", + [AccountIMSyncInitiator(AccountId("account-1")), SysUserIMSyncInitiator(SysUserId("sys-user-1"))], +) +def test_sync_run_mapping_round_trips_captured_revision_counts_and_initiator(started_by: IMSyncInitiator) -> None: + run = replace(_run(), started_by=started_by) assert sync_run_from_record(sync_run_to_record(run)) == run +@pytest.mark.parametrize( + ("account_id", "sys_user_id"), + [(None, None), ("account-1", "sys-user-1")], + ids=["missing-initiator", "multiple-initiators"], +) +def test_sync_run_mapping_rejects_invalid_initiator_columns(account_id: str | None, sys_user_id: str | None) -> None: + record = sync_run_to_record(_run()) + record.started_by_account_id = account_id + record.started_by_sys_user_id = sys_user_id + + with pytest.raises(AssertionError): + sync_run_from_record(record) + + def test_sync_result_mapping_round_trips_all_structured_snapshots() -> None: result = _result() diff --git a/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_repository.py b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_repository.py index 82ada3ffc85..f0fae5bbd07 100644 --- a/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_repository.py +++ b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_repository.py @@ -4,13 +4,16 @@ from __future__ import annotations from datetime import datetime +import pytest from sqlalchemy import Engine, func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from core.human_input_v2.entities import IMProvider, IMSyncResultType from core.human_input_v2.im_integration import IMChannelRevision, IMSyncRun, SyncResultFact +from core.human_input_v2.im_integration.sync_records import SysUserId, SysUserIMSyncInitiator from core.human_input_v2.shared import IMSyncResultId, IMSyncRunId -from models.human_input_v2 import HumanInputIMSyncResult +from models.human_input_v2 import HumanInputIMSyncResult, HumanInputIMSyncRun from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials from repositories.human_input_v2.im_channel_repository import ( IMChannel, @@ -44,7 +47,7 @@ def _run() -> IMSyncRun: sync_run_id=IMSyncRunId("00000000-0000-0000-0000-000000000201"), channel_revision=IMChannelRevision(str(_CHANNEL_ID), 1), provider=IMProvider.FEISHU, - started_by_account_id=None, + started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")), now=_NOW, ) @@ -88,3 +91,29 @@ def test_diagnostic_append_uses_the_supplied_session_transaction(sqlite_engine: with Session(sqlite_engine) as session: assert session.scalar(select(func.count(HumanInputIMSyncResult.id))) == 0 + + +@pytest.mark.parametrize( + ("account_id", "sys_user_id"), + [ + (None, None), + ("00000000-0000-0000-0000-000000000501", "00000000-0000-0000-0000-000000000601"), + ], + ids=["missing-initiator", "multiple-initiators"], +) +def test_sync_run_requires_exactly_one_initiator( + sqlite_engine: Engine, account_id: str | None, sys_user_id: str | None +) -> None: + with Session(sqlite_engine) as session: + session.add( + HumanInputIMSyncRun( + channel_id=str(_CHANNEL_ID), + channel_config_version=1, + provider=IMProvider.FEISHU, + status=_run().status, + started_by_account_id=account_id, + started_by_sys_user_id=sys_user_id, + ) + ) + with pytest.raises(IntegrityError, match="initiator_exclusive"): + session.flush() diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_channel_reconciliation_service.py b/api/tests/unit_tests/services/human_input_v2/test_im_channel_reconciliation_service.py index 359567aaad6..20df02ffe39 100644 --- a/api/tests/unit_tests/services/human_input_v2/test_im_channel_reconciliation_service.py +++ b/api/tests/unit_tests/services/human_input_v2/test_im_channel_reconciliation_service.py @@ -28,6 +28,7 @@ from core.human_input_v2.im_integration.adapters import ( ProviderUserId, ) from core.human_input_v2.im_integration.adapters.entities import Avatar +from core.human_input_v2.im_integration.sync_records import AccountIMSyncInitiator from core.human_input_v2.shared import AccountId, ContactId, IMSyncRunId, TenantId from extensions.ext_storage import storage from models.account import Account, TenantAccountJoin, TenantAccountRole @@ -187,7 +188,7 @@ def reconciliation_context(sqlite_engine: Engine) -> _ReconciliationContext: WorkspaceIMChannelWriter(session, _TENANT_ID, _ACCOUNT_ID).create(channel) decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run( sync_run_id=_RUN_ID, - started_by_account_id=_ACCOUNT_ID, + started_by=AccountIMSyncInitiator(_ACCOUNT_ID), now=_NOW, ) assert decision.run is not None @@ -214,17 +215,17 @@ def _create_active_run( with sessions.begin() as session: decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run( sync_run_id=sync_run_id, - started_by_account_id=_ACCOUNT_ID, + started_by=AccountIMSyncInitiator(_ACCOUNT_ID), now=_NOW, ) assert decision.run is not None -@pytest.mark.parametrize("has_initiator", [True, False]) -def test_workspace_sync_stores_avatar_with_initiator_or_owner( +@pytest.mark.parametrize("has_account_initiator", [True, False]) +def test_workspace_sync_stores_avatar_with_account_initiator_or_owner( reconciliation_context: _ReconciliationContext, mocker: MockerFixture, - has_initiator: bool, + has_account_initiator: bool, ) -> None: save = mocker.patch.object(storage, "save") with reconciliation_context.sessions.begin() as session: @@ -241,9 +242,11 @@ def test_workspace_sync_stores_avatar_with_initiator_or_owner( ), ] ) - if not has_initiator: - session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).started_by_account_id = None - expected_creator = str(_ACCOUNT_ID) if has_initiator else owner.id + if not has_account_initiator: + run_record = session.get_one(HumanInputIMSyncRun, str(_RUN_ID)) + run_record.started_by_account_id = None + run_record.started_by_sys_user_id = "00000000-0000-0000-0000-000000000999" + expected_creator = str(_ACCOUNT_ID) if has_account_initiator else owner.id entry = replace(_successful_directory().entries[0], avatar=Avatar(mime_type="image/png", data=b"first avatar")) service = reconciliation_context.service(_Adapter(Directory((entry,)))) @@ -295,7 +298,9 @@ def test_missing_workspace_owner_fails_before_storing_avatars( ) -> None: save = mocker.patch.object(storage, "save") with reconciliation_context.sessions.begin() as session: - session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).started_by_account_id = None + run_record = session.get_one(HumanInputIMSyncRun, str(_RUN_ID)) + run_record.started_by_account_id = None + run_record.started_by_sys_user_id = "00000000-0000-0000-0000-000000000999" entry = replace(_successful_directory().entries[0], avatar=Avatar(mime_type="image/png", data=b"avatar")) run = reconciliation_context.service(_Adapter(Directory((entry,)))).reconcile(_RUN_ID) @@ -726,7 +731,7 @@ def test_trigger_retires_abandoned_old_revision_and_creates_current_run( with reconciliation_context.sessions.begin() as session: repository = SQLAlchemyIMControlPlaneRepository(session, current_channel) decision = repository.create_or_get_active_run( - sync_run_id=_SECOND_RUN_ID, started_by_account_id=_ACCOUNT_ID, now=_NOW + sync_run_id=_SECOND_RUN_ID, started_by=AccountIMSyncInitiator(_ACCOUNT_ID), now=_NOW ) assert decision.run is not None assert decision.run.id == _SECOND_RUN_ID diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_composition.py b/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_composition.py index fed4a3dfe8f..95c90d7b665 100644 --- a/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_composition.py +++ b/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_composition.py @@ -27,6 +27,7 @@ from core.human_input_v2.im_integration.adapters import ( SlackCredentials, ) from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials, IMProviderCredentials +from core.human_input_v2.im_integration.sync_records import SysUserId, SysUserIMSyncInitiator from core.human_input_v2.shared import AccountId, ContactId, DeploymentScope, IMSyncRunId, TenantId, WorkspaceScope from libs.uuid_utils import uuidv7 from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials @@ -225,7 +226,7 @@ def _seed_run( DeploymentIMChannelWriter(session).create(channel) decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run( sync_run_id=_RUN_ID, - started_by_account_id=None, + started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")), now=_NOW, ) assert decision.run is not None diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_worker.py b/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_worker.py index 2c93dbaad2b..efc697cb1f9 100644 --- a/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_worker.py +++ b/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_worker.py @@ -10,6 +10,7 @@ import pytest from core.human_input_v2.entities import IMProvider, IMSyncRunStatus from core.human_input_v2.im_integration import IMChannelRevision, IMSyncRun +from core.human_input_v2.im_integration.sync_records import SysUserId, SysUserIMSyncInitiator from core.human_input_v2.shared import IMSyncRunId, TenantId, WorkspaceScope from services.human_input_v2.im_contact_sync.coordinator import IMChannelReconciliationService, IMSyncRetryableError from services.human_input_v2.im_contact_sync.service import IMSyncService @@ -25,7 +26,7 @@ def _queued_run() -> IMSyncRun: sync_run_id=IMSyncRunId("run-1"), channel_revision=IMChannelRevision("channel-1", 1), provider=IMProvider.FEISHU, - started_by_account_id=None, + started_by=SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000999")), now=_NOW, ) diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_sync_service.py b/api/tests/unit_tests/services/human_input_v2/test_im_sync_service.py index 7d962a2c26c..784c246b5f7 100644 --- a/api/tests/unit_tests/services/human_input_v2/test_im_sync_service.py +++ b/api/tests/unit_tests/services/human_input_v2/test_im_sync_service.py @@ -13,9 +13,16 @@ from sqlalchemy.orm import Session, sessionmaker from core.human_input_v2.entities import IMProvider, IMSyncResultType, IMSyncRunStatus from core.human_input_v2.im_integration import SyncResultFact +from core.human_input_v2.im_integration.sync_records import ( + AccountIMSyncInitiator, + IMSyncInitiator, + SysUserId, + SysUserIMSyncInitiator, +) from core.human_input_v2.shared import ( AccountId, ContactId, + DeploymentScope, DirectoryScope, IMBindingId, IMIdentityId, @@ -24,7 +31,9 @@ from core.human_input_v2.shared import ( TenantId, WorkspaceScope, ) +from models.account import Account from models.human_input_v2 import HumanInputIMSyncRun +from repositories.account.repository import SQLAlchemyAccountRepository from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials from repositories.human_input_v2.im_binding_repository import IMBindingAssignment from repositories.human_input_v2.im_channel_repository import ( @@ -37,6 +46,7 @@ from repositories.human_input_v2.im_identity_repository import IMIdentityObserva from repositories.human_input_v2.im_integration.mappers import sync_result_to_record from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository from repositories.human_input_v2.sqlalchemy_im_channel_repository import ( + DeploymentIMChannelWriter, WorkspaceIMChannelReader, WorkspaceIMChannelWriter, ) @@ -90,6 +100,7 @@ class _ServiceContext: self.sessions, resolve_channel, lambda run_id, scope: self.dispatched.append((run_id, scope)), + account_query=SQLAlchemyAccountRepository(self.sessions), clock=lambda: _NOW, run_id_factory=lambda: _RUN_ID, ) @@ -108,17 +119,17 @@ def test_created_or_existing_active_run_is_serialized_and_dispatched_by_state( ) -> None: service = service_context.service() - created = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) - retried = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) + created = service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID)) + retried = service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID)) assert created == retried - assert created.channel_revision.channel_id == str(_CHANNEL_ID) + assert created.run.channel_revision.channel_id == str(_CHANNEL_ID) assert service_context.dispatched == [(_RUN_ID, _SCOPE), (_RUN_ID, _SCOPE)] with service_context.sessions.begin() as session: session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).status = IMSyncRunStatus.RUNNING - running = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) - assert running.status is IMSyncRunStatus.RUNNING + running = service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID)) + assert running.run.status is IMSyncRunStatus.RUNNING assert service_context.dispatched == [(_RUN_ID, _SCOPE), (_RUN_ID, _SCOPE)] with service_context.sessions() as session: assert session.scalar(sa.select(sa.func.count(HumanInputIMSyncRun.id))) == 1 @@ -139,12 +150,13 @@ def test_dispatch_failure_occurs_after_queued_run_is_committed( service_context.sessions, resolve_channel, fail_dispatch, + account_query=SQLAlchemyAccountRepository(service_context.sessions), clock=lambda: _NOW, run_id_factory=lambda: _RUN_ID, ) with caplog.at_level(logging.ERROR), pytest.raises(IMSyncDispatchUnavailableError) as error_info: - service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) + service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID)) assert isinstance(error_info.value.__cause__, ConnectionError) assert str(_RUN_ID) in caplog.text @@ -153,14 +165,19 @@ def test_dispatch_failure_occurs_after_queued_run_is_committed( def test_missing_or_stale_channel_fails_before_run_creation(service_context: _ServiceContext) -> None: - missing = IMSyncService(service_context.sessions, lambda _session, _scope: None, lambda _id, _scope: None) + missing = IMSyncService( + service_context.sessions, + lambda _session, _scope: None, + lambda _id, _scope: None, + account_query=SQLAlchemyAccountRepository(service_context.sessions), + ) with pytest.raises(IMChannelNotConfiguredError): - missing.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) + missing.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID)) with pytest.raises(IMSyncRevisionChangedError): service_context.service(channel_override=replace(_channel(), config_version=2)).create_or_get_active_run( _SCOPE, - _ACCOUNT_ID, + AccountIMSyncInitiator(_ACCOUNT_ID), ) with service_context.sessions() as session: @@ -169,7 +186,8 @@ def test_missing_or_stale_channel_fails_before_run_creation(service_context: _Se def test_latest_result_and_identity_queries_use_current_channel(service_context: _ServiceContext) -> None: service = service_context.service() - run = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) + summary = service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID)) + run = summary.run with service_context.sessions.begin() as session: identities = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID) identities.create( @@ -218,7 +236,7 @@ def test_latest_result_and_identity_queries_use_current_channel(service_context: ) ) - assert service.get_latest_run(_SCOPE) == run + assert service.get_latest_run(_SCOPE) == summary result_page = service.list_latest_results(_SCOPE, IMSyncResultType.NOT_MATCHED, page=1, limit=20) identity_page = service.search_identities(_SCOPE, keyword="REVIEWER", page=1, limit=20) assert [result.id for result in result_page.items] == [IMSyncResultId("00000000-0000-0000-0000-000000000701")] @@ -229,3 +247,75 @@ def test_latest_result_and_identity_queries_use_current_channel(service_context: def test_latest_queries_fail_closed_without_a_current_run(service_context: _ServiceContext) -> None: with pytest.raises(IMSyncRunNotFoundError): service_context.service().get_latest_run(_SCOPE) + + +@pytest.mark.parametrize( + ("owner_scope", "started_by", "account_id", "sys_user_id", "display_name"), + [ + (_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID), _ACCOUNT_ID, None, "Workspace Owner"), + (DeploymentScope(), SysUserIMSyncInitiator(SysUserId(str(_ACCOUNT_ID))), None, str(_ACCOUNT_ID), ""), + ], + ids=["ce-saas-account", "ee-administrator"], +) +def test_sync_run_preserves_initiating_identity_when_another_caller_reuses_it( + sqlite_engine: Engine, + owner_scope: DirectoryScope, + started_by: IMSyncInitiator, + account_id: AccountId | None, + sys_user_id: str | None, + display_name: str, +) -> None: + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + channel = _channel() + with sessions.begin() as session: + account = Account(name="Workspace Owner", email="owner@example.com") + account.id = str(_ACCOUNT_ID) + session.add(account) + if isinstance(owner_scope, WorkspaceScope): + WorkspaceIMChannelWriter(session, owner_scope.id, _ACCOUNT_ID).create(channel) + else: + DeploymentIMChannelWriter(session).create(channel) + service = IMSyncService( + sessions, + lambda _session, _scope: channel, + lambda _run_id, _scope: None, + account_query=SQLAlchemyAccountRepository(sessions), + ) + + created = service.create_or_get_active_run(owner_scope, started_by) + next_initiator = ( + AccountIMSyncInitiator(AccountId("00000000-0000-0000-0000-000000000202")) + if isinstance(started_by, AccountIMSyncInitiator) + else SysUserIMSyncInitiator(SysUserId("00000000-0000-0000-0000-000000000202")) + ) + reused = service.create_or_get_active_run(owner_scope, next_initiator) + + assert reused == created + assert created.initiator_display_name == display_name + assert service.get_latest_run(owner_scope).initiator_display_name == display_name + loaded = service.load_run(created.run.id) + assert loaded is not None + assert loaded.started_by == started_by + with sessions() as session: + stored = session.get_one(HumanInputIMSyncRun, str(created.run.id)) + assert stored.started_by_account_id == account_id + assert stored.started_by_sys_user_id == sys_user_id + + +def test_sync_run_returns_current_account_name(service_context: _ServiceContext) -> None: + with service_context.sessions.begin() as session: + account = Account(name="Original Admin", email="initiator@example.com") + account.id = str(_ACCOUNT_ID) + session.add(account) + service = service_context.service() + service.create_or_get_active_run(_SCOPE, AccountIMSyncInitiator(_ACCOUNT_ID)) + with service_context.sessions.begin() as session: + account = session.get_one(Account, str(_ACCOUNT_ID)) + account.name = "Renamed Admin" + + assert service.get_latest_run(_SCOPE).initiator_display_name == "Renamed Admin" + + with service_context.sessions.begin() as session: + session.delete(session.get_one(Account, str(_ACCOUNT_ID))) + + assert service.get_latest_run(_SCOPE).initiator_display_name == "" diff --git a/api/tests/unit_tests/services/human_input_v2/test_submission_service.py b/api/tests/unit_tests/services/human_input_v2/test_submission_service.py index d53db53a23d..46015d3bda0 100644 --- a/api/tests/unit_tests/services/human_input_v2/test_submission_service.py +++ b/api/tests/unit_tests/services/human_input_v2/test_submission_service.py @@ -1,17 +1,31 @@ """Accepted submissions retain the exact authenticated delivery across all surfaces.""" from collections.abc import Callable +from contextlib import nullcontext +from datetime import timedelta from hashlib import sha256 +from uuid import uuid4 import pytest from flask.testing import FlaskClient from sqlalchemy import event -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker +from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom +from core.app.file_access.controller import DatabaseFileAccessController +from core.app.file_access.scope import FileAccessScope, bind_file_access_scope +from core.app.workflow.file_runtime import DifyWorkflowFileRuntime from core.human_input_v2.entities import IMProvider +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import RecipientId, TenantId +from core.workflow.file_reference import build_file_reference from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from graphon.file import File, FileTransferMethod, FileType +from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment +from libs.datetime_utils import naive_utc_now from models.human_input_v2 import HumanInputForm from repositories.human_input_v2.delivery_repository import DeliveryCreateParams +from repositories.human_input_v2.form_repository import Form, FormCreateParams from repositories.human_input_v2.im_channel_repository import IMChannelId from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository @@ -24,6 +38,7 @@ from services.human_input_v2.submission_service import ( ServiceAPIFormActor, WebAppFormActor, ) +from tests.helpers.unit.model_factories import make_upload_file from tests.unit_tests.services.human_input_v2.test_form_delivery_runtime import Context, PublicFormApproval pytest_plugins = ( @@ -32,6 +47,112 @@ pytest_plugins = ( ) +@pytest.mark.parametrize("transfer_method", [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL]) +@pytest.mark.parametrize( + ("form_tenant", "form_app", "form_run", "status", "accessible"), + [ + ("tenant", "app", "run", HumanInputFormStatus.SUBMITTED, True), + ("other-tenant", "app", "run", HumanInputFormStatus.SUBMITTED, False), + ("tenant", "other-app", "run", HumanInputFormStatus.SUBMITTED, False), + ("tenant", "app", "other-run", HumanInputFormStatus.SUBMITTED, False), + ("tenant", "app", "run", HumanInputFormStatus.WAITING, False), + ("tenant", "app", "run", HumanInputFormStatus.TIMEOUT, False), + ("tenant", "app", "run", HumanInputFormStatus.EXPIRED, False), + ], +) +def test_restored_file_access_requires_an_accepted_submission_in_the_current_run( + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + transfer_method: FileTransferMethod, + form_tenant: str, + form_app: str, + form_run: str, + status: HumanInputFormStatus, + accessible: bool, +) -> None: + uploaded = make_upload_file(tenant_id="tenant", created_by="approver") + list_uploaded = make_upload_file(tenant_id="tenant", created_by="approver") + unrelated = make_upload_file(tenant_id="tenant", created_by="approver") + files = [ + File( + file_type=FileType.DOCUMENT, + transfer_method=transfer_method, + reference=build_file_reference(record_id=record.id), + remote_url="https://example.com/document.txt" if transfer_method == FileTransferMethod.REMOTE_URL else None, + filename=record.name, + extension=".txt", + mime_type=record.mime_type, + size=record.size, + ) + for record in (uploaded, list_uploaded) + ] + now = naive_utc_now() + with sqlite_session_factory.begin() as session: + session.add_all([uploaded, list_uploaded, unrelated]) + repository = SQLAlchemyFormRepository(session, TenantId(form_tenant), form_app) + form = repository.create_form( + FormCreateParams( + workflow_run_id=form_run, + node_execution_id=str(uuid4()), + resolved_form=ResolvedForm(title="Approval", parts=(), actions=(), legacy_form_content=""), + expiration_time=now + timedelta(hours=1), + global_timeout_deadline=now + timedelta(days=1), + ) + ) + if status == HumanInputFormStatus.SUBMITTED: + accepted = repository.submit_form( + form.id, + submitted_by_recipient_id=RecipientId("recipient"), + submitted_by_delivery_id="delivery", + selected_action_id="approve", + raw_submission_inputs={"untrusted_file": unrelated.id}, + normalized_submission_data={ + "single": FileSegment(value=files[0]), + "multiple": ArrayFileSegment(value=[files[1]]), + "empty": ArrayFileSegment(value=[]), + "text": StringSegment(value=unrelated.id), + }, + ) + assert isinstance(accepted, Form) + else: + session.get_one(HumanInputForm, form.id).status = status + + runtime = DifyWorkflowFileRuntime(file_access_controller=DatabaseFileAccessController()) + monkeypatch.setattr(runtime, "_secret_key", lambda: b"test-key") + scope = FileAccessScope( + tenant_id="tenant", user_id="end-user", user_from=UserFrom.END_USER, invoke_from=InvokeFrom.WEB_APP + ) + service = HumanInputSubmissionService(session_factory=sqlite_session_factory) + with bind_file_access_scope(scope): + service.grant_submitted_file_access(app_id="app", workflow_run_id="run") + for record in (uploaded, list_uploaded): + if accessible: + assert runtime.resolve_upload_file_uri(upload_file_id=record.id).startswith( + f"/files/{record.id}/file-preview?" + ) + else: + with pytest.raises(ValueError, match="not found"): + runtime.resolve_upload_file_uri(upload_file_id=record.id) + with pytest.raises(ValueError, match="not found"): + runtime.resolve_upload_file_uri(upload_file_id=unrelated.id) + + with bind_file_access_scope(scope), pytest.raises(ValueError, match="not found"): + runtime.resolve_upload_file_uri(upload_file_id=uploaded.id) + + +@pytest.mark.parametrize("account_scope", [False, True], ids=["no-scope", "account"]) +def test_restoring_file_access_outside_end_user_scope_does_not_read_storage( + unbound_session_factory: sessionmaker[Session], account_scope: bool +) -> None: + scope = FileAccessScope( + tenant_id="tenant", user_id="account", user_from=UserFrom.ACCOUNT, invoke_from=InvokeFrom.DEBUGGER + ) + with bind_file_access_scope(scope) if account_scope else nullcontext(): + HumanInputSubmissionService(session_factory=unbound_session_factory).grant_submitted_file_access( + app_id="app", workflow_run_id="run" + ) + + @pytest.fixture(params=[False, True], ids=["commit", "rollback-then-retry"]) def fail_commit(request: pytest.FixtureRequest) -> bool: return request.param is True diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index b9524889951..c9ce9bab601 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1562,11 +1562,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx": { - "better-tailwindcss/enforce-logical-properties": { - "count": 4 - } - }, "web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -1575,11 +1570,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx": { - "better-tailwindcss/enforce-logical-properties": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.tsx": { "better-tailwindcss/enforce-logical-properties": { "count": 2 diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 3827461929a..543829cbb3d 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -1785,12 +1785,12 @@ export const results = { } /** - * Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; the response does not include started_by. + * Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; initiator_display_name is resolved from the initiating Account. */ export const get23 = oc .route({ description: - 'Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; the response does not include started_by.', + 'Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; initiator_display_name is resolved from the initiating Account.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentHumanInputV2ImSyncRunsLatest', @@ -1804,12 +1804,16 @@ export const latest = { results, } +/** + * Start or reuse a workspace run using the authenticated Account ID + */ export const post22 = oc .route({ inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentHumanInputV2ImSyncRuns', path: '/workspaces/current/human-input/v2/im-sync-runs', + summary: 'Start or reuse a workspace run using the authenticated Account ID', tags: ['console'], }) .output(zPostWorkspacesCurrentHumanInputV2ImSyncRunsResponse) diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 5d837ff28b1..ef083dc4f83 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -2034,6 +2034,7 @@ export type HumanInputContact = { export type HumanInputContactSummary = { avatar_url?: string created_at: number + email?: string | null id: string name: string } @@ -2060,6 +2061,7 @@ export type ImSyncRun = { error_message?: string | null finished_at?: number | null id: string + initiator_display_name: string provider: ImProvider result_counts: ImSyncRunResultCounts started_at?: number | null diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 12fb6f2239d..4935335566f 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -1752,6 +1752,7 @@ export const zUnchangedInput = z.object({ export const zHumanInputContactSummary = z.object({ avatar_url: z.string().optional().default(''), created_at: z.int(), + email: z.string().nullish(), id: z.string(), name: z.string(), }) @@ -3393,8 +3394,8 @@ export const zImSyncRunStatus = z.enum(['failed', 'queued', 'running', 'succeede * * One IM sync run snapshot. * - * The latest-only UI displays ``finished_at`` as the explicit sync time. The - * transport contract intentionally does not expose a ``started_by`` actor. + * The latest-only UI displays ``finished_at`` as the explicit sync time. + * The initiator display name is resolved from the initiating Dify Account. */ export const zImSyncRun = z.object({ channel_config_version: z.int().gte(1), @@ -3402,6 +3403,7 @@ export const zImSyncRun = z.object({ error_message: z.string().nullish(), finished_at: z.int().nullish(), id: z.string(), + initiator_display_name: z.string(), provider: zImProvider, result_counts: zImSyncRunResultCounts, started_at: z.int().nullish(), diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 7d3919e664d..04d3a63c58e 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -97,9 +97,12 @@ type PromptEditorAriaProps = Pick< | 'aria-labelledby' | 'aria-describedby' | 'aria-invalid' + | 'aria-multiline' + | 'aria-disabled' > type PromptEditorProps = PromptEditorAriaProps & { + id?: string instanceId?: string children?: React.ReactNode compact?: boolean @@ -143,6 +146,9 @@ const PromptEditor: FC = ({ 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, + 'aria-multiline': ariaMultiline, + 'aria-disabled': ariaDisabled, + id, instanceId, children, compact, @@ -244,12 +250,15 @@ const PromptEditor: FC = ({ { - throw error - }, - nodes: [HITLInputNode], - }} - > - - , + const initialConfig = { + namespace: `hitl-input-test-${crypto.randomUUID()}`, + onError: (error: Error) => { + throw error + }, + nodes: [HITLInputNode], + } + const subject = (readonly = defaultProps.readonly) => ( + + + ) + const utils = render(subject()) return { ...utils, onChange, onRename, onRemove, + rerenderReadonly: (readonly: boolean) => utils.rerender(subject(readonly)), } } @@ -113,56 +113,41 @@ describe('HITLInputComponentUI', () => { ).not.toBeInTheDocument() }) - it('should close the edit modal when readonly becomes true', async () => { + it('should close the edit modal while readonly and allow reopening when editing resumes', async () => { const user = userEvent.setup() - let setReadonlyValue: ((readonly: boolean) => void) | undefined - const Harness = () => { - const [readonly, setReadonly] = useState(false) - const [namespace] = useState(() => `hitl-input-test-${crypto.randomUUID()}`) - - useEffect(() => { - setReadonlyValue = setReadonly - return () => { - setReadonlyValue = undefined - } - }, []) - - return ( - { - throw error - }, - nodes: [HITLInputNode], - }} - > - - - ) - } - - render() + const { rerenderReadonly } = renderComponent() await user.click(await screen.findByRole('button', { name: 'common.operation.edit' })) expect(await screen.findByRole('textbox')).toBeInTheDocument() - act(() => { - setReadonlyValue?.(true) - }) + rerenderReadonly(true) await waitFor(() => { expect(screen.queryByRole('textbox')).not.toBeInTheDocument() }) + + expect( + screen.queryByRole('button', { name: 'common.operation.edit' }), + ).not.toBeInTheDocument() + rerenderReadonly(false) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) + + expect(await screen.findByRole('textbox')).toBeInTheDocument() + }) + + it('should enable edit and remove actions when an initially readonly field becomes editable', async () => { + const user = userEvent.setup() + const { onRemove, rerenderReadonly } = renderComponent({ readonly: true }) + + rerenderReadonly(false) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) + expect(await screen.findByRole('textbox')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'common.operation.remove' })) + expect(onRemove).toHaveBeenCalledExactlyOnceWith(varName) }) it('should render select option summary for constant options', () => { @@ -224,6 +209,26 @@ describe('HITLInputComponentUI', () => { expect(onRemove).toHaveBeenCalledWith(varName) expect(onRemove).toHaveBeenCalledTimes(1) }) + + it('should remove exactly once per click after repeated readonly transitions', async () => { + const user = userEvent.setup() + const { onRemove, rerenderReadonly } = renderComponent() + + await user.click(screen.getByRole('button', { name: 'common.operation.remove' })) + expect(onRemove).toHaveBeenCalledExactlyOnceWith(varName) + + for (const expectedCalls of [2, 3]) { + rerenderReadonly(true) + expect( + screen.queryByRole('button', { name: 'common.operation.remove' }), + ).not.toBeInTheDocument() + rerenderReadonly(false) + await user.click(screen.getByRole('button', { name: 'common.operation.remove' })) + + expect(onRemove).toHaveBeenCalledTimes(expectedCalls) + expect(onRemove).toHaveBeenLastCalledWith(varName) + } + }) }) describe('Edit flow', () => { diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx index a233f56520d..8b74dfddb24 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx @@ -164,6 +164,11 @@ describe('InputField', () => { await user.click( await screen.findByRole('option', { name: /appDebug\.variableConfig\.select\b/ }), ) + await user.click(screen.getByRole('button', { name: 'appDebug.variableConfig.addOption' })) + await user.type( + screen.getByRole('textbox', { name: 'appDebug.variableConfig.options 1' }), + 'alpha', + ) await user.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(onChange).toHaveBeenCalledExactlyOnceWith( @@ -467,7 +472,7 @@ describe('InputField', () => { }) }) - it('should switch to select payload when field type changes', async () => { + it('should block insertion of a select field without options', async () => { const user = userEvent.setup() const onChange = vi.fn() @@ -495,21 +500,55 @@ describe('InputField', () => { }), ) - expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange.mock.calls[0]![0]).toEqual({ - type: InputVarType.select, - output_variable_name: 'valid_name', - option_source: { - type: 'constant', - selector: [], - value: [], - }, - }) + const error = 'appDebug.variableConfig.errorMsg.atLeastOneOption' + expect(screen.getByRole('alert')).toHaveTextContent(error) expect( - screen.queryByText( - /workflowHumanInput\.nodes\.humanInput\.insertInputField\.prePopulateField/i, - ), - ).not.toBeInTheDocument() + screen.getByRole('button', { name: 'appDebug.variableConfig.addOption' }), + ).toHaveAccessibleDescription(error) + expect(onChange).not.toHaveBeenCalled() + }) + + it('should reject blank and duplicate select options until they are corrected', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render( + , + ) + + const option = screen.getByRole('textbox', { name: 'appDebug.variableConfig.options 2' }) + const save = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(save) + expect(screen.getByRole('alert')).toHaveTextContent('common.errorMsg.fieldRequired') + expect(option).toHaveAccessibleDescription(/common\.errorMsg\.fieldRequired/) + expect(onChange).not.toHaveBeenCalled() + + await user.clear(option) + await user.type(option, ' alpha ') + await user.keyboard('{Control>}{Enter}{/Control}') + expect(screen.getByRole('alert')).toHaveTextContent( + 'appDebug.variableConfig.errorMsg.optionRepeat', + ) + expect(onChange).not.toHaveBeenCalled() + + await user.clear(option) + await user.type(option, ' Alpha ') + await user.click(save) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(onChange).toHaveBeenCalledExactlyOnceWith({ + type: InputVarType.select, + output_variable_name: 'choice', + option_source: { type: 'constant', selector: [], value: ['alpha', ' Alpha '] }, + }) }) it('should keep paragraph pre-populate editor available after switching back to paragraph', async () => { @@ -677,15 +716,16 @@ describe('InputField', () => { }) }) - it('should only allow array[string] variables for select option source', async () => { + it('should save a select using an array[string] variable without constant options', async () => { const user = userEvent.setup() + const onChange = vi.fn() render( , ) @@ -703,9 +743,17 @@ describe('InputField', () => { ) await user.click(screen.getByRole('button', { name: /common.setVarValuePlaceholder/ })) - expect(await screen.findByText('choices')).toBeInTheDocument() + const choices = await screen.findByText('choices') expect(screen.queryByText('text', { exact: true })).not.toBeInTheDocument() expect(screen.queryByText('files', { exact: true })).not.toBeInTheDocument() + await user.click(choices) + await user.click(screen.getByRole('button', { name: /insertInputField\.insert/ })) + + expect(onChange).toHaveBeenCalledExactlyOnceWith({ + type: InputVarType.select, + output_variable_name: 'valid_name', + option_source: { type: 'variable', selector: ['code', 'choices'], value: [] }, + }) }) it('should save single file upload settings', async () => { diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx index 86b76af5f88..e36bfc4c5ae 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx @@ -72,7 +72,7 @@ const HITLInputComponentUI: FC = ({ return () => { if (editBtn) editBtn.removeEventListener('click', showEditModal) } - }, [showEditModal]) + }, [showEditModal, readonly]) const removeBtnRef = useRef(null) useEffect(() => { @@ -83,7 +83,7 @@ const HITLInputComponentUI: FC = ({ return () => { if (removeBtn) removeBtn.removeEventListener('click', removeHandler) } - }, [onRemove, varName]) + }, [onRemove, varName, readonly]) const handleChange = useCallback( (newPayload: FormInputItem) => { @@ -131,8 +131,8 @@ const HITLInputComponentUI: FC = ({ }, [inputTypeLabel, paragraphDefault?.value, resolvedFormInput, t]) return ( - - + + @@ -140,7 +140,7 @@ const HITLInputComponentUI: FC = ({ - + {variableSelector ? ( = ({ onChange, onCancel, }) => { - const rootRef = useRef(null) + const rootRef = useRef(null) const outputVariableNameInputId = React.useId() + const optionsErrorId = React.useId() const { t } = useTranslation(['appDebug', 'common', 'workflow', 'workflowHumanInput']) const [tempPayload, setTempPayload] = useState( () => payload || createDefaultParagraphFormInput(), @@ -100,8 +101,26 @@ const InputField: React.FC = ({ const nameValid = useMemo(() => { return !!tempPayload.output_variable_name.trim() && !variableNameError }, [tempPayload.output_variable_name, variableNameError]) + + let optionsError = '' + if (isSelectFormInput(tempPayload) && tempPayload.option_source.type === 'constant') { + const options = tempPayload.option_source.value.map((option) => option.trim()) + const blankOptionIndex = options.findIndex((option) => !option) + if (options.length === 0) { + optionsError = t(($) => $['variableConfig.errorMsg.atLeastOneOption'], { ns: 'appDebug' }) + } else if (blankOptionIndex !== -1) { + const label = t(($) => $['variableConfig.options'], { ns: 'appDebug' }) + optionsError = t(($) => $['errorMsg.fieldRequired'], { + ns: 'common', + field: `${label} ${blankOptionIndex + 1}`, + }) + } else if (new Set(options).size !== options.length) { + optionsError = t(($) => $['variableConfig.errorMsg.optionRepeat'], { ns: 'appDebug' }) + } + } + const handleSave = useCallback(() => { - if (!nameValid) return + if (!nameValid || optionsError) return if (isFileListFormInput(tempPayload)) { const value = tempPayload.number_limits ?? 5 if (!Number.isFinite(value)) { @@ -113,7 +132,7 @@ const InputField: React.FC = ({ } } onChange(tempPayload) - }, [nameValid, onChange, tempPayload]) + }, [nameValid, onChange, optionsError, tempPayload]) const handleTypeChange = useCallback((item: TypeSelectItem) => { setTempPayload((prev) => createDefaultFormInputByType(item.value as FormInputItem['type'], prev.output_variable_name), @@ -219,7 +238,7 @@ const InputField: React.FC = ({ event.preventDefault() event.stopPropagation() if (event.repeat) return - handleSave() + rootRef.current?.requestSubmit() }, { enabled: nameValid, @@ -230,9 +249,27 @@ const InputField: React.FC = ({ ) return ( - { + event.preventDefault() + event.stopPropagation() + handleSave() + }} + onKeyDown={(event) => { + // Preserve Mod+Enter as the save shortcut while editing a field. + if ( + event.key === 'Enter' && + !event.metaKey && + !event.ctrlKey && + !event.nativeEvent.isComposing && + event.target instanceof HTMLInputElement && + event.target.form === event.currentTarget + ) + event.preventDefault() + }} > @@ -310,7 +347,7 @@ const InputField: React.FC = ({ filterVar={(varPayload) => varPayload.type === VarType.arrayString} /> @@ -324,7 +361,18 @@ const InputField: React.FC = ({ + {optionsError && ( + + {optionsError} + + )} = ({ - {t(($) => $['operation.cancel'], { ns: 'common' })} + + {t(($) => $['operation.cancel'], { ns: 'common' })} + {isEdit ? ( - + {t(($) => $['operation.save'], { ns: 'common' })} ) : ( - + {t(($) => $[`${i18nPrefix}.insert`], { ns: 'workflowHumanInput' })} {formatForDisplay(SAVE_HOTKEY, { parts: true }).map((key) => ( @@ -380,7 +430,7 @@ const InputField: React.FC = ({ )} - + ) } diff --git a/web/app/components/base/prompt-editor/prompt-editor-content.tsx b/web/app/components/base/prompt-editor/prompt-editor-content.tsx index 55671824a1c..c00299656c0 100644 --- a/web/app/components/base/prompt-editor/prompt-editor-content.tsx +++ b/web/app/components/base/prompt-editor/prompt-editor-content.tsx @@ -65,9 +65,12 @@ type PromptEditorContentAriaProps = Pick< | 'aria-labelledby' | 'aria-describedby' | 'aria-invalid' + | 'aria-multiline' + | 'aria-disabled' > type PromptEditorContentProps = PromptEditorContentAriaProps & { + id?: string compact?: boolean className?: string placeholder?: string | React.ReactNode @@ -114,6 +117,9 @@ const PromptEditorContent: FC = ({ 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, + 'aria-multiline': ariaMultiline, + 'aria-disabled': ariaDisabled, + id, compact, className, placeholder, @@ -147,12 +153,15 @@ const PromptEditorContent: FC = ({ subject.element().textContent).toBe('Converted approval') await page.getByRole('button', { name: 'common.operation.cancel' }).click() - await expect - .element(page.getByRole('textbox', { name: 'workflow.nodes.humanInputV2.template.subject' })) - .not.toBeInTheDocument() + await expect.element(subject).not.toBeInTheDocument() await lastRun.click() await expect.element(lastRun).toHaveAttribute('aria-selected', 'true') diff --git a/web/app/components/workflow/nodes/human-input-v2/__tests__/message-template-subject.browser.spec.tsx b/web/app/components/workflow/nodes/human-input-v2/__tests__/message-template-subject.browser.spec.tsx new file mode 100644 index 00000000000..8e695986ad1 --- /dev/null +++ b/web/app/components/workflow/nodes/human-input-v2/__tests__/message-template-subject.browser.spec.tsx @@ -0,0 +1,125 @@ +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { Field, FieldLabel } from '@langgenius/dify-ui/field' +import { detectPlatform } from '@tanstack/react-hotkeys' +import { QueryClientProvider } from '@tanstack/react-query' +import { useState } from 'react' +import { ReactFlowProvider } from 'reactflow' +import { page, userEvent } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { createNode } from '@/app/components/workflow/__tests__/fixtures' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { seedModelProviderQueries } from '@/test/console/model-provider' +import { createTestQueryClient } from '@/test/query-client' +import MessageTemplateSubject from '../components/message-template-subject' + +const insertVariableLabel = 'workflow.nodes.humanInputV2.template.insertVariable' +const searchVariableLabel = 'workflow.common.searchVar' + +function MessageTemplateSubjectHarness() { + const [subject, setSubject] = useState('Before placeholder After') + const [queryClient] = useState(() => { + const client = createTestQueryClient() + seedModelProviderQueries(client) + return client + }) + return ( + + + + + + Message template + + + + + Subject + + + + {subject} + + + + + + + ) +} + +it('replaces a native subject selection through the portalled picker and keeps Escape local to the picker', async () => { + // Chromium must exercise native selection and focus moving through Dialog and Popover portals. + // A happy-dom-only selection simulation does not prove typing resumes after the inserted variable. + await render() + const dialog = page.getByRole('dialog', { name: 'Message template', exact: true }) + const subject = page.getByRole('textbox', { name: 'Subject', exact: true }) + await expect.element(dialog).toBeVisible() + await subject.click() + await userEvent.keyboard(detectPlatform() === 'mac' ? '{Meta>}{ArrowLeft}{/Meta}' : '{Home}') + await userEvent.keyboard('{ArrowRight>7/}{Shift>}{ArrowRight>11/}{/Shift}') + expect(window.getSelection()?.toString()).toBe('placeholder') + + await page.getByRole('button', { name: insertVariableLabel }).click() + const search = page.getByRole('searchbox', { name: searchVariableLabel }) + await expect.element(search).toBeVisible() + await search.fill('round') + await page.getByText('round', { exact: true }).click() + + await expect.element(subject.getByText('round', { exact: true })).toBeVisible() + await expect.element(subject).not.toHaveTextContent('{{#loop.round#}}') + await expect.element(subject).toHaveFocus() + await userEvent.keyboard('!') + await expect + .element(page.getByRole('status', { name: 'Draft subject' })) + .toHaveTextContent('Before {{#loop.round#}}! After') + + await page.getByRole('button', { name: insertVariableLabel }).click() + await expect.element(search).toBeVisible() + await userEvent.keyboard('{Escape}') + await expect.element(search).not.toBeInTheDocument() + await expect.element(dialog).toBeVisible() + await expect.element(subject.getByText('round', { exact: true })).toBeVisible() + + await subject.click() + await userEvent.keyboard('{Enter}{Shift>}{Enter}{/Shift}') + const draft = page.getByRole('status', { name: 'Draft subject' }) + await expect.poll(() => draft.element().textContent).toBe('Before {{#loop.round#}}! After') + + const modifier = detectPlatform() === 'mac' ? 'Meta' : 'Control' + await page.getByRole('textbox', { name: 'Clipboard source' }).click() + await userEvent.keyboard(`{${modifier}>}a{/${modifier}}`) + await userEvent.copy() + await subject.click() + await userEvent.keyboard(`{${modifier}>}a{/${modifier}}`) + await userEvent.paste() + await expect.poll(() => draft.element().textContent).toBe('Pasted {{#loop.round#}} Next') + await expect.element(subject.getByText('round', { exact: true })).toBeVisible() +}) diff --git a/web/app/components/workflow/nodes/human-input-v2/__tests__/message-template.spec.tsx b/web/app/components/workflow/nodes/human-input-v2/__tests__/message-template.spec.tsx index 86347262825..0d96b2da5ed 100644 --- a/web/app/components/workflow/nodes/human-input-v2/__tests__/message-template.spec.tsx +++ b/web/app/components/workflow/nodes/human-input-v2/__tests__/message-template.spec.tsx @@ -11,15 +11,16 @@ import type { ComponentProps } from 'react' import type { EditorResource } from '@/app/components/workflow/editor-resource/resource' import type { Shape } from '@/app/components/workflow/hooks-store/store' import { ORPCError } from '@orpc/client' -import { act, screen, waitFor } from '@testing-library/react' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' import { ReactFlowProvider } from 'reactflow' import { replacePromptEditorText } from '@/app/components/base/prompt-editor/plugins/__tests__/test-helpers' +import { createNode } from '@/app/components/workflow/__tests__/fixtures' import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { EditorResourceScope } from '@/app/components/workflow/editor-resource/provider' import { HooksStoreContextProvider } from '@/app/components/workflow/hooks-store/provider' -import { VarType } from '@/app/components/workflow/types' +import { BlockEnum, VarType } from '@/app/components/workflow/types' import { consoleQuery } from '@/service/console' import { seedModelProviderQueries } from '@/test/console/model-provider' import { createDeferred } from '@/test/deferred' @@ -43,6 +44,19 @@ const mocks = vi.hoisted(() => ({ })) const originalBody = 'Original body {{#url#}}' +const subjectVariableButtonLabel = 'workflow.nodes.humanInputV2.template.insertVariable' +const loopTemplateVariables = { + availableVars: [ + { + nodeId: 'loop', + title: 'Contract loop', + vars: [{ variable: 'round', type: VarType.number }], + }, + ], + availableNodes: [ + createNode({ id: 'loop', data: { type: BlockEnum.Loop, title: 'Contract loop' } }), + ], +} vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() @@ -164,6 +178,21 @@ const getBodyEditor = () => const setBody = async (value: string) => replacePromptEditorText(getBodyEditor(), value) +const getSubjectEditor = () => + screen.getByRole('textbox', { name: 'workflow.nodes.humanInputV2.template.subject' }) + +const setSubject = async (value: string) => replacePromptEditorText(getSubjectEditor(), value) + +const openSubjectVariables = async (user: ReturnType) => { + await user.click(screen.getByRole('button', { name: subjectVariableButtonLabel })) + return screen.findByRole('searchbox', { name: 'workflow.common.searchVar' }) +} + +const insertSubjectRound = async (user: ReturnType) => { + await user.type(await openSubjectVariables(user), 'round') + await user.click(screen.getByText('round', { exact: true })) +} + const expectNoTemplateTest = () => { expect(mocks.workflowTest).not.toHaveBeenCalled() expect(mocks.chatflowTest).not.toHaveBeenCalled() @@ -186,11 +215,16 @@ describe('Human Input v2 Message Template', () => { }) await openTemplate(user) - const subject = screen.getByLabelText('workflow.nodes.humanInputV2.template.subject') - expect(subject).toHaveValue('Original subject{{#start.email#}}') + const subject = getSubjectEditor() + await user.click( + screen.getByText('workflow.nodes.humanInputV2.template.subject', { exact: true }), + ) + expect(subject).toHaveFocus() + expect(subject).toHaveTextContent('Original subject') + expect(within(subject).getByText('email', { exact: true })).toBeInTheDocument() expect(screen.getByLabelText('Committed body')).toHaveTextContent(originalBody) - await user.type(subject, ' updated') + await setSubject('Original subject{{#start.email#}} updated') await setBody('Updated body {{#url#}}') await user.click(screen.getByRole('button', { name: 'common.operation.save' })) @@ -203,45 +237,126 @@ describe('Human Input v2 Message Template', () => { ) expect(screen.getByLabelText('Committed body')).toHaveTextContent('Updated body {{#url#}}') await openTemplate(user) - expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue( - 'Original subject{{#start.email#}} updated', + expect(getSubjectEditor()).toHaveTextContent('Original subject') + expect(within(getSubjectEditor()).getByText('email', { exact: true })).toBeInTheDocument() + expect(getSubjectEditor()).toHaveTextContent('updated') + }) + + it('inserts a parent loop variable as a visible badge and saves its complete selector', async () => { + const user = userEvent.setup() + const { props } = renderTemplate(loopTemplateVariables) + await openTemplate(user) + await setSubject('Review ') + + await insertSubjectRound(user) + + const subject = getSubjectEditor() + expect(subject).toHaveTextContent('Review') + expect(within(subject).getByText('Contract loop', { exact: true })).toBeInTheDocument() + expect(within(subject).getByText('round', { exact: true })).toBeInTheDocument() + expect(subject).not.toHaveTextContent('{{#loop.round#}}') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(props.onChange).toHaveBeenCalledExactlyOnceWith({ + subject: 'Review {{#loop.round#}}', + body: originalBody, + }) + expectNoTemplateTest() + }) + + it('closes the subject variable picker on Escape and discards inserted variables on Cancel', async () => { + const user = userEvent.setup() + const { props } = renderTemplate(loopTemplateVariables) + await openTemplate(user) + const subject = getSubjectEditor() + await user.type(await openSubjectVariables(user), 'round') + await user.keyboard('{Escape}') + await waitFor(() => + expect( + screen.queryByRole('searchbox', { name: 'workflow.common.searchVar' }), + ).not.toBeInTheDocument(), ) + expect(subject).toHaveTextContent('Original subject') + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() + + await setSubject('Original subject') + await insertSubjectRound(user) + expect(within(subject).getByText('round', { exact: true })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + await openTemplate(user) + expect(getSubjectEditor()).toHaveTextContent('Original subject') + expect(within(getSubjectEditor()).queryByText('round', { exact: true })).not.toBeInTheDocument() + expect(props.onChange).not.toHaveBeenCalled() + expectNoTemplateTest() + }) + + it('collects a variable inserted in the subject before saving and sending a template test', async () => { + const user = userEvent.setup() + const { props } = renderTemplate(loopTemplateVariables) + await openTest(user) + await setSubject('Original subject') + await insertSubjectRound(user) + await sendTest(user) + expect( + await screen.findByText('workflow.nodes.humanInputV2.template.testInputsRequired'), + ).toBeInTheDocument() + expect(mocks.syncDraft).not.toHaveBeenCalled() + expectNoTemplateTest() + + await user.type(screen.getByLabelText('loop.round'), '3') + await sendTest(user) + expect( + await screen.findByText('workflow.nodes.humanInputV2.template.testCompleted'), + ).toBeInTheDocument() + expect(props.onChange).toHaveBeenCalledWith({ + subject: 'Original subject{{#loop.round#}}', + body: originalBody, + }) + expect(mocks.workflowTest).toHaveBeenCalledExactlyOnceWith({ + params: { app_id: 'app-template-test', node_id: 'human-input-v2' }, + body: { channel: 'email', inputs: { '#loop.round#': 3 } }, + }) + expect(mocks.chatflowTest).not.toHaveBeenCalled() + + await user.click( + screen.getByText('workflow.nodes.humanInputV2.template.subject', { exact: true }), + ) + await user.keyboard('{ArrowLeft}') + expect(getSubjectEditor()).toHaveFocus() + expect( + screen.getByText('workflow.nodes.humanInputV2.template.testCompleted'), + ).toBeInTheDocument() + + await setSubject('Updated subject{{#loop.round#}}') + expect( + screen.queryByText('workflow.nodes.humanInputV2.template.testCompleted'), + ).not.toBeInTheDocument() + expect(mocks.workflowTest).toHaveBeenCalledTimes(1) }) it('discards local drafts on Cancel and Escape and restores trigger focus', async () => { const user = userEvent.setup() renderTemplate() await openTemplate(user) - const subject = screen.getByLabelText('workflow.nodes.humanInputV2.template.subject') - await user.clear(subject) - await user.type(subject, 'Unsaved') + await setSubject('Unsaved') await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => expect(screen.getByRole('button', { name: 'workflow.common.configure' })).toHaveFocus(), ) await openTemplate(user) - expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue( - 'Original subject', - ) + expect(getSubjectEditor()).toHaveTextContent('Original subject') - await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')) - await user.type( - screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'), - 'Discard with Escape', - ) + await setSubject('Discard with Escape') await user.keyboard('{Escape}') await openTemplate(user) - expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue( - 'Original subject', - ) + expect(getSubjectEditor()).toHaveTextContent('Original subject') }) it('validates both local fields and does not submit twice', async () => { const user = userEvent.setup() const { props } = renderTemplate() await openTemplate(user) - await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')) + await setSubject('') await setBody('') await user.click(screen.getByRole('button', { name: 'common.operation.save' })) @@ -255,10 +370,7 @@ describe('Human Input v2 Message Template', () => { expect(screen.getByText('workflow.nodes.humanInputV2.error.bodyRequired')).toBeInTheDocument() expect(props.onChange).not.toHaveBeenCalled() - await user.type( - screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'), - 'Subject', - ) + await setSubject('Subject') await setBody('Body {{#url#}}') const save = screen.getByRole('button', { name: 'common.operation.save' }) await user.dblClick(save) @@ -273,7 +385,9 @@ describe('Human Input v2 Message Template', () => { }) await openTemplate(user) - expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toBeDisabled() + expect(getSubjectEditor()).toHaveAttribute('contenteditable', 'false') + expect(getSubjectEditor()).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('button', { name: subjectVariableButtonLabel })).toBeDisabled() expect(getBodyEditor()).toHaveAttribute('contenteditable', 'false') expect(screen.queryByRole('button', { name: 'common.operation.save' })).not.toBeInTheDocument() expect(props.onChange).not.toHaveBeenCalled() @@ -355,18 +469,12 @@ describe('Human Input v2 Message Template', () => { ).toBeInTheDocument() expectNoTemplateTest() expect(mocks.syncDraft).not.toHaveBeenCalled() - await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')) - await user.type( - screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'), - 'Unsaved test subject', - ) + await setSubject('Unsaved test subject') await user.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(props.onChange).not.toHaveBeenCalled() await waitFor(() => expect(testTrigger).toHaveFocus()) await openTemplate(user) - expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue( - 'Original subject', - ) + expect(getSubjectEditor()).toHaveTextContent('Original subject') }) it('offers only the workspace configured Email and Slack channels for template tests', async () => { @@ -522,11 +630,7 @@ describe('Human Input v2 Message Template', () => { mode: chat ? 'advanced-chat' : 'workflow', }) await openTest(user) - await user.clear(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')) - await user.type( - screen.getByLabelText('workflow.nodes.humanInputV2.template.subject'), - 'Updated test subject', - ) + await setSubject('Updated test subject') await user.click( screen.getByRole('combobox', { name: 'workflow.nodes.humanInputV2.template.testChannel' }), ) @@ -575,7 +679,8 @@ describe('Human Input v2 Message Template', () => { await openTest(user) await sendTest(user) expect(mocks.syncDraft).toHaveBeenCalledTimes(1) - expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toBeDisabled() + expect(getSubjectEditor()).toHaveAttribute('contenteditable', 'false') + expect(getSubjectEditor()).toHaveAttribute('aria-disabled', 'true') if (change === 'app changes') { rerenderEnvironment({ @@ -632,9 +737,7 @@ describe('Human Input v2 Message Template', () => { expect( screen.queryByText('workflow.nodes.humanInputV2.template.testCompleted'), ).not.toBeInTheDocument() - expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toHaveValue( - 'Original subject', - ) + expect(getSubjectEditor()).toHaveTextContent('Original subject') }, ) @@ -777,7 +880,10 @@ describe('Human Input v2 Message Template', () => { await waitFor(() => expect(mocks.workflowTest).toHaveBeenCalledOnce()) expect(mocks.syncDraft).toHaveBeenCalledTimes(1) expect(props.onChange).toHaveBeenCalledTimes(1) - expect(screen.getByLabelText('workflow.nodes.humanInputV2.template.subject')).toBeDisabled() + expect(getSubjectEditor()).toHaveAttribute('contenteditable', 'false') + expect(getSubjectEditor()).toHaveAttribute('aria-disabled', 'true') + + expect(screen.getByRole('button', { name: subjectVariableButtonLabel })).toBeDisabled() await act(async () => request.resolve({})) expect( diff --git a/web/app/components/workflow/nodes/human-input-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/human-input-v2/__tests__/panel.spec.tsx index 0291e0f40b0..1f1d3d8ff35 100644 --- a/web/app/components/workflow/nodes/human-input-v2/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/human-input-v2/__tests__/panel.spec.tsx @@ -3,8 +3,11 @@ import type { ListContactsResponse, } from '@dify/contracts/api/console/workspaces/types.gen' import type { HumanInputV2NodeType } from '../types' +import type { Node } from '@/app/components/workflow/types' import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { replacePromptEditorText } from '@/app/components/base/prompt-editor/plugins/__tests__/test-helpers' +import { createNode, createTriggerNode } from '@/app/components/workflow/__tests__/fixtures' import { BlockEnum } from '@/app/components/workflow/types' import { consoleQuery } from '@/service/console' import { seedAccountProfileQuery } from '@/test/console/account-profile' @@ -52,7 +55,7 @@ const createData = (overrides: Partial = {}): HumanInputV2 ...overrides, }) -const renderPanel = (data = createData()) => { +const renderPanel = (data = createData(), entry?: Node) => { const queryClient = createChannelsQueryClient(['resend', 'slack']) seedToolProviders(queryClient, { builtInTools: [], @@ -62,8 +65,11 @@ const renderPanel = (data = createData()) => { }) queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), []) seedAccountProfileQuery(queryClient, { email: 'reviewer@example.com' }) + const nodes = entry ? [entry, createNode({ id: 'node', data })] : undefined return renderNodePanel(HumanInputV2Panel, data, { queryClient, + nodes, + initialStoreState: nodes ? { nodes } : undefined, hooksStoreProps: { doSyncWorkflowDraft: async () => null }, }) } @@ -89,8 +95,7 @@ describe('Human Input v2 panel composition', () => { await user.click(screen.getByRole('button', { name: 'workflow.common.configure' })) const subject = screen.getByLabelText('workflow.nodes.humanInputV2.template.subject') - await user.clear(subject) - await user.type(subject, 'Updated subject') + await replacePromptEditorText(subject, 'Updated subject') await user.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(data().message_template).toEqual({ subject: 'Updated subject', @@ -111,6 +116,19 @@ describe('Human Input v2 panel composition', () => { expect(data().debug_mode).toEqual({ enabled: true, channels: ['email'] }) }) + it('disables Initiator when the workflow starts from a Schedule trigger', async () => { + const user = userEvent.setup() + renderPanel(createData(), createTriggerNode(BlockEnum.TriggerSchedule)) + + await user.click( + screen.getByRole('button', { name: 'workflow.nodes.humanInputV2.recipients.addContact' }), + ) + const initiator = await screen.findByRole('option', { + name: 'workflow.nodes.humanInputV2.recipients.type.initiator', + }) + expect(initiator).toHaveAttribute('aria-disabled', 'true') + }) + it('keeps stored v2 settings intact and unavailable while the feature is disabled', () => { hitlIm.enabled = false const stored = createData({ diff --git a/web/app/components/workflow/nodes/human-input-v2/__tests__/recipients-keyboard.browser.spec.tsx b/web/app/components/workflow/nodes/human-input-v2/__tests__/recipients-keyboard.browser.spec.tsx index 4b93850c411..9585f2ba06b 100644 --- a/web/app/components/workflow/nodes/human-input-v2/__tests__/recipients-keyboard.browser.spec.tsx +++ b/web/app/components/workflow/nodes/human-input-v2/__tests__/recipients-keyboard.browser.spec.tsx @@ -35,6 +35,7 @@ function Harness({ observe }: { observe: (value: HumanInputV2Recipient[]) => voi nodeId="hitl" value={value} readonly={false} + isTriggerMode={false} onChange={(next) => { setValue(next) observe(next) diff --git a/web/app/components/workflow/nodes/human-input-v2/__tests__/recipients.spec.tsx b/web/app/components/workflow/nodes/human-input-v2/__tests__/recipients.spec.tsx index e8357bcef3c..cec766714c7 100644 --- a/web/app/components/workflow/nodes/human-input-v2/__tests__/recipients.spec.tsx +++ b/web/app/components/workflow/nodes/human-input-v2/__tests__/recipients.spec.tsx @@ -38,10 +38,12 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference const Harness = ({ initial = [], readonly = false, + isTriggerMode = false, observe = () => {}, }: { initial?: HumanInputV2Recipient[] readonly?: boolean + isTriggerMode?: boolean observe?: (value: HumanInputV2Recipient[]) => void }) => { const [value, setValue] = useState(initial) @@ -49,6 +51,7 @@ const Harness = ({ { setValue(next) @@ -66,6 +69,9 @@ const openPicker = async (user: ReturnType) => { return screen.findByRole('listbox') } const field = () => screen.getByRole('combobox') +const initiatorLabel = 'workflow.nodes.humanInputV2.recipients.type.initiator' +const initiatorUnavailableMessage = + 'workflow.nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow' beforeEach(resetContactApi) describe('recipient selection through generated queries', () => { @@ -128,6 +134,58 @@ describe('recipient selection through generated queries', () => { expect(field()).toHaveFocus() }) + it('prevents adding Initiator with the pointer or Enter in a trigger workflow', async () => { + contactApi.options.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 }) + const user = userEvent.setup() + const observe = vi.fn() + renderContactOwner() + await openPicker(user) + + const initiator = await screen.findByRole('option', { name: initiatorLabel }) + expect(initiator).toHaveAttribute('aria-disabled', 'true') + expect(initiator).toHaveAccessibleDescription(initiatorUnavailableMessage) + expect(within(initiator).getByText(initiatorUnavailableMessage)).toBeVisible() + await user.click(initiator) + expect(observe).not.toHaveBeenCalled() + + await user.type(field(), 'initiator') + await screen.findByText(/recipients\.resultCount/) + await user.keyboard('{Enter}') + expect(observe).not.toHaveBeenCalled() + }) + + it('keeps Initiator selectable with Enter in a manual workflow', async () => { + contactApi.options.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 }) + const user = userEvent.setup() + const observe = vi.fn() + renderContactOwner() + + await user.type(await screen.findByRole('combobox'), 'initiator') + const initiator = await screen.findByRole('option', { name: initiatorLabel }) + expect(initiator).not.toHaveAttribute('aria-disabled', 'true') + await screen.findByText(/recipients\.resultCount/) + await user.keyboard('{Enter}') + expect(observe).toHaveBeenLastCalledWith([{ type: 'initiator' }]) + }) + + it('keeps an existing Initiator visible and removable in a trigger workflow', async () => { + const user = userEvent.setup() + const observe = vi.fn() + renderContactOwner( + , + ) + + const initiator = screen.getByLabelText(initiatorLabel) + expect(initiator).toHaveAttribute('aria-invalid', 'true') + expect(initiator).toHaveAccessibleDescription(initiatorUnavailableMessage) + expect(screen.getByText(initiatorUnavailableMessage)).toBeVisible() + expect(observe).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: /recipients.remove.*initiator/ })) + expect(observe).toHaveBeenLastCalledWith([]) + expect(screen.queryByText(initiatorUnavailableMessage)).not.toBeInTheDocument() + }) + it.each([true, false])( 'gates the platform source on authoritative features (%s)', async (enabled) => { diff --git a/web/app/components/workflow/nodes/human-input-v2/components/message-template-subject.tsx b/web/app/components/workflow/nodes/human-input-v2/components/message-template-subject.tsx new file mode 100644 index 00000000000..cd8d71c90e2 --- /dev/null +++ b/web/app/components/workflow/nodes/human-input-v2/components/message-template-subject.tsx @@ -0,0 +1,228 @@ +'use client' + +import type { RangeSelection } from 'lexical' +import type { AriaAttributes } from 'react' +import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' +import { cn } from '@langgenius/dify-ui/cn' +import { Field } from '@langgenius/dify-ui/field' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { mergeRegister } from '@lexical/utils' +import { + $getNodeByKey, + $getRoot, + $getSelection, + $isRangeSelection, + $setSelection, + COMMAND_PRIORITY_HIGH, + HISTORY_PUSH_TAG, + INSERT_LINE_BREAK_COMMAND, + INSERT_PARAGRAPH_COMMAND, + KEY_ENTER_COMMAND, + PASTE_COMMAND, +} from 'lexical' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import PromptEditor from '@/app/components/base/prompt-editor' +import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/workflow-variable-block' +import { BlockEnum } from '@/app/components/workflow/types' +import VarReferenceVars from '../../_base/components/variable/var-reference-vars' + +const normalizeSubject = (value: string) => value.replace(/[\r\n\u2028\u2029]+/g, ' ') + +// Subject owns its single-line editing rules and the selection used by its toolbar picker. +const SubjectEditorControls = ({ + disabled, + availableVars, +}: { + disabled: boolean + availableVars: NodeOutPutVar[] +}) => { + const [editor] = useLexicalComposerContext() + const { t } = useTranslation(['workflow']) + const searchInputRef = useRef(null) + const inputRef = useRef(null) + const selectionRef = useRef(null) + const [open, setOpen] = useState(false) + const insertVariableLabel = t(($) => $['nodes.humanInputV2.template.insertVariable'], { + ns: 'workflow', + }) + + useEffect( + () => + mergeRegister( + editor.registerRootListener((root) => { + inputRef.current = root + }), + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + const selection = $getSelection() + if ($isRangeSelection(selection)) selectionRef.current = selection.clone() + }) + }), + editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (event?.isComposing) return false + event?.preventDefault() + return true + }, + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand(INSERT_PARAGRAPH_COMMAND, () => true, COMMAND_PRIORITY_HIGH), + editor.registerCommand(INSERT_LINE_BREAK_COMMAND, () => true, COMMAND_PRIORITY_HIGH), + editor.registerCommand( + PASTE_COMMAND, + (event) => { + if (!editor.isEditable() || !event || !('clipboardData' in event)) return false + const selection = $getSelection() + if (!$isRangeSelection(selection) || !event.clipboardData) return false + event.preventDefault() + selection.insertText(normalizeSubject(event.clipboardData.getData('text/plain'))) + return true + }, + COMMAND_PRIORITY_HIGH, + ), + ), + [editor], + ) + + const insertVariable = (selector: ValueSelector) => { + if (disabled || !editor.isEditable()) { + setOpen(false) + return + } + editor.update( + () => { + if (!editor.isEditable()) return + const previous = selectionRef.current + if ( + previous && + $getNodeByKey(previous.anchor.key)?.isAttached() && + $getNodeByKey(previous.focus.key)?.isAttached() + ) + $setSelection(previous.clone()) + else $getRoot().selectEnd() + editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, selector) + }, + { tag: HISTORY_PUSH_TAG }, + ) + setOpen(false) + editor.focus(undefined, { defaultSelection: 'rootEnd' }) + } + + return ( + { + if (nextOpen && disabled) return + setOpen(nextOpen) + }} + > + + + + } + /> + + {/* The picker search must not inherit the Subject field's id, label, or validation. */} + + setOpen(false)} + /> + + + + ) +} + +type MessageTemplateSubjectProps = Pick< + AriaAttributes, + 'aria-labelledby' | 'aria-describedby' | 'aria-invalid' +> & { + id: string + value: string + onValueChange: (value: string) => void + availableVars: NodeOutPutVar[] + availableNodes: Node[] + disabled?: boolean + placeholder?: string +} + +const MessageTemplateSubject = ({ + value, + onValueChange, + availableVars, + availableNodes, + disabled = false, + placeholder, + ...props +}: MessageTemplateSubjectProps) => { + const { t } = useTranslation(['workflow']) + const workflowNodesMap = useMemo( + () => + 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(($) => $['blocks.start'], { ns: 'workflow' }), + type: BlockEnum.Start, + } + return acc + }, + {} as Record>, + ), + [availableNodes, t], + ) + + return ( + { + const nextValue = normalizeSubject(text) + if (nextValue !== normalizeSubject(value)) onValueChange(nextValue) + }} + workflowVariableBlock={{ show: true, variables: availableVars, workflowNodesMap }} + disableSlashPicker + disableBracePicker + > + + + ) +} + +export default MessageTemplateSubject diff --git a/web/app/components/workflow/nodes/human-input-v2/components/message-template.tsx b/web/app/components/workflow/nodes/human-input-v2/components/message-template.tsx index 4abcd8091ce..0a9942b3a33 100644 --- a/web/app/components/workflow/nodes/human-input-v2/components/message-template.tsx +++ b/web/app/components/workflow/nodes/human-input-v2/components/message-template.tsx @@ -49,6 +49,7 @@ import { isOutput } from '../../human-input/utils' import { getRequestURLCount } from '../message-template-utils' import useConfiguredChannels from '../use-configured-channels' import MessageTemplateBody from './message-template-body' +import MessageTemplateSubject from './message-template-subject' const getTemplateVariableType = (selector: ValueSelector, availableVars: NodeOutPutVar[]) => { const vars = availableVars.find((item) => item.nodeId === selector[0])?.vars @@ -287,14 +288,22 @@ const MessageTemplateSession = ({ { + if (!readonly && !pending) + document.getElementById(`${nodeId}-message-subject`)?.focus() + }} className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary" > {t(($) => $['nodes.humanInputV2.template.subject'], { ns: 'workflow' })} - { + onValueChange={(subject) => { resetTestResult() - setDraft((current) => ({ ...current, subject: event.target.value })) + setDraft((current) => ({ ...current, subject })) }} placeholder={t(($) => $['nodes.humanInputV2.template.subjectPlaceholder'], { ns: 'workflow', diff --git a/web/app/components/workflow/nodes/human-input-v2/components/recipients.tsx b/web/app/components/workflow/nodes/human-input-v2/components/recipients.tsx index e825dd14217..7fec1260257 100644 --- a/web/app/components/workflow/nodes/human-input-v2/components/recipients.tsx +++ b/web/app/components/workflow/nodes/human-input-v2/components/recipients.tsx @@ -59,6 +59,7 @@ type RecipientsProps = { value: HumanInputV2Recipient[] onChange: (value: HumanInputV2Recipient[]) => void readonly: boolean + isTriggerMode: boolean availableNodes?: Node[] } type SourceFilter = 'all' | 'workspace' | 'organization' | 'external' @@ -77,6 +78,7 @@ const RecipientsContent = ({ value, onChange, readonly, + isTriggerMode, availableNodes = [], workspaceId, workspace, @@ -221,6 +223,13 @@ const RecipientsContent = ({ ]) const selectedContacts = selectedItems.flatMap((item) => (item.contact ? [item.contact] : [])) const selectedKeys = selectedItems.map((item) => item.key) + const isUnavailableInitiator = (recipient: HumanInputV2Recipient) => + isTriggerMode && recipient.type === 'initiator' + const initiatorUnavailableMessage = t( + ($) => $['nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow'], + { ns: 'workflow' }, + ) + const initiatorUnavailableId = `${nodeId}-initiator-unavailable` const previewConflict = previewContact && findConflictingContact( @@ -312,6 +321,10 @@ const RecipientsContent = ({ if (added) { const item = itemMap.get(added) if (!item) return + if (isUnavailableInitiator(item.recipient)) { + details.cancel() + return + } if ( item.contact && findConflictingContact( @@ -342,7 +355,9 @@ const RecipientsContent = ({ > {selectedItems.map((item, index) => { const recipient = item.recipient + const initiatorUnavailable = isUnavailableInitiator(recipient) const invalid = + initiatorUnavailable || !!getRecipientValidationError(recipient) || value.findIndex( (other) => @@ -375,7 +390,11 @@ const RecipientsContent = ({ aria-invalid={invalid || undefined} aria-describedby={ [ - invalid ? `${nodeId}-invalid-${index}` : undefined, + initiatorUnavailable + ? initiatorUnavailableId + : invalid + ? `${nodeId}-invalid-${index}` + : undefined, recipient.type === 'contact' && !item.contact ? `${nodeId}-contact-${index}` : undefined, @@ -438,7 +457,7 @@ const RecipientsContent = ({ workspace.contactCount !== undefined && ( {workspace.contactCount} )} - {invalid && ( + {invalid && !initiatorUnavailable && ( {invalidMessage} @@ -513,6 +532,7 @@ const RecipientsContent = ({ item.contact?.email?.toLocaleLowerCase() === query.trim().toLocaleLowerCase()), ) + if (candidate && isUnavailableInitiator(candidate.recipient)) return if ( candidate && (!candidate.contact || @@ -537,6 +557,11 @@ const RecipientsContent = ({ {inputHint} + {value.some(isUnavailableInitiator) && ( + + {initiatorUnavailableMessage} + + )} {emailError && ( > {(item) => { + const initiatorUnavailable = isUnavailableInitiator(item.recipient) const added = value.some( (recipient) => getRecipientCanonicalKey(recipient) === @@ -742,8 +768,16 @@ const RecipientsContent = ({ key={item.key} value={item.key} - disabled={added || !!conflict} - className="min-w-0 grow" + disabled={initiatorUnavailable || added || !!conflict} + aria-label={initiatorUnavailable ? item.label : undefined} + aria-describedby={ + initiatorUnavailable ? `${initiatorUnavailableId}-option` : undefined + } + className={ + initiatorUnavailable + ? 'min-w-0 grow data-disabled:opacity-100' + : 'min-w-0 grow' + } > {item.contact && ( @@ -755,9 +789,23 @@ const RecipientsContent = ({ /> )} - + {item.contact?.name ?? item.label} + {initiatorUnavailable && ( + + {initiatorUnavailableMessage} + + )} {item.contact?.email && ( {item.contact.email} diff --git a/web/app/components/workflow/nodes/human-input-v2/panel.tsx b/web/app/components/workflow/nodes/human-input-v2/panel.tsx index e9bbcaee9e3..4cfb2f40b03 100644 --- a/web/app/components/workflow/nodes/human-input-v2/panel.tsx +++ b/web/app/components/workflow/nodes/human-input-v2/panel.tsx @@ -4,7 +4,9 @@ import { Separator } from '@langgenius/dify-ui/separator' import { useQuery } from '@tanstack/react-query' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import HumanInputSharedPanelSections from '@/app/components/workflow/nodes/human-input/shared/panel-sections' +import useWorkflowNodes from '@/app/components/workflow/store/workflow/use-nodes' import { VarType } from '@/app/components/workflow/types' +import { isTriggerWorkflow } from '@/app/components/workflow/utils/workflow-entry' import { ENABLE_HITL_IM } from '@/config' import { userProfileQueryOptions } from '@/features/account-profile/client' import DebugMode from './components/debug-mode' @@ -15,6 +17,7 @@ import useHumanInputV2Config from './use-config' const HumanInputV2PanelContent = ({ id, data }: NodePanelProps) => { const config = useHumanInputV2Config(id, data) + const nodes = useWorkflowNodes() const { data: email } = useQuery({ ...userProfileQueryOptions(), select: (data) => data.profile.email, @@ -40,6 +43,7 @@ const HumanInputV2PanelContent = ({ id, data }: NodePanelProps diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.hitl-v2.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.hitl-v2.spec.tsx index af40c7dbf32..11f04f7ae4b 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.hitl-v2.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.hitl-v2.spec.tsx @@ -1,10 +1,14 @@ +import type { ComponentProps } from 'react' +import type { NodeOutPutVar } from '../../../../types' import { act, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createNode } from '../../../../__tests__/fixtures' import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env' +import { BlockEnum, InputVarType, VarType } from '../../../../types' import FormContentPreview, { FormContentPreviewTrigger } from '../form-content-preview' const preview = vi.hoisted(() => vi.fn()) +const chatPreview = vi.hoisted(() => vi.fn()) vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() const { withConsoleClientResponses } = await import('@/test/console/service-responses') @@ -12,6 +16,8 @@ vi.mock('@/service/console', async (importOriginal) => { ...actual, consoleClient: withConsoleClientResponses(actual.consoleClient, { 'apps.byAppId.workflows.draft.humanInput.nodes.byNodeId.form.preview.post': preview, + 'apps.byAppId.advancedChat.workflows.draft.humanInput.nodes.byNodeId.form.preview.post': + chatPreview, }), } }) @@ -26,22 +32,57 @@ const props = { const renderPreview = ( options: Parameters[1] = {}, readOnly = false, + overrides: Partial> = {}, ) => renderWorkflowComponent( readOnly ? ( - + ) : ( - + ), { ...options, initialStoreState: { panelWidth: 320, nodes: [createNode({ id: 'node-1', data: { title: 'Classifier' } })], + ...options.initialStoreState, }, }, ) +const previewLabel = 'workflowHumanInput.nodes.humanInput.formContent.preview' +const savedDraft = async () => ({ hash: 'saved', updatedAt: 1 }) +const sourceNode = createNode({ + id: 'source', + data: { title: 'Contract source', type: BlockEnum.Code }, +}) +const sourceVars: NodeOutPutVar = { + nodeId: 'source', + title: 'Contract source', + vars: [ + { variable: 'draft', type: VarType.string }, + { + variable: 'object', + type: VarType.object, + children: [{ variable: 'name', type: VarType.string }], + }, + { variable: 'amount', type: VarType.number }, + { variable: 'approved', type: VarType.boolean }, + { variable: 'settings', type: VarType.object }, + { variable: 'options', type: VarType.arrayString }, + ], +} + +const submitPreview = async (user: ReturnType) => { + const form = screen.getByRole('form', { name: previewLabel }) + await user.click(within(form).getByRole('button', { name: previewLabel })) +} + +beforeEach(() => { + preview.mockReset() + chatPreview.mockReset() +}) + it('waits for the saved workflow before displaying the real server markdown', async () => { let finishSave!: () => void const saving = new Promise<{ hash: string; updatedAt: number }>((resolve) => { @@ -100,3 +141,280 @@ it('renders historical readonly content without loading the current draft', asyn expect(preview).not.toHaveBeenCalled() expect(sync).not.toHaveBeenCalled() }) + +it.each([ + { mode: 'workflow' as const, selector: 'source.draft', field: /draft/ }, + { mode: 'advanced-chat' as const, selector: 'source.object.name', field: /name/ }, +])( + 'collects missing dependency values before requesting a $mode preview', + async ({ mode, selector, field }) => { + const user = userEvent.setup() + const endpoint = mode === 'workflow' ? preview : chatPreview + endpoint.mockResolvedValue({ + form_content: 'Server-resolved contract', + inputs: [], + actions: [], + }) + renderPreview( + { + resource: { kind: 'app', appId: 'app-1', mode }, + hooksStoreProps: { doSyncWorkflowDraft: savedDraft }, + }, + false, + { + content: `Review {{#${selector}#}} {{#$output.feedback#}}`, + availableVars: [sourceVars], + availableNodes: [sourceNode], + }, + ) + + await user.click(screen.getByRole('button', { name: previewLabel })) + const input = await screen.findByRole('textbox', { name: field }) + expect(endpoint).not.toHaveBeenCalled() + expect(screen.queryByRole('textbox', { name: /feedback/ })).not.toBeInTheDocument() + await user.type(input, 'Reviewed contract') + await submitPreview(user) + + expect(await screen.findByText('Server-resolved contract')).toBeInTheDocument() + expect(endpoint).toHaveBeenCalledExactlyOnceWith( + { + params: { app_id: 'app-1', node_id: 'node-1' }, + body: { inputs: { [`#${selector}#`]: 'Reviewed contract' } }, + }, + { context: { silent: true } }, + ) + }, +) + +it.each([ + { selector: 'source.amount', value: 0 }, + { selector: 'source.approved', value: false }, + { selector: 'source.draft', value: '' }, +])( + 'uses a cached dependency value of $value without requesting it again', + async ({ selector, value }) => { + const cachedValues = new Map([[selector, value]]) + preview.mockResolvedValue({ form_content: 'Cached server preview', inputs: [], actions: [] }) + renderPreview( + { + hooksStoreProps: { + doSyncWorkflowDraft: savedDraft, + hasSetInspectVar: (nodeId, name) => cachedValues.has(`${nodeId}.${name}`), + }, + }, + false, + { + content: `{{#${selector}#}}`, + availableVars: [sourceVars], + availableNodes: [sourceNode], + }, + ) + + await userEvent.setup().click(screen.getByRole('button', { name: previewLabel })) + expect(await screen.findByText('Cached server preview')).toBeInTheDocument() + expect(screen.queryByRole('form', { name: previewLabel })).not.toBeInTheDocument() + expect(preview).toHaveBeenCalledExactlyOnceWith( + { params: { app_id: 'app-1', node_id: 'node-1' }, body: { inputs: {} } }, + { context: { silent: true } }, + ) + }, +) + +it('preserves typed dependency values when retrying a failed preview', async () => { + const user = userEvent.setup() + preview + .mockRejectedValueOnce(new Error('Server preview failed')) + .mockResolvedValue({ form_content: 'Retried with contract', inputs: [], actions: [] }) + renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: savedDraft } }, false, { + content: '{{#source.draft#}}', + availableVars: [sourceVars], + availableNodes: [sourceNode], + }) + await user.click(screen.getByRole('button', { name: previewLabel })) + await user.type(await screen.findByRole('textbox', { name: /draft/ }), 'Retained contract') + await submitPreview(user) + expect(await screen.findByRole('alert')).toHaveTextContent('common.error') + expect(screen.getByRole('textbox', { name: /draft/ })).toHaveValue('Retained contract') + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + + expect(await screen.findByText('Retried with contract')).toBeInTheDocument() + expect(preview).toHaveBeenCalledTimes(2) + for (const [request] of preview.mock.calls) { + expect(request.body).toEqual({ inputs: { '#source.draft#': 'Retained contract' } }) + } +}) + +it('lets the user supply dependency values when cached data cannot render the server preview', async () => { + const user = userEvent.setup() + preview + .mockRejectedValueOnce(new Error('Cached value is not available')) + .mockResolvedValue({ form_content: 'Manual server preview', inputs: [], actions: [] }) + renderPreview( + { + hooksStoreProps: { doSyncWorkflowDraft: savedDraft, hasSetInspectVar: () => true }, + }, + false, + { + content: '{{#source.draft#}}', + availableVars: [sourceVars], + availableNodes: [sourceNode], + }, + ) + await user.click(screen.getByRole('button', { name: previewLabel })) + expect(await screen.findByRole('alert')).toHaveTextContent('common.error') + await user.type(await screen.findByRole('textbox', { name: /draft/ }), 'Manual contract') + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + expect(await screen.findByText('Manual server preview')).toBeInTheDocument() + expect(preview.mock.calls[1]?.[0].body).toEqual({ + inputs: { '#source.draft#': 'Manual contract' }, + }) +}) + +it('submits number, boolean and JSON dependency values using their declared types', async () => { + const user = userEvent.setup() + preview.mockResolvedValue({ form_content: 'Typed server preview', inputs: [], actions: [] }) + renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: savedDraft } }, false, { + content: '{{#source.amount#}} {{#source.approved#}} {{#source.settings#}}', + availableVars: [sourceVars], + availableNodes: [sourceNode], + }) + await user.click(screen.getByRole('button', { name: previewLabel })) + await user.type(await screen.findByLabelText(/amount/), '0') + expect(screen.getByRole('checkbox')).not.toBeChecked() + await user.click(screen.getByTestId('monaco-editor')) + await user.paste('{"term":30}') + await submitPreview(user) + + expect(await screen.findByText('Typed server preview')).toBeInTheDocument() + expect(preview).toHaveBeenCalledExactlyOnceWith( + { + params: { app_id: 'app-1', node_id: 'node-1' }, + body: { + inputs: { + '#source.amount#': 0, + '#source.approved#': false, + '#source.settings#': { term: 30 }, + }, + }, + }, + { context: { silent: true } }, + ) +}) + +it('collects variable defaults and select options without treating output fields as dependencies', async () => { + const user = userEvent.setup() + preview.mockResolvedValue({ form_content: 'Server field defaults', inputs: [], actions: [] }) + renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: savedDraft } }, false, { + content: '{{#$output.feedback#}} {{#$output.choice#}}', + formInputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'feedback', + default: { type: 'variable', selector: ['source', 'draft'], value: '' }, + }, + { + type: InputVarType.select, + output_variable_name: 'choice', + option_source: { type: 'variable', selector: ['source', 'options'], value: [] }, + }, + ], + availableVars: [sourceVars], + availableNodes: [sourceNode], + }) + await user.click(screen.getByRole('button', { name: previewLabel })) + await user.type(await screen.findByRole('textbox', { name: /draft/ }), 'Default feedback') + expect(screen.queryByRole('textbox', { name: /feedback/ })).not.toBeInTheDocument() + await user.click(screen.getByTestId('monaco-editor')) + await user.paste('["approve","revise"]') + await submitPreview(user) + + expect(await screen.findByText('Server field defaults')).toBeInTheDocument() + expect(preview).toHaveBeenCalledExactlyOnceWith( + { + params: { app_id: 'app-1', node_id: 'node-1' }, + body: { + inputs: { + '#source.draft#': 'Default feedback', + '#source.options#': ['approve', 'revise'], + }, + }, + }, + { context: { silent: true } }, + ) +}) + +it('shows an inline required-field error before requesting a preview and accepts the corrected value', async () => { + const user = userEvent.setup() + const sync = vi.fn(savedDraft) + preview.mockResolvedValue({ form_content: 'Validated server preview', inputs: [], actions: [] }) + renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: sync } }, false, { + content: '{{#source.draft#}}', + availableVars: [sourceVars], + availableNodes: [sourceNode], + }) + + await user.click(screen.getByRole('button', { name: previewLabel })) + const form = await screen.findByRole('form', { name: previewLabel }) + await submitPreview(user) + expect(within(form).getByRole('alert')).toHaveTextContent('workflow.errorMsg.fieldRequired') + expect(sync).not.toHaveBeenCalled() + expect(preview).not.toHaveBeenCalled() + + await user.type(within(form).getByRole('textbox', { name: /draft/ }), 'Validated contract') + expect(within(form).queryByRole('alert')).not.toBeInTheDocument() + await submitPreview(user) + expect(await screen.findByText('Validated server preview')).toBeInTheDocument() + expect(sync).toHaveBeenCalledTimes(1) + expect(preview).toHaveBeenCalledExactlyOnceWith( + { + params: { app_id: 'app-1', node_id: 'node-1' }, + body: { inputs: { '#source.draft#': 'Validated contract' } }, + }, + { context: { silent: true } }, + ) +}) + +it('ignores a closed preview request failure after reopening with different dependencies', async () => { + const user = userEvent.setup() + let failOldPreview!: (error: Error) => void + preview.mockReturnValue( + new Promise((_resolve, reject) => { + failOldPreview = reject + }), + ) + const view = renderPreview( + { + hooksStoreProps: { + doSyncWorkflowDraft: savedDraft, + hasSetInspectVar: (nodeId, name) => nodeId === 'source' && name === 'draft', + }, + }, + false, + { + content: '{{#source.draft#}}', + availableVars: [sourceVars], + availableNodes: [sourceNode], + }, + ) + + await user.click(screen.getByRole('button', { name: previewLabel })) + expect(preview).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) + view.rerender( + , + ) + await user.click(screen.getByRole('button', { name: previewLabel })) + expect(await screen.findByLabelText(/amount/)).toBeInTheDocument() + expect(screen.queryByRole('textbox', { name: /draft/ })).not.toBeInTheDocument() + + await act(async () => failOldPreview(new Error('The closed preview failed'))) + + expect(screen.getByLabelText(/amount/)).toBeInTheDocument() + expect(screen.queryByRole('textbox', { name: /draft/ })).not.toBeInTheDocument() + expect(preview).toHaveBeenCalledTimes(1) +}) diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.browser.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.browser.spec.tsx index 051864f68b2..c8382288c6c 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.browser.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.browser.spec.tsx @@ -1,7 +1,7 @@ import type { ComponentProps, ReactElement } from 'react' import { detectPlatform } from '@tanstack/react-hotkeys' import { ReactFlowProvider } from 'reactflow' -import { userEvent } from 'vite-plus/test/browser' +import { page, userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import { createNode } from '@/app/components/workflow/__tests__/fixtures' import { WorkflowContext } from '@/app/components/workflow/context' @@ -15,8 +15,7 @@ import { seedToolProviders } from '@/test/console/tool-providers' import { createDefaultParagraphFormInput } from '../../types' import FormContent from '../form-content' -it('preserves the shortcut draft and inserts at the editor selection after focus moves into the input form', async () => { - // Native focus and selection must survive the portal and parent rerender; a callback stub cannot prove insertion. +function createFormContentFixture() { const { queryClient, wrapper: QueryProvider } = createConsoleQueryWrapper() seedModelProviderQueries(queryClient) seedToolProviders(queryClient, { @@ -51,6 +50,12 @@ it('preserves the shortcut draft and inserts at the editor selection after focus ) + return { queryClient, props, wrap } +} + +it('preserves the shortcut draft and inserts at the editor selection after focus moves into the input form', async () => { + // Native focus and selection must survive the portal and parent rerender; a callback stub cannot prove insertion. + const { queryClient, props, wrap } = createFormContentFixture() const screen = await render(wrap()) const editor = screen.getByRole('textbox') await editor.click() @@ -83,3 +88,51 @@ it('preserves the shortcut draft and inserts at the editor selection after focus .toBeVisible() queryClient.clear() }) + +it('restores field editing and removal after the mounted editor leaves readonly mode', async () => { + // Native hover and clicks inside Lexical must reach the recreated action buttons without remounting the editor. + const { queryClient, props, wrap } = createFormContentFixture() + const field = createDefaultParagraphFormInput('notes') + const fieldProps = { + ...props, + value: '{{#$output.notes#}}', + formInputs: [{ ...field, default: { ...field.default, value: 'Review notes' } }], + } + const subject = (readonly: boolean) => + wrap( + + + , + ) + const screen = await render(subject(false)) + const content = screen.getByRole('region', { name: 'Form content' }) + const edit = content.getByRole('button', { name: 'common.operation.edit' }) + const remove = content.getByRole('button', { name: 'common.operation.remove' }) + try { + await content.getByText('Review notes', { exact: true }).hover() + await edit.click() + const dialog = page.getByRole('dialog') + await expect.element(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'common.operation.cancel' }).click() + await expect.element(dialog).not.toBeInTheDocument() + + await screen.rerender(subject(true)) + await expect.element(edit).not.toBeInTheDocument() + await expect.element(remove).not.toBeInTheDocument() + await screen.rerender(subject(false)) + await content.getByText('Review notes', { exact: true }).hover() + await edit.click() + await expect.element(dialog).toBeVisible() + await expect + .element(dialog.getByRole('textbox', { name: /saveResponseAs/ })) + .toHaveValue('notes') + await dialog.getByRole('button', { name: 'common.operation.cancel' }).click() + await expect.element(dialog).not.toBeInTheDocument() + + await content.getByText('Review notes', { exact: true }).hover() + await remove.click() + expect(props.onFormInputItemRemove).toHaveBeenCalledExactlyOnceWith('notes') + } finally { + queryClient.clear() + } +}) diff --git a/web/app/components/workflow/nodes/human-input/components/form-content-preview-inputs.ts b/web/app/components/workflow/nodes/human-input/components/form-content-preview-inputs.ts new file mode 100644 index 00000000000..9e195ceee02 --- /dev/null +++ b/web/app/components/workflow/nodes/human-input/components/form-content-preview-inputs.ts @@ -0,0 +1,149 @@ +import type { Field } from '../../llm/types' +import type { FormInputItem } from '../types' +import type { + InputVar, + Node, + NodeOutPutVar, + ValueSelector, + Var, +} from '@/app/components/workflow/types' +import { getInputVars } from '@/app/components/base/prompt-editor/constants' +import { InputVarType, VarType } from '@/app/components/workflow/types' +import { isENV } from '../../_base/components/variable/utils' +import { Type } from '../../llm/types' +import { getHumanInputFormDependencySelectors, isOutput } from '../utils' + +type PreviewInputParams = { + content: string + formInputs: FormInputItem[] + availableVars: NodeOutPutVar[] + availableNodes: Node[] +} + +const getSchemaVariable = (field: Field, path: string[]): Var => { + const options = field.enum?.filter((value): value is string => typeof value === 'string') + const type = (() => { + switch (field.type) { + case Type.number: + return VarType.number + case Type.boolean: + return VarType.boolean + case Type.object: + return VarType.object + case Type.file: + return VarType.file + case Type.array: + return field.items?.type === Type.file ? VarType.arrayFile : VarType.array + case Type.arrayString: + return VarType.arrayString + case Type.arrayNumber: + return VarType.arrayNumber + case Type.arrayObject: + return VarType.arrayObject + default: + return VarType.string + } + })() + return { + variable: path.join('.'), + type, + isSelect: !!options?.length && options.length === field.enum?.length, + options, + } +} + +const findVariable = (variables: Var[], path: string[]): Var | undefined => { + // Metadata can contain relative child names or flattened names such as sys.query. + for (let length = path.length; length > 0; length--) { + const variable = variables.find((item) => item.variable === path.slice(0, length).join('.')) + if (!variable) continue + if (length === path.length) return variable + + const remainingPath = path.slice(length) + if (Array.isArray(variable.children)) return findVariable(variable.children, remainingPath) + if (!variable.children) continue + + let properties = variable.children.schema.properties + let field: Field | undefined + for (const key of remainingPath) { + field = properties?.[key] + if (!field) break + properties = field.properties ?? {} + } + if (field) return getSchemaVariable(field, remainingPath) + } + return undefined +} + +const getInputType = (variable?: Var): InputVarType => { + if (variable?.isSelect) return InputVarType.select + if (variable?.isParagraph) return InputVarType.paragraph + switch (variable?.type) { + case VarType.number: + case VarType.integer: + return InputVarType.number + case VarType.boolean: + return InputVarType.checkbox + case VarType.object: + case VarType.array: + case VarType.arrayString: + case VarType.arrayNumber: + case VarType.arrayObject: + case VarType.arrayBoolean: + case VarType.arrayAny: + return InputVarType.json + case VarType.file: + return InputVarType.singleFile + case VarType.arrayFile: + return InputVarType.multiFiles + default: + return InputVarType.textInput + } +} + +export const getHumanInputPreviewInputs = ({ + content, + formInputs, + availableVars, + availableNodes, +}: PreviewInputParams): InputVar[] => { + const selectors = new Map() + for (const selector of [ + ...getInputVars(content), + ...getHumanInputFormDependencySelectors(formInputs), + ]) { + if (selector.length < 2 || isOutput(selector) || isENV(selector)) continue + selectors.set(selector.join('.'), selector) + } + + return Array.from(selectors.values(), (selector) => { + let group = availableVars.find((item) => item.nodeId === selector[0]) + let variable = group + ? (findVariable(group.vars, selector.slice(1)) ?? findVariable(group.vars, selector)) + : undefined + if (!variable) { + group = availableVars.find((item) => findVariable(item.vars, selector)) + variable = group ? findVariable(group.vars, selector) : undefined + } + const node = availableNodes.find((item) => item.id === selector[0]) + const path = selector.join('.') + const nodeName = node?.data.title || group?.title || selector[0] || path + const type = getInputType(variable) + return { + variable: `#${path}#`, + value_selector: selector, + label: + node && type !== InputVarType.checkbox + ? { + nodeType: node.data.type, + nodeName, + variable: selector.slice(1).join('.'), + isChatVar: selector[0] === 'conversation', + } + : `${nodeName} · ${path}`, + type, + required: true, + ...(variable?.options ? { options: variable.options } : {}), + } + }) +} diff --git a/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx index a93544be3a9..142349b3a11 100644 --- a/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx +++ b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import type { FormInputItem, UserAction } from '../types' +import type { InputVar, Node, NodeOutPutVar } from '@/app/components/workflow/types' import { zFormInputConfig, zUserActionConfig } from '@dify/contracts/api/console/apps/zod.gen' import { Button } from '@langgenius/dify-ui/button' import { IconButton } from '@langgenius/dify-ui/icon-button' @@ -13,12 +14,20 @@ import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-inp import { Markdown } from '@/app/components/base/markdown' import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton' import { editorResourceAtom } from '@/app/components/workflow/editor-resource/state' +import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' import { useStore } from '@/app/components/workflow/store' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { consoleClient } from '@/service/console' +import Form from '../../_base/components/before-run-form/form' +import { + buildSubmitData, + getFormErrorMessage, + isFilesLoaded, +} from '../../_base/components/before-run-form/helpers' import { HumanInputDraftSyncError, syncHumanInputDraft } from '../shared/sync-draft' import { normalizeHumanInputFormInput } from '../shared/types' +import { getHumanInputPreviewInputs } from './form-content-preview-inputs' import { Note, rehypeNotes, rehypeVariable, Variable } from './variable-in-markdown' const NODE_ID_RE = /#([^#.]+)([.#])/g @@ -117,7 +126,10 @@ const FormContentPreview: FC = ({ ) } -type PreviewTriggerProps = Omit +type PreviewTriggerProps = Omit & { + availableVars?: NodeOutPutVar[] + availableNodes?: Node[] +} const PreviewButton = ({ open, @@ -146,15 +158,46 @@ const PreviewButton = ({ ) } -const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string }) => { - const { t } = useTranslation(['workflow', 'common']) +const ServerPreviewTrigger = ({ + nodeId, + content, + formInputs, + availableVars = [], + availableNodes = [], +}: PreviewTriggerProps & { nodeId: string }) => { + const { t } = useTranslation(['appDebug', 'workflow', 'common', 'workflowHumanInput']) const resource = useAtomValue(editorResourceAtom) const { doSyncWorkflowDraft } = useNodesSyncDraft() + const { hasSetInspectVar, systemVars, conversationVars } = useInspectVarsCrud() const panelWidth = useStore((state) => state.panelWidth) const [open, setOpen] = React.useState(false) + const [inputVars, setInputVars] = React.useState([]) + const [inputValues, setInputValues] = React.useState>({}) + const [formError, setFormError] = React.useState('') const buttonRef = React.useRef(null) + const previewSessionRef = React.useRef(0) + const dependencyInputs = getHumanInputPreviewInputs({ + content, + formInputs, + availableVars, + availableNodes, + }) + const hasCachedValue = (input: InputVar) => { + const selector = input.value_selector + return !!selector && hasSetInspectVar(selector[0]!, selector[1]!, systemVars, conversationVars) + } + const forms = [ + { + inputs: inputVars.map((input) => ({ ...input, required: !hasCachedValue(input) })), + values: inputValues, + onChange: (values: Record) => { + setInputValues(values) + setFormError('') + }, + }, + ] const preview = useMutation({ - mutationFn: async () => { + mutationFn: async ({ inputs }: { inputs: Record; session: number }) => { if (resource.kind !== 'app') throw new Error('Missing application') await syncHumanInputDraft(doSyncWorkflowDraft) const api = @@ -164,7 +207,7 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string const response = await api.preview.post( { params: { app_id: resource.appId, node_id: nodeId }, - body: { inputs: {} }, + body: { inputs }, }, { context: { silent: true } }, ) @@ -179,17 +222,56 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string }), } }, + onError: (_error, { session }) => { + if (session !== previewSessionRef.current) return + // A cached root may still lack a nested value. Let the user supply it instead of retrying empty inputs. + if (dependencyInputs.length) setInputVars(dependencyInputs) + }, }) + const requestPreview = (inputs: Record) => { + preview.mutate({ inputs, session: previewSessionRef.current }) + } + const submitPreview = () => { + const error = getFormErrorMessage(forms, [{}], t) + if (error) { + setFormError(error) + return + } + const { submitData, parseErrorJsonField } = buildSubmitData(forms) + if (parseErrorJsonField) { + setFormError( + t(($) => $['errorMsg.invalidJson'], { ns: 'workflow', field: parseErrorJsonField }), + ) + return + } + setFormError('') + const cachedInputKeys = new Set(inputVars.filter(hasCachedValue).map((input) => input.variable)) + requestPreview( + Object.fromEntries( + Object.entries(submitData).filter( + ([key, value]) => + value !== undefined && (key in inputValues || !cachedInputKeys.has(key)), + ), + ), + ) + } const close = () => { + previewSessionRef.current += 1 setOpen(false) preview.reset() + setInputVars([]) + setInputValues({}) + setFormError('') buttonRef.current?.focus() } const toggle = () => { if (open) close() else { + previewSessionRef.current += 1 setOpen(true) - preview.mutate() + const missingInputs = dependencyInputs.filter((input) => !hasCachedValue(input)) + setInputVars(missingInputs) + if (!missingInputs.length) requestPreview({}) } } return ( @@ -203,7 +285,7 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string ) : ( @@ -213,6 +295,12 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string ? t(($) => $['nodes.humanInputV2.template.testSaveFailed'], { ns: 'workflow' }) : t(($) => $.error, { ns: 'common' })} + ) : inputVars.length ? ( + + {t(($) => $['nodes.humanInput.formContent.preview'], { + ns: 'workflowHumanInput', + })} + ) : ( @@ -226,8 +314,45 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string - {preview.isError && ( - preview.mutate()}> + {!!inputVars.length && ( + $['nodes.humanInput.formContent.preview'], { + ns: 'workflowHumanInput', + })} + onSubmit={(event) => { + event.preventDefault() + submitPreview() + }} + > + + $['nodes.humanInput.singleRun.label'], { + ns: 'workflowHumanInput', + })} + /> + + {formError && ( + + {formError} + + )} + + {preview.isError + ? t(($) => $['operation.retry'], { ns: 'common' }) + : t(($) => $['nodes.humanInput.formContent.preview'], { + ns: 'workflowHumanInput', + })} + + + )} + {preview.isError && !inputVars.length && ( + requestPreview({})}> {t(($) => $['operation.retry'], { ns: 'common' })} )} diff --git a/web/app/components/workflow/nodes/human-input/shared/panel-sections.tsx b/web/app/components/workflow/nodes/human-input/shared/panel-sections.tsx index 11465fee757..7e018e63fc9 100644 --- a/web/app/components/workflow/nodes/human-input/shared/panel-sections.tsx +++ b/web/app/components/workflow/nodes/human-input/shared/panel-sections.tsx @@ -130,6 +130,8 @@ const HumanInputSharedPanelSections = ({ formInputs={inputs.inputs} userActions={inputs.user_actions} nodeId={isHumanInputV2NodeData(inputs) ? id : undefined} + availableVars={availableVars} + availableNodes={availableNodes} /> diff --git a/web/features/contacts/im-platform/__tests__/fixtures/http.ts b/web/features/contacts/im-platform/__tests__/fixtures/http.ts index 09887aa9480..1b9642134f4 100644 --- a/web/features/contacts/im-platform/__tests__/fixtures/http.ts +++ b/web/features/contacts/im-platform/__tests__/fixtures/http.ts @@ -22,6 +22,7 @@ export const syncRun = (overrides: Partial = {}): ImSyncRun => ({ channel_id: channel.id, channel_config_version: 1, provider: 'feishu', + initiator_display_name: '', started_at: 1, finished_at: 2, status: 'succeeded', diff --git a/web/features/contacts/im-platform/__tests__/management-surface.spec.tsx b/web/features/contacts/im-platform/__tests__/management-surface.spec.tsx index da5b26c206a..c7eda369e7c 100644 --- a/web/features/contacts/im-platform/__tests__/management-surface.spec.tsx +++ b/web/features/contacts/im-platform/__tests__/management-surface.spec.tsx @@ -1,8 +1,10 @@ +import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { consoleQuery } from '@/service/console' +import { seedSystemFeatures } from '@/test/console/query-data' import { ContactsImPlatformRuntimeProvider } from '../composition' import { ContactsImPlatformManagementSurface } from '../management-surface' import { channelSummary, emailDetail } from './fixtures/channel-client' @@ -27,10 +29,11 @@ vi.mock('@/context/i18n', async (original) => ({ useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, })) const clients: QueryClient[] = [] -function renderSurface(canManage = true) { +function renderSurface(canManage = true, deploymentEdition: DeploymentEdition = 'CLOUD') { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) + seedSystemFeatures(client, { deployment_edition: deploymentEdition }) clients.push(client) render( @@ -70,6 +73,25 @@ it('does not fetch or expose channels to a non-manager', () => { expect(network.handle).not.toHaveBeenCalled() }) +it.each(['available', 'configured'] as const)( + 'hides %s workspace Email configuration in Community Edition', + async (emailState) => { + const channels = [channel] + if (emailState === 'configured') + channels.push(channelSummary({ id: 'channel-email', kind: 'email', provider: 'resend' })) + network.handle.mockImplementation((request) => + new URL(request.url).pathname.endsWith('/channels') + ? json({ channels }) + : defaultResponse(request), + ) + + renderSurface(true, 'COMMUNITY') + + expect(await screen.findByRole('group', { name: 'Feishu' })).toHaveTextContent('saved-tenant') + expect(screen.queryByRole('group', { name: 'Email' })).not.toBeInTheDocument() + }, +) + it('uses saved redacted metadata for an update and keeps generated cache invalidation', async () => { const user = userEvent.setup() const client = renderSurface() diff --git a/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx b/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx index 726126df70f..34e5b5457a6 100644 --- a/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx +++ b/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx @@ -3,6 +3,7 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { page, userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import MenuDialog from '@/app/components/header/account-setting/menu-dialog' +import { seedSystemFeatures } from '@/test/console/query-data' import { ContactsImPlatformRuntimeProvider } from '../composition' import { ContactsImPlatformManagementSurface } from '../management-surface' import { channelSummary } from './fixtures/channel-client' @@ -66,6 +67,7 @@ async function setup() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) + seedSystemFeatures(queryClient, { deployment_edition: 'CLOUD' }) await render( diff --git a/web/features/contacts/im-platform/management-surface.tsx b/web/features/contacts/im-platform/management-surface.tsx index 236ed778d19..a58f9461bad 100644 --- a/web/features/contacts/im-platform/management-surface.tsx +++ b/web/features/contacts/im-platform/management-surface.tsx @@ -25,6 +25,7 @@ import { import { skipToken, useIsMutating, useMutation, useQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { consoleQuery } from '@/service/console' import { ContactImBindingContent } from './binding-dialog' import { useContactsImPlatformOrganization } from './composition-context' @@ -116,6 +117,11 @@ export function ContactsImPlatformManagementSurface() { const { t } = useTranslation(['contacts']) const { t: tCommon } = useTranslation(['common']) const organization = useContactsImPlatformOrganization() + const { data: deploymentEdition } = useQuery({ + ...systemFeaturesQueryOptions(), + enabled: organization.canManage, + select: (data) => data.deployment_edition, + }) const api = consoleQuery.workspaces.current.humanInput.v2 const integrationsQuery = useQuery( api.channels.get.queryOptions({ enabled: organization.canManage, context: { silent: true } }), @@ -143,14 +149,15 @@ export function ContactsImPlatformManagementSurface() { const [syncRunId, setSyncRunId] = useContactImSyncRunUrlState() const [, setSyncResult] = useContactImSyncResultUrlState() const integrations = integrationsQuery.data?.channels ?? [] - const providers = [ - ...(providersQuery.data?.email_providers ?? []), - ...(providersQuery.data?.im_providers ?? []), - ].map(({ provider }) => ({ - provider, - displayName: providerMetadata[provider].name, - requiredFields: providerMetadata[provider].fields, - })) + const emailProviders = + deploymentEdition === 'CLOUD' ? (providersQuery.data?.email_providers ?? []) : [] + const providers = [...emailProviders, ...(providersQuery.data?.im_providers ?? [])].map( + ({ provider }) => ({ + provider, + displayName: providerMetadata[provider].name, + requiredFields: providerMetadata[provider].fields, + }), + ) const byProvider = new Map(integrations.map((integration) => [integration.provider, integration])) const configured = providers.filter((provider) => byProvider.has(provider.provider)) const available = providers.filter((provider) => !byProvider.has(provider.provider)) diff --git a/web/i18n/locales/ar-TN/workflow.json b/web/i18n/locales/ar-TN/workflow.json index 102957c9b97..c5fd5026553 100644 --- a/web/i18n/locales/ar-TN/workflow.json +++ b/web/i18n/locales/ar-TN/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "اختر من يمكنه الرد على خطوة الإدخال البشري هذه.", "nodes.humanInputV2.recipients.initiator": "يمكن للمستخدم الذي يبدأ هذا التشغيل عبر Web App أو CLI الموافقة", "nodes.humanInputV2.recipients.initiatorDescription": "يمكن للشخص الذي يبدأ تشغيل سير العمل هذا الرد.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "عمليات التشغيل التي تبدأ عبر مُشغّل ليس لها مُبادر يتلقى طلبات الموافقة. اختر جهة اتصال أو استخدم متغيرًا لتحديد عنوان بريد إلكتروني.", "nodes.humanInputV2.recipients.inputHelp": "اكتب أو الصق عدة عناوين بريد إلكتروني مفصولة بفواصل. اضغط على / لإدراج متغير.", "nodes.humanInputV2.recipients.insertVariable": "إدراج متغيّر", "nodes.humanInputV2.recipients.loadError": "تعذّر تحميل جهات الاتصال. تم الاحتفاظ بمعرّفات جهات الاتصال المحفوظة.", diff --git a/web/i18n/locales/az-AZ/workflow.json b/web/i18n/locales/az-AZ/workflow.json index 9b95f171f1a..a992412e59b 100644 --- a/web/i18n/locales/az-AZ/workflow.json +++ b/web/i18n/locales/az-AZ/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Bu İnsan girişi addımına kimin cavab verə biləcəyini seçin.", "nodes.humanInputV2.recipients.initiator": "Bu icranı Web App və ya CLI vasitəsilə başladan istifadəçi təsdiqləyə bilər", "nodes.humanInputV2.recipients.initiatorDescription": "Bu iş axınının icrasını başladan şəxs cavab verə bilər.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Tətikləyici ilə başlayan icralarda təsdiq sorğularını qəbul edə biləcək bir başladan şəxs yoxdur. Kontakt seçin və ya e-poçt ünvanını dəyişən vasitəsilə göstərin.", "nodes.humanInputV2.recipients.inputHelp": "Vergüllə ayrılmış bir neçə e-poçt ünvanı yazın və ya yapışdırın. Dəyişən əlavə etmək üçün / düyməsini basın.", "nodes.humanInputV2.recipients.insertVariable": "Dəyişən əlavə et", "nodes.humanInputV2.recipients.loadError": "Kontaktları yükləmək mümkün olmadı. Saxlanılmış kontakt ID-ləri qorunur.", diff --git a/web/i18n/locales/de-DE/workflow.json b/web/i18n/locales/de-DE/workflow.json index aae61a7b8a8..e7d592a7686 100644 --- a/web/i18n/locales/de-DE/workflow.json +++ b/web/i18n/locales/de-DE/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Wählen Sie aus, wer auf diesen Schritt für menschliche Eingaben antworten kann.", "nodes.humanInputV2.recipients.initiator": "Der Benutzer, der diesen Durchlauf über Web App oder CLI startet, kann genehmigen", "nodes.humanInputV2.recipients.initiatorDescription": "Die Person, die diesen Workflow-Lauf startet, kann antworten.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Bei einem durch einen Trigger gestarteten Lauf gibt es keinen Initiator, der Genehmigungsanfragen empfangen kann. Wählen Sie einen Kontakt aus oder geben Sie eine E-Mail-Adresse über eine Variable an.", "nodes.humanInputV2.recipients.inputHelp": "Geben Sie mehrere durch Kommas getrennte E-Mail-Adressen ein oder fügen Sie sie ein. Drücken Sie /, um eine Variable einzufügen.", "nodes.humanInputV2.recipients.insertVariable": "Variable einfügen", "nodes.humanInputV2.recipients.loadError": "Kontakte konnten nicht geladen werden. Gespeicherte Kontakt-IDs bleiben erhalten.", diff --git a/web/i18n/locales/en-US/workflow.json b/web/i18n/locales/en-US/workflow.json index b1c3ac7b9aa..c7342c0fa3d 100644 --- a/web/i18n/locales/en-US/workflow.json +++ b/web/i18n/locales/en-US/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Choose who can respond to this Human Input step.", "nodes.humanInputV2.recipients.initiator": "The user who starts this run through Web App or CLI can approve", "nodes.humanInputV2.recipients.initiatorDescription": "The person who starts this workflow run can respond.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Trigger runs have no initiator to receive approval requests. Select a contact or use a variable to provide an email address.", "nodes.humanInputV2.recipients.inputHelp": "Type or paste multiple emails separated by commas. Press / to insert a variable.", "nodes.humanInputV2.recipients.insertVariable": "Insert variable", "nodes.humanInputV2.recipients.loadError": "Contacts could not be loaded. Stored contact IDs are preserved.", diff --git a/web/i18n/locales/es-ES/workflow.json b/web/i18n/locales/es-ES/workflow.json index 2184a063e63..e248caf57fc 100644 --- a/web/i18n/locales/es-ES/workflow.json +++ b/web/i18n/locales/es-ES/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Elige quién puede responder a este paso de Entrada Humana.", "nodes.humanInputV2.recipients.initiator": "El usuario que inicia esta ejecución a través de Web App o CLI puede aprobar", "nodes.humanInputV2.recipients.initiatorDescription": "La persona que inicia esta ejecución del flujo de trabajo puede responder.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Las ejecuciones iniciadas por un activador no tienen un iniciador que pueda recibir solicitudes de aprobación. Selecciona un contacto o usa una variable para indicar una dirección de correo electrónico.", "nodes.humanInputV2.recipients.inputHelp": "Escribe o pega varias direcciones de correo separadas por comas. Pulsa / para insertar una variable.", "nodes.humanInputV2.recipients.insertVariable": "Insertar variable", "nodes.humanInputV2.recipients.loadError": "No se pudieron cargar los contactos. Se conservan los ID de contacto guardados.", diff --git a/web/i18n/locales/fa-IR/workflow.json b/web/i18n/locales/fa-IR/workflow.json index 8f31d5a85f9..fa90efb9c67 100644 --- a/web/i18n/locales/fa-IR/workflow.json +++ b/web/i18n/locales/fa-IR/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "انتخاب کنید چه کسی میتواند به این مرحله ورودی انسانی پاسخ دهد.", "nodes.humanInputV2.recipients.initiator": "کاربری که این اجرا را از طریق Web App یا CLI آغاز میکند، میتواند تأیید کند", "nodes.humanInputV2.recipients.initiatorDescription": "فردی که این اجرای گردش کار را آغاز میکند میتواند پاسخ دهد.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "در اجراهایی که با محرک شروع میشوند، آغازکنندهای برای دریافت درخواستهای تأیید وجود ندارد. یک مخاطب انتخاب کنید یا نشانی ایمیل را با یک متغیر مشخص کنید.", "nodes.humanInputV2.recipients.inputHelp": "چند نشانی ایمیل را با جداکنندهٔ ویرگول تایپ یا جایگذاری کنید. برای درج متغیر، / را فشار دهید.", "nodes.humanInputV2.recipients.insertVariable": "درج متغیر", "nodes.humanInputV2.recipients.loadError": "بارگذاری مخاطبان ممکن نبود. شناسههای ذخیرهشده مخاطبان حفظ شدهاند.", diff --git a/web/i18n/locales/fr-FR/workflow.json b/web/i18n/locales/fr-FR/workflow.json index 7a3ac58036c..9ed60d40adc 100644 --- a/web/i18n/locales/fr-FR/workflow.json +++ b/web/i18n/locales/fr-FR/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Choisissez qui peut répondre à cette étape de saisie humaine.", "nodes.humanInputV2.recipients.initiator": "L’utilisateur qui lance cette exécution via Web App ou CLI peut approuver", "nodes.humanInputV2.recipients.initiatorDescription": "La personne qui lance cette exécution du workflow peut répondre.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Lorsqu’une exécution démarre via un déclencheur, il n’y a pas d’initiateur pour recevoir les demandes d’approbation. Sélectionnez un contact ou indiquez une adresse e-mail à l’aide d’une variable.", "nodes.humanInputV2.recipients.inputHelp": "Saisissez ou collez plusieurs adresses e-mail séparées par des virgules. Appuyez sur / pour insérer une variable.", "nodes.humanInputV2.recipients.insertVariable": "Insérer une variable", "nodes.humanInputV2.recipients.loadError": "Impossible de charger les contacts. Les ID de contact enregistrés sont conservés.", diff --git a/web/i18n/locales/hi-IN/workflow.json b/web/i18n/locales/hi-IN/workflow.json index 690a95c054f..6379c7d7457 100644 --- a/web/i18n/locales/hi-IN/workflow.json +++ b/web/i18n/locales/hi-IN/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "चुनें कि मानव इनपुट के इस चरण का उत्तर कौन दे सकता है।", "nodes.humanInputV2.recipients.initiator": "Web App या CLI के ज़रिए यह रन शुरू करने वाला उपयोगकर्ता अनुमोदन कर सकता है", "nodes.humanInputV2.recipients.initiatorDescription": "इस वर्कफ़्लो को शुरू करने वाला व्यक्ति उत्तर दे सकता है।", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "ट्रिगर से शुरू होने वाले रन में अनुमोदन अनुरोध पाने के लिए कोई आरंभकर्ता नहीं होता। कोई संपर्क चुनें या ईमेल पता देने के लिए किसी वेरिएबल का उपयोग करें।", "nodes.humanInputV2.recipients.inputHelp": "अल्पविराम से अलग किए गए कई ईमेल टाइप या पेस्ट करें। वेरिएबल डालने के लिए / दबाएँ।", "nodes.humanInputV2.recipients.insertVariable": "वेरिएबल डालें", "nodes.humanInputV2.recipients.loadError": "संपर्क लोड नहीं किए जा सके। सहेजी गई संपर्क ID सुरक्षित हैं।", diff --git a/web/i18n/locales/id-ID/workflow.json b/web/i18n/locales/id-ID/workflow.json index f987cdc0118..8ae0d6981cd 100644 --- a/web/i18n/locales/id-ID/workflow.json +++ b/web/i18n/locales/id-ID/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Pilih siapa yang dapat merespons langkah Input Manusia ini.", "nodes.humanInputV2.recipients.initiator": "Pengguna yang memulai proses ini melalui Web App atau CLI dapat menyetujui", "nodes.humanInputV2.recipients.initiatorDescription": "Orang yang memulai eksekusi alur kerja ini dapat merespons.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Eksekusi yang dimulai oleh pemicu tidak memiliki inisiator yang dapat menerima permintaan persetujuan. Pilih kontak atau gunakan variabel untuk menentukan alamat email.", "nodes.humanInputV2.recipients.inputHelp": "Ketik atau tempel beberapa alamat email yang dipisahkan dengan koma. Tekan / untuk menyisipkan variabel.", "nodes.humanInputV2.recipients.insertVariable": "Sisipkan variabel", "nodes.humanInputV2.recipients.loadError": "Kontak tidak dapat dimuat. ID kontak yang tersimpan tetap dipertahankan.", diff --git a/web/i18n/locales/it-IT/workflow.json b/web/i18n/locales/it-IT/workflow.json index 0783d38feba..8d97e4d792d 100644 --- a/web/i18n/locales/it-IT/workflow.json +++ b/web/i18n/locales/it-IT/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Scegli chi può rispondere a questo passaggio di input umano.", "nodes.humanInputV2.recipients.initiator": "L’utente che avvia questa esecuzione tramite Web App o CLI può approvare", "nodes.humanInputV2.recipients.initiatorDescription": "La persona che avvia questa esecuzione del flusso di lavoro può rispondere.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Quando un trigger avvia l’esecuzione, nessun utente può ricevere le richieste di approvazione come iniziatore. Seleziona un contatto o indica un indirizzo email tramite una variabile.", "nodes.humanInputV2.recipients.inputHelp": "Digita o incolla più indirizzi email separati da virgole. Premi / per inserire una variabile.", "nodes.humanInputV2.recipients.insertVariable": "Inserisci variabile", "nodes.humanInputV2.recipients.loadError": "Impossibile caricare i contatti. Gli ID dei contatti salvati vengono mantenuti.", diff --git a/web/i18n/locales/ja-JP/workflow.json b/web/i18n/locales/ja-JP/workflow.json index 1e59dcb4586..83a2d17aca0 100644 --- a/web/i18n/locales/ja-JP/workflow.json +++ b/web/i18n/locales/ja-JP/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "この人間の入力ステップに回答できる人を選択します。", "nodes.humanInputV2.recipients.initiator": "Web App または CLI から今回の実行を開始したユーザーが承認できます", "nodes.humanInputV2.recipients.initiatorDescription": "このワークフローの実行を開始した人が回答できます。", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "トリガーから開始した実行には、承認リクエストを受け取る開始者がいません。連絡先を選択するか、変数でメールアドレスを指定してください。", "nodes.humanInputV2.recipients.inputHelp": "複数のメールアドレスをカンマで区切って入力または貼り付けます。/ を押すと変数を挿入できます。", "nodes.humanInputV2.recipients.insertVariable": "変数を挿入", "nodes.humanInputV2.recipients.loadError": "連絡先を読み込めませんでした。保存済みの連絡先 ID は保持されています。", diff --git a/web/i18n/locales/ko-KR/workflow.json b/web/i18n/locales/ko-KR/workflow.json index a97da6ea4e6..fbee8fe29c5 100644 --- a/web/i18n/locales/ko-KR/workflow.json +++ b/web/i18n/locales/ko-KR/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "이 사람 입력 단계에 응답할 수 있는 사람을 선택하세요.", "nodes.humanInputV2.recipients.initiator": "Web App 또는 CLI를 통해 이번 실행을 시작한 사용자가 승인할 수 있습니다", "nodes.humanInputV2.recipients.initiatorDescription": "이 워크플로 실행을 시작한 사람이 응답할 수 있습니다.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "트리거로 시작된 실행에는 승인 요청을 받을 시작자가 없습니다. 연락처를 선택하거나 변수로 이메일 주소를 지정하세요.", "nodes.humanInputV2.recipients.inputHelp": "여러 이메일 주소를 쉼표로 구분하여 입력하거나 붙여넣으세요. /를 눌러 변수를 삽입하세요.", "nodes.humanInputV2.recipients.insertVariable": "변수 삽입", "nodes.humanInputV2.recipients.loadError": "연락처를 불러올 수 없습니다. 저장된 연락처 ID는 유지됩니다.", diff --git a/web/i18n/locales/lo-LA/workflow.json b/web/i18n/locales/lo-LA/workflow.json index 314a2b0dd3f..185aff0b8ab 100644 --- a/web/i18n/locales/lo-LA/workflow.json +++ b/web/i18n/locales/lo-LA/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "ເລືອກຜູ້ທີ່ສາມາດຕອບຂັ້ນຕອນການປ້ອນຂໍ້ມູນໂດຍມະນຸດນີ້ໄດ້.", "nodes.humanInputV2.recipients.initiator": "ຜູ້ໃຊ້ທີ່ເລີ່ມການເຮັດວຽກຄັ້ງນີ້ຜ່ານ Web App ຫຼື CLI ສາມາດອະນຸມັດໄດ້", "nodes.humanInputV2.recipients.initiatorDescription": "ຜູ້ທີ່ເລີ່ມການເຮັດວຽກຂອງ Workflow ຄັ້ງນີ້ສາມາດຕອບໄດ້.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "ການເຮັດວຽກທີ່ເລີ່ມຈາກຕົວກະຕຸ້ນບໍ່ມີຜູ້ເລີ່ມທີ່ຈະຮັບຄຳຂໍອະນຸມັດ. ເລືອກຜູ້ຕິດຕໍ່ ຫຼື ໃຊ້ຕົວແປເພື່ອລະບຸທີ່ຢູ່ອີເມວ.", "nodes.humanInputV2.recipients.inputHelp": "ພິມ ຫຼື ວາງຫຼາຍອີເມວໂດຍໃຊ້ , ຄັ່ນແຕ່ລະອີເມວ. ກົດ / ເພື່ອແຊກຕົວແປ.", "nodes.humanInputV2.recipients.insertVariable": "ແຊກຕົວແປ", "nodes.humanInputV2.recipients.loadError": "ບໍ່ສາມາດໂຫຼດຜູ້ຕິດຕໍ່ໄດ້. ID ຜູ້ຕິດຕໍ່ທີ່ບັນທຶກໄວ້ຍັງຖືກຮັກສາໄວ້.", diff --git a/web/i18n/locales/nl-NL/workflow.json b/web/i18n/locales/nl-NL/workflow.json index 143e008ec0b..e167efaa9a6 100644 --- a/web/i18n/locales/nl-NL/workflow.json +++ b/web/i18n/locales/nl-NL/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Kies wie op deze Human Input-stap kan reageren.", "nodes.humanInputV2.recipients.initiator": "De gebruiker die deze uitvoering via Web App of CLI start, kan goedkeuren", "nodes.humanInputV2.recipients.initiatorDescription": "De persoon die deze workflowuitvoering start, kan reageren.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Bij uitvoeringen die door een trigger worden gestart, is er geen initiator die goedkeuringsverzoeken kan ontvangen. Selecteer een contact of geef een e-mailadres op via een variabele.", "nodes.humanInputV2.recipients.inputHelp": "Typ of plak meerdere e-mailadressen, gescheiden door komma’s. Druk op / om een variabele in te voegen.", "nodes.humanInputV2.recipients.insertVariable": "Variabele invoegen", "nodes.humanInputV2.recipients.loadError": "Contacten konden niet worden geladen. Opgeslagen contact-ID’s blijven behouden.", diff --git a/web/i18n/locales/pl-PL/workflow.json b/web/i18n/locales/pl-PL/workflow.json index e196c45ca0e..ab326776672 100644 --- a/web/i18n/locales/pl-PL/workflow.json +++ b/web/i18n/locales/pl-PL/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Wybierz, kto może odpowiedzieć w tym kroku danych wprowadzanych przez człowieka.", "nodes.humanInputV2.recipients.initiator": "Użytkownik, który uruchomi to wykonanie przez Web App lub CLI, może je zatwierdzić", "nodes.humanInputV2.recipients.initiatorDescription": "Osoba rozpoczynająca to uruchomienie przepływu pracy może odpowiedzieć.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Uruchomienia przez wyzwalacz nie mają inicjatora, który mógłby otrzymywać prośby o zatwierdzenie. Wybierz kontakt lub podaj adres e-mail za pomocą zmiennej.", "nodes.humanInputV2.recipients.inputHelp": "Wpisz lub wklej wiele adresów e-mail oddzielonych przecinkami. Naciśnij /, aby wstawić zmienną.", "nodes.humanInputV2.recipients.insertVariable": "Wstaw zmienną", "nodes.humanInputV2.recipients.loadError": "Nie udało się wczytać kontaktów. Zapisane identyfikatory kontaktów zostały zachowane.", diff --git a/web/i18n/locales/pt-BR/workflow.json b/web/i18n/locales/pt-BR/workflow.json index 57133958c78..d0124f8cc5e 100644 --- a/web/i18n/locales/pt-BR/workflow.json +++ b/web/i18n/locales/pt-BR/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Escolha quem pode responder a esta etapa de Entrada Humana.", "nodes.humanInputV2.recipients.initiator": "O usuário que inicia esta execução pelo Web App ou CLI pode aprovar", "nodes.humanInputV2.recipients.initiatorDescription": "A pessoa que inicia esta execução do fluxo de trabalho pode responder.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "As execuções iniciadas por um gatilho não têm um iniciador que possa receber solicitações de aprovação. Selecione um contato ou use uma variável para informar um endereço de e-mail.", "nodes.humanInputV2.recipients.inputHelp": "Digite ou cole vários endereços de e-mail separados por vírgulas. Pressione / para inserir uma variável.", "nodes.humanInputV2.recipients.insertVariable": "Inserir variável", "nodes.humanInputV2.recipients.loadError": "Não foi possível carregar os contatos. Os IDs de contato armazenados foram preservados.", diff --git a/web/i18n/locales/ro-RO/workflow.json b/web/i18n/locales/ro-RO/workflow.json index afc7b069f1b..6b3d5d0ce6a 100644 --- a/web/i18n/locales/ro-RO/workflow.json +++ b/web/i18n/locales/ro-RO/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Alege cine poate răspunde la acest pas de input uman.", "nodes.humanInputV2.recipients.initiator": "Utilizatorul care pornește această execuție prin Web App sau CLI poate aproba", "nodes.humanInputV2.recipients.initiatorDescription": "Persoana care pornește această rulare a fluxului de lucru poate răspunde.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Execuțiile pornite de un declanșator nu au un inițiator care să primească cereri de aprobare. Selectează un contact sau specifică o adresă de e-mail folosind o variabilă.", "nodes.humanInputV2.recipients.inputHelp": "Introdu sau lipește mai multe adrese de e-mail separate prin virgule. Apasă / pentru a insera o variabilă.", "nodes.humanInputV2.recipients.insertVariable": "Inserează o variabilă", "nodes.humanInputV2.recipients.loadError": "Contactele nu au putut fi încărcate. ID-urile de contact salvate sunt păstrate.", diff --git a/web/i18n/locales/ru-RU/workflow.json b/web/i18n/locales/ru-RU/workflow.json index 4ee420273bf..6be5a5bbfff 100644 --- a/web/i18n/locales/ru-RU/workflow.json +++ b/web/i18n/locales/ru-RU/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Выберите, кто может ответить на этом шаге ввода человека.", "nodes.humanInputV2.recipients.initiator": "Пользователь, запустивший это выполнение через Web App или CLI, может его одобрить", "nodes.humanInputV2.recipients.initiatorDescription": "Ответить может пользователь, запустивший этот рабочий процесс.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "При запуске по триггеру нет инициатора, который мог бы получить запрос на согласование. Выберите контакт или укажите адрес электронной почты с помощью переменной.", "nodes.humanInputV2.recipients.inputHelp": "Введите или вставьте несколько адресов электронной почты через запятую. Нажмите /, чтобы вставить переменную.", "nodes.humanInputV2.recipients.insertVariable": "Вставить переменную", "nodes.humanInputV2.recipients.loadError": "Не удалось загрузить контакты. Сохранённые ID контактов не изменены.", diff --git a/web/i18n/locales/sl-SI/workflow.json b/web/i18n/locales/sl-SI/workflow.json index 042cccc09de..afe4b224eb3 100644 --- a/web/i18n/locales/sl-SI/workflow.json +++ b/web/i18n/locales/sl-SI/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Izberite, kdo lahko odgovori v tem koraku človeškega vnosa.", "nodes.humanInputV2.recipients.initiator": "Uporabnik, ki zažene to izvajanje prek Web App ali CLI, lahko odobri", "nodes.humanInputV2.recipients.initiatorDescription": "Odgovori lahko oseba, ki zažene ta delovni tok.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Izvajanja, ki jih zažene sprožilec, nimajo pobudnika, ki bi lahko prejel zahteve za odobritev. Izberite stik ali določite e-poštni naslov s spremenljivko.", "nodes.humanInputV2.recipients.inputHelp": "Vnesite ali prilepite več e-poštnih naslovov, ločenih z vejicami. Pritisnite /, da vstavite spremenljivko.", "nodes.humanInputV2.recipients.insertVariable": "Vstavi spremenljivko", "nodes.humanInputV2.recipients.loadError": "Stikov ni bilo mogoče naložiti. Shranjeni ID-ji stikov so ohranjeni.", diff --git a/web/i18n/locales/th-TH/workflow.json b/web/i18n/locales/th-TH/workflow.json index 6550695a6d5..d56c96dd5e8 100644 --- a/web/i18n/locales/th-TH/workflow.json +++ b/web/i18n/locales/th-TH/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "เลือกผู้ที่สามารถตอบในขั้นตอนข้อมูลจากมนุษย์นี้", "nodes.humanInputV2.recipients.initiator": "ผู้ใช้ที่เริ่มการทำงานครั้งนี้ผ่าน Web App หรือ CLI สามารถอนุมัติได้", "nodes.humanInputV2.recipients.initiatorDescription": "ผู้ที่เริ่มการเรียกใช้เวิร์กโฟลว์นี้สามารถตอบได้", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "การทำงานที่เริ่มด้วยทริกเกอร์ไม่มีผู้เริ่มที่สามารถรับคำขออนุมัติได้ เลือกผู้ติดต่อหรือใช้ตัวแปรเพื่อระบุที่อยู่อีเมล", "nodes.humanInputV2.recipients.inputHelp": "พิมพ์หรือวางหลายอีเมลโดยคั่นด้วยเครื่องหมายจุลภาค กด / เพื่อแทรกตัวแปร", "nodes.humanInputV2.recipients.insertVariable": "แทรกตัวแปร", "nodes.humanInputV2.recipients.loadError": "ไม่สามารถโหลดผู้ติดต่อได้ ID ผู้ติดต่อที่บันทึกไว้ยังคงอยู่", diff --git a/web/i18n/locales/tr-TR/workflow.json b/web/i18n/locales/tr-TR/workflow.json index 13128d41dcd..881962bc9de 100644 --- a/web/i18n/locales/tr-TR/workflow.json +++ b/web/i18n/locales/tr-TR/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Bu İnsan Girdisi adımına kimlerin yanıt verebileceğini seçin.", "nodes.humanInputV2.recipients.initiator": "Bu çalıştırmayı Web App veya CLI üzerinden başlatan kullanıcı onaylayabilir", "nodes.humanInputV2.recipients.initiatorDescription": "Bu iş akışını başlatan kişi yanıt verebilir.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Tetikleyiciyle başlatılan çalıştırmalarda onay isteklerini alabilecek bir başlatan yoktur. Bir kişi seçin veya e-posta adresini bir değişkenle belirtin.", "nodes.humanInputV2.recipients.inputHelp": "Virgülle ayrılmış birden fazla e-posta adresi yazın veya yapıştırın. Değişken eklemek için / tuşuna basın.", "nodes.humanInputV2.recipients.insertVariable": "Değişken ekle", "nodes.humanInputV2.recipients.loadError": "Kişiler yüklenemedi. Saklanan kişi kimlikleri korunuyor.", diff --git a/web/i18n/locales/uk-UA/workflow.json b/web/i18n/locales/uk-UA/workflow.json index 92de92ecd36..8f23835eca7 100644 --- a/web/i18n/locales/uk-UA/workflow.json +++ b/web/i18n/locales/uk-UA/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Виберіть, хто може відповісти на цьому кроці введення людини.", "nodes.humanInputV2.recipients.initiator": "Користувач, який запускає це виконання через Web App або CLI, може його схвалити", "nodes.humanInputV2.recipients.initiatorDescription": "Відповісти може особа, яка запускає цей робочий процес.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Запуски за тригером не мають ініціатора, який міг би отримати запит на схвалення. Виберіть контакт або вкажіть адресу електронної пошти за допомогою змінної.", "nodes.humanInputV2.recipients.inputHelp": "Введіть або вставте кілька електронних адрес, розділених комами. Натисніть /, щоб вставити змінну.", "nodes.humanInputV2.recipients.insertVariable": "Вставити змінну", "nodes.humanInputV2.recipients.loadError": "Не вдалося завантажити контакти. Збережені ID контактів не змінено.", diff --git a/web/i18n/locales/vi-VN/workflow.json b/web/i18n/locales/vi-VN/workflow.json index 6ed16e52368..9a8cfd499c6 100644 --- a/web/i18n/locales/vi-VN/workflow.json +++ b/web/i18n/locales/vi-VN/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Chọn người có thể phản hồi ở bước Đầu vào của con người này.", "nodes.humanInputV2.recipients.initiator": "Người dùng bắt đầu lần chạy này qua Web App hoặc CLI có thể phê duyệt", "nodes.humanInputV2.recipients.initiatorDescription": "Người bắt đầu lần chạy quy trình này có thể phản hồi.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Lần chạy do trình kích hoạt khởi động không có người khởi tạo để nhận yêu cầu phê duyệt. Hãy chọn một liên hệ hoặc dùng biến để cung cấp địa chỉ email.", "nodes.humanInputV2.recipients.inputHelp": "Nhập hoặc dán nhiều email, phân tách bằng dấu phẩy. Nhấn / để chèn biến.", "nodes.humanInputV2.recipients.insertVariable": "Chèn biến", "nodes.humanInputV2.recipients.loadError": "Không thể tải các liên hệ. ID liên hệ đã lưu vẫn được giữ lại.", diff --git a/web/i18n/locales/zh-Hans/workflow.json b/web/i18n/locales/zh-Hans/workflow.json index c75374e7e57..83223fe5b55 100644 --- a/web/i18n/locales/zh-Hans/workflow.json +++ b/web/i18n/locales/zh-Hans/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "选择可以响应此人工介入步骤的人员。", "nodes.humanInputV2.recipients.initiator": "通过 Web App 或 CLI 发起本次运行的用户可审批", "nodes.humanInputV2.recipients.initiatorDescription": "发起本次工作流运行的用户可以响应。", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "触发器运行没有可接收审批的发起人,请选择联系人或使用变量提供邮箱。", "nodes.humanInputV2.recipients.inputHelp": "输入或粘贴多个邮箱,以逗号分隔。按 / 插入变量。", "nodes.humanInputV2.recipients.insertVariable": "插入变量", "nodes.humanInputV2.recipients.loadError": "无法加载联系人。已保存的联系人 ID 会继续保留。", diff --git a/web/i18n/locales/zh-Hant/workflow.json b/web/i18n/locales/zh-Hant/workflow.json index d3252744555..c3364eeed14 100644 --- a/web/i18n/locales/zh-Hant/workflow.json +++ b/web/i18n/locales/zh-Hant/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "選擇可以回應此人工輸入步驟的人員。", "nodes.humanInputV2.recipients.initiator": "透過 Web App 或 CLI 發起本次執行的使用者可審批", "nodes.humanInputV2.recipients.initiatorDescription": "啟動此次工作流程執行的人員可以回應。", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "透過觸發器啟動的執行沒有可接收審批請求的發起人。請選取聯絡人,或使用變數提供電子郵件地址。", "nodes.humanInputV2.recipients.inputHelp": "輸入或貼上多個電子郵件地址,以逗號分隔。按 / 插入變數。", "nodes.humanInputV2.recipients.insertVariable": "插入變數", "nodes.humanInputV2.recipients.loadError": "無法載入聯絡人。已保留儲存的聯絡人 ID。", diff --git a/web/i18n/resources.generated.d.ts b/web/i18n/resources.generated.d.ts index 9b17e0fb72b..803dab21c08 100644 --- a/web/i18n/resources.generated.d.ts +++ b/web/i18n/resources.generated.d.ts @@ -6146,6 +6146,7 @@ export default interface Resources { 'nodes.humanInputV2.recipients.help': 'Choose who can respond to this Human Input step.' 'nodes.humanInputV2.recipients.initiator': 'The user who starts this run through Web App or CLI can approve' 'nodes.humanInputV2.recipients.initiatorDescription': 'The person who starts this workflow run can respond.' + 'nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow': 'Trigger runs have no initiator to receive approval requests. Select a contact or use a variable to provide an email address.' 'nodes.humanInputV2.recipients.inputHelp': 'Type or paste multiple emails separated by commas. Press / to insert a variable.' 'nodes.humanInputV2.recipients.insertVariable': 'Insert variable' 'nodes.humanInputV2.recipients.loadError': 'Contacts could not be loaded. Stored contact IDs are preserved.'
{inputHint}
+ {initiatorUnavailableMessage} +
> {(item) => { + const initiatorUnavailable = isUnavailableInitiator(item.recipient) const added = value.some( (recipient) => getRecipientCanonicalKey(recipient) === @@ -742,8 +768,16 @@ const RecipientsContent = ({ key={item.key} value={item.key} - disabled={added || !!conflict} - className="min-w-0 grow" + disabled={initiatorUnavailable || added || !!conflict} + aria-label={initiatorUnavailable ? item.label : undefined} + aria-describedby={ + initiatorUnavailable ? `${initiatorUnavailableId}-option` : undefined + } + className={ + initiatorUnavailable + ? 'min-w-0 grow data-disabled:opacity-100' + : 'min-w-0 grow' + } > {item.contact && ( @@ -755,9 +789,23 @@ const RecipientsContent = ({ /> )} - + {item.contact?.name ?? item.label} + {initiatorUnavailable && ( + + {initiatorUnavailableMessage} + + )} {item.contact?.email && ( {item.contact.email} diff --git a/web/app/components/workflow/nodes/human-input-v2/panel.tsx b/web/app/components/workflow/nodes/human-input-v2/panel.tsx index e9bbcaee9e3..4cfb2f40b03 100644 --- a/web/app/components/workflow/nodes/human-input-v2/panel.tsx +++ b/web/app/components/workflow/nodes/human-input-v2/panel.tsx @@ -4,7 +4,9 @@ import { Separator } from '@langgenius/dify-ui/separator' import { useQuery } from '@tanstack/react-query' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import HumanInputSharedPanelSections from '@/app/components/workflow/nodes/human-input/shared/panel-sections' +import useWorkflowNodes from '@/app/components/workflow/store/workflow/use-nodes' import { VarType } from '@/app/components/workflow/types' +import { isTriggerWorkflow } from '@/app/components/workflow/utils/workflow-entry' import { ENABLE_HITL_IM } from '@/config' import { userProfileQueryOptions } from '@/features/account-profile/client' import DebugMode from './components/debug-mode' @@ -15,6 +17,7 @@ import useHumanInputV2Config from './use-config' const HumanInputV2PanelContent = ({ id, data }: NodePanelProps) => { const config = useHumanInputV2Config(id, data) + const nodes = useWorkflowNodes() const { data: email } = useQuery({ ...userProfileQueryOptions(), select: (data) => data.profile.email, @@ -40,6 +43,7 @@ const HumanInputV2PanelContent = ({ id, data }: NodePanelProps diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.hitl-v2.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.hitl-v2.spec.tsx index af40c7dbf32..11f04f7ae4b 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.hitl-v2.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.hitl-v2.spec.tsx @@ -1,10 +1,14 @@ +import type { ComponentProps } from 'react' +import type { NodeOutPutVar } from '../../../../types' import { act, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createNode } from '../../../../__tests__/fixtures' import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env' +import { BlockEnum, InputVarType, VarType } from '../../../../types' import FormContentPreview, { FormContentPreviewTrigger } from '../form-content-preview' const preview = vi.hoisted(() => vi.fn()) +const chatPreview = vi.hoisted(() => vi.fn()) vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() const { withConsoleClientResponses } = await import('@/test/console/service-responses') @@ -12,6 +16,8 @@ vi.mock('@/service/console', async (importOriginal) => { ...actual, consoleClient: withConsoleClientResponses(actual.consoleClient, { 'apps.byAppId.workflows.draft.humanInput.nodes.byNodeId.form.preview.post': preview, + 'apps.byAppId.advancedChat.workflows.draft.humanInput.nodes.byNodeId.form.preview.post': + chatPreview, }), } }) @@ -26,22 +32,57 @@ const props = { const renderPreview = ( options: Parameters[1] = {}, readOnly = false, + overrides: Partial> = {}, ) => renderWorkflowComponent( readOnly ? ( - + ) : ( - + ), { ...options, initialStoreState: { panelWidth: 320, nodes: [createNode({ id: 'node-1', data: { title: 'Classifier' } })], + ...options.initialStoreState, }, }, ) +const previewLabel = 'workflowHumanInput.nodes.humanInput.formContent.preview' +const savedDraft = async () => ({ hash: 'saved', updatedAt: 1 }) +const sourceNode = createNode({ + id: 'source', + data: { title: 'Contract source', type: BlockEnum.Code }, +}) +const sourceVars: NodeOutPutVar = { + nodeId: 'source', + title: 'Contract source', + vars: [ + { variable: 'draft', type: VarType.string }, + { + variable: 'object', + type: VarType.object, + children: [{ variable: 'name', type: VarType.string }], + }, + { variable: 'amount', type: VarType.number }, + { variable: 'approved', type: VarType.boolean }, + { variable: 'settings', type: VarType.object }, + { variable: 'options', type: VarType.arrayString }, + ], +} + +const submitPreview = async (user: ReturnType) => { + const form = screen.getByRole('form', { name: previewLabel }) + await user.click(within(form).getByRole('button', { name: previewLabel })) +} + +beforeEach(() => { + preview.mockReset() + chatPreview.mockReset() +}) + it('waits for the saved workflow before displaying the real server markdown', async () => { let finishSave!: () => void const saving = new Promise<{ hash: string; updatedAt: number }>((resolve) => { @@ -100,3 +141,280 @@ it('renders historical readonly content without loading the current draft', asyn expect(preview).not.toHaveBeenCalled() expect(sync).not.toHaveBeenCalled() }) + +it.each([ + { mode: 'workflow' as const, selector: 'source.draft', field: /draft/ }, + { mode: 'advanced-chat' as const, selector: 'source.object.name', field: /name/ }, +])( + 'collects missing dependency values before requesting a $mode preview', + async ({ mode, selector, field }) => { + const user = userEvent.setup() + const endpoint = mode === 'workflow' ? preview : chatPreview + endpoint.mockResolvedValue({ + form_content: 'Server-resolved contract', + inputs: [], + actions: [], + }) + renderPreview( + { + resource: { kind: 'app', appId: 'app-1', mode }, + hooksStoreProps: { doSyncWorkflowDraft: savedDraft }, + }, + false, + { + content: `Review {{#${selector}#}} {{#$output.feedback#}}`, + availableVars: [sourceVars], + availableNodes: [sourceNode], + }, + ) + + await user.click(screen.getByRole('button', { name: previewLabel })) + const input = await screen.findByRole('textbox', { name: field }) + expect(endpoint).not.toHaveBeenCalled() + expect(screen.queryByRole('textbox', { name: /feedback/ })).not.toBeInTheDocument() + await user.type(input, 'Reviewed contract') + await submitPreview(user) + + expect(await screen.findByText('Server-resolved contract')).toBeInTheDocument() + expect(endpoint).toHaveBeenCalledExactlyOnceWith( + { + params: { app_id: 'app-1', node_id: 'node-1' }, + body: { inputs: { [`#${selector}#`]: 'Reviewed contract' } }, + }, + { context: { silent: true } }, + ) + }, +) + +it.each([ + { selector: 'source.amount', value: 0 }, + { selector: 'source.approved', value: false }, + { selector: 'source.draft', value: '' }, +])( + 'uses a cached dependency value of $value without requesting it again', + async ({ selector, value }) => { + const cachedValues = new Map([[selector, value]]) + preview.mockResolvedValue({ form_content: 'Cached server preview', inputs: [], actions: [] }) + renderPreview( + { + hooksStoreProps: { + doSyncWorkflowDraft: savedDraft, + hasSetInspectVar: (nodeId, name) => cachedValues.has(`${nodeId}.${name}`), + }, + }, + false, + { + content: `{{#${selector}#}}`, + availableVars: [sourceVars], + availableNodes: [sourceNode], + }, + ) + + await userEvent.setup().click(screen.getByRole('button', { name: previewLabel })) + expect(await screen.findByText('Cached server preview')).toBeInTheDocument() + expect(screen.queryByRole('form', { name: previewLabel })).not.toBeInTheDocument() + expect(preview).toHaveBeenCalledExactlyOnceWith( + { params: { app_id: 'app-1', node_id: 'node-1' }, body: { inputs: {} } }, + { context: { silent: true } }, + ) + }, +) + +it('preserves typed dependency values when retrying a failed preview', async () => { + const user = userEvent.setup() + preview + .mockRejectedValueOnce(new Error('Server preview failed')) + .mockResolvedValue({ form_content: 'Retried with contract', inputs: [], actions: [] }) + renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: savedDraft } }, false, { + content: '{{#source.draft#}}', + availableVars: [sourceVars], + availableNodes: [sourceNode], + }) + await user.click(screen.getByRole('button', { name: previewLabel })) + await user.type(await screen.findByRole('textbox', { name: /draft/ }), 'Retained contract') + await submitPreview(user) + expect(await screen.findByRole('alert')).toHaveTextContent('common.error') + expect(screen.getByRole('textbox', { name: /draft/ })).toHaveValue('Retained contract') + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + + expect(await screen.findByText('Retried with contract')).toBeInTheDocument() + expect(preview).toHaveBeenCalledTimes(2) + for (const [request] of preview.mock.calls) { + expect(request.body).toEqual({ inputs: { '#source.draft#': 'Retained contract' } }) + } +}) + +it('lets the user supply dependency values when cached data cannot render the server preview', async () => { + const user = userEvent.setup() + preview + .mockRejectedValueOnce(new Error('Cached value is not available')) + .mockResolvedValue({ form_content: 'Manual server preview', inputs: [], actions: [] }) + renderPreview( + { + hooksStoreProps: { doSyncWorkflowDraft: savedDraft, hasSetInspectVar: () => true }, + }, + false, + { + content: '{{#source.draft#}}', + availableVars: [sourceVars], + availableNodes: [sourceNode], + }, + ) + await user.click(screen.getByRole('button', { name: previewLabel })) + expect(await screen.findByRole('alert')).toHaveTextContent('common.error') + await user.type(await screen.findByRole('textbox', { name: /draft/ }), 'Manual contract') + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + expect(await screen.findByText('Manual server preview')).toBeInTheDocument() + expect(preview.mock.calls[1]?.[0].body).toEqual({ + inputs: { '#source.draft#': 'Manual contract' }, + }) +}) + +it('submits number, boolean and JSON dependency values using their declared types', async () => { + const user = userEvent.setup() + preview.mockResolvedValue({ form_content: 'Typed server preview', inputs: [], actions: [] }) + renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: savedDraft } }, false, { + content: '{{#source.amount#}} {{#source.approved#}} {{#source.settings#}}', + availableVars: [sourceVars], + availableNodes: [sourceNode], + }) + await user.click(screen.getByRole('button', { name: previewLabel })) + await user.type(await screen.findByLabelText(/amount/), '0') + expect(screen.getByRole('checkbox')).not.toBeChecked() + await user.click(screen.getByTestId('monaco-editor')) + await user.paste('{"term":30}') + await submitPreview(user) + + expect(await screen.findByText('Typed server preview')).toBeInTheDocument() + expect(preview).toHaveBeenCalledExactlyOnceWith( + { + params: { app_id: 'app-1', node_id: 'node-1' }, + body: { + inputs: { + '#source.amount#': 0, + '#source.approved#': false, + '#source.settings#': { term: 30 }, + }, + }, + }, + { context: { silent: true } }, + ) +}) + +it('collects variable defaults and select options without treating output fields as dependencies', async () => { + const user = userEvent.setup() + preview.mockResolvedValue({ form_content: 'Server field defaults', inputs: [], actions: [] }) + renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: savedDraft } }, false, { + content: '{{#$output.feedback#}} {{#$output.choice#}}', + formInputs: [ + { + type: InputVarType.paragraph, + output_variable_name: 'feedback', + default: { type: 'variable', selector: ['source', 'draft'], value: '' }, + }, + { + type: InputVarType.select, + output_variable_name: 'choice', + option_source: { type: 'variable', selector: ['source', 'options'], value: [] }, + }, + ], + availableVars: [sourceVars], + availableNodes: [sourceNode], + }) + await user.click(screen.getByRole('button', { name: previewLabel })) + await user.type(await screen.findByRole('textbox', { name: /draft/ }), 'Default feedback') + expect(screen.queryByRole('textbox', { name: /feedback/ })).not.toBeInTheDocument() + await user.click(screen.getByTestId('monaco-editor')) + await user.paste('["approve","revise"]') + await submitPreview(user) + + expect(await screen.findByText('Server field defaults')).toBeInTheDocument() + expect(preview).toHaveBeenCalledExactlyOnceWith( + { + params: { app_id: 'app-1', node_id: 'node-1' }, + body: { + inputs: { + '#source.draft#': 'Default feedback', + '#source.options#': ['approve', 'revise'], + }, + }, + }, + { context: { silent: true } }, + ) +}) + +it('shows an inline required-field error before requesting a preview and accepts the corrected value', async () => { + const user = userEvent.setup() + const sync = vi.fn(savedDraft) + preview.mockResolvedValue({ form_content: 'Validated server preview', inputs: [], actions: [] }) + renderPreview({ hooksStoreProps: { doSyncWorkflowDraft: sync } }, false, { + content: '{{#source.draft#}}', + availableVars: [sourceVars], + availableNodes: [sourceNode], + }) + + await user.click(screen.getByRole('button', { name: previewLabel })) + const form = await screen.findByRole('form', { name: previewLabel }) + await submitPreview(user) + expect(within(form).getByRole('alert')).toHaveTextContent('workflow.errorMsg.fieldRequired') + expect(sync).not.toHaveBeenCalled() + expect(preview).not.toHaveBeenCalled() + + await user.type(within(form).getByRole('textbox', { name: /draft/ }), 'Validated contract') + expect(within(form).queryByRole('alert')).not.toBeInTheDocument() + await submitPreview(user) + expect(await screen.findByText('Validated server preview')).toBeInTheDocument() + expect(sync).toHaveBeenCalledTimes(1) + expect(preview).toHaveBeenCalledExactlyOnceWith( + { + params: { app_id: 'app-1', node_id: 'node-1' }, + body: { inputs: { '#source.draft#': 'Validated contract' } }, + }, + { context: { silent: true } }, + ) +}) + +it('ignores a closed preview request failure after reopening with different dependencies', async () => { + const user = userEvent.setup() + let failOldPreview!: (error: Error) => void + preview.mockReturnValue( + new Promise((_resolve, reject) => { + failOldPreview = reject + }), + ) + const view = renderPreview( + { + hooksStoreProps: { + doSyncWorkflowDraft: savedDraft, + hasSetInspectVar: (nodeId, name) => nodeId === 'source' && name === 'draft', + }, + }, + false, + { + content: '{{#source.draft#}}', + availableVars: [sourceVars], + availableNodes: [sourceNode], + }, + ) + + await user.click(screen.getByRole('button', { name: previewLabel })) + expect(preview).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) + view.rerender( + , + ) + await user.click(screen.getByRole('button', { name: previewLabel })) + expect(await screen.findByLabelText(/amount/)).toBeInTheDocument() + expect(screen.queryByRole('textbox', { name: /draft/ })).not.toBeInTheDocument() + + await act(async () => failOldPreview(new Error('The closed preview failed'))) + + expect(screen.getByLabelText(/amount/)).toBeInTheDocument() + expect(screen.queryByRole('textbox', { name: /draft/ })).not.toBeInTheDocument() + expect(preview).toHaveBeenCalledTimes(1) +}) diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.browser.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.browser.spec.tsx index 051864f68b2..c8382288c6c 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.browser.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.browser.spec.tsx @@ -1,7 +1,7 @@ import type { ComponentProps, ReactElement } from 'react' import { detectPlatform } from '@tanstack/react-hotkeys' import { ReactFlowProvider } from 'reactflow' -import { userEvent } from 'vite-plus/test/browser' +import { page, userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import { createNode } from '@/app/components/workflow/__tests__/fixtures' import { WorkflowContext } from '@/app/components/workflow/context' @@ -15,8 +15,7 @@ import { seedToolProviders } from '@/test/console/tool-providers' import { createDefaultParagraphFormInput } from '../../types' import FormContent from '../form-content' -it('preserves the shortcut draft and inserts at the editor selection after focus moves into the input form', async () => { - // Native focus and selection must survive the portal and parent rerender; a callback stub cannot prove insertion. +function createFormContentFixture() { const { queryClient, wrapper: QueryProvider } = createConsoleQueryWrapper() seedModelProviderQueries(queryClient) seedToolProviders(queryClient, { @@ -51,6 +50,12 @@ it('preserves the shortcut draft and inserts at the editor selection after focus ) + return { queryClient, props, wrap } +} + +it('preserves the shortcut draft and inserts at the editor selection after focus moves into the input form', async () => { + // Native focus and selection must survive the portal and parent rerender; a callback stub cannot prove insertion. + const { queryClient, props, wrap } = createFormContentFixture() const screen = await render(wrap()) const editor = screen.getByRole('textbox') await editor.click() @@ -83,3 +88,51 @@ it('preserves the shortcut draft and inserts at the editor selection after focus .toBeVisible() queryClient.clear() }) + +it('restores field editing and removal after the mounted editor leaves readonly mode', async () => { + // Native hover and clicks inside Lexical must reach the recreated action buttons without remounting the editor. + const { queryClient, props, wrap } = createFormContentFixture() + const field = createDefaultParagraphFormInput('notes') + const fieldProps = { + ...props, + value: '{{#$output.notes#}}', + formInputs: [{ ...field, default: { ...field.default, value: 'Review notes' } }], + } + const subject = (readonly: boolean) => + wrap( + + + , + ) + const screen = await render(subject(false)) + const content = screen.getByRole('region', { name: 'Form content' }) + const edit = content.getByRole('button', { name: 'common.operation.edit' }) + const remove = content.getByRole('button', { name: 'common.operation.remove' }) + try { + await content.getByText('Review notes', { exact: true }).hover() + await edit.click() + const dialog = page.getByRole('dialog') + await expect.element(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'common.operation.cancel' }).click() + await expect.element(dialog).not.toBeInTheDocument() + + await screen.rerender(subject(true)) + await expect.element(edit).not.toBeInTheDocument() + await expect.element(remove).not.toBeInTheDocument() + await screen.rerender(subject(false)) + await content.getByText('Review notes', { exact: true }).hover() + await edit.click() + await expect.element(dialog).toBeVisible() + await expect + .element(dialog.getByRole('textbox', { name: /saveResponseAs/ })) + .toHaveValue('notes') + await dialog.getByRole('button', { name: 'common.operation.cancel' }).click() + await expect.element(dialog).not.toBeInTheDocument() + + await content.getByText('Review notes', { exact: true }).hover() + await remove.click() + expect(props.onFormInputItemRemove).toHaveBeenCalledExactlyOnceWith('notes') + } finally { + queryClient.clear() + } +}) diff --git a/web/app/components/workflow/nodes/human-input/components/form-content-preview-inputs.ts b/web/app/components/workflow/nodes/human-input/components/form-content-preview-inputs.ts new file mode 100644 index 00000000000..9e195ceee02 --- /dev/null +++ b/web/app/components/workflow/nodes/human-input/components/form-content-preview-inputs.ts @@ -0,0 +1,149 @@ +import type { Field } from '../../llm/types' +import type { FormInputItem } from '../types' +import type { + InputVar, + Node, + NodeOutPutVar, + ValueSelector, + Var, +} from '@/app/components/workflow/types' +import { getInputVars } from '@/app/components/base/prompt-editor/constants' +import { InputVarType, VarType } from '@/app/components/workflow/types' +import { isENV } from '../../_base/components/variable/utils' +import { Type } from '../../llm/types' +import { getHumanInputFormDependencySelectors, isOutput } from '../utils' + +type PreviewInputParams = { + content: string + formInputs: FormInputItem[] + availableVars: NodeOutPutVar[] + availableNodes: Node[] +} + +const getSchemaVariable = (field: Field, path: string[]): Var => { + const options = field.enum?.filter((value): value is string => typeof value === 'string') + const type = (() => { + switch (field.type) { + case Type.number: + return VarType.number + case Type.boolean: + return VarType.boolean + case Type.object: + return VarType.object + case Type.file: + return VarType.file + case Type.array: + return field.items?.type === Type.file ? VarType.arrayFile : VarType.array + case Type.arrayString: + return VarType.arrayString + case Type.arrayNumber: + return VarType.arrayNumber + case Type.arrayObject: + return VarType.arrayObject + default: + return VarType.string + } + })() + return { + variable: path.join('.'), + type, + isSelect: !!options?.length && options.length === field.enum?.length, + options, + } +} + +const findVariable = (variables: Var[], path: string[]): Var | undefined => { + // Metadata can contain relative child names or flattened names such as sys.query. + for (let length = path.length; length > 0; length--) { + const variable = variables.find((item) => item.variable === path.slice(0, length).join('.')) + if (!variable) continue + if (length === path.length) return variable + + const remainingPath = path.slice(length) + if (Array.isArray(variable.children)) return findVariable(variable.children, remainingPath) + if (!variable.children) continue + + let properties = variable.children.schema.properties + let field: Field | undefined + for (const key of remainingPath) { + field = properties?.[key] + if (!field) break + properties = field.properties ?? {} + } + if (field) return getSchemaVariable(field, remainingPath) + } + return undefined +} + +const getInputType = (variable?: Var): InputVarType => { + if (variable?.isSelect) return InputVarType.select + if (variable?.isParagraph) return InputVarType.paragraph + switch (variable?.type) { + case VarType.number: + case VarType.integer: + return InputVarType.number + case VarType.boolean: + return InputVarType.checkbox + case VarType.object: + case VarType.array: + case VarType.arrayString: + case VarType.arrayNumber: + case VarType.arrayObject: + case VarType.arrayBoolean: + case VarType.arrayAny: + return InputVarType.json + case VarType.file: + return InputVarType.singleFile + case VarType.arrayFile: + return InputVarType.multiFiles + default: + return InputVarType.textInput + } +} + +export const getHumanInputPreviewInputs = ({ + content, + formInputs, + availableVars, + availableNodes, +}: PreviewInputParams): InputVar[] => { + const selectors = new Map() + for (const selector of [ + ...getInputVars(content), + ...getHumanInputFormDependencySelectors(formInputs), + ]) { + if (selector.length < 2 || isOutput(selector) || isENV(selector)) continue + selectors.set(selector.join('.'), selector) + } + + return Array.from(selectors.values(), (selector) => { + let group = availableVars.find((item) => item.nodeId === selector[0]) + let variable = group + ? (findVariable(group.vars, selector.slice(1)) ?? findVariable(group.vars, selector)) + : undefined + if (!variable) { + group = availableVars.find((item) => findVariable(item.vars, selector)) + variable = group ? findVariable(group.vars, selector) : undefined + } + const node = availableNodes.find((item) => item.id === selector[0]) + const path = selector.join('.') + const nodeName = node?.data.title || group?.title || selector[0] || path + const type = getInputType(variable) + return { + variable: `#${path}#`, + value_selector: selector, + label: + node && type !== InputVarType.checkbox + ? { + nodeType: node.data.type, + nodeName, + variable: selector.slice(1).join('.'), + isChatVar: selector[0] === 'conversation', + } + : `${nodeName} · ${path}`, + type, + required: true, + ...(variable?.options ? { options: variable.options } : {}), + } + }) +} diff --git a/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx index a93544be3a9..142349b3a11 100644 --- a/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx +++ b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import type { FormInputItem, UserAction } from '../types' +import type { InputVar, Node, NodeOutPutVar } from '@/app/components/workflow/types' import { zFormInputConfig, zUserActionConfig } from '@dify/contracts/api/console/apps/zod.gen' import { Button } from '@langgenius/dify-ui/button' import { IconButton } from '@langgenius/dify-ui/icon-button' @@ -13,12 +14,20 @@ import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-inp import { Markdown } from '@/app/components/base/markdown' import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton' import { editorResourceAtom } from '@/app/components/workflow/editor-resource/state' +import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' import { useStore } from '@/app/components/workflow/store' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { consoleClient } from '@/service/console' +import Form from '../../_base/components/before-run-form/form' +import { + buildSubmitData, + getFormErrorMessage, + isFilesLoaded, +} from '../../_base/components/before-run-form/helpers' import { HumanInputDraftSyncError, syncHumanInputDraft } from '../shared/sync-draft' import { normalizeHumanInputFormInput } from '../shared/types' +import { getHumanInputPreviewInputs } from './form-content-preview-inputs' import { Note, rehypeNotes, rehypeVariable, Variable } from './variable-in-markdown' const NODE_ID_RE = /#([^#.]+)([.#])/g @@ -117,7 +126,10 @@ const FormContentPreview: FC = ({ ) } -type PreviewTriggerProps = Omit +type PreviewTriggerProps = Omit & { + availableVars?: NodeOutPutVar[] + availableNodes?: Node[] +} const PreviewButton = ({ open, @@ -146,15 +158,46 @@ const PreviewButton = ({ ) } -const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string }) => { - const { t } = useTranslation(['workflow', 'common']) +const ServerPreviewTrigger = ({ + nodeId, + content, + formInputs, + availableVars = [], + availableNodes = [], +}: PreviewTriggerProps & { nodeId: string }) => { + const { t } = useTranslation(['appDebug', 'workflow', 'common', 'workflowHumanInput']) const resource = useAtomValue(editorResourceAtom) const { doSyncWorkflowDraft } = useNodesSyncDraft() + const { hasSetInspectVar, systemVars, conversationVars } = useInspectVarsCrud() const panelWidth = useStore((state) => state.panelWidth) const [open, setOpen] = React.useState(false) + const [inputVars, setInputVars] = React.useState([]) + const [inputValues, setInputValues] = React.useState>({}) + const [formError, setFormError] = React.useState('') const buttonRef = React.useRef(null) + const previewSessionRef = React.useRef(0) + const dependencyInputs = getHumanInputPreviewInputs({ + content, + formInputs, + availableVars, + availableNodes, + }) + const hasCachedValue = (input: InputVar) => { + const selector = input.value_selector + return !!selector && hasSetInspectVar(selector[0]!, selector[1]!, systemVars, conversationVars) + } + const forms = [ + { + inputs: inputVars.map((input) => ({ ...input, required: !hasCachedValue(input) })), + values: inputValues, + onChange: (values: Record) => { + setInputValues(values) + setFormError('') + }, + }, + ] const preview = useMutation({ - mutationFn: async () => { + mutationFn: async ({ inputs }: { inputs: Record; session: number }) => { if (resource.kind !== 'app') throw new Error('Missing application') await syncHumanInputDraft(doSyncWorkflowDraft) const api = @@ -164,7 +207,7 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string const response = await api.preview.post( { params: { app_id: resource.appId, node_id: nodeId }, - body: { inputs: {} }, + body: { inputs }, }, { context: { silent: true } }, ) @@ -179,17 +222,56 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string }), } }, + onError: (_error, { session }) => { + if (session !== previewSessionRef.current) return + // A cached root may still lack a nested value. Let the user supply it instead of retrying empty inputs. + if (dependencyInputs.length) setInputVars(dependencyInputs) + }, }) + const requestPreview = (inputs: Record) => { + preview.mutate({ inputs, session: previewSessionRef.current }) + } + const submitPreview = () => { + const error = getFormErrorMessage(forms, [{}], t) + if (error) { + setFormError(error) + return + } + const { submitData, parseErrorJsonField } = buildSubmitData(forms) + if (parseErrorJsonField) { + setFormError( + t(($) => $['errorMsg.invalidJson'], { ns: 'workflow', field: parseErrorJsonField }), + ) + return + } + setFormError('') + const cachedInputKeys = new Set(inputVars.filter(hasCachedValue).map((input) => input.variable)) + requestPreview( + Object.fromEntries( + Object.entries(submitData).filter( + ([key, value]) => + value !== undefined && (key in inputValues || !cachedInputKeys.has(key)), + ), + ), + ) + } const close = () => { + previewSessionRef.current += 1 setOpen(false) preview.reset() + setInputVars([]) + setInputValues({}) + setFormError('') buttonRef.current?.focus() } const toggle = () => { if (open) close() else { + previewSessionRef.current += 1 setOpen(true) - preview.mutate() + const missingInputs = dependencyInputs.filter((input) => !hasCachedValue(input)) + setInputVars(missingInputs) + if (!missingInputs.length) requestPreview({}) } } return ( @@ -203,7 +285,7 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string ) : ( @@ -213,6 +295,12 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string ? t(($) => $['nodes.humanInputV2.template.testSaveFailed'], { ns: 'workflow' }) : t(($) => $.error, { ns: 'common' })} + ) : inputVars.length ? ( + + {t(($) => $['nodes.humanInput.formContent.preview'], { + ns: 'workflowHumanInput', + })} + ) : ( @@ -226,8 +314,45 @@ const ServerPreviewTrigger = ({ nodeId }: PreviewTriggerProps & { nodeId: string - {preview.isError && ( - preview.mutate()}> + {!!inputVars.length && ( + $['nodes.humanInput.formContent.preview'], { + ns: 'workflowHumanInput', + })} + onSubmit={(event) => { + event.preventDefault() + submitPreview() + }} + > + + $['nodes.humanInput.singleRun.label'], { + ns: 'workflowHumanInput', + })} + /> + + {formError && ( + + {formError} + + )} + + {preview.isError + ? t(($) => $['operation.retry'], { ns: 'common' }) + : t(($) => $['nodes.humanInput.formContent.preview'], { + ns: 'workflowHumanInput', + })} + + + )} + {preview.isError && !inputVars.length && ( + requestPreview({})}> {t(($) => $['operation.retry'], { ns: 'common' })} )} diff --git a/web/app/components/workflow/nodes/human-input/shared/panel-sections.tsx b/web/app/components/workflow/nodes/human-input/shared/panel-sections.tsx index 11465fee757..7e018e63fc9 100644 --- a/web/app/components/workflow/nodes/human-input/shared/panel-sections.tsx +++ b/web/app/components/workflow/nodes/human-input/shared/panel-sections.tsx @@ -130,6 +130,8 @@ const HumanInputSharedPanelSections = ({ formInputs={inputs.inputs} userActions={inputs.user_actions} nodeId={isHumanInputV2NodeData(inputs) ? id : undefined} + availableVars={availableVars} + availableNodes={availableNodes} /> diff --git a/web/features/contacts/im-platform/__tests__/fixtures/http.ts b/web/features/contacts/im-platform/__tests__/fixtures/http.ts index 09887aa9480..1b9642134f4 100644 --- a/web/features/contacts/im-platform/__tests__/fixtures/http.ts +++ b/web/features/contacts/im-platform/__tests__/fixtures/http.ts @@ -22,6 +22,7 @@ export const syncRun = (overrides: Partial = {}): ImSyncRun => ({ channel_id: channel.id, channel_config_version: 1, provider: 'feishu', + initiator_display_name: '', started_at: 1, finished_at: 2, status: 'succeeded', diff --git a/web/features/contacts/im-platform/__tests__/management-surface.spec.tsx b/web/features/contacts/im-platform/__tests__/management-surface.spec.tsx index da5b26c206a..c7eda369e7c 100644 --- a/web/features/contacts/im-platform/__tests__/management-surface.spec.tsx +++ b/web/features/contacts/im-platform/__tests__/management-surface.spec.tsx @@ -1,8 +1,10 @@ +import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { consoleQuery } from '@/service/console' +import { seedSystemFeatures } from '@/test/console/query-data' import { ContactsImPlatformRuntimeProvider } from '../composition' import { ContactsImPlatformManagementSurface } from '../management-surface' import { channelSummary, emailDetail } from './fixtures/channel-client' @@ -27,10 +29,11 @@ vi.mock('@/context/i18n', async (original) => ({ useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, })) const clients: QueryClient[] = [] -function renderSurface(canManage = true) { +function renderSurface(canManage = true, deploymentEdition: DeploymentEdition = 'CLOUD') { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) + seedSystemFeatures(client, { deployment_edition: deploymentEdition }) clients.push(client) render( @@ -70,6 +73,25 @@ it('does not fetch or expose channels to a non-manager', () => { expect(network.handle).not.toHaveBeenCalled() }) +it.each(['available', 'configured'] as const)( + 'hides %s workspace Email configuration in Community Edition', + async (emailState) => { + const channels = [channel] + if (emailState === 'configured') + channels.push(channelSummary({ id: 'channel-email', kind: 'email', provider: 'resend' })) + network.handle.mockImplementation((request) => + new URL(request.url).pathname.endsWith('/channels') + ? json({ channels }) + : defaultResponse(request), + ) + + renderSurface(true, 'COMMUNITY') + + expect(await screen.findByRole('group', { name: 'Feishu' })).toHaveTextContent('saved-tenant') + expect(screen.queryByRole('group', { name: 'Email' })).not.toBeInTheDocument() + }, +) + it('uses saved redacted metadata for an update and keeps generated cache invalidation', async () => { const user = userEvent.setup() const client = renderSurface() diff --git a/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx b/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx index 726126df70f..34e5b5457a6 100644 --- a/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx +++ b/web/features/contacts/im-platform/__tests__/nested-dialog-backdrops.browser.spec.tsx @@ -3,6 +3,7 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { page, userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import MenuDialog from '@/app/components/header/account-setting/menu-dialog' +import { seedSystemFeatures } from '@/test/console/query-data' import { ContactsImPlatformRuntimeProvider } from '../composition' import { ContactsImPlatformManagementSurface } from '../management-surface' import { channelSummary } from './fixtures/channel-client' @@ -66,6 +67,7 @@ async function setup() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) + seedSystemFeatures(queryClient, { deployment_edition: 'CLOUD' }) await render( diff --git a/web/features/contacts/im-platform/management-surface.tsx b/web/features/contacts/im-platform/management-surface.tsx index 236ed778d19..a58f9461bad 100644 --- a/web/features/contacts/im-platform/management-surface.tsx +++ b/web/features/contacts/im-platform/management-surface.tsx @@ -25,6 +25,7 @@ import { import { skipToken, useIsMutating, useMutation, useQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { consoleQuery } from '@/service/console' import { ContactImBindingContent } from './binding-dialog' import { useContactsImPlatformOrganization } from './composition-context' @@ -116,6 +117,11 @@ export function ContactsImPlatformManagementSurface() { const { t } = useTranslation(['contacts']) const { t: tCommon } = useTranslation(['common']) const organization = useContactsImPlatformOrganization() + const { data: deploymentEdition } = useQuery({ + ...systemFeaturesQueryOptions(), + enabled: organization.canManage, + select: (data) => data.deployment_edition, + }) const api = consoleQuery.workspaces.current.humanInput.v2 const integrationsQuery = useQuery( api.channels.get.queryOptions({ enabled: organization.canManage, context: { silent: true } }), @@ -143,14 +149,15 @@ export function ContactsImPlatformManagementSurface() { const [syncRunId, setSyncRunId] = useContactImSyncRunUrlState() const [, setSyncResult] = useContactImSyncResultUrlState() const integrations = integrationsQuery.data?.channels ?? [] - const providers = [ - ...(providersQuery.data?.email_providers ?? []), - ...(providersQuery.data?.im_providers ?? []), - ].map(({ provider }) => ({ - provider, - displayName: providerMetadata[provider].name, - requiredFields: providerMetadata[provider].fields, - })) + const emailProviders = + deploymentEdition === 'CLOUD' ? (providersQuery.data?.email_providers ?? []) : [] + const providers = [...emailProviders, ...(providersQuery.data?.im_providers ?? [])].map( + ({ provider }) => ({ + provider, + displayName: providerMetadata[provider].name, + requiredFields: providerMetadata[provider].fields, + }), + ) const byProvider = new Map(integrations.map((integration) => [integration.provider, integration])) const configured = providers.filter((provider) => byProvider.has(provider.provider)) const available = providers.filter((provider) => !byProvider.has(provider.provider)) diff --git a/web/i18n/locales/ar-TN/workflow.json b/web/i18n/locales/ar-TN/workflow.json index 102957c9b97..c5fd5026553 100644 --- a/web/i18n/locales/ar-TN/workflow.json +++ b/web/i18n/locales/ar-TN/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "اختر من يمكنه الرد على خطوة الإدخال البشري هذه.", "nodes.humanInputV2.recipients.initiator": "يمكن للمستخدم الذي يبدأ هذا التشغيل عبر Web App أو CLI الموافقة", "nodes.humanInputV2.recipients.initiatorDescription": "يمكن للشخص الذي يبدأ تشغيل سير العمل هذا الرد.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "عمليات التشغيل التي تبدأ عبر مُشغّل ليس لها مُبادر يتلقى طلبات الموافقة. اختر جهة اتصال أو استخدم متغيرًا لتحديد عنوان بريد إلكتروني.", "nodes.humanInputV2.recipients.inputHelp": "اكتب أو الصق عدة عناوين بريد إلكتروني مفصولة بفواصل. اضغط على / لإدراج متغير.", "nodes.humanInputV2.recipients.insertVariable": "إدراج متغيّر", "nodes.humanInputV2.recipients.loadError": "تعذّر تحميل جهات الاتصال. تم الاحتفاظ بمعرّفات جهات الاتصال المحفوظة.", diff --git a/web/i18n/locales/az-AZ/workflow.json b/web/i18n/locales/az-AZ/workflow.json index 9b95f171f1a..a992412e59b 100644 --- a/web/i18n/locales/az-AZ/workflow.json +++ b/web/i18n/locales/az-AZ/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Bu İnsan girişi addımına kimin cavab verə biləcəyini seçin.", "nodes.humanInputV2.recipients.initiator": "Bu icranı Web App və ya CLI vasitəsilə başladan istifadəçi təsdiqləyə bilər", "nodes.humanInputV2.recipients.initiatorDescription": "Bu iş axınının icrasını başladan şəxs cavab verə bilər.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Tətikləyici ilə başlayan icralarda təsdiq sorğularını qəbul edə biləcək bir başladan şəxs yoxdur. Kontakt seçin və ya e-poçt ünvanını dəyişən vasitəsilə göstərin.", "nodes.humanInputV2.recipients.inputHelp": "Vergüllə ayrılmış bir neçə e-poçt ünvanı yazın və ya yapışdırın. Dəyişən əlavə etmək üçün / düyməsini basın.", "nodes.humanInputV2.recipients.insertVariable": "Dəyişən əlavə et", "nodes.humanInputV2.recipients.loadError": "Kontaktları yükləmək mümkün olmadı. Saxlanılmış kontakt ID-ləri qorunur.", diff --git a/web/i18n/locales/de-DE/workflow.json b/web/i18n/locales/de-DE/workflow.json index aae61a7b8a8..e7d592a7686 100644 --- a/web/i18n/locales/de-DE/workflow.json +++ b/web/i18n/locales/de-DE/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Wählen Sie aus, wer auf diesen Schritt für menschliche Eingaben antworten kann.", "nodes.humanInputV2.recipients.initiator": "Der Benutzer, der diesen Durchlauf über Web App oder CLI startet, kann genehmigen", "nodes.humanInputV2.recipients.initiatorDescription": "Die Person, die diesen Workflow-Lauf startet, kann antworten.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Bei einem durch einen Trigger gestarteten Lauf gibt es keinen Initiator, der Genehmigungsanfragen empfangen kann. Wählen Sie einen Kontakt aus oder geben Sie eine E-Mail-Adresse über eine Variable an.", "nodes.humanInputV2.recipients.inputHelp": "Geben Sie mehrere durch Kommas getrennte E-Mail-Adressen ein oder fügen Sie sie ein. Drücken Sie /, um eine Variable einzufügen.", "nodes.humanInputV2.recipients.insertVariable": "Variable einfügen", "nodes.humanInputV2.recipients.loadError": "Kontakte konnten nicht geladen werden. Gespeicherte Kontakt-IDs bleiben erhalten.", diff --git a/web/i18n/locales/en-US/workflow.json b/web/i18n/locales/en-US/workflow.json index b1c3ac7b9aa..c7342c0fa3d 100644 --- a/web/i18n/locales/en-US/workflow.json +++ b/web/i18n/locales/en-US/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Choose who can respond to this Human Input step.", "nodes.humanInputV2.recipients.initiator": "The user who starts this run through Web App or CLI can approve", "nodes.humanInputV2.recipients.initiatorDescription": "The person who starts this workflow run can respond.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Trigger runs have no initiator to receive approval requests. Select a contact or use a variable to provide an email address.", "nodes.humanInputV2.recipients.inputHelp": "Type or paste multiple emails separated by commas. Press / to insert a variable.", "nodes.humanInputV2.recipients.insertVariable": "Insert variable", "nodes.humanInputV2.recipients.loadError": "Contacts could not be loaded. Stored contact IDs are preserved.", diff --git a/web/i18n/locales/es-ES/workflow.json b/web/i18n/locales/es-ES/workflow.json index 2184a063e63..e248caf57fc 100644 --- a/web/i18n/locales/es-ES/workflow.json +++ b/web/i18n/locales/es-ES/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Elige quién puede responder a este paso de Entrada Humana.", "nodes.humanInputV2.recipients.initiator": "El usuario que inicia esta ejecución a través de Web App o CLI puede aprobar", "nodes.humanInputV2.recipients.initiatorDescription": "La persona que inicia esta ejecución del flujo de trabajo puede responder.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Las ejecuciones iniciadas por un activador no tienen un iniciador que pueda recibir solicitudes de aprobación. Selecciona un contacto o usa una variable para indicar una dirección de correo electrónico.", "nodes.humanInputV2.recipients.inputHelp": "Escribe o pega varias direcciones de correo separadas por comas. Pulsa / para insertar una variable.", "nodes.humanInputV2.recipients.insertVariable": "Insertar variable", "nodes.humanInputV2.recipients.loadError": "No se pudieron cargar los contactos. Se conservan los ID de contacto guardados.", diff --git a/web/i18n/locales/fa-IR/workflow.json b/web/i18n/locales/fa-IR/workflow.json index 8f31d5a85f9..fa90efb9c67 100644 --- a/web/i18n/locales/fa-IR/workflow.json +++ b/web/i18n/locales/fa-IR/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "انتخاب کنید چه کسی میتواند به این مرحله ورودی انسانی پاسخ دهد.", "nodes.humanInputV2.recipients.initiator": "کاربری که این اجرا را از طریق Web App یا CLI آغاز میکند، میتواند تأیید کند", "nodes.humanInputV2.recipients.initiatorDescription": "فردی که این اجرای گردش کار را آغاز میکند میتواند پاسخ دهد.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "در اجراهایی که با محرک شروع میشوند، آغازکنندهای برای دریافت درخواستهای تأیید وجود ندارد. یک مخاطب انتخاب کنید یا نشانی ایمیل را با یک متغیر مشخص کنید.", "nodes.humanInputV2.recipients.inputHelp": "چند نشانی ایمیل را با جداکنندهٔ ویرگول تایپ یا جایگذاری کنید. برای درج متغیر، / را فشار دهید.", "nodes.humanInputV2.recipients.insertVariable": "درج متغیر", "nodes.humanInputV2.recipients.loadError": "بارگذاری مخاطبان ممکن نبود. شناسههای ذخیرهشده مخاطبان حفظ شدهاند.", diff --git a/web/i18n/locales/fr-FR/workflow.json b/web/i18n/locales/fr-FR/workflow.json index 7a3ac58036c..9ed60d40adc 100644 --- a/web/i18n/locales/fr-FR/workflow.json +++ b/web/i18n/locales/fr-FR/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Choisissez qui peut répondre à cette étape de saisie humaine.", "nodes.humanInputV2.recipients.initiator": "L’utilisateur qui lance cette exécution via Web App ou CLI peut approuver", "nodes.humanInputV2.recipients.initiatorDescription": "La personne qui lance cette exécution du workflow peut répondre.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Lorsqu’une exécution démarre via un déclencheur, il n’y a pas d’initiateur pour recevoir les demandes d’approbation. Sélectionnez un contact ou indiquez une adresse e-mail à l’aide d’une variable.", "nodes.humanInputV2.recipients.inputHelp": "Saisissez ou collez plusieurs adresses e-mail séparées par des virgules. Appuyez sur / pour insérer une variable.", "nodes.humanInputV2.recipients.insertVariable": "Insérer une variable", "nodes.humanInputV2.recipients.loadError": "Impossible de charger les contacts. Les ID de contact enregistrés sont conservés.", diff --git a/web/i18n/locales/hi-IN/workflow.json b/web/i18n/locales/hi-IN/workflow.json index 690a95c054f..6379c7d7457 100644 --- a/web/i18n/locales/hi-IN/workflow.json +++ b/web/i18n/locales/hi-IN/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "चुनें कि मानव इनपुट के इस चरण का उत्तर कौन दे सकता है।", "nodes.humanInputV2.recipients.initiator": "Web App या CLI के ज़रिए यह रन शुरू करने वाला उपयोगकर्ता अनुमोदन कर सकता है", "nodes.humanInputV2.recipients.initiatorDescription": "इस वर्कफ़्लो को शुरू करने वाला व्यक्ति उत्तर दे सकता है।", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "ट्रिगर से शुरू होने वाले रन में अनुमोदन अनुरोध पाने के लिए कोई आरंभकर्ता नहीं होता। कोई संपर्क चुनें या ईमेल पता देने के लिए किसी वेरिएबल का उपयोग करें।", "nodes.humanInputV2.recipients.inputHelp": "अल्पविराम से अलग किए गए कई ईमेल टाइप या पेस्ट करें। वेरिएबल डालने के लिए / दबाएँ।", "nodes.humanInputV2.recipients.insertVariable": "वेरिएबल डालें", "nodes.humanInputV2.recipients.loadError": "संपर्क लोड नहीं किए जा सके। सहेजी गई संपर्क ID सुरक्षित हैं।", diff --git a/web/i18n/locales/id-ID/workflow.json b/web/i18n/locales/id-ID/workflow.json index f987cdc0118..8ae0d6981cd 100644 --- a/web/i18n/locales/id-ID/workflow.json +++ b/web/i18n/locales/id-ID/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Pilih siapa yang dapat merespons langkah Input Manusia ini.", "nodes.humanInputV2.recipients.initiator": "Pengguna yang memulai proses ini melalui Web App atau CLI dapat menyetujui", "nodes.humanInputV2.recipients.initiatorDescription": "Orang yang memulai eksekusi alur kerja ini dapat merespons.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Eksekusi yang dimulai oleh pemicu tidak memiliki inisiator yang dapat menerima permintaan persetujuan. Pilih kontak atau gunakan variabel untuk menentukan alamat email.", "nodes.humanInputV2.recipients.inputHelp": "Ketik atau tempel beberapa alamat email yang dipisahkan dengan koma. Tekan / untuk menyisipkan variabel.", "nodes.humanInputV2.recipients.insertVariable": "Sisipkan variabel", "nodes.humanInputV2.recipients.loadError": "Kontak tidak dapat dimuat. ID kontak yang tersimpan tetap dipertahankan.", diff --git a/web/i18n/locales/it-IT/workflow.json b/web/i18n/locales/it-IT/workflow.json index 0783d38feba..8d97e4d792d 100644 --- a/web/i18n/locales/it-IT/workflow.json +++ b/web/i18n/locales/it-IT/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Scegli chi può rispondere a questo passaggio di input umano.", "nodes.humanInputV2.recipients.initiator": "L’utente che avvia questa esecuzione tramite Web App o CLI può approvare", "nodes.humanInputV2.recipients.initiatorDescription": "La persona che avvia questa esecuzione del flusso di lavoro può rispondere.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Quando un trigger avvia l’esecuzione, nessun utente può ricevere le richieste di approvazione come iniziatore. Seleziona un contatto o indica un indirizzo email tramite una variabile.", "nodes.humanInputV2.recipients.inputHelp": "Digita o incolla più indirizzi email separati da virgole. Premi / per inserire una variabile.", "nodes.humanInputV2.recipients.insertVariable": "Inserisci variabile", "nodes.humanInputV2.recipients.loadError": "Impossibile caricare i contatti. Gli ID dei contatti salvati vengono mantenuti.", diff --git a/web/i18n/locales/ja-JP/workflow.json b/web/i18n/locales/ja-JP/workflow.json index 1e59dcb4586..83a2d17aca0 100644 --- a/web/i18n/locales/ja-JP/workflow.json +++ b/web/i18n/locales/ja-JP/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "この人間の入力ステップに回答できる人を選択します。", "nodes.humanInputV2.recipients.initiator": "Web App または CLI から今回の実行を開始したユーザーが承認できます", "nodes.humanInputV2.recipients.initiatorDescription": "このワークフローの実行を開始した人が回答できます。", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "トリガーから開始した実行には、承認リクエストを受け取る開始者がいません。連絡先を選択するか、変数でメールアドレスを指定してください。", "nodes.humanInputV2.recipients.inputHelp": "複数のメールアドレスをカンマで区切って入力または貼り付けます。/ を押すと変数を挿入できます。", "nodes.humanInputV2.recipients.insertVariable": "変数を挿入", "nodes.humanInputV2.recipients.loadError": "連絡先を読み込めませんでした。保存済みの連絡先 ID は保持されています。", diff --git a/web/i18n/locales/ko-KR/workflow.json b/web/i18n/locales/ko-KR/workflow.json index a97da6ea4e6..fbee8fe29c5 100644 --- a/web/i18n/locales/ko-KR/workflow.json +++ b/web/i18n/locales/ko-KR/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "이 사람 입력 단계에 응답할 수 있는 사람을 선택하세요.", "nodes.humanInputV2.recipients.initiator": "Web App 또는 CLI를 통해 이번 실행을 시작한 사용자가 승인할 수 있습니다", "nodes.humanInputV2.recipients.initiatorDescription": "이 워크플로 실행을 시작한 사람이 응답할 수 있습니다.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "트리거로 시작된 실행에는 승인 요청을 받을 시작자가 없습니다. 연락처를 선택하거나 변수로 이메일 주소를 지정하세요.", "nodes.humanInputV2.recipients.inputHelp": "여러 이메일 주소를 쉼표로 구분하여 입력하거나 붙여넣으세요. /를 눌러 변수를 삽입하세요.", "nodes.humanInputV2.recipients.insertVariable": "변수 삽입", "nodes.humanInputV2.recipients.loadError": "연락처를 불러올 수 없습니다. 저장된 연락처 ID는 유지됩니다.", diff --git a/web/i18n/locales/lo-LA/workflow.json b/web/i18n/locales/lo-LA/workflow.json index 314a2b0dd3f..185aff0b8ab 100644 --- a/web/i18n/locales/lo-LA/workflow.json +++ b/web/i18n/locales/lo-LA/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "ເລືອກຜູ້ທີ່ສາມາດຕອບຂັ້ນຕອນການປ້ອນຂໍ້ມູນໂດຍມະນຸດນີ້ໄດ້.", "nodes.humanInputV2.recipients.initiator": "ຜູ້ໃຊ້ທີ່ເລີ່ມການເຮັດວຽກຄັ້ງນີ້ຜ່ານ Web App ຫຼື CLI ສາມາດອະນຸມັດໄດ້", "nodes.humanInputV2.recipients.initiatorDescription": "ຜູ້ທີ່ເລີ່ມການເຮັດວຽກຂອງ Workflow ຄັ້ງນີ້ສາມາດຕອບໄດ້.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "ການເຮັດວຽກທີ່ເລີ່ມຈາກຕົວກະຕຸ້ນບໍ່ມີຜູ້ເລີ່ມທີ່ຈະຮັບຄຳຂໍອະນຸມັດ. ເລືອກຜູ້ຕິດຕໍ່ ຫຼື ໃຊ້ຕົວແປເພື່ອລະບຸທີ່ຢູ່ອີເມວ.", "nodes.humanInputV2.recipients.inputHelp": "ພິມ ຫຼື ວາງຫຼາຍອີເມວໂດຍໃຊ້ , ຄັ່ນແຕ່ລະອີເມວ. ກົດ / ເພື່ອແຊກຕົວແປ.", "nodes.humanInputV2.recipients.insertVariable": "ແຊກຕົວແປ", "nodes.humanInputV2.recipients.loadError": "ບໍ່ສາມາດໂຫຼດຜູ້ຕິດຕໍ່ໄດ້. ID ຜູ້ຕິດຕໍ່ທີ່ບັນທຶກໄວ້ຍັງຖືກຮັກສາໄວ້.", diff --git a/web/i18n/locales/nl-NL/workflow.json b/web/i18n/locales/nl-NL/workflow.json index 143e008ec0b..e167efaa9a6 100644 --- a/web/i18n/locales/nl-NL/workflow.json +++ b/web/i18n/locales/nl-NL/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Kies wie op deze Human Input-stap kan reageren.", "nodes.humanInputV2.recipients.initiator": "De gebruiker die deze uitvoering via Web App of CLI start, kan goedkeuren", "nodes.humanInputV2.recipients.initiatorDescription": "De persoon die deze workflowuitvoering start, kan reageren.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Bij uitvoeringen die door een trigger worden gestart, is er geen initiator die goedkeuringsverzoeken kan ontvangen. Selecteer een contact of geef een e-mailadres op via een variabele.", "nodes.humanInputV2.recipients.inputHelp": "Typ of plak meerdere e-mailadressen, gescheiden door komma’s. Druk op / om een variabele in te voegen.", "nodes.humanInputV2.recipients.insertVariable": "Variabele invoegen", "nodes.humanInputV2.recipients.loadError": "Contacten konden niet worden geladen. Opgeslagen contact-ID’s blijven behouden.", diff --git a/web/i18n/locales/pl-PL/workflow.json b/web/i18n/locales/pl-PL/workflow.json index e196c45ca0e..ab326776672 100644 --- a/web/i18n/locales/pl-PL/workflow.json +++ b/web/i18n/locales/pl-PL/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Wybierz, kto może odpowiedzieć w tym kroku danych wprowadzanych przez człowieka.", "nodes.humanInputV2.recipients.initiator": "Użytkownik, który uruchomi to wykonanie przez Web App lub CLI, może je zatwierdzić", "nodes.humanInputV2.recipients.initiatorDescription": "Osoba rozpoczynająca to uruchomienie przepływu pracy może odpowiedzieć.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Uruchomienia przez wyzwalacz nie mają inicjatora, który mógłby otrzymywać prośby o zatwierdzenie. Wybierz kontakt lub podaj adres e-mail za pomocą zmiennej.", "nodes.humanInputV2.recipients.inputHelp": "Wpisz lub wklej wiele adresów e-mail oddzielonych przecinkami. Naciśnij /, aby wstawić zmienną.", "nodes.humanInputV2.recipients.insertVariable": "Wstaw zmienną", "nodes.humanInputV2.recipients.loadError": "Nie udało się wczytać kontaktów. Zapisane identyfikatory kontaktów zostały zachowane.", diff --git a/web/i18n/locales/pt-BR/workflow.json b/web/i18n/locales/pt-BR/workflow.json index 57133958c78..d0124f8cc5e 100644 --- a/web/i18n/locales/pt-BR/workflow.json +++ b/web/i18n/locales/pt-BR/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Escolha quem pode responder a esta etapa de Entrada Humana.", "nodes.humanInputV2.recipients.initiator": "O usuário que inicia esta execução pelo Web App ou CLI pode aprovar", "nodes.humanInputV2.recipients.initiatorDescription": "A pessoa que inicia esta execução do fluxo de trabalho pode responder.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "As execuções iniciadas por um gatilho não têm um iniciador que possa receber solicitações de aprovação. Selecione um contato ou use uma variável para informar um endereço de e-mail.", "nodes.humanInputV2.recipients.inputHelp": "Digite ou cole vários endereços de e-mail separados por vírgulas. Pressione / para inserir uma variável.", "nodes.humanInputV2.recipients.insertVariable": "Inserir variável", "nodes.humanInputV2.recipients.loadError": "Não foi possível carregar os contatos. Os IDs de contato armazenados foram preservados.", diff --git a/web/i18n/locales/ro-RO/workflow.json b/web/i18n/locales/ro-RO/workflow.json index afc7b069f1b..6b3d5d0ce6a 100644 --- a/web/i18n/locales/ro-RO/workflow.json +++ b/web/i18n/locales/ro-RO/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Alege cine poate răspunde la acest pas de input uman.", "nodes.humanInputV2.recipients.initiator": "Utilizatorul care pornește această execuție prin Web App sau CLI poate aproba", "nodes.humanInputV2.recipients.initiatorDescription": "Persoana care pornește această rulare a fluxului de lucru poate răspunde.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Execuțiile pornite de un declanșator nu au un inițiator care să primească cereri de aprobare. Selectează un contact sau specifică o adresă de e-mail folosind o variabilă.", "nodes.humanInputV2.recipients.inputHelp": "Introdu sau lipește mai multe adrese de e-mail separate prin virgule. Apasă / pentru a insera o variabilă.", "nodes.humanInputV2.recipients.insertVariable": "Inserează o variabilă", "nodes.humanInputV2.recipients.loadError": "Contactele nu au putut fi încărcate. ID-urile de contact salvate sunt păstrate.", diff --git a/web/i18n/locales/ru-RU/workflow.json b/web/i18n/locales/ru-RU/workflow.json index 4ee420273bf..6be5a5bbfff 100644 --- a/web/i18n/locales/ru-RU/workflow.json +++ b/web/i18n/locales/ru-RU/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Выберите, кто может ответить на этом шаге ввода человека.", "nodes.humanInputV2.recipients.initiator": "Пользователь, запустивший это выполнение через Web App или CLI, может его одобрить", "nodes.humanInputV2.recipients.initiatorDescription": "Ответить может пользователь, запустивший этот рабочий процесс.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "При запуске по триггеру нет инициатора, который мог бы получить запрос на согласование. Выберите контакт или укажите адрес электронной почты с помощью переменной.", "nodes.humanInputV2.recipients.inputHelp": "Введите или вставьте несколько адресов электронной почты через запятую. Нажмите /, чтобы вставить переменную.", "nodes.humanInputV2.recipients.insertVariable": "Вставить переменную", "nodes.humanInputV2.recipients.loadError": "Не удалось загрузить контакты. Сохранённые ID контактов не изменены.", diff --git a/web/i18n/locales/sl-SI/workflow.json b/web/i18n/locales/sl-SI/workflow.json index 042cccc09de..afe4b224eb3 100644 --- a/web/i18n/locales/sl-SI/workflow.json +++ b/web/i18n/locales/sl-SI/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Izberite, kdo lahko odgovori v tem koraku človeškega vnosa.", "nodes.humanInputV2.recipients.initiator": "Uporabnik, ki zažene to izvajanje prek Web App ali CLI, lahko odobri", "nodes.humanInputV2.recipients.initiatorDescription": "Odgovori lahko oseba, ki zažene ta delovni tok.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Izvajanja, ki jih zažene sprožilec, nimajo pobudnika, ki bi lahko prejel zahteve za odobritev. Izberite stik ali določite e-poštni naslov s spremenljivko.", "nodes.humanInputV2.recipients.inputHelp": "Vnesite ali prilepite več e-poštnih naslovov, ločenih z vejicami. Pritisnite /, da vstavite spremenljivko.", "nodes.humanInputV2.recipients.insertVariable": "Vstavi spremenljivko", "nodes.humanInputV2.recipients.loadError": "Stikov ni bilo mogoče naložiti. Shranjeni ID-ji stikov so ohranjeni.", diff --git a/web/i18n/locales/th-TH/workflow.json b/web/i18n/locales/th-TH/workflow.json index 6550695a6d5..d56c96dd5e8 100644 --- a/web/i18n/locales/th-TH/workflow.json +++ b/web/i18n/locales/th-TH/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "เลือกผู้ที่สามารถตอบในขั้นตอนข้อมูลจากมนุษย์นี้", "nodes.humanInputV2.recipients.initiator": "ผู้ใช้ที่เริ่มการทำงานครั้งนี้ผ่าน Web App หรือ CLI สามารถอนุมัติได้", "nodes.humanInputV2.recipients.initiatorDescription": "ผู้ที่เริ่มการเรียกใช้เวิร์กโฟลว์นี้สามารถตอบได้", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "การทำงานที่เริ่มด้วยทริกเกอร์ไม่มีผู้เริ่มที่สามารถรับคำขออนุมัติได้ เลือกผู้ติดต่อหรือใช้ตัวแปรเพื่อระบุที่อยู่อีเมล", "nodes.humanInputV2.recipients.inputHelp": "พิมพ์หรือวางหลายอีเมลโดยคั่นด้วยเครื่องหมายจุลภาค กด / เพื่อแทรกตัวแปร", "nodes.humanInputV2.recipients.insertVariable": "แทรกตัวแปร", "nodes.humanInputV2.recipients.loadError": "ไม่สามารถโหลดผู้ติดต่อได้ ID ผู้ติดต่อที่บันทึกไว้ยังคงอยู่", diff --git a/web/i18n/locales/tr-TR/workflow.json b/web/i18n/locales/tr-TR/workflow.json index 13128d41dcd..881962bc9de 100644 --- a/web/i18n/locales/tr-TR/workflow.json +++ b/web/i18n/locales/tr-TR/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Bu İnsan Girdisi adımına kimlerin yanıt verebileceğini seçin.", "nodes.humanInputV2.recipients.initiator": "Bu çalıştırmayı Web App veya CLI üzerinden başlatan kullanıcı onaylayabilir", "nodes.humanInputV2.recipients.initiatorDescription": "Bu iş akışını başlatan kişi yanıt verebilir.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Tetikleyiciyle başlatılan çalıştırmalarda onay isteklerini alabilecek bir başlatan yoktur. Bir kişi seçin veya e-posta adresini bir değişkenle belirtin.", "nodes.humanInputV2.recipients.inputHelp": "Virgülle ayrılmış birden fazla e-posta adresi yazın veya yapıştırın. Değişken eklemek için / tuşuna basın.", "nodes.humanInputV2.recipients.insertVariable": "Değişken ekle", "nodes.humanInputV2.recipients.loadError": "Kişiler yüklenemedi. Saklanan kişi kimlikleri korunuyor.", diff --git a/web/i18n/locales/uk-UA/workflow.json b/web/i18n/locales/uk-UA/workflow.json index 92de92ecd36..8f23835eca7 100644 --- a/web/i18n/locales/uk-UA/workflow.json +++ b/web/i18n/locales/uk-UA/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Виберіть, хто може відповісти на цьому кроці введення людини.", "nodes.humanInputV2.recipients.initiator": "Користувач, який запускає це виконання через Web App або CLI, може його схвалити", "nodes.humanInputV2.recipients.initiatorDescription": "Відповісти може особа, яка запускає цей робочий процес.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Запуски за тригером не мають ініціатора, який міг би отримати запит на схвалення. Виберіть контакт або вкажіть адресу електронної пошти за допомогою змінної.", "nodes.humanInputV2.recipients.inputHelp": "Введіть або вставте кілька електронних адрес, розділених комами. Натисніть /, щоб вставити змінну.", "nodes.humanInputV2.recipients.insertVariable": "Вставити змінну", "nodes.humanInputV2.recipients.loadError": "Не вдалося завантажити контакти. Збережені ID контактів не змінено.", diff --git a/web/i18n/locales/vi-VN/workflow.json b/web/i18n/locales/vi-VN/workflow.json index 6ed16e52368..9a8cfd499c6 100644 --- a/web/i18n/locales/vi-VN/workflow.json +++ b/web/i18n/locales/vi-VN/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "Chọn người có thể phản hồi ở bước Đầu vào của con người này.", "nodes.humanInputV2.recipients.initiator": "Người dùng bắt đầu lần chạy này qua Web App hoặc CLI có thể phê duyệt", "nodes.humanInputV2.recipients.initiatorDescription": "Người bắt đầu lần chạy quy trình này có thể phản hồi.", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "Lần chạy do trình kích hoạt khởi động không có người khởi tạo để nhận yêu cầu phê duyệt. Hãy chọn một liên hệ hoặc dùng biến để cung cấp địa chỉ email.", "nodes.humanInputV2.recipients.inputHelp": "Nhập hoặc dán nhiều email, phân tách bằng dấu phẩy. Nhấn / để chèn biến.", "nodes.humanInputV2.recipients.insertVariable": "Chèn biến", "nodes.humanInputV2.recipients.loadError": "Không thể tải các liên hệ. ID liên hệ đã lưu vẫn được giữ lại.", diff --git a/web/i18n/locales/zh-Hans/workflow.json b/web/i18n/locales/zh-Hans/workflow.json index c75374e7e57..83223fe5b55 100644 --- a/web/i18n/locales/zh-Hans/workflow.json +++ b/web/i18n/locales/zh-Hans/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "选择可以响应此人工介入步骤的人员。", "nodes.humanInputV2.recipients.initiator": "通过 Web App 或 CLI 发起本次运行的用户可审批", "nodes.humanInputV2.recipients.initiatorDescription": "发起本次工作流运行的用户可以响应。", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "触发器运行没有可接收审批的发起人,请选择联系人或使用变量提供邮箱。", "nodes.humanInputV2.recipients.inputHelp": "输入或粘贴多个邮箱,以逗号分隔。按 / 插入变量。", "nodes.humanInputV2.recipients.insertVariable": "插入变量", "nodes.humanInputV2.recipients.loadError": "无法加载联系人。已保存的联系人 ID 会继续保留。", diff --git a/web/i18n/locales/zh-Hant/workflow.json b/web/i18n/locales/zh-Hant/workflow.json index d3252744555..c3364eeed14 100644 --- a/web/i18n/locales/zh-Hant/workflow.json +++ b/web/i18n/locales/zh-Hant/workflow.json @@ -459,6 +459,7 @@ "nodes.humanInputV2.recipients.help": "選擇可以回應此人工輸入步驟的人員。", "nodes.humanInputV2.recipients.initiator": "透過 Web App 或 CLI 發起本次執行的使用者可審批", "nodes.humanInputV2.recipients.initiatorDescription": "啟動此次工作流程執行的人員可以回應。", + "nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow": "透過觸發器啟動的執行沒有可接收審批請求的發起人。請選取聯絡人,或使用變數提供電子郵件地址。", "nodes.humanInputV2.recipients.inputHelp": "輸入或貼上多個電子郵件地址,以逗號分隔。按 / 插入變數。", "nodes.humanInputV2.recipients.insertVariable": "插入變數", "nodes.humanInputV2.recipients.loadError": "無法載入聯絡人。已保留儲存的聯絡人 ID。", diff --git a/web/i18n/resources.generated.d.ts b/web/i18n/resources.generated.d.ts index 9b17e0fb72b..803dab21c08 100644 --- a/web/i18n/resources.generated.d.ts +++ b/web/i18n/resources.generated.d.ts @@ -6146,6 +6146,7 @@ export default interface Resources { 'nodes.humanInputV2.recipients.help': 'Choose who can respond to this Human Input step.' 'nodes.humanInputV2.recipients.initiator': 'The user who starts this run through Web App or CLI can approve' 'nodes.humanInputV2.recipients.initiatorDescription': 'The person who starts this workflow run can respond.' + 'nodes.humanInputV2.recipients.initiatorUnavailableInTriggerWorkflow': 'Trigger runs have no initiator to receive approval requests. Select a contact or use a variable to provide an email address.' 'nodes.humanInputV2.recipients.inputHelp': 'Type or paste multiple emails separated by commas. Press / to insert a variable.' 'nodes.humanInputV2.recipients.insertVariable': 'Insert variable' 'nodes.humanInputV2.recipients.loadError': 'Contacts could not be loaded. Stored contact IDs are preserved.'
+ {t(($) => $['nodes.humanInput.formContent.preview'], { + ns: 'workflowHumanInput', + })} +
+ {formError} +