diff --git a/api/controllers/common/fields.py b/api/controllers/common/fields.py index 6a0b35aa633..393f36cbc9d 100644 --- a/api/controllers/common/fields.py +++ b/api/controllers/common/fields.py @@ -183,6 +183,7 @@ class Site(BaseModel): description: str | None = None copyright: str | None = None privacy_policy: str | None = None + input_placeholder: str | None = None custom_disclaimer: str | None = None default_language: str show_workflow_steps: bool diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index ff1eeeb8907..7ed527d6876 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -345,6 +345,7 @@ class Site(ResponseModel): customize_domain: str | None = None copyright: str | None = None privacy_policy: str | None = None + input_placeholder: str | None = None custom_disclaimer: str | None = None customize_token_strategy: str | None = None prompt_public: bool | None = None diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index edc79f8fbc6..1c3fa5de5f8 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -40,6 +40,7 @@ class AppSiteUpdatePayload(BaseModel): customize_domain: str | None = Field(default=None) copyright: str | None = Field(default=None) privacy_policy: str | None = Field(default=None) + input_placeholder: str | None = Field(default=None) custom_disclaimer: str | None = Field(default=None) customize_token_strategy: Literal["must", "allow", "not_allow"] | None = Field(default=None) prompt_public: bool | None = Field(default=None) @@ -66,6 +67,7 @@ class AppSiteResponse(ResponseModel): customize_domain: str | None = None copyright: str | None = None privacy_policy: str | None = None + input_placeholder: str | None = None custom_disclaimer: str | None = None customize_token_strategy: str prompt_public: bool @@ -110,6 +112,7 @@ class AppSite(Resource): "customize_domain", "copyright", "privacy_policy", + "input_placeholder", "custom_disclaimer", "customize_token_strategy", "prompt_public", diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 5e0f8326517..0f0d8694f51 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -14,7 +14,7 @@ from fields.base import ResponseModel from libs.helper import AppIconUrlField from models.account import TenantStatus from models.model import App, EndUser, Site -from services.feature_service import FeatureService +from services.feature_service import FeatureModel, FeatureService class AppSiteModelConfigResponse(ResponseModel): @@ -38,6 +38,7 @@ class AppSiteResponse(ResponseModel): description: str | None = None copyright: str | None = None privacy_policy: str | None = None + input_placeholder: str | None = None custom_disclaimer: str | None = None default_language: str | None = None prompt_public: bool | None = None @@ -84,6 +85,7 @@ class AppSiteApi(WebApiResource): "description": fields.String, "copyright": fields.String, "privacy_policy": fields.String, + "input_placeholder": fields.String, "custom_disclaimer": fields.String, "default_language": fields.String, "prompt_public": fields.Boolean, @@ -127,9 +129,15 @@ class AppSiteApi(WebApiResource): if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE: raise Forbidden() - can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo + features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True) - return AppSiteInfo(app_model.tenant, app_model, site, end_user.id, can_replace_logo) + return AppSiteInfo( + app_model.tenant, + app_model, + serialize_runtime_site(site, features), + end_user.id, + features.can_replace_logo, + ) class AppSiteInfo: @@ -164,7 +172,23 @@ def serialize_site(site: Site) -> dict[str, Any]: return cast(dict[str, Any], marshal(site, AppSiteApi.site_fields)) +def serialize_runtime_site(site: Site, features: FeatureModel) -> dict[str, Any]: + site_payload = serialize_site(site) + if not features.billing.enabled or features.webapp_copyright_enabled: + return site_payload + + site_payload["copyright"] = None + site_payload["input_placeholder"] = None + return site_payload + + def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict[str, Any]: - can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo - app_site_info = AppSiteInfo(app_model.tenant, app_model, site, end_user_id, can_replace_logo) + features = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True) + app_site_info = AppSiteInfo( + app_model.tenant, + app_model, + serialize_runtime_site(site, features), + end_user_id, + features.can_replace_logo, + ) return cast(dict[str, Any], marshal(app_site_info, AppSiteApi.app_fields)) diff --git a/api/migrations/versions/2026_06_24_1900-a6f1c9d2e8b4_add_input_placeholder_to_sites.py b/api/migrations/versions/2026_06_24_1900-a6f1c9d2e8b4_add_input_placeholder_to_sites.py new file mode 100644 index 00000000000..1f2a57ce194 --- /dev/null +++ b/api/migrations/versions/2026_06_24_1900-a6f1c9d2e8b4_add_input_placeholder_to_sites.py @@ -0,0 +1,26 @@ +"""add input placeholder to sites + +Revision ID: a6f1c9d2e8b4 +Revises: d9e8f7a6b5c4 +Create Date: 2026-06-24 19:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a6f1c9d2e8b4" +down_revision = "d9e8f7a6b5c4" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("sites", schema=None) as batch_op: + batch_op.add_column(sa.Column("input_placeholder", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("sites", schema=None) as batch_op: + batch_op.drop_column("input_placeholder") diff --git a/api/models/model.py b/api/models/model.py index 38d67004de4..05399b07c5f 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -2183,6 +2183,7 @@ class Site(Base): chat_color_theme_inverted: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) copyright = mapped_column(String(255)) privacy_policy = mapped_column(String(255)) + input_placeholder = mapped_column(String(255)) show_workflow_steps: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true")) use_icon_as_answer_icon: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) _custom_disclaimer: Mapped[str] = mapped_column("custom_disclaimer", LongText, default="") diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index fb4f4fb21a8..0debde6c238 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -13925,6 +13925,7 @@ AppMCPServer Status Enum | description | string | | No | | icon | string | | No | | icon_background | string | | No | +| input_placeholder | string | | No | | privacy_policy | string | | No | | prompt_public | boolean | | Yes | | show_workflow_steps | boolean | | Yes | @@ -13952,6 +13953,7 @@ AppMCPServer Status Enum | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | +| input_placeholder | string | | No | | privacy_policy | string | | No | | prompt_public | boolean | | No | | show_workflow_steps | boolean | | No | @@ -19265,6 +19267,7 @@ Simple provider entity response. | icon_background | string | | No | | icon_type | string | | No | | icon_url | string | | Yes | +| input_placeholder | string | | No | | privacy_policy | string | | No | | show_workflow_steps | boolean | | Yes | | title | string | | Yes | diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 8fc5e75e3cf..1018bde6b3e 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -3999,6 +3999,7 @@ Model class for provider with models response. | icon_background | string | | No | | icon_type | string | | No | | icon_url | string | | Yes | +| input_placeholder | string | | No | | privacy_policy | string | | No | | show_workflow_steps | boolean | | Yes | | title | string | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 0f368895ab6..ee50341812a 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1006,6 +1006,7 @@ Returns Server-Sent Events stream. | icon_background | string | | No | | icon_type | string | | No | | icon_url | string | | No | +| input_placeholder | string | | No | | privacy_policy | string | | No | | prompt_public | boolean | | No | | show_workflow_steps | boolean | | No | diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py index be13f993a10..a07fcf40fc2 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py @@ -343,8 +343,13 @@ class TestSiteEndpoints: return flask_app_with_containers def test_site_response_structure(self): - payload = AppSiteUpdatePayload(title="My Site", description="Test site") + payload = AppSiteUpdatePayload( + title="My Site", + description="Test site", + input_placeholder="Ask me anything", + ) assert payload.title == "My Site" + assert payload.input_placeholder == "Ask me anything" def test_site_default_language_validation(self): payload = AppSiteUpdatePayload(default_language="en-US") @@ -365,6 +370,7 @@ class TestSiteEndpoints: site.customize_domain = None site.copyright = None site.privacy_policy = None + site.input_placeholder = None site.custom_disclaimer = "" site.customize_token_strategy = "not_allow" site.prompt_public = False @@ -377,11 +383,13 @@ class TestSiteEndpoints: ) monkeypatch.setattr(site_module, "naive_utc_now", lambda: "now") - with app.test_request_context("/", json={"title": "My Site"}): + with app.test_request_context("/", json={"title": "My Site", "input_placeholder": "Ask me anything"}): result = method(api, SimpleNamespace(id="u1"), app_model=SimpleNamespace(id="app-1")) assert isinstance(result, dict) assert result["title"] == "My Site" + assert result["input_placeholder"] == "Ask me anything" + assert site.input_placeholder == "Ask me anything" def test_app_site_access_token_reset(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = site_module.AppSiteAccessTokenReset() @@ -398,6 +406,7 @@ class TestSiteEndpoints: site.customize_domain = None site.copyright = None site.privacy_policy = None + site.input_placeholder = None site.custom_disclaimer = "" site.customize_token_strategy = "not_allow" site.prompt_public = False diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py index b1a8bb33946..3554ea48b9a 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py @@ -222,7 +222,7 @@ def test_get_human_input_form_resolves_runtime_select_options( ) def mock_get_features(tenant_id: str, exclude_vector_space: bool = False) -> FeatureModel: - features = FeatureModel(can_replace_logo=True) + features = FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True) return features monkeypatch.setattr( diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_site.py b/api/tests/test_containers_integration_tests/controllers/web/test_site.py index 9adb26ff3d2..bdda2d7a056 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_site.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_site.py @@ -13,6 +13,7 @@ from werkzeug.exceptions import Forbidden from controllers.web.site import AppSiteApi, AppSiteInfo from models import Tenant, TenantStatus from models.model import App, AppMode, CustomizeTokenStrategy, Site +from services.feature_service import FeatureModel @pytest.fixture @@ -51,6 +52,7 @@ def _create_site(db_session: Session, app_id: str) -> Site: default_language="en", chat_color_theme="light", chat_color_theme_inverted=False, + input_placeholder="Ask the app", customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, code=f"code-{app_id[-6:]}", prompt_public=False, @@ -70,7 +72,7 @@ class TestAppSiteApi: app_model = _create_app(db_session_with_containers, tenant.id) _create_site(db_session_with_containers, app_model.id) end_user = SimpleNamespace(id="eu-1") - mock_features.return_value = SimpleNamespace(can_replace_logo=False) + mock_features.return_value = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True) with app.test_request_context("/site"): result = AppSiteApi().get(app_model, end_user) @@ -78,6 +80,7 @@ class TestAppSiteApi: assert result["app_id"] == app_model.id assert result["plan"] == "basic" assert result["enable_site"] is True + assert result["site"]["input_placeholder"] == "Ask the app" def test_missing_site_raises_forbidden(self, app: Flask, db_session_with_containers: Session) -> None: app.config["RESTX_MASK_HEADER"] = "X-Fields" @@ -98,7 +101,7 @@ class TestAppSiteApi: app_model = _create_app(db_session_with_containers, tenant.id) _create_site(db_session_with_containers, app_model.id) end_user = SimpleNamespace(id="eu-1") - mock_features.return_value = SimpleNamespace(can_replace_logo=False) + mock_features.return_value = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True) with app.test_request_context("/site"): with pytest.raises(Forbidden): diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index 48a19bb0364..7801fe83336 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -379,6 +379,7 @@ def test_app_detail_with_site_includes_nested_serialization(app_models): title="Public Site", icon_type="image", icon="site-icon", + input_placeholder="Ask anything", created_at=timestamp, updated_at=timestamp, ) @@ -421,6 +422,7 @@ def test_app_detail_with_site_includes_nested_serialization(app_models): assert serialized["model_config"]["retriever_resource"] == {"enabled": True} assert serialized["deleted_tools"][0]["tool_name"] == "search" assert serialized["site"]["icon_url"] == "signed:site-icon" + assert serialized["site"]["input_placeholder"] == "Ask anything" assert serialized["site"]["created_at"] == int(timestamp.timestamp()) assert serialized["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] assert serialized["bound_agent_id"] == "agent-1" diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index 0caeae2cee4..88a7fce7055 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -112,6 +112,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): chat_color_theme_inverted=False, copyright=None, privacy_policy=None, + input_placeholder="Ask me anything", custom_disclaimer=None, prompt_public=False, show_workflow_steps=True, @@ -132,7 +133,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): monkeypatch.setattr( site_module.FeatureService, "get_features", - lambda tenant_id, **_kwargs: SimpleNamespace(can_replace_logo=True), + lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True), ) with app.test_request_context("/api/form/human_input/token-1", method="GET"): @@ -167,6 +168,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): "description": "desc", "copyright": None, "privacy_policy": None, + "input_placeholder": "Ask me anything", "custom_disclaimer": None, "default_language": "en", "prompt_public": False, @@ -186,6 +188,72 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask): limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10") +def test_serialize_app_site_payload_masks_paid_webapp_fields_when_feature_disabled(monkeypatch: pytest.MonkeyPatch): + """Runtime site payload hides paid-only fields for plans that cannot use them.""" + + tenant = SimpleNamespace(id="tenant-1", plan="sandbox", custom_config_dict={}) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True) + site_model = SimpleNamespace( + title="My Site", + icon_type="emoji", + icon="robot", + icon_background="#fff", + description="desc", + default_language="en", + chat_color_theme="light", + chat_color_theme_inverted=False, + copyright="Dify", + privacy_policy=None, + input_placeholder="Ask me anything", + custom_disclaimer=None, + prompt_public=False, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + features = FeatureModel(can_replace_logo=False, webapp_copyright_enabled=False) + features.billing.enabled = True + monkeypatch.setattr(site_module.FeatureService, "get_features", lambda tenant_id, **_kwargs: features) + + payload = site_module.serialize_app_site_payload(app_model, site_model, end_user_id=None) + + assert payload["site"]["copyright"] is None + assert payload["site"]["input_placeholder"] is None + + +def test_serialize_app_site_payload_keeps_paid_webapp_fields_when_billing_disabled(monkeypatch: pytest.MonkeyPatch): + """Self-hosted community runtimes keep site fields because billing is not enforcing the paid gate.""" + + tenant = SimpleNamespace(id="tenant-1", plan="basic", custom_config_dict={}) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True) + site_model = SimpleNamespace( + title="My Site", + icon_type="emoji", + icon="robot", + icon_background="#fff", + description="desc", + default_language="en", + chat_color_theme="light", + chat_color_theme_inverted=False, + copyright="Dify", + privacy_policy=None, + input_placeholder="Ask me anything", + custom_disclaimer=None, + prompt_public=False, + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + monkeypatch.setattr( + site_module.FeatureService, + "get_features", + lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=False, webapp_copyright_enabled=False), + ) + + payload = site_module.serialize_app_site_payload(app_model, site_model, end_user_id=None) + + assert payload["site"]["copyright"] == "Dify" + assert payload["site"]["input_placeholder"] == "Ask me anything" + + def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, app: Flask): """GET returns variable-backed select options resolved from runtime state.""" @@ -256,6 +324,7 @@ def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, a chat_color_theme_inverted=False, copyright=None, privacy_policy=None, + input_placeholder="Ask me anything", custom_disclaimer=None, prompt_public=False, show_workflow_steps=True, @@ -380,6 +449,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F chat_color_theme_inverted=False, copyright=None, privacy_policy=None, + input_placeholder="Ask me anything", custom_disclaimer=None, prompt_public=False, show_workflow_steps=True, @@ -397,7 +467,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F monkeypatch.setattr( site_module.FeatureService, "get_features", - lambda tenant_id, **_kwargs: SimpleNamespace(can_replace_logo=True), + lambda tenant_id, **_kwargs: FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True), ) with app.test_request_context("/api/form/human_input/token-1", method="GET"): @@ -432,6 +502,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F "description": "desc", "copyright": None, "privacy_policy": None, + "input_placeholder": "Ask me anything", "custom_disclaimer": None, "default_language": "en", "prompt_public": False, diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 753aa00f187..dd17f02f97c 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -891,14 +891,6 @@ "count": 3 } }, - "web/app/components/app/overview/settings/index.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/app/overview/workflow-hidden-input-fields.tsx": { "no-restricted-imports": { "count": 1 diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 4026c0d35eb..60f760ca76e 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -424,6 +424,7 @@ export type Site = { icon_background?: string | null icon_type?: string | null readonly icon_url: string | null + input_placeholder?: string | null privacy_policy?: string | null show_workflow_steps: boolean title: string @@ -1560,6 +1561,7 @@ export type SiteWritable = { icon?: string | null icon_background?: string | null icon_type?: string | null + input_placeholder?: string | null privacy_policy?: string | null show_workflow_steps: boolean title: string diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 45a68beb8a6..1e188dd5e73 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -203,6 +203,7 @@ export const zSite = z.object({ icon_background: z.string().nullish(), icon_type: z.string().nullish(), icon_url: z.string().nullable(), + input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), show_workflow_steps: z.boolean(), title: z.string(), @@ -2126,6 +2127,7 @@ export const zSiteWritable = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), + input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), show_workflow_steps: z.boolean(), title: z.string(), diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 88fb429f4f9..0c9d22ed995 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -580,6 +580,7 @@ export type AppSiteUpdatePayload = { icon?: string | null icon_background?: string | null icon_type?: string | null + input_placeholder?: string | null privacy_policy?: string | null prompt_public?: boolean | null show_workflow_steps?: boolean | null @@ -598,6 +599,7 @@ export type AppSiteResponse = { description?: string | null icon?: string | null icon_background?: string | null + input_placeholder?: string | null privacy_policy?: string | null prompt_public: boolean show_workflow_steps: boolean @@ -1242,6 +1244,7 @@ export type Site = { icon_background?: string | null icon_type?: string | null readonly icon_url: string | null + input_placeholder?: string | null privacy_policy?: string | null show_workflow_steps: boolean title: string @@ -2691,6 +2694,7 @@ export type SiteWritable = { icon?: string | null icon_background?: string | null icon_type?: string | null + input_placeholder?: string | null privacy_policy?: string | null show_workflow_steps: boolean title: string diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index c881edbc9f1..73e98b8837a 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -358,6 +358,7 @@ export const zAppSiteUpdatePayload = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), + input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), prompt_public: z.boolean().nullish(), show_workflow_steps: z.boolean().nullish(), @@ -379,6 +380,7 @@ export const zAppSiteResponse = z.object({ description: z.string().nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), + input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), prompt_public: z.boolean(), show_workflow_steps: z.boolean(), @@ -858,6 +860,7 @@ export const zSite = z.object({ icon_background: z.string().nullish(), icon_type: z.string().nullish(), icon_url: z.string().nullable(), + input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), show_workflow_steps: z.boolean(), title: z.string(), @@ -3546,6 +3549,7 @@ export const zSiteWritable = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), + input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), show_workflow_steps: z.boolean(), title: z.string(), diff --git a/packages/contracts/generated/api/console/trial-apps/types.gen.ts b/packages/contracts/generated/api/console/trial-apps/types.gen.ts index 894da1102ee..93eb2362d59 100644 --- a/packages/contracts/generated/api/console/trial-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/types.gen.ts @@ -96,6 +96,7 @@ export type Site = { icon_background?: string | null icon_type?: string | null readonly icon_url: string | null + input_placeholder?: string | null privacy_policy?: string | null show_workflow_steps: boolean title: string @@ -346,6 +347,7 @@ export type SiteWritable = { icon?: string | null icon_background?: string | null icon_type?: string | null + input_placeholder?: string | null privacy_policy?: string | null show_workflow_steps: boolean title: string diff --git a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts index b8768790ef9..7d8a11abe0b 100644 --- a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts @@ -53,6 +53,7 @@ export const zSite = z.object({ icon_background: z.string().nullish(), icon_type: z.string().nullish(), icon_url: z.string().nullable(), + input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), show_workflow_steps: z.boolean(), title: z.string(), @@ -430,6 +431,7 @@ export const zSiteWritable = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), + input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), show_workflow_steps: z.boolean(), title: z.string(), diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index 97921643514..8ab35c133e3 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -1463,6 +1463,7 @@ export type Site = { icon_background?: string | null icon_type?: string | null readonly icon_url: string | null + input_placeholder?: string | null privacy_policy?: string | null show_workflow_steps: boolean title: string @@ -1689,6 +1690,7 @@ export type SiteWritable = { icon?: string | null icon_background?: string | null icon_type?: string | null + input_placeholder?: string | null privacy_policy?: string | null show_workflow_steps: boolean title: string diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 6ccc5671cb2..00edc06ef95 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -1752,6 +1752,7 @@ export const zSite = z.object({ icon_background: z.string().nullish(), icon_type: z.string().nullish(), icon_url: z.string().nullable(), + input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), show_workflow_steps: z.boolean(), title: z.string(), @@ -2311,6 +2312,7 @@ export const zSiteWritable = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), + input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), show_workflow_steps: z.boolean(), title: z.string(), diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 722b3042841..7ed46330f11 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -80,6 +80,7 @@ export type AppSiteResponse = { icon_background?: string | null icon_type?: string | null icon_url?: string | null + input_placeholder?: string | null privacy_policy?: string | null prompt_public?: boolean | null show_workflow_steps?: boolean | null diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index d555ad5f85c..d6ccba0a170 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -73,6 +73,7 @@ export const zAppSiteResponse = z.object({ icon_background: z.string().nullish(), icon_type: z.string().nullish(), icon_url: z.string().nullish(), + input_placeholder: z.string().nullish(), privacy_policy: z.string().nullish(), prompt_public: z.boolean().nullish(), show_workflow_steps: z.boolean().nullish(), diff --git a/web/app/components/app/overview/settings/__tests__/index.spec.tsx b/web/app/components/app/overview/settings/__tests__/index.spec.tsx index b69298268a2..280b35c5437 100644 --- a/web/app/components/app/overview/settings/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/settings/__tests__/index.spec.tsx @@ -53,14 +53,6 @@ const mockSetShowPricingModal = vi.fn() const mockSetShowAccountSettingModal = vi.fn() const mockUseProviderContext = vi.fn<() => ProviderContextState>() -vi.mock('@/config', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - IS_CLOUD_EDITION: true, - } -}) - const buildModalContext = (): ModalContextState => ({ setShowAccountSettingModal: mockSetShowAccountSettingModal, setShowModerationSettingModal: vi.fn(), @@ -109,6 +101,7 @@ const mockAppInfo = { copyright: '© Dify', privacy_policy: '', custom_disclaimer: 'Disclaimer', + input_placeholder: 'Ask me anything', default_language: 'en-US', show_workflow_steps: true, use_icon_as_answer_icon: true, @@ -127,6 +120,8 @@ const renderSettingsModal = (appInfo = mockAppInfo) => render( />, ) +const inputPlaceholderName = 'appOverview.overview.appInfo.settings.more.inputPlaceholder' + describe('SettingsModal', () => { beforeEach(() => { toastMocks.call.mockClear() @@ -139,7 +134,7 @@ describe('SettingsModal', () => { enableBilling: true, plan: { ...baseProviderContextValue.plan, - type: Plan.sandbox, + type: Plan.professional, }, webappCopyrightEnabled: true, }) @@ -157,6 +152,7 @@ describe('SettingsModal', () => { fireEvent.click(showMoreEntry) await waitFor(() => { + expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toBeInTheDocument() expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.copyRightPlaceholder')).toBeInTheDocument() expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).toBeInTheDocument() }) @@ -221,6 +217,7 @@ describe('SettingsModal', () => { copyright: mockAppInfo.site.copyright, privacy_policy: mockAppInfo.site.privacy_policy, custom_disclaimer: mockAppInfo.site.custom_disclaimer, + input_placeholder: mockAppInfo.site.input_placeholder, icon_type: 'emoji', icon: mockAppInfo.site.icon, icon_background: mockAppInfo.site.icon_background, @@ -278,11 +275,115 @@ describe('SettingsModal', () => { expect(screen.getByText('appOverview.overview.appInfo.settings.more.entry')).toBeInTheDocument() }) - it('should open the pricing modal from the copyright upgrade badge for sandbox plans', async () => { + it('should reset the input placeholder when app info changes while open', () => { + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry')) + expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue('Ask me anything') + + rerender( + , + ) + + fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry')) + expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue('Updated prompt') + }) + + it('should display paid webapp settings as defaults for Cloud sandbox plans', async () => { + mockOnSave.mockResolvedValueOnce(undefined) + mockUseProviderContext.mockReturnValue({ + ...baseProviderContextValue, + enableBilling: true, + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + }, + webappCopyrightEnabled: true, + }) + renderSettingsModal() fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry')) - fireEvent.click(await screen.findByText('billing.upgradeBtn.encourageShort')) + + const inputPlaceholder = screen.getByRole('textbox', { name: inputPlaceholderName }) + expect(inputPlaceholder).toBeDisabled() + expect(inputPlaceholder).toHaveValue('') + expect(screen.queryByPlaceholderText('appOverview.overview.appInfo.settings.more.copyRightPlaceholder')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('common.operation.save')) + + await waitFor(() => { + expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ + copyright: '', + input_placeholder: '', + })) + }) + }) + + it('should keep the input placeholder editable when billing is disabled', async () => { + mockOnSave.mockResolvedValueOnce(undefined) + mockUseProviderContext.mockReturnValue({ + ...baseProviderContextValue, + enableBilling: false, + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + }, + webappCopyrightEnabled: false, + }) + + renderSettingsModal() + + fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry')) + const inputPlaceholder = screen.getByRole('textbox', { name: inputPlaceholderName }) + fireEvent.change(inputPlaceholder, { target: { value: 'Self-hosted prompt' } }) + fireEvent.click(screen.getByText('common.operation.save')) + + expect(inputPlaceholder).toBeEnabled() + expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() + await waitFor(() => { + expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ + copyright: '', + input_placeholder: 'Self-hosted prompt', + })) + }) + }) + + it('should open the pricing modal from the copyright upgrade badge for sandbox plans', async () => { + mockUseProviderContext.mockReturnValue({ + ...baseProviderContextValue, + enableBilling: true, + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + }, + webappCopyrightEnabled: false, + }) + + renderSettingsModal() + + fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry')) + fireEvent.click((await screen.findAllByText('billing.upgradeBtn.encourageShort'))[0]!) expect(mockSetShowPricingModal).toHaveBeenCalled() expect(mockSetShowAccountSettingModal).not.toHaveBeenCalled() diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index 703e88dcee0..5c1a52304ef 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -19,8 +19,7 @@ import AppIcon from '@/app/components/base/app-icon' import AppIconPicker from '@/app/components/base/app-icon-picker' import Divider from '@/app/components/base/divider' import { PremiumBadgeButton } from '@/app/components/base/premium-badge' -import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' -import { IS_CLOUD_EDITION } from '@/config' +import { Plan } from '@/app/components/billing/type' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { languages } from '@/i18n-config/language' @@ -46,6 +45,7 @@ export type ConfigParams = { copyright: string privacy_policy: string custom_disclaimer: string + input_placeholder: string icon_type: AppIconType icon: string icon_background?: string @@ -54,6 +54,13 @@ export type ConfigParams = { enable_sso?: boolean } +const INPUT_PLACEHOLDER_MAX_LENGTH = 64 +const INPUT_PLACEHOLDER_SUPPORTED_MODES: ReadonlyArray = [ + AppModeEnum.CHAT, + AppModeEnum.AGENT_CHAT, + AppModeEnum.ADVANCED_CHAT, +] + const prefixSettings = 'overview.appInfo.settings' type SelectOption = { value: Language @@ -71,6 +78,7 @@ const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => { copyright, privacy_policy, custom_disclaimer, + input_placeholder, show_workflow_steps, use_icon_as_answer_icon, } = appInfo.site @@ -84,6 +92,7 @@ const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => { copyrightSwitchValue: !!copyright, privacyPolicy: privacy_policy, customDisclaimer: custom_disclaimer, + inputPlaceholder: input_placeholder ?? '', show_workflow_steps, use_icon_as_answer_icon, enable_sso: appInfo.enable_sso, @@ -108,6 +117,7 @@ const getSettingsResetKey = (appInfo: ISettingsModalProps['appInfo']) => JSON.st appInfo.site.copyright, appInfo.site.privacy_policy, appInfo.site.custom_disclaimer, + appInfo.site.input_placeholder, appInfo.site.default_language, appInfo.site.icon_type, appInfo.site.icon, @@ -125,6 +135,7 @@ const SettingsModal: FC = ({ onSave, }) => { const [isShowMore, setIsShowMore] = useState(false) + const [inputPlaceholderFocused, setInputPlaceholderFocused] = useState(false) const { default_language } = appInfo.site const nextInputInfo = createInputInfo(appInfo) const nextAppIcon = createAppIcon(appInfo) @@ -140,10 +151,51 @@ const SettingsModal: FC = ({ const [previousSettingsResetKey, setPreviousSettingsResetKey] = useState(settingsResetKey) const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext() - const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() - const isFreePlan = plan.type === 'sandbox' - const showUpgradeAction = IS_CLOUD_EDITION && enableBilling && isFreePlan + const { setShowPricingModal } = useModalContext() + const isCloudSandboxPlan = enableBilling && plan.type === Plan.sandbox const selectedLanguage = LANGUAGE_OPTIONS.find(item => item.value === language) + const inputPlaceholderLabelId = React.useId() + const inputPlaceholderDescriptionId = React.useId() + + const inputPlaceholderValue = isCloudSandboxPlan ? '' : (inputInfo.inputPlaceholder ?? '') + const copyrightSwitchValue = isCloudSandboxPlan ? false : inputInfo.copyrightSwitchValue + // Editable + has value + blurred -> preview as gray placeholder text (matches how it'll render in chat). + const showInputPlaceholderPreview = !isCloudSandboxPlan && inputPlaceholderValue.trim().length > 0 && !inputPlaceholderFocused + const inputPlaceholderField = ( +
+ setInputInfo(item => ({ ...item, inputPlaceholder: e.target.value }))} + onFocus={() => setInputPlaceholderFocused(true)} + onBlur={() => setInputPlaceholderFocused(false)} + disabled={isCloudSandboxPlan} + maxLength={INPUT_PLACEHOLDER_MAX_LENGTH} + autoComplete="off" + aria-labelledby={inputPlaceholderLabelId} + aria-describedby={inputPlaceholderDescriptionId} + placeholder={t(`${prefixSettings}.more.inputPlaceholderPlaceholder`, { ns: 'appOverview' }) as string} + className={cn( + 'flex-1 bg-transparent body-md-regular outline-hidden', + showInputPlaceholderPreview ? 'text-text-placeholder' : 'text-text-primary', + isCloudSandboxPlan && 'cursor-not-allowed', + )} + /> +
+ ) const handleLanguageChange = (nextValue: string | null) => { const nextLanguage = LANGUAGE_OPTIONS.find(item => item.value === nextValue) @@ -151,11 +203,8 @@ const SettingsModal: FC = ({ setLanguage(nextLanguage.value) } const handlePlanClick = useCallback(() => { - if (isFreePlan) - setShowPricingModal() - else - setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) - }, [isFreePlan, setShowAccountSettingModal, setShowPricingModal]) + setShowPricingModal() + }, [setShowPricingModal]) const shouldResetForm = isShow && (!previousIsShow || settingsResetKey !== previousSettingsResetKey) if (isShow !== previousIsShow || shouldResetForm) { @@ -215,13 +264,16 @@ const SettingsModal: FC = ({ chat_color_theme: inputInfo.chatColorTheme, chat_color_theme_inverted: inputInfo.chatColorThemeInverted, prompt_public: false, - copyright: !webappCopyrightEnabled + copyright: (!webappCopyrightEnabled || isCloudSandboxPlan) ? '' - : inputInfo.copyrightSwitchValue + : copyrightSwitchValue ? inputInfo.copyright : '', privacy_policy: inputInfo.privacyPolicy, custom_disclaimer: inputInfo.customDisclaimer, + input_placeholder: (isCloudSandboxPlan || !INPUT_PLACEHOLDER_SUPPORTED_MODES.includes(appInfo.mode)) + ? '' + : (inputInfo.inputPlaceholder ?? '').slice(0, INPUT_PLACEHOLDER_MAX_LENGTH), icon_type: appIcon.type, icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, @@ -373,7 +425,7 @@ const SettingsModal: FC = ({ {/* more settings switch */} {!isShowMore && ( -
setIsShowMore(true)}> +
+ )} {/* more settings */} {isShowMore && ( <> + {/* input placeholder — Chatbot / Agent / Chatflow only */} + {INPUT_PLACEHOLDER_SUPPORTED_MODES.includes(appInfo.mode) && ( +
+
+
+
{t(`${prefixSettings}.more.inputPlaceholder`, { ns: 'appOverview' })}
+ {isCloudSandboxPlan && ( +
+ + +
+ )} +
+
+

{t(`${prefixSettings}.more.inputPlaceholderTip`, { ns: 'appOverview' })}

+ {isCloudSandboxPlan + ? ( + + + + {t(`${prefixSettings}.more.inputPlaceholderTooltip`, { ns: 'appOverview' })} + + + ) + : inputPlaceholderField} + {!isCloudSandboxPlan && ( +
+ {`${inputInfo.inputPlaceholder?.length ?? 0} / ${INPUT_PLACEHOLDER_MAX_LENGTH}`} +
+ )} +
+ )} {/* copyright */}
{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}
{/* upgrade button */} - {showUpgradeAction && ( + {isCloudSandboxPlan && (

{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}

- {inputInfo.copyrightSwitchValue && ( + {copyrightSwitchValue && ( ({ })) vi.mock('../chat-input-area', () => ({ - default: ({ disabled, readonly }: { disabled?: boolean, readonly?: boolean }) => ( + default: ({ + customPlaceholder, + disabled, + readonly, + }: { + customPlaceholder?: string + disabled?: boolean + readonly?: boolean + }) => (
@@ -828,6 +837,14 @@ describe('Chat', () => { expect(screen.getByTestId('chat-input-area')).toBeInTheDocument() }) + it('should pass appData.site.input_placeholder as customPlaceholder to ChatInputArea', () => { + renderChat({ + appData: { site: { input_placeholder: 'Ask the assistant' } } as unknown as ChatProps['appData'], + noChatInput: false, + }) + expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-custom-placeholder', 'Ask the assistant') + }) + it('should pass Bot as default botName when appData.site.title is missing', () => { renderChat({ appData: {} as unknown as ChatProps['appData'], diff --git a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx index 8896533f282..cdc5583daef 100644 --- a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx @@ -299,6 +299,21 @@ describe('ChatInputArea', () => { expect(getTextarea()!).toHaveAttribute('placeholder', expect.stringContaining('botName')) }) + it('should render the custom placeholder when provided', () => { + render() + expect(screen.getByPlaceholderText('Ask the assistant')).toBeInTheDocument() + }) + + it('should fall back to the readonly placeholder when readonly has a custom placeholder', () => { + render() + expect(screen.getByPlaceholderText(/inputDisabledPlaceholder/i)).toBeInTheDocument() + }) + + it('should fall back to the default placeholder when custom placeholder is blank', () => { + render() + expect(getTextarea()!).toBeInTheDocument() + }) + it('should apply disabled styles when the disabled prop is true', () => { const { container } = render() expect(container.firstChild).toHaveClass('opacity-50') diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx index ea9e631cba9..7b4be33f5e4 100644 --- a/web/app/components/base/chat/chat/chat-input-area/index.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx @@ -23,6 +23,7 @@ import Operation from './operation' type ChatInputAreaProps = { readonly?: boolean botName?: string + customPlaceholder?: string showFeatureBar?: boolean showFileUpload?: boolean featureBarReadonly?: boolean @@ -44,7 +45,7 @@ type ChatInputAreaProps = { */ sendOnEnter?: boolean } -const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendOnEnter = true }: ChatInputAreaProps) => { +const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendOnEnter = true }: ChatInputAreaProps) => { const { t } = useTranslation() const { wrapperRef, textareaRef, textValueRef, holdSpaceRef, handleTextareaResize, isMultipleLine } = useTextAreaHeight() const [query, setQuery] = useState('') @@ -147,7 +148,7 @@ const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, feat
{query}
-