Compare commits

...
14 changed files with 863 additions and 83 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()
),
+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)
+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()
@@ -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:
"""
@@ -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,53 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
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) => {
const { t } = useTranslation()
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">
{t('humanInput.submissionID', { id: submissionID, ns: 'share' })}
</div>
)}
</div>
<BrandingFooter
removeWebappBrand={removeWebappBrand}
replaceWebappLogo={replaceWebappLogo}
/>
</div>
</div>
)
}
export default FormStatusCard
@@ -1,7 +1,7 @@
'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'
@@ -25,7 +25,10 @@ import { useParams } from '@/next/navigation'
import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share'
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>
@@ -46,6 +49,11 @@ const FormContent = () => {
const { data: formData, isLoading, error } = useGetHumanInputForm(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'
@@ -99,29 +107,14 @@ 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}
/>
)
}
@@ -236,53 +229,14 @@ const FormContent = () => {
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,108 @@
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(() => {
const contentCounts = new Map<string, number>()
return splitByOutputVar(formData.form_content).map((content) => {
const occurrence = (contentCounts.get(content) || 0) + 1
contentCounts.set(content, occurrence)
return {
key: `${content}-${occurrence}`,
content,
}
})
}, [formData.form_content])
const handleInputsChange = (name: string, value: HumanInputFieldValue) => {
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(({ key, content }) => (
<ContentItem
key={key}
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