Compare commits

...
Author SHA1 Message Date
林玮 (Jade Lin) bf6aee659f fix(api): tongyi credential compatibility (#37942)
(cherry picked from commit aa37c1d833)
2026-06-26 14:16:22 +08:00
林玮 (Jade Lin) dcd2df9c40 fix: Add tenant-level Redis lock for credit pool deduction (#37753)
(cherry picked from commit 7cca8b6bb0)
2026-06-22 17:44:24 +08:00
林玮 (Jade Lin) 22272bf16d fix: handle GraphRunAbortedEvent in TriggerPostLayer (#37350)
(cherry picked from commit 62ee1fff62)
2026-06-22 17:42:57 +08:00
fatelei 35f42c8f59 fix: add miss file 2026-06-15 16:36:47 +08:00
fatelei 740ba505b0 fix: fix some error 2026-06-15 16:33:41 +08:00
fatelei 8fb9245cf1 fix: fix LoadedFormContent is not define 2026-06-15 16:17:53 +08:00
wangxiaolei 1c692d7b0b fix: fix human input form logo replace (#37452) 2026-06-15 15:56:16 +08:00
wangxiaolei 33448bed6e fix: fix remove logo not work (#37435) 2026-06-15 13:08:28 +08:00
Yansong Zhang 14bd643664 fix user token 2026-06-02 14:59:06 +08:00
34a17c3ce6 fix(security): reject path traversal sequences before plugin daemon forward (GHSA-gvc6-fh3x-89xh) (#35796)
Co-authored-by: Ido Shani <ido@zafran.io>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-28 18:10:00 +08:00
Evanandfatelei 18f083607b fix: normalize summary_index_setting None to fix preview error (#36626) 2026-05-28 18:07:59 +08:00
wangxiaolei 2fe8dbd7ca fix: fix cannot extract elements from a scalar (#36769) 2026-05-28 15:50:27 +08:00
Cocoon-Breakand林玮 (Jade Lin) 80cd289e87 fix: replace .distinct() with .group_by(Conversation.id) for PostgreSQL JSON compatibility (#36610)
Co-authored-by: cocoon <kuishou68@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
(cherry picked from commit e617435d03)
2026-05-28 13:19:27 +08:00
wangxiaolei a14bc8a371 fix: fix DocumentSegment.keywords can not a valid json (#36715) 2026-05-27 17:11:06 +08:00
23 changed files with 1423 additions and 257 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ class EnterpriseFeatureConfig(BaseSettings):
CAN_REPLACE_LOGO: bool = Field(
description="Allow customization of the enterprise logo.",
default=False,
default=True,
)
ENTERPRISE_REQUEST_TIMEOUT: int = Field(
+2 -2
View File
@@ -137,7 +137,7 @@ class CompletionConversationApi(Resource):
.join( # type: ignore
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
)
.distinct()
.group_by(Conversation.id)
)
elif args.annotation_status == "not_annotated":
query = (
@@ -275,7 +275,7 @@ class ChatConversationApi(Resource):
.join( # type: ignore
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
)
.distinct()
.group_by(Conversation.id)
)
case "not_annotated":
query = (
@@ -3,7 +3,7 @@ import uuid
from flask import request
from flask_restx import Resource, marshal
from pydantic import BaseModel, Field
from sqlalchemy import String, cast, func, or_, select
from sqlalchemy import String, case, cast, func, literal, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from werkzeug.exceptions import Forbidden, NotFound
@@ -159,9 +159,17 @@ class DatasetDocumentSegmentListApi(Resource):
# Use database-specific methods for JSON array search
if dify_config.SQLALCHEMY_DATABASE_URI_SCHEME == "postgresql":
# PostgreSQL: Use jsonb_array_elements_text to properly handle Unicode/Chinese text
# Feed the set-returning function a JSON array in every row. Filtering in
# the subquery is not enough because PostgreSQL can still evaluate the
# SRF on scalar JSON before applying the predicate.
keywords_jsonb = cast(DocumentSegment.keywords, JSONB)
keywords_array = case(
(func.jsonb_typeof(keywords_jsonb) == "array", keywords_jsonb),
else_=cast(literal("[]"), JSONB),
)
keywords_condition = func.array_to_string(
func.array(
select(func.jsonb_array_elements_text(cast(DocumentSegment.keywords, JSONB)))
select(func.jsonb_array_elements_text(keywords_array))
.correlate(DocumentSegment)
.scalar_subquery()
),
+10 -1
View File
@@ -7,7 +7,13 @@ from pydantic import TypeAdapter
from core.db.session_factory import session_factory
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.graph_engine.layers import GraphEngineLayer
from graphon.graph_events import GraphEngineEvent, GraphRunFailedEvent, GraphRunPausedEvent, GraphRunSucceededEvent
from graphon.graph_events import (
GraphEngineEvent,
GraphRunAbortedEvent,
GraphRunFailedEvent,
GraphRunPausedEvent,
GraphRunSucceededEvent,
)
from models.enums import WorkflowTriggerStatus
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from tasks.workflow_cfs_scheduler.cfs_scheduler import AsyncWorkflowCFSPlanEntity
@@ -23,6 +29,7 @@ class TriggerPostLayer(GraphEngineLayer):
_STATUS_MAP: ClassVar[dict[type[GraphEngineEvent], WorkflowTriggerStatus]] = {
GraphRunSucceededEvent: WorkflowTriggerStatus.SUCCEEDED,
GraphRunFailedEvent: WorkflowTriggerStatus.FAILED,
GraphRunAbortedEvent: WorkflowTriggerStatus.FAILED,
GraphRunPausedEvent: WorkflowTriggerStatus.PAUSED,
}
@@ -71,6 +78,8 @@ class TriggerPostLayer(GraphEngineLayer):
trigger_log.status = self._STATUS_MAP[type(event)]
trigger_log.workflow_run_id = workflow_run_id
trigger_log.outputs = TypeAdapter(dict[str, Any]).dump_json(outputs).decode()
if isinstance(event, GraphRunAbortedEvent):
trigger_log.error = event.reason or "Workflow execution aborted"
if trigger_log.elapsed_time is None:
trigger_log.elapsed_time = elapsed_time
+2 -1
View File
@@ -240,7 +240,8 @@ class HostingConfiguration:
if len(quotas) > 0:
credentials = {
"dashscope_api_key": dify_config.HOSTED_TONGYI_API_KEY,
"use_international_endpoint": dify_config.HOSTED_TONGYI_USE_INTERNATIONAL_ENDPOINT,
# SNP-494: keep temporary compatibility with tongyi plugin string credential checks.
"use_international_endpoint": str(dify_config.HOSTED_TONGYI_USE_INTERNATIONAL_ENDPOINT).lower(),
}
return HostingProvider(enabled=True, credentials=credentials, quota_unit=quota_unit, quotas=quotas)
+18
View File
@@ -3,6 +3,7 @@ import json
import logging
from collections.abc import Callable, Generator
from typing import Any, cast
from urllib.parse import unquote
import httpx
from pydantic import BaseModel
@@ -53,6 +54,9 @@ else:
logger = logging.getLogger(__name__)
PLUGIN_DAEMON_MAX_PATH_LENGTH = 4096
PLUGIN_DAEMON_MAX_PATH_DECODE_DEPTH = 8
_httpx_client: httpx.Client = get_pooled_http_client(
"plugin_daemon",
lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100), trust_env=False),
@@ -103,6 +107,20 @@ class BasePluginClient:
params: dict[str, Any] | None,
files: dict[str, Any] | None,
) -> tuple[str, dict[str, str], bytes | dict[str, Any] | str | None, dict[str, Any] | None, dict[str, Any] | None]:
if len(path) > PLUGIN_DAEMON_MAX_PATH_LENGTH:
raise ValueError(f"Invalid plugin daemon path: path length exceeds {PLUGIN_DAEMON_MAX_PATH_LENGTH}")
decoded_path = path
for _ in range(PLUGIN_DAEMON_MAX_PATH_DECODE_DEPTH):
next_decoded_path = unquote(decoded_path)
if next_decoded_path == decoded_path:
break
decoded_path = next_decoded_path
else:
raise ValueError("Invalid plugin daemon path: path is too deeply encoded")
if any(seg == ".." for seg in decoded_path.split("/")):
raise ValueError(f"Invalid plugin daemon path: traversal sequence detected in {path!r}")
url = plugin_daemon_inner_api_baseurl / path
prepared_headers = dict(headers or {})
prepared_headers["X-Api-Key"] = dify_config.PLUGIN_DAEMON_KEY
+37 -3
View File
@@ -110,6 +110,8 @@ class TokenPair(BaseModel):
REFRESH_TOKEN_PREFIX = "refresh_token:"
ACCOUNT_REFRESH_TOKEN_PREFIX = "account_refresh_token:"
REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS)
ACCOUNT_LAST_ACTIVE_REFRESH_PREFIX = "account_last_active_refresh:"
ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL = timedelta(minutes=10)
class AccountService:
@@ -146,6 +148,40 @@ class AccountService:
def _get_account_refresh_token_key(account_id: str) -> str:
return f"{ACCOUNT_REFRESH_TOKEN_PREFIX}{account_id}"
@staticmethod
def _get_account_last_active_refresh_key(account_id: str) -> str:
return f"{ACCOUNT_LAST_ACTIVE_REFRESH_PREFIX}{account_id}"
@staticmethod
@redis_fallback(default_return=True)
def _should_refresh_account_last_active(account_id: str) -> bool:
return bool(
redis_client.set(
AccountService._get_account_last_active_refresh_key(account_id),
1,
ex=int(ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL.total_seconds()),
nx=True,
)
)
@staticmethod
def _refresh_account_last_active(account: Account) -> None:
now = naive_utc_now()
refresh_before = now - ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL
if account.last_active_at >= refresh_before:
return
if not AccountService._should_refresh_account_last_active(account.id):
return
db.session.execute(
update(Account)
.where(Account.id == account.id, Account.last_active_at < refresh_before)
.values(last_active_at=now, updated_at=func.current_timestamp())
)
db.session.commit()
@staticmethod
def _store_refresh_token(refresh_token: str, account_id: str):
redis_client.setex(AccountService._get_refresh_token_key(refresh_token), REFRESH_TOKEN_EXPIRY, account_id)
@@ -188,9 +224,7 @@ class AccountService:
available_ta.current = True
db.session.commit()
if naive_utc_now() - account.last_active_at > timedelta(minutes=10):
account.last_active_at = naive_utc_now()
db.session.commit()
AccountService._refresh_account_last_active(account)
# NOTE: make sure account is accessible outside of a db session
# This ensures that it will work correctly after upgrading to Flask version 3.1.2
db.session.refresh(account)
+48 -4
View File
@@ -1,4 +1,12 @@
"""Tenant credit pool accounting.
Credit deductions are guarded by a tenant-level Redis lock before the database
row lock is acquired. This keeps concurrent usage accounting for one tenant
from piling up database transactions while preserving cross-tenant concurrency.
"""
import logging
from collections.abc import Callable
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -7,13 +15,44 @@ from configs import dify_config
from core.db.session_factory import session_factory
from core.errors.error import QuotaExceededError
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from models import TenantCreditPool
from models.enums import ProviderQuotaType
logger = logging.getLogger(__name__)
CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS = 10
CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS = 5
class CreditPoolService:
@staticmethod
def _get_tenant_lock_key(tenant_id: str) -> str:
return f"credit_pool:tenant:{tenant_id}:deduct_lock"
@classmethod
def _deduct_with_tenant_lock(cls, tenant_id: str, deduct: Callable[[], int]) -> int:
lock_key = cls._get_tenant_lock_key(tenant_id)
lock = redis_client.lock(
lock_key,
timeout=CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS,
blocking_timeout=CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS,
)
lock_acquired = False
try:
lock_acquired = lock.acquire(blocking=True)
if not lock_acquired:
raise QuotaExceededError("Failed to acquire credit pool lock")
return deduct()
finally:
if lock_acquired:
try:
lock.release()
except Exception:
logger.warning("Failed to release credit pool lock, tenant_id=%s", tenant_id, exc_info=True)
@staticmethod
def _get_locked_pool(session: Session, tenant_id: str, pool_type: str) -> TenantCreditPool | None:
return session.scalar(
@@ -76,7 +115,7 @@ class CreditPoolService:
if credits_required <= 0:
return 0
try:
def deduct() -> int:
with session_factory.get_session_maker().begin() as session:
pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type)
if not pool:
@@ -89,14 +128,16 @@ class CreditPoolService:
raise QuotaExceededError("Insufficient credits remaining")
pool.quota_used += credits_required
return credits_required
try:
return cls._deduct_with_tenant_lock(tenant_id, deduct)
except QuotaExceededError:
raise
except Exception:
logger.exception("Failed to deduct credits for tenant %s", tenant_id)
raise QuotaExceededError("Failed to deduct credits")
return credits_required
@classmethod
def deduct_credits_capped(
cls,
@@ -108,7 +149,7 @@ class CreditPoolService:
if credits_required <= 0:
return 0
try:
def deduct() -> int:
with session_factory.get_session_maker().begin() as session:
pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type)
if not pool:
@@ -121,6 +162,9 @@ class CreditPoolService:
pool.quota_used += deducted_credits
return deducted_credits
try:
return cls._deduct_with_tenant_lock(tenant_id, deduct)
except QuotaExceededError:
raise
except Exception:
+30
View File
@@ -170,6 +170,16 @@ class _AutomaticProcessRule(BaseModel):
mode: Literal[ProcessRuleMode.AUTOMATIC]
summary_index_setting: _SummaryIndexSetting | None = None
@field_validator("summary_index_setting", mode="before")
@classmethod
def _normalize_summary_index_setting(cls, v: Any) -> Any:
"""Treat dicts with enable=None (or missing enable) as None (#36602)."""
if v is None:
return None
if isinstance(v, dict) and v.get("enable") is None:
return None
return v
class _CustomProcessRule(BaseModel):
model_config = ConfigDict(extra="allow")
@@ -178,6 +188,16 @@ class _CustomProcessRule(BaseModel):
rules: _EstimateRules
summary_index_setting: _SummaryIndexSetting | None = None
@field_validator("summary_index_setting", mode="before")
@classmethod
def _normalize_summary_index_setting(cls, v: Any) -> Any:
"""Treat dicts with enable=None (or missing enable) as None (#36602)."""
if v is None:
return None
if isinstance(v, dict) and v.get("enable") is None:
return None
return v
class _HierarchicalProcessRule(BaseModel):
model_config = ConfigDict(extra="allow")
@@ -186,6 +206,16 @@ class _HierarchicalProcessRule(BaseModel):
rules: _EstimateRules
summary_index_setting: _SummaryIndexSetting | None = None
@field_validator("summary_index_setting", mode="before")
@classmethod
def _normalize_summary_index_setting(cls, v: Any) -> Any:
"""Treat dicts with enable=None (or missing enable) as None (#36602)."""
if v is None:
return None
if isinstance(v, dict) and v.get("enable") is None:
return None
return v
_EstimateProcessRule = Annotated[
_AutomaticProcessRule | _CustomProcessRule | _HierarchicalProcessRule,
@@ -1036,6 +1036,48 @@ class TestSegmentListAdvancedCases:
assert status == 200
assert response["total"] == 1
def test_segment_list_postgres_keyword_filter_handles_scalar_keywords(self, app: Flask):
api = DatasetDocumentSegmentListApi()
method = unwrap(api.get)
dataset = MagicMock()
document = MagicMock()
pagination = MagicMock(items=[], total=0, pages=0)
with (
app.test_request_context("/?keyword=test"),
patch(
"controllers.console.datasets.datasets_segments.current_account_with_tenant",
return_value=(MagicMock(), "11111111-1111-1111-1111-111111111111"),
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.get_dataset",
return_value=dataset,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
return_value=None,
),
patch(
"controllers.console.datasets.datasets_segments.DocumentService.get_document",
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.dify_config",
SimpleNamespace(SQLALCHEMY_DATABASE_URI_SCHEME="postgresql"),
),
patch(
"controllers.console.datasets.datasets_segments.db.paginate",
return_value=pagination,
) as paginate_mock,
):
method(api, "22222222-2222-2222-2222-222222222222", "33333333-3333-3333-3333-333333333333")
query = paginate_mock.call_args.kwargs["select"]
sql = str(query.compile(compile_kwargs={"literal_binds": True}))
assert "jsonb_array_elements_text(CASE" in sql
assert "ELSE CAST('[]' AS JSONB)" in sql
def test_segment_list_permission_denied(self, app: Flask):
"""Test segment list with permission denied"""
api = DatasetDocumentSegmentListApi()
@@ -4,7 +4,11 @@ from unittest.mock import Mock, patch
from core.app.layers.trigger_post_layer import TriggerPostLayer
from core.workflow.system_variables import build_system_variables
from graphon.graph_events import GraphRunFailedEvent, GraphRunSucceededEvent
from graphon.graph_events import (
GraphRunAbortedEvent,
GraphRunFailedEvent,
GraphRunSucceededEvent,
)
from graphon.runtime import VariablePool
from models.enums import WorkflowTriggerStatus
@@ -59,6 +63,57 @@ class TestTriggerPostLayer:
repo.update.assert_called_once_with(trigger_log)
session.commit.assert_called_once()
def test_on_event_updates_trigger_log_for_aborted_event(self):
trigger_log = SimpleNamespace(
status=None,
workflow_run_id=None,
outputs=None,
error=None,
elapsed_time=None,
total_tokens=None,
finished_at=None,
)
runtime_state = SimpleNamespace(
outputs={"partial": "ok"},
variable_pool=VariablePool.from_bootstrap(
system_variables=build_system_variables(workflow_execution_id="run-1")
),
total_tokens=7,
)
with (
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
):
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = session
repo = Mock()
repo.get_by_id.return_value = trigger_log
mock_repo_cls.return_value = repo
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
trigger_log_id="log-1",
)
layer.initialize(runtime_state, Mock())
layer.on_event(GraphRunAbortedEvent(reason="timeout"))
assert trigger_log.status == WorkflowTriggerStatus.FAILED
assert trigger_log.workflow_run_id == "run-1"
assert trigger_log.outputs is not None
assert trigger_log.error == "timeout"
assert trigger_log.elapsed_time is not None
assert trigger_log.total_tokens == 7
assert trigger_log.finished_at is not None
repo.update.assert_called_once_with(trigger_log)
session.commit.assert_called_once()
def test_on_event_handles_missing_trigger_log(self):
runtime_state = SimpleNamespace(
outputs={},
@@ -1,11 +1,12 @@
import json
from urllib.parse import quote
import pytest
from pytest_mock import MockerFixture
from core.plugin.endpoint.exc import EndpointSetupFailedError
from core.plugin.entities.plugin_daemon import PluginDaemonInnerError
from core.plugin.impl.base import BasePluginClient
from core.plugin.impl.base import PLUGIN_DAEMON_MAX_PATH_LENGTH, BasePluginClient
from core.trigger.errors import (
EventIgnoreError,
TriggerInvokeError,
@@ -67,6 +68,36 @@ class TestBasePluginClientImpl:
assert result == ["hello", "world"]
assert stream_mock.call_args.kwargs["data"] == {"k": "v"}
@pytest.mark.parametrize(
"path",
[
"plugin/tenant/%252e%252e%252ftarget",
"plugin/tenant/%2e%2e%252ftarget",
],
)
def test_prepare_request_rejects_encoded_traversal_with_encoded_separator(self, path: str):
client = BasePluginClient()
with pytest.raises(ValueError, match="traversal sequence detected"):
client._prepare_request(path, None, None, None, None)
def test_prepare_request_rejects_path_exceeding_max_length(self):
client = BasePluginClient()
path = "a" * (PLUGIN_DAEMON_MAX_PATH_LENGTH + 1)
with pytest.raises(ValueError, match="path length exceeds"):
client._prepare_request(path, None, None, None, None)
def test_prepare_request_rejects_excessively_encoded_path(self):
client = BasePluginClient()
segment = "..%2Ftarget"
for _ in range(9):
segment = quote(segment, safe="")
path = f"plugin/tenant/{segment}"
with pytest.raises(ValueError, match="too deeply encoded"):
client._prepare_request(path, None, None, None, None)
def test_request_with_plugin_daemon_response_handles_request_exception(self, mocker: MockerFixture):
client = BasePluginClient()
mocker.patch.object(client, "_request", side_effect=RuntimeError("boom"))
@@ -436,7 +436,10 @@ class TestAccountService:
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant_join
# Mock datetime
with patch("services.account_service.datetime") as mock_datetime:
with (
patch("services.account_service.datetime") as mock_datetime,
patch.object(AccountService, "_refresh_account_last_active") as mock_refresh_last_active,
):
mock_now = datetime.now()
mock_datetime.now.return_value = mock_now
mock_datetime.UTC = "UTC"
@@ -447,6 +450,7 @@ class TestAccountService:
# Verify results
assert result == mock_account
assert mock_account.set_tenant_id.called
mock_refresh_last_active.assert_called_once_with(mock_account)
def test_load_user_not_found(self, mock_db_dependencies):
"""Test user loading when user does not exist."""
@@ -483,7 +487,10 @@ class TestAccountService:
mock_db_dependencies["db"].session.scalar.side_effect = [None, mock_available_tenant]
# Mock datetime
with patch("services.account_service.datetime") as mock_datetime:
with (
patch("services.account_service.datetime") as mock_datetime,
patch.object(AccountService, "_refresh_account_last_active") as mock_refresh_last_active,
):
mock_now = datetime.now()
mock_datetime.now.return_value = mock_now
mock_datetime.UTC = "UTC"
@@ -495,6 +502,7 @@ class TestAccountService:
assert result == mock_account
assert mock_available_tenant.current is True
self._assert_database_operations_called(mock_db_dependencies["db"])
mock_refresh_last_active.assert_called_once_with(mock_account)
def test_load_user_no_tenants(self, mock_db_dependencies):
"""Test user loading when user has no tenants at all."""
@@ -517,6 +525,68 @@ class TestAccountService:
# Verify results
assert result is None
def test_refresh_account_last_active_uses_redis_gate_and_conditional_update(self, mock_db_dependencies):
"""Test last-active refresh is gated in Redis and conditionally written to DB."""
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
now = datetime(2026, 6, 2, 2, 45, 49)
mock_account.last_active_at = now - timedelta(minutes=15)
with (
patch("services.account_service.naive_utc_now", return_value=now),
patch("services.account_service.redis_client") as mock_redis_client,
):
mock_redis_client.set.return_value = True
AccountService._refresh_account_last_active(mock_account)
mock_redis_client.set.assert_called_once_with(
"account_last_active_refresh:user-123",
1,
ex=600,
nx=True,
)
mock_db_dependencies["db"].session.execute.assert_called_once()
mock_db_dependencies["db"].session.commit.assert_called_once()
def test_refresh_account_last_active_skips_db_when_redis_gate_exists(self, mock_db_dependencies):
"""Test concurrent refresh attempts do not enqueue duplicate DB updates."""
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
now = datetime(2026, 6, 2, 2, 45, 49)
mock_account.last_active_at = now - timedelta(minutes=15)
with (
patch("services.account_service.naive_utc_now", return_value=now),
patch("services.account_service.redis_client") as mock_redis_client,
):
mock_redis_client.set.return_value = None
AccountService._refresh_account_last_active(mock_account)
mock_redis_client.set.assert_called_once_with(
"account_last_active_refresh:user-123",
1,
ex=600,
nx=True,
)
mock_db_dependencies["db"].session.execute.assert_not_called()
mock_db_dependencies["db"].session.commit.assert_not_called()
def test_refresh_account_last_active_skips_recent_account(self, mock_db_dependencies):
"""Test recent activity does not touch Redis or DB."""
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
now = datetime(2026, 6, 2, 2, 45, 49)
mock_account.last_active_at = now - timedelta(minutes=5)
with (
patch("services.account_service.naive_utc_now", return_value=now),
patch("services.account_service.redis_client") as mock_redis_client,
):
AccountService._refresh_account_last_active(mock_account)
mock_redis_client.set.assert_not_called()
mock_db_dependencies["db"].session.execute.assert_not_called()
mock_db_dependencies["db"].session.commit.assert_not_called()
class TestTenantService:
"""
@@ -1,6 +1,7 @@
from collections.abc import Generator
from contextlib import contextmanager
from unittest.mock import patch
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
@@ -11,7 +12,11 @@ from sqlalchemy.orm import sessionmaker
from core.errors.error import QuotaExceededError
from models import TenantCreditPool
from models.enums import ProviderQuotaType
from services.credit_pool_service import CreditPoolService
from services.credit_pool_service import (
CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS,
CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS,
CreditPoolService,
)
def _create_engine_with_pool(*, quota_limit: int, quota_used: int) -> tuple[Engine, str, str]:
@@ -45,6 +50,20 @@ def _get_quota_used(*, engine: Engine, pool_id: str) -> int | None:
return connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id))
def _make_session_maker(session: MagicMock) -> MagicMock:
session_maker = MagicMock()
transaction = session_maker.begin.return_value
transaction.__enter__.return_value = session
transaction.__exit__.return_value = None
return session_maker
def _make_redis_lock() -> MagicMock:
lock = MagicMock()
lock.acquire.return_value = True
return lock
def test_get_pool_uses_configured_session_factory_without_flask_app_context() -> None:
engine, tenant_id, _ = _create_engine_with_pool(quota_limit=10, quota_used=2)
@@ -176,3 +195,110 @@ def test_deduct_credits_capped_reraises_quota_exceeded_errors() -> None:
CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1)
assert _get_quota_used(engine=engine, pool_id=pool_id) == 2
def test_check_and_deduct_credits_uses_tenant_redis_lock_before_db_deduction() -> None:
tenant_id = "tenant-1"
session = MagicMock()
session_maker = _make_session_maker(session)
pool = SimpleNamespace(remaining_credits=10, quota_used=2)
redis_lock = _make_redis_lock()
with (
patch("services.credit_pool_service.redis_client.lock", return_value=redis_lock) as lock,
patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker),
patch.object(CreditPoolService, "_get_locked_pool", return_value=pool) as get_locked_pool,
):
result = CreditPoolService.check_and_deduct_credits(
tenant_id=tenant_id,
credits_required=3,
pool_type=ProviderQuotaType.TRIAL,
)
assert result == 3
assert pool.quota_used == 5
lock.assert_called_once_with(
"credit_pool:tenant:tenant-1:deduct_lock",
timeout=CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS,
blocking_timeout=CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS,
)
redis_lock.acquire.assert_called_once_with(blocking=True)
redis_lock.release.assert_called_once_with()
get_locked_pool.assert_called_once_with(session=session, tenant_id=tenant_id, pool_type=ProviderQuotaType.TRIAL)
def test_deduct_credits_capped_uses_tenant_redis_lock_before_db_deduction() -> None:
tenant_id = "tenant-1"
session = MagicMock()
session_maker = _make_session_maker(session)
pool = SimpleNamespace(remaining_credits=2, quota_used=8)
redis_lock = _make_redis_lock()
with (
patch("services.credit_pool_service.redis_client.lock", return_value=redis_lock) as lock,
patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker),
patch.object(CreditPoolService, "_get_locked_pool", return_value=pool) as get_locked_pool,
):
result = CreditPoolService.deduct_credits_capped(
tenant_id=tenant_id,
credits_required=5,
pool_type=ProviderQuotaType.PAID,
)
assert result == 2
assert pool.quota_used == 10
lock.assert_called_once_with(
"credit_pool:tenant:tenant-1:deduct_lock",
timeout=CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS,
blocking_timeout=CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS,
)
redis_lock.acquire.assert_called_once_with(blocking=True)
redis_lock.release.assert_called_once_with()
get_locked_pool.assert_called_once_with(session=session, tenant_id=tenant_id, pool_type=ProviderQuotaType.PAID)
@pytest.mark.parametrize(
"deduct_method",
[
CreditPoolService.check_and_deduct_credits,
CreditPoolService.deduct_credits_capped,
],
)
def test_non_positive_credit_request_skips_tenant_redis_lock(deduct_method) -> None:
with patch("services.credit_pool_service.redis_client.lock") as lock:
result = deduct_method(tenant_id="tenant-1", credits_required=0)
assert result == 0
lock.assert_not_called()
def test_check_and_deduct_credits_wraps_redis_lock_errors_without_querying_db() -> None:
session_maker = MagicMock()
with (
patch("services.credit_pool_service.redis_client.lock", side_effect=RuntimeError("redis unavailable")),
patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker),
pytest.raises(QuotaExceededError, match="Failed to deduct credits"),
):
CreditPoolService.check_and_deduct_credits(tenant_id="tenant-1", credits_required=1)
session_maker.begin.assert_not_called()
def test_deduct_credits_capped_ignores_release_errors_after_successful_deduction() -> None:
session = MagicMock()
session_maker = _make_session_maker(session)
pool = SimpleNamespace(remaining_credits=3, quota_used=7)
redis_lock = _make_redis_lock()
redis_lock.release.side_effect = RuntimeError("release failed")
with (
patch("services.credit_pool_service.redis_client.lock", return_value=redis_lock),
patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker),
patch.object(CreditPoolService, "_get_locked_pool", return_value=pool),
):
result = CreditPoolService.deduct_credits_capped(tenant_id="tenant-1", credits_required=2)
assert result == 2
assert pool.quota_used == 9
redis_lock.release.assert_called_once_with()
@@ -0,0 +1,402 @@
import type { FormData } from '../form'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import { act, render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import FormContent from '../form'
import { useFormSubmit } from '../use-form-submit'
const mockSubmitForm = vi.hoisted(() => vi.fn())
const mockUseGetHumanInputForm = vi.hoisted(() => vi.fn())
const mockContentItemState = vi.hoisted(() => ({
staleAttachmentInputChange: undefined as ((name: string, value: unknown) => void) | undefined,
uploadedFile: {
id: 'file-1',
name: 'review.pdf',
size: 128,
type: 'document',
progress: 100,
transferMethod: 'local_file',
supportFileType: 'document',
uploadedId: 'upload-file-1',
},
uploadingFile: {
id: 'file-1',
name: 'review.pdf',
size: 128,
type: 'document',
progress: 50,
transferMethod: 'local_file',
supportFileType: 'document',
uploadedId: undefined,
},
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: 'token-123' }),
}))
vi.mock('@/service/use-share', () => ({
useGetHumanInputForm: (...args: unknown[]) => mockUseGetHumanInputForm(...args),
useSubmitHumanInputForm: () => ({
mutate: mockSubmitForm,
isPending: false,
}),
}))
vi.mock('@/hooks/use-document-title', () => ({
__esModule: true,
default: vi.fn(),
}))
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({
__esModule: true,
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => {
const isSummaryField = content.includes('summary')
const isAttachmentField = content.includes('attachments')
if (isAttachmentField && !mockContentItemState.staleAttachmentInputChange)
mockContentItemState.staleAttachmentInputChange = onInputChange
return (
<div data-testid="share-form-content-item">
{content}
{isSummaryField && (
<>
<button type="button" onClick={() => onInputChange('summary', '')}>
share-clear-summary
</button>
<button type="button" onClick={() => onInputChange('summary', 'updated summary')}>
share-update-summary
</button>
</>
)}
{isAttachmentField && (
<>
<button
type="button"
onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadingFile])}
>
share-uploading-attachments
</button>
<button
type="button"
onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadedFile])}
>
share-update-attachments
</button>
</>
)}
</div>
)
},
}))
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/expiration-time', () => ({
__esModule: true,
default: () => <div>expiration-time</div>,
}))
vi.mock('@/app/components/base/loading', () => ({
__esModule: true,
default: () => <div>loading</div>,
}))
vi.mock('@/app/components/base/logo/dify-logo', () => ({
__esModule: true,
default: () => <div>dify-logo</div>,
}))
vi.mock('@/app/components/base/app-icon', () => ({
__esModule: true,
default: () => <div>app-icon</div>,
}))
describe('Human input share form', () => {
const formData: FormData = {
site: {
site: {
title: 'Review App',
icon_type: 'emoji',
icon: 'R',
icon_background: '#fff',
icon_url: '',
default_language: 'en-US',
description: '',
copyright: '',
privacy_policy: '',
custom_disclaimer: '',
prompt_public: false,
use_icon_as_answer_icon: false,
},
},
form_content: '{{#$output.summary#}} {{#$output.attachments#}}',
inputs: [
{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: {
type: 'constant',
value: 'initial summary',
selector: [],
},
},
{
type: InputVarType.multiFiles,
output_variable_name: 'attachments',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 3,
},
],
resolved_default_values: {},
user_actions: [
{
id: 'approve',
title: 'Approve',
button_style: UserActionButtonType.Primary,
},
],
expiration_time: 60,
}
beforeEach(() => {
vi.clearAllMocks()
mockContentItemState.staleAttachmentInputChange = undefined
mockUseGetHumanInputForm.mockReturnValue({
data: formData,
isLoading: false,
error: null,
})
})
it('should render the loading state while the form is being fetched', () => {
mockUseGetHumanInputForm.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
})
render(<FormContent />)
expect(screen.getByText('loading')).toBeInTheDocument()
})
it('should render status cards for terminal fetch states', () => {
const cases = [
{
error: { code: 'human_input_form_expired' },
title: 'share.humanInput.sorry',
subtitle: 'share.humanInput.expired',
submissionID: true,
},
{
error: { code: 'human_input_form_submitted' },
title: 'share.humanInput.sorry',
subtitle: 'share.humanInput.completed',
submissionID: true,
},
{
error: { code: 'web_form_rate_limit_exceeded' },
title: 'share.humanInput.rateLimitExceeded',
subtitle: undefined,
submissionID: false,
},
{
error: null,
title: 'share.humanInput.formNotFound',
subtitle: undefined,
submissionID: false,
},
]
cases.forEach(({ error, title, subtitle, submissionID }) => {
mockUseGetHumanInputForm.mockReturnValue({
data: undefined,
isLoading: false,
error,
})
const { unmount } = render(<FormContent />)
expect(screen.getByText(title)).toBeInTheDocument()
if (subtitle)
expect(screen.getByText(subtitle)).toBeInTheDocument()
else
expect(screen.queryByText('share.humanInput.expired')).not.toBeInTheDocument()
if (submissionID)
expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument()
else
expect(screen.queryByText(/share\.humanInput\.submissionID/)).not.toBeInTheDocument()
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
expect(screen.getByText('dify-logo')).toBeInTheDocument()
unmount()
})
})
it('submits typed human input values through the share form mutation', async () => {
const user = userEvent.setup()
render(<FormContent />)
await user.click(screen.getByRole('button', { name: 'share-update-summary' }))
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
await user.click(screen.getByRole('button', { name: 'Approve' }))
expect(mockSubmitForm).toHaveBeenCalledWith({
token: 'token-123',
data: {
action: 'approve',
inputs: {
summary: 'updated summary',
attachments: [{
type: 'document',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
})
it('should show the success status after the submit mutation succeeds', async () => {
const user = userEvent.setup()
render(<FormContent />)
await user.click(screen.getByRole('button', { name: 'share-update-summary' }))
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
await user.click(screen.getByRole('button', { name: 'Approve' }))
const options = mockSubmitForm.mock.calls[0]![1] as { onSuccess: () => void }
act(() => {
options.onSuccess()
})
expect(screen.getByText('share.humanInput.thanks')).toBeInTheDocument()
expect(screen.getByText('share.humanInput.recorded')).toBeInTheDocument()
expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument()
})
it('should submit empty inputs when there are no form values to process', () => {
const { result } = renderHook(() => useFormSubmit('token-empty'))
act(() => {
result.current.submit(
undefined as unknown as Record<string, HumanInputFieldValue>,
'reject',
[],
)
})
expect(mockSubmitForm).toHaveBeenCalledWith({
token: 'token-empty',
data: {
action: 'reject',
inputs: {},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
})
it('should keep initialized defaults when file upload uses the initial change callback', async () => {
const user = userEvent.setup()
render(<FormContent />)
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
await user.click(screen.getByRole('button', { name: 'Approve' }))
expect(mockSubmitForm).toHaveBeenCalledWith({
token: 'token-123',
data: {
action: 'approve',
inputs: {
summary: 'initial summary',
attachments: [{
type: 'document',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
})
it('should disable action buttons until every required field is filled and files are uploaded', async () => {
const user = userEvent.setup()
render(<FormContent />)
const approveButton = screen.getByRole('button', { name: 'Approve' })
expect(approveButton).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'share-uploading-attachments' }))
expect(approveButton).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
expect(approveButton).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'share-clear-summary' }))
expect(approveButton).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'share-update-summary' }))
expect(approveButton).toBeEnabled()
})
it('should hide branding when remove_webapp_brand is enabled', () => {
mockUseGetHumanInputForm.mockReturnValue({
data: {
...formData,
site: {
...formData.site,
custom_config: {
remove_webapp_brand: true,
replace_webapp_logo: null,
},
},
},
isLoading: false,
error: null,
})
render(<FormContent />)
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
expect(screen.queryByText('dify-logo')).not.toBeInTheDocument()
})
it('should render the custom branding logo when replace_webapp_logo is provided', () => {
mockUseGetHumanInputForm.mockReturnValue({
data: {
...formData,
site: {
...formData.site,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: 'https://example.com/custom-logo.png',
},
},
},
isLoading: false,
error: null,
})
render(<FormContent />)
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'logo' })).toHaveAttribute('src', 'https://example.com/custom-logo.png')
expect(screen.queryByText('dify-logo')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next'
import DifyLogo from '@/app/components/base/logo/dify-logo'
type BrandingFooterProps = {
removeWebappBrand?: boolean
replaceWebappLogo?: string | null
}
const BrandingFooter = ({
removeWebappBrand,
replaceWebappLogo,
}: BrandingFooterProps) => {
const { t } = useTranslation()
if (removeWebappBrand)
return null
return (
<div className="flex flex-row-reverse px-2 py-3">
<div className="flex shrink-0 items-center gap-1.5 px-1">
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
{replaceWebappLogo
? <img src={replaceWebappLogo} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />}
</div>
</div>
)
}
export default BrandingFooter
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import BrandingFooter from './branding-footer'
type FormStatusCardProps = {
iconClassName: string
title: ReactNode
subtitle?: ReactNode
submissionID?: string
removeWebappBrand?: boolean
replaceWebappLogo?: string | null
}
const FormStatusCard = ({
iconClassName,
title,
subtitle,
submissionID,
removeWebappBrand,
replaceWebappLogo,
}: FormStatusCardProps) => {
return (
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-160 min-w-120">
<div className="flex h-80 flex-col gap-4 rounded-[20px] border border-divider-subtle bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<span className={cn('size-8', iconClassName)} />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{title}</div>
{!!subtitle && (
<div className="title-4xl-semi-bold text-text-primary">{subtitle}</div>
)}
</div>
{submissionID && (
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">
{submissionID}
</div>
)}
</div>
<BrandingFooter removeWebappBrand={removeWebappBrand} replaceWebappLogo={replaceWebappLogo} />
</div>
</div>
)
}
export default FormStatusCard
+53 -225
View File
@@ -1,34 +1,26 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { SiteInfo } from '@/models/share'
import type { CustomConfigValueType, SiteInfo } from '@/models/share'
import type { HumanInputFormError } from '@/service/use-share'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiInformation2Fill,
} from '@remixicon/react'
import { produce } from 'immer'
import type { HumanInputResolvedValue } from '@/types/workflow'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time'
import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import Loading from '@/app/components/base/loading'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import useDocumentTitle from '@/hooks/use-document-title'
import { useParams } from '@/next/navigation'
import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share'
import { useGetHumanInputForm } from '@/service/use-share'
import FormStatusCard from './form-status-card'
import LoadedFormContent from './loaded-form-content'
import { useFormSubmit } from './use-form-submit'
export type FormData = {
site: { site: SiteInfo }
site: {
site: SiteInfo
custom_config?: Record<string, CustomConfigValueType> | null
}
form_content: string
inputs: FormInputItem[]
resolved_default_values: Record<string, string>
resolved_default_values: Record<string, HumanInputResolvedValue>
user_actions: UserAction[]
expiration_time: number
}
@@ -39,58 +31,18 @@ const FormContent = () => {
const { token } = useParams<{ token: string }>()
useDocumentTitle('')
const [inputs, setInputs] = useState<Record<string, string>>({})
const [success, setSuccess] = useState(false)
const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm()
const { data: formData, isLoading, error } = useGetHumanInputForm(token)
const { isSubmitting, submit, success } = useFormSubmit(token)
const removeWebappBrand = formData?.site?.custom_config?.remove_webapp_brand === true
const replaceWebappLogo = typeof formData?.site?.custom_config?.replace_webapp_logo === 'string'
? formData.site.custom_config.replace_webapp_logo
: null
const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired'
const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted'
const rateLimitExceeded = (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded'
const splitByOutputVar = (content: string): string[] => {
const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
const parts = content.split(outputVarRegex)
return parts.filter(part => part.length > 0)
}
const contentList = useMemo(() => {
if (!formData?.form_content)
return []
return splitByOutputVar(formData.form_content)
}, [formData?.form_content])
useEffect(() => {
if (!formData?.inputs)
return
const initialInputs: Record<string, string> = {}
formData.inputs.forEach((item) => {
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? formData.resolved_default_values[item.output_variable_name] || '' : item.default.value
})
setInputs(initialInputs)
}, [formData?.inputs, formData?.resolved_default_values])
// use immer
const handleInputsChange = (name: string, value: string) => {
const newInputs = produce(inputs, (draft) => {
draft[name] = value
})
setInputs(newInputs)
}
const submit = (actionID: string) => {
submitForm(
{ token, data: { inputs, action: actionID } },
{
onSuccess: () => {
setSuccess(true)
},
},
)
}
if (isLoading) {
return (
<Loading type="app" />
@@ -99,190 +51,66 @@ const FormContent = () => {
if (success) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiCheckboxCircleFill className="h-8 w-8 text-text-success" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.thanks', { ns: 'share' })}</div>
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.recorded', { ns: 'share' })}</div>
</div>
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<FormStatusCard
iconClassName="i-ri-checkbox-circle-fill text-text-success"
title={t('humanInput.thanks', { ns: 'share' })}
subtitle={t('humanInput.recorded', { ns: 'share' })}
submissionID={token}
removeWebappBrand={removeWebappBrand}
replaceWebappLogo={replaceWebappLogo}
/>
)
}
if (expired) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.expired', { ns: 'share' })}</div>
</div>
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<FormStatusCard
iconClassName="i-ri-information-2-fill text-text-accent"
title={t('humanInput.sorry', { ns: 'share' })}
subtitle={t('humanInput.expired', { ns: 'share' })}
submissionID={token}
/>
)
}
if (submitted) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.completed', { ns: 'share' })}</div>
</div>
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<FormStatusCard
iconClassName="i-ri-information-2-fill text-text-accent"
title={t('humanInput.sorry', { ns: 'share' })}
subtitle={t('humanInput.completed', { ns: 'share' })}
submissionID={token}
/>
)
}
if (rateLimitExceeded) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.rateLimitExceeded', { ns: 'share' })}</div>
</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<FormStatusCard
iconClassName="i-ri-error-warning-fill text-text-destructive"
title={t('humanInput.rateLimitExceeded', { ns: 'share' })}
/>
)
}
if (!formData) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.formNotFound', { ns: 'share' })}</div>
</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<FormStatusCard
iconClassName="i-ri-error-warning-fill text-text-destructive"
title={t('humanInput.formNotFound', { ns: 'share' })}
/>
)
}
const site = formData.site.site
return (
<div className={cn('mx-auto flex h-full w-full max-w-[720px] flex-col items-center')}>
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
<AppIcon
size="large"
iconType={site.icon_type}
icon={site.icon}
background={site.icon_background}
imageUrl={site.icon_url}
/>
<div className="grow system-xl-semibold text-text-primary">{site.title}</div>
</div>
<div className="h-0 w-full grow overflow-y-auto">
<div className="border-components-divider-subtle rounded-[20px] border bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-xs">
{contentList.map((content, index) => (
<ContentItem
key={index}
content={content}
formInputFields={formData.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className="flex flex-wrap gap-1 py-1">
{formData.user_actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(action.id)}
>
{action.title}
</Button>
))}
</div>
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<LoadedFormContent
key={token}
formData={formData}
isSubmitting={isSubmitting}
onSubmit={submit}
removeWebappBrand={removeWebappBrand}
replaceWebappLogo={replaceWebappLogo}
/>
)
}
@@ -0,0 +1,95 @@
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { FormData } from './form'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { produce } from 'immer'
import { useMemo, useState } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time'
import { getButtonStyle, getRenderedFormInputs, hasInvalidRequiredHumanInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import BrandingFooter from './branding-footer'
type LoadedFormContentProps = {
formData: FormData
isSubmitting: boolean
onSubmit: (inputs: Record<string, HumanInputFieldValue>, actionID: string, formInputs: FormData['inputs']) => void
removeWebappBrand?: boolean
replaceWebappLogo?: string | null
}
const LoadedFormContent = ({
formData,
isSubmitting,
onSubmit,
removeWebappBrand,
replaceWebappLogo,
}: LoadedFormContentProps) => {
const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content)
const [inputs, setInputs] = useState<Record<string, HumanInputFieldValue>>(() =>
initializeInputs(renderedFormInputs, formData.resolved_default_values),
)
const contentList = useMemo(() => {
return splitByOutputVar(formData.form_content)
}, [formData.form_content])
const handleInputsChange = (name: string, value: HumanInputFieldValue) => {
setInputs(prevInputs => produce(prevInputs, (draft) => {
draft[name] = value
}))
}
const submit = (actionID: string) => {
onSubmit(inputs, actionID, formData.inputs)
}
const isActionDisabled = isSubmitting || hasInvalidRequiredHumanInput(renderedFormInputs, inputs)
const site = formData.site.site
return (
<div className={cn('mx-auto flex size-full max-w-180 flex-col items-center')}>
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
<AppIcon
size="large"
iconType={site.icon_type}
icon={site.icon}
background={site.icon_background}
imageUrl={site.icon_url}
/>
<div className="grow system-xl-semibold text-text-primary">{site.title}</div>
</div>
<div className="h-0 w-full grow overflow-y-auto">
<div className="rounded-[20px] border border-divider-subtle bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-xs">
{contentList.map((content, index) => (
<ContentItem
key={index}
content={content}
formInputFields={formData.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className="flex flex-wrap gap-1 py-1">
{formData.user_actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isActionDisabled}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(action.id)}
>
{action.title}
</Button>
))}
</div>
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
</div>
<BrandingFooter removeWebappBrand={removeWebappBrand} replaceWebappLogo={replaceWebappLogo} />
</div>
</div>
)
}
export default LoadedFormContent
@@ -0,0 +1,33 @@
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { useCallback, useState } from 'react'
import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import { useSubmitHumanInputForm } from '@/service/use-share'
export const useFormSubmit = (token: string) => {
const [success, setSuccess] = useState(false)
const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm()
const submit = useCallback((inputs: Record<string, HumanInputFieldValue>, actionID: string, formInputs: FormInputItem[]) => {
submitForm(
{
token,
data: {
inputs: getProcessedHumanInputFormInputs(formInputs, inputs) || {},
action: actionID,
},
},
{
onSuccess: () => {
setSuccess(true)
},
},
)
}, [submitForm, token])
return {
isSubmitting,
submit,
success,
}
}
@@ -1,10 +1,20 @@
import type { HumanInputFieldValue } from './field-renderer'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Locale } from '@/i18n-config'
import type { HumanInputResolvedValue } from '@/types/workflow'
import dayjs from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import relativeTime from 'dayjs/plugin/relativeTime'
import utc from 'dayjs/plugin/utc'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { fileIsUploaded, getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import {
isFileFormInput,
isFileListFormInput,
isParagraphFormInput,
isSelectFormInput,
UserActionButtonType,
} from '@/app/components/workflow/nodes/human-input/types'
import 'dayjs/locale/en'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/ja'
@@ -31,17 +41,140 @@ export const splitByOutputVar = (content: string): string[] => {
return parts.filter(part => part.length > 0)
}
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, string> = {}) => {
const initialInputs: Record<string, any> = {}
export const getFormContentInputNames = (content: string) => {
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/g
return [...content.matchAll(outputVarRegex)].map(match => match[1]!)
}
export const getRenderedFormInputs = (formInputs: FormInputItem[], content: string) => {
const inputNames = new Set(getFormContentInputNames(content))
return formInputs.filter(input => inputNames.has(input.output_variable_name))
}
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, HumanInputResolvedValue> = {}) => {
const initialInputs: Record<string, HumanInputFieldValue> = {}
formInputs.forEach((item) => {
if (item.type === 'text-input' || item.type === 'paragraph')
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? defaultValues[item.output_variable_name] || '' : item.default.value
else
initialInputs[item.output_variable_name] = undefined
if (isParagraphFormInput(item)) {
const resolvedValue = defaultValues[item.output_variable_name]
initialInputs[item.output_variable_name] = item.default.type === 'variable' && typeof resolvedValue === 'string'
? resolvedValue
: item.default.value
return
}
if (isSelectFormInput(item)) {
initialInputs[item.output_variable_name] = ''
return
}
if (isFileFormInput(item)) {
initialInputs[item.output_variable_name] = null
return
}
if (isFileListFormInput(item)) {
initialInputs[item.output_variable_name] = []
}
})
return initialInputs
}
const isHumanInputFileUploaded = (value: HumanInputFieldValue | undefined) => {
return !!value
&& !Array.isArray(value)
&& typeof value !== 'string'
&& !!fileIsUploaded(value as FileEntity)
}
const hasUploadedHumanInputFiles = (value: HumanInputFieldValue | undefined) => {
return Array.isArray(value)
&& value.length > 0
&& value.every(file => !!fileIsUploaded(file))
}
export const hasInvalidSelectOrFileInput = (
formInputs: FormInputItem[],
values: Record<string, HumanInputFieldValue>,
) => {
return formInputs.some((input) => {
if (!(input.output_variable_name in values))
return false
const value = values[input.output_variable_name]
if (isSelectFormInput(input))
return typeof value !== 'string' || value.length === 0
if (isFileFormInput(input))
return Array.isArray(value) ? !hasUploadedHumanInputFiles(value) : !isHumanInputFileUploaded(value)
if (isFileListFormInput(input))
return !hasUploadedHumanInputFiles(value)
return false
})
}
export const hasInvalidRequiredHumanInput = (
formInputs: FormInputItem[],
values: Record<string, HumanInputFieldValue>,
) => {
return formInputs.some((input) => {
if (!(input.output_variable_name in values))
return false
const value = values[input.output_variable_name]
if (isParagraphFormInput(input))
return typeof value !== 'string' || value.trim().length === 0
if (isSelectFormInput(input))
return typeof value !== 'string' || value.length === 0
if (isFileFormInput(input))
return Array.isArray(value) ? !hasUploadedHumanInputFiles(value) : !isHumanInputFileUploaded(value)
if (isFileListFormInput(input))
return !hasUploadedHumanInputFiles(value)
return false
})
}
export const getProcessedHumanInputFormInputs = (
formInputs: FormInputItem[],
values: Record<string, HumanInputFieldValue> | undefined,
) => {
if (!values)
return undefined
const processedInputs: Record<string, unknown> = { ...values }
formInputs.forEach((input) => {
const value = values[input.output_variable_name]
if (isFileListFormInput(input)) {
processedInputs[input.output_variable_name] = Array.isArray(value)
? getProcessedFiles(value)
: []
return
}
if (isFileFormInput(input)) {
if (Array.isArray(value)) {
processedInputs[input.output_variable_name] = getProcessedFiles(value)[0]
return
}
processedInputs[input.output_variable_name] = value && typeof value !== 'string'
? getProcessedFiles([value as FileEntity])[0]
: undefined
}
})
return processedInputs
}
const localeMap: Record<string, string> = {
'en-US': 'en',
'zh-Hans': 'zh-cn',
@@ -1,8 +1,13 @@
import type {
CommonNodeType,
InputVarType,
UploadFileSetting,
ValueSelector,
} from '@/app/components/workflow/types'
import {
InputVarType,
SupportUploadFileTypes,
} from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
export type HumanInputNodeType = CommonNodeType & {
delivery_methods: DeliveryMethod[]
@@ -59,14 +64,137 @@ export type UserAction = {
button_style: UserActionButtonType
}
export type FormInputItemDefault = {
type StringDefault = {
selector: ValueSelector
type: 'variable' | 'constant'
value: string
}
export type FormInputItem = {
type: InputVarType
type StringListSource = {
selector: ValueSelector
type: 'variable' | 'constant'
value: string[]
}
// Preserve the old export during the transition to the new schema names.
export type FormInputItemDefault = StringDefault
type BaseFormInputItem = {
output_variable_name: string
default: FormInputItemDefault
}
export type ParagraphFormInput = BaseFormInputItem & {
type: InputVarType.paragraph
default: StringDefault
}
export type SelectFormInput = BaseFormInputItem & {
type: InputVarType.select
option_source: StringListSource
}
type SharedFileFormInput = Pick<
UploadFileSetting,
'allowed_file_extensions' | 'allowed_file_types' | 'allowed_file_upload_methods'
>
export type FileFormInput = BaseFormInputItem & SharedFileFormInput & {
type: InputVarType.singleFile
}
export type FileListFormInput = BaseFormInputItem & SharedFileFormInput & {
type: InputVarType.multiFiles
number_limits?: UploadFileSetting['number_limits']
}
export type FormInputItem
= | ParagraphFormInput
| SelectFormInput
| FileFormInput
| FileListFormInput
export const isParagraphFormInput = (
input: FormInputItem,
): input is ParagraphFormInput => {
return input.type === InputVarType.paragraph
}
export const isSelectFormInput = (
input: FormInputItem,
): input is SelectFormInput => {
return input.type === InputVarType.select
}
export const isFileFormInput = (
input: FormInputItem,
): input is FileFormInput => {
return input.type === InputVarType.singleFile
}
export const isFileListFormInput = (
input: FormInputItem,
): input is FileListFormInput => {
return input.type === InputVarType.multiFiles
}
export const createDefaultParagraphFormInput = (
output_variable_name = '',
): ParagraphFormInput => ({
type: InputVarType.paragraph,
output_variable_name,
default: {
type: 'constant',
selector: [],
value: '',
},
})
const createDefaultSelectFormInput = (
output_variable_name = '',
): SelectFormInput => ({
type: InputVarType.select,
output_variable_name,
option_source: {
type: 'constant',
selector: [],
value: [],
},
})
const createDefaultFileFormInput = (
output_variable_name = '',
): FileFormInput => ({
type: InputVarType.singleFile,
output_variable_name,
allowed_file_extensions: [],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
const createDefaultFileListFormInput = (
output_variable_name = '',
): FileListFormInput => ({
type: InputVarType.multiFiles,
output_variable_name,
allowed_file_extensions: [],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 5,
})
export const createDefaultFormInputByType = (
type: FormInputItem['type'],
output_variable_name = '',
): FormInputItem => {
switch (type) {
case InputVarType.select:
return createDefaultSelectFormInput(output_variable_name)
case InputVarType.singleFile:
return createDefaultFileFormInput(output_variable_name)
case InputVarType.multiFiles:
return createDefaultFileListFormInput(output_variable_name)
case InputVarType.paragraph:
default:
return createDefaultParagraphFormInput(output_variable_name)
}
}
+2
View File
@@ -39,6 +39,8 @@ export type AgentLogItemWithChildren = AgentLogItem & {
children: AgentLogItemWithChildren[]
}
export type HumanInputResolvedValue = string | FileResponse | FileResponse[]
export type NodeTracing = {
id: string
index: number