diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index bd47abc710..a08e7aacae 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -99,7 +99,7 @@ jobs: - name: Set up dotenvs run: | cp docker/.env.example docker/.env - cp docker/middleware.env.example docker/middleware.env + cp docker/envs/middleware.env.example docker/middleware.env - name: Expose Service Ports run: sh .github/workflows/expose_service_ports.sh diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 65f0149a74..9d3ccb34b2 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -37,7 +37,7 @@ jobs: - name: Prepare middleware env run: | cd docker - cp middleware.env.example middleware.env + cp envs/middleware.env.example middleware.env - name: Set up Middlewares uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 @@ -87,7 +87,7 @@ jobs: - name: Prepare middleware env for MySQL run: | cd docker - cp middleware.env.example middleware.env + cp envs/middleware.env.example middleware.env sed -i 's/DB_TYPE=postgresql/DB_TYPE=mysql/' middleware.env sed -i 's/DB_HOST=db_postgres/DB_HOST=db_mysql/' middleware.env sed -i 's/DB_PORT=5432/DB_PORT=3306/' middleware.env diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 8071d6204d..f624e8f872 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -57,7 +57,7 @@ jobs: - '.github/workflows/api-tests.yml' - '.github/workflows/expose_service_ports.sh' - 'docker/.env.example' - - 'docker/middleware.env.example' + - 'docker/envs/middleware.env.example' - 'docker/docker-compose.middleware.yaml' - 'docker/docker-compose-template.yaml' - 'docker/generate_docker_compose' @@ -84,7 +84,7 @@ jobs: - 'pnpm-workspace.yaml' - '.nvmrc' - 'docker/docker-compose.middleware.yaml' - - 'docker/middleware.env.example' + - 'docker/envs/middleware.env.example' - '.github/workflows/web-e2e.yml' - '.github/actions/setup-web/**' vdb: @@ -94,7 +94,7 @@ jobs: - '.github/workflows/vdb-tests.yml' - '.github/workflows/expose_service_ports.sh' - 'docker/.env.example' - - 'docker/middleware.env.example' + - 'docker/envs/middleware.env.example' - 'docker/docker-compose.yaml' - 'docker/docker-compose-template.yaml' - 'docker/generate_docker_compose' @@ -116,7 +116,7 @@ jobs: - '.github/workflows/db-migration-test.yml' - '.github/workflows/expose_service_ports.sh' - 'docker/.env.example' - - 'docker/middleware.env.example' + - 'docker/envs/middleware.env.example' - 'docker/docker-compose.middleware.yaml' - 'docker/docker-compose-template.yaml' - 'docker/generate_docker_compose' diff --git a/.github/workflows/vdb-tests-full.yml b/.github/workflows/vdb-tests-full.yml index 5c241af5c5..1405eb4eeb 100644 --- a/.github/workflows/vdb-tests-full.yml +++ b/.github/workflows/vdb-tests-full.yml @@ -51,7 +51,7 @@ jobs: - name: Set up dotenvs run: | cp docker/.env.example docker/.env - cp docker/middleware.env.example docker/middleware.env + cp docker/envs/middleware.env.example docker/middleware.env - name: Expose Service Ports run: sh .github/workflows/expose_service_ports.sh diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml index 38ec96f00f..cdcdcb27d7 100644 --- a/.github/workflows/vdb-tests.yml +++ b/.github/workflows/vdb-tests.yml @@ -48,7 +48,7 @@ jobs: - name: Set up dotenvs run: | cp docker/.env.example docker/.env - cp docker/middleware.env.example docker/middleware.env + cp docker/envs/middleware.env.example docker/middleware.env - name: Expose Service Ports run: sh .github/workflows/expose_service_ports.sh diff --git a/README.md b/README.md index e6f8d84931..b6cbb0e126 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,10 @@ The easiest way to start the Dify server is through [Docker Compose](docker/dock ```bash cd dify cd docker -./dify-compose up -d +cp .env.example .env +docker compose up -d ``` -On Windows PowerShell, run `.\dify-compose.ps1 up -d` from the `docker` directory. - After running, you can access the Dify dashboard in your browser at [http://localhost/install](http://localhost/install) and start the initialization process. #### Seeking help @@ -138,7 +137,7 @@ Star Dify on GitHub and be instantly notified of new releases. ### Custom configurations -If you need to customize the configuration, add only the values you want to override to `docker/.env`. The default values live in [`docker/.env.default`](docker/.env.default), and the full reference remains in [`docker/.env.example`](docker/.env.example). After making any changes, re-run `./dify-compose up -d` or `.\dify-compose.ps1 up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments). +If you need to customize the configuration, edit `docker/.env`. The essential startup defaults live in [`docker/.env.example`](docker/.env.example), and optional advanced variables are split under `docker/envs/` by theme. After making any changes, re-run `docker compose up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments). ### Metrics Monitoring with Grafana diff --git a/api/.env.example b/api/.env.example index f6f65011ea..56ba8a6c5d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -98,6 +98,8 @@ DB_DATABASE=dify SQLALCHEMY_POOL_PRE_PING=true SQLALCHEMY_POOL_TIMEOUT=30 +# Connection pool reset behavior on return +SQLALCHEMY_POOL_RESET_ON_RETURN=rollback # Storage configuration # use for store upload files, private keys... @@ -381,7 +383,7 @@ VIKINGDB_ACCESS_KEY=your-ak VIKINGDB_SECRET_KEY=your-sk VIKINGDB_REGION=cn-shanghai VIKINGDB_HOST=api-vikingdb.xxx.volces.com -VIKINGDB_SCHEMA=http +VIKINGDB_SCHEME=http VIKINGDB_CONNECTION_TIMEOUT=30 VIKINGDB_SOCKET_TIMEOUT=30 @@ -432,8 +434,6 @@ UPLOAD_FILE_EXTENSION_BLACKLIST= # Model configuration MULTIMODAL_SEND_FORMAT=base64 -PROMPT_GENERATION_MAX_TOKENS=512 -CODE_GENERATION_MAX_TOKENS=1024 PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false # Mail configuration, support: resend, smtp, sendgrid diff --git a/api/configs/middleware/__init__.py b/api/configs/middleware/__init__.py index c392b8840f..ee8b93aa9f 100644 --- a/api/configs/middleware/__init__.py +++ b/api/configs/middleware/__init__.py @@ -114,7 +114,7 @@ class SQLAlchemyEngineOptionsDict(TypedDict): pool_pre_ping: bool connect_args: dict[str, str] pool_use_lifo: bool - pool_reset_on_return: None + pool_reset_on_return: Literal["commit", "rollback", None] pool_timeout: int @@ -223,6 +223,11 @@ class DatabaseConfig(BaseSettings): default=30, ) + SQLALCHEMY_POOL_RESET_ON_RETURN: Literal["commit", "rollback", None] = Field( + description="Connection pool reset behavior on return. Options: 'commit', 'rollback', or None", + default="rollback", + ) + RETRIEVAL_SERVICE_EXECUTORS: NonNegativeInt = Field( description="Number of processes for the retrieval service, default to CPU cores.", default=os.cpu_count() or 1, @@ -252,7 +257,7 @@ class DatabaseConfig(BaseSettings): "pool_pre_ping": self.SQLALCHEMY_POOL_PRE_PING, "connect_args": connect_args, "pool_use_lifo": self.SQLALCHEMY_POOL_USE_LIFO, - "pool_reset_on_return": None, + "pool_reset_on_return": self.SQLALCHEMY_POOL_RESET_ON_RETURN, "pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT, } return result diff --git a/api/constants/recommended_apps.json b/api/constants/recommended_apps.json index 3779fb0180..3d728f1b2e 100644 --- a/api/constants/recommended_apps.json +++ b/api/constants/recommended_apps.json @@ -19,7 +19,7 @@ "name": "Website Generator" }, "app_id": "b53545b1-79ea-4da3-b31a-c39391c6f041", - "category": "Programming", + "categories": ["Programming"], "copyright": null, "description": null, "is_listed": true, @@ -35,7 +35,7 @@ "name": "Investment Analysis Report Copilot" }, "app_id": "a23b57fa-85da-49c0-a571-3aff375976c1", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "Welcome to your personalized Investment Analysis Copilot service, where we delve into the depths of stock analysis to provide you with comprehensive insights. \n", "is_listed": true, @@ -51,7 +51,7 @@ "name": "Workflow Planning Assistant " }, "app_id": "f3303a7d-a81c-404e-b401-1f8711c998c1", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "An assistant that helps you plan and select the right node for a workflow (V0.6.0). ", "is_listed": true, @@ -67,7 +67,7 @@ "name": "Automated Email Reply " }, "app_id": "e9d92058-7d20-4904-892f-75d90bef7587", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Reply emails using Gmail API. It will automatically retrieve email in your inbox and create a response in Gmail. \nConfigure your Gmail API in Google Cloud Console. ", "is_listed": true, @@ -83,7 +83,7 @@ "name": "Book Translation " }, "app_id": "98b87f88-bd22-4d86-8b74-86beba5e0ed4", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A workflow designed to translate a full book up to 15000 tokens per run. Uses Code node to separate text into chunks and Iteration to translate each chunk. ", "is_listed": true, @@ -99,7 +99,7 @@ "name": "Python bug fixer" }, "app_id": "cae337e6-aec5-4c7b-beca-d6f1a808bd5e", - "category": "Programming", + "categories": ["Programming"], "copyright": null, "description": null, "is_listed": true, @@ -115,7 +115,7 @@ "name": "Code Interpreter" }, "app_id": "d077d587-b072-4f2c-b631-69ed1e7cdc0f", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "Code interpreter, clarifying the syntax and semantics of the code.", "is_listed": true, @@ -131,7 +131,7 @@ "name": "SVG Logo Design " }, "app_id": "73fbb5f1-c15d-4d74-9cc8-46d9db9b2cca", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "Hello, I am your creative partner in bringing ideas to vivid life! I can assist you in creating stunning designs by leveraging abilities of DALL·E 3. ", "is_listed": true, @@ -147,7 +147,7 @@ "name": "Long Story Generator (Iteration) " }, "app_id": "5efb98d7-176b-419c-b6ef-50767391ab62", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A workflow demonstrating how to use Iteration node to generate long article that is longer than the context length of LLMs. ", "is_listed": true, @@ -163,7 +163,7 @@ "name": "Text Summarization Workflow" }, "app_id": "f00c4531-6551-45ee-808f-1d7903099515", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.", "is_listed": true, @@ -179,7 +179,7 @@ "name": "YouTube Channel Data Analysis" }, "app_id": "be591209-2ca8-410f-8f3b-ca0e530dd638", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "I am a YouTube Channel Data Analysis Copilot, I am here to provide expert data analysis tailored to your needs. ", "is_listed": true, @@ -195,7 +195,7 @@ "name": "Article Grading Bot" }, "app_id": "a747f7b4-c48b-40d6-b313-5e628232c05f", - "category": "Writing", + "categories": ["Writing"], "copyright": null, "description": "Assess the quality of articles and text based on user defined criteria. ", "is_listed": true, @@ -211,7 +211,7 @@ "name": "SEO Blog Generator" }, "app_id": "18f3bd03-524d-4d7a-8374-b30dbe7c69d5", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Workflow for retrieving information from the internet, followed by segmented generation of SEO blogs.", "is_listed": true, @@ -227,7 +227,7 @@ "name": "SQL Creator" }, "app_id": "050ef42e-3e0c-40c1-a6b6-a64f2c49d744", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "Write SQL from natural language by pasting in your schema with the request.Please describe your query requirements in natural language and select the target database type.", "is_listed": true, @@ -243,7 +243,7 @@ "name": "Sentiment Analysis " }, "app_id": "f06bf86b-d50c-4895-a942-35112dbe4189", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Batch sentiment analysis of text, followed by JSON output of sentiment classification along with scores.", "is_listed": true, @@ -259,7 +259,7 @@ "name": "Strategic Consulting Expert" }, "app_id": "7e8ca1ae-02f2-4b5f-979e-62d19133bee2", - "category": "Assistant", + "categories": ["Assistant"], "copyright": "Copyright 2023 Dify", "description": "I can answer your questions related to strategic marketing.", "is_listed": true, @@ -275,7 +275,7 @@ "name": "Code Converter" }, "app_id": "4006c4b2-0735-4f37-8dbb-fb1a8c5bd87a", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "This is an application that provides the ability to convert code snippets in multiple programming languages. You can input the code you wish to convert, select the target programming language, and get the desired output.", "is_listed": true, @@ -291,7 +291,7 @@ "name": "Question Classifier + Knowledge + Chatbot " }, "app_id": "d9f6b733-e35d-4a40-9f38-ca7bbfa009f7", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.", "is_listed": true, @@ -307,7 +307,7 @@ "name": "AI Front-end interviewer" }, "app_id": "127efead-8944-4e20-ba9d-12402eb345e0", - "category": "HR", + "categories": ["HR"], "copyright": "Copyright 2023 Dify", "description": "A simulated front-end interviewer that tests the skill level of front-end development through questioning.", "is_listed": true, @@ -323,7 +323,7 @@ "name": "Knowledge Retrieval + Chatbot " }, "app_id": "e9870913-dd01-4710-9f06-15d4180ca1ce", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Basic Workflow Template, A chatbot with a knowledge base. ", "is_listed": true, @@ -339,7 +339,7 @@ "name": "Email Assistant Workflow " }, "app_id": "dd5b6353-ae9b-4bce-be6a-a681a12cf709", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A multifunctional email assistant capable of summarizing, replying, composing, proofreading, and checking grammar.", "is_listed": true, @@ -355,7 +355,7 @@ "name": "Customer Review Analysis Workflow " }, "app_id": "9c0cd31f-4b62-4005-adf5-e3888d08654a", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Utilize LLM (Large Language Models) to classify customer reviews and forward them to the internal system.", "is_listed": true, diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index c8334bfd18..58ed6efc14 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -25,6 +25,7 @@ from controllers.console.wraps import ( is_admin_or_owner_required, setup_required, ) +from core.db.session_factory import session_factory from core.ops.ops_trace_manager import OpsTraceManager from core.rag.entities import PreProcessingRule, Rule, Segmentation from core.rag.retrieval.retrieval_methods import RetrievalMethod @@ -841,7 +842,8 @@ class AppTraceApi(Resource): @account_initialization_required def get(self, app_id): """Get app trace""" - app_trace_config = OpsTraceManager.get_app_tracing_config(app_id=app_id) + with session_factory.create_session() as session: + app_trace_config = OpsTraceManager.get_app_tracing_config(app_id, session) return app_trace_config diff --git a/api/controllers/console/explore/recommended_app.py b/api/controllers/console/explore/recommended_app.py index 55bd679b48..fa65c8daf1 100644 --- a/api/controllers/console/explore/recommended_app.py +++ b/api/controllers/console/explore/recommended_app.py @@ -52,7 +52,7 @@ class RecommendedAppResponse(ResponseModel): copyright: str | None = None privacy_policy: str | None = None custom_disclaimer: str | None = None - category: str | None = None + categories: list[str] = Field(default_factory=list) position: int | None = None is_listed: bool | None = None can_trial: bool | None = None diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index 7bab3f7bff..4a741d3154 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -842,24 +842,24 @@ class WorkflowResponseConverter: return [] files: list[Mapping[str, Any]] = [] - if isinstance(value, FileSegment): - files.append(value.value.to_dict()) - elif isinstance(value, ArrayFileSegment): - files.extend([i.to_dict() for i in value.value]) - elif isinstance(value, File): - files.append(value.to_dict()) - elif isinstance(value, list): - for item in value: - file = cls._get_file_var_from_value(item) + match value: + case FileSegment(): + files.append(value.value.to_dict()) + case ArrayFileSegment(): + files.extend([i.to_dict() for i in value.value]) + case File(): + files.append(value.to_dict()) + case list(): + for item in value: + file = cls._get_file_var_from_value(item) + if file: + files.append(file) + case dict(): + file = cls._get_file_var_from_value(value) if file: files.append(file) - elif isinstance( - value, - dict, - ): - file = cls._get_file_var_from_value(value) - if file: - files.append(file) + case _: + pass return files diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index e7ba6e502b..bae0016744 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -569,13 +569,13 @@ class OpsTraceManager: db.session.commit() @classmethod - def get_app_tracing_config(cls, app_id: str): + def get_app_tracing_config(cls, app_id: str, session: Session): """ Get app tracing config :param app_id: app id :return: """ - app: App | None = db.session.get(App, app_id) + app: App | None = session.get(App, app_id) if not app: raise ValueError("App not found") if not app.tracing: diff --git a/api/core/prompt/utils/prompt_message_util.py b/api/core/prompt/utils/prompt_message_util.py index ba76eb0c4e..11414832e3 100644 --- a/api/core/prompt/utils/prompt_message_util.py +++ b/api/core/prompt/utils/prompt_message_util.py @@ -53,24 +53,27 @@ class PromptMessageUtil: files = [] if isinstance(prompt_message.content, list): for content in prompt_message.content: - if isinstance(content, TextPromptMessageContent): - text += content.data - elif isinstance(content, ImagePromptMessageContent): - files.append( - { - "type": "image", - "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], - "detail": content.detail.value, - } - ) - elif isinstance(content, AudioPromptMessageContent): - files.append( - { - "type": "audio", - "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], - "format": content.format, - } - ) + match content: + case TextPromptMessageContent(): + text += content.data + case ImagePromptMessageContent(): + files.append( + { + "type": "image", + "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], + "detail": content.detail.value, + } + ) + case AudioPromptMessageContent(): + files.append( + { + "type": "audio", + "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], + "format": content.format, + } + ) + case _: + continue else: text = cast(str, prompt_message.content) diff --git a/api/core/tools/utils/message_transformer.py b/api/core/tools/utils/message_transformer.py index 5679466cbc..4c6e647335 100644 --- a/api/core/tools/utils/message_transformer.py +++ b/api/core/tools/utils/message_transformer.py @@ -23,36 +23,37 @@ _TOOL_FILE_URL_PATTERN = re.compile(r"(?:^|/+)files/tools/(?P[^/?# def safe_json_value(v): - if isinstance(v, datetime): - tz_name = "UTC" - if isinstance(current_user, Account) and current_user.timezone is not None: - tz_name = current_user.timezone - return v.astimezone(pytz.timezone(tz_name)).isoformat() - elif isinstance(v, date): - return v.isoformat() - elif isinstance(v, UUID): - return str(v) - elif isinstance(v, Decimal): - return float(v) - elif isinstance(v, bytes): - try: - return v.decode("utf-8") - except UnicodeDecodeError: - return v.hex() - elif isinstance(v, memoryview): - return v.tobytes().hex() - elif isinstance(v, np.integer): - return int(v) - elif isinstance(v, np.floating): - return float(v) - elif isinstance(v, np.ndarray): - return v.tolist() - elif isinstance(v, dict): - return safe_json_dict(v) - elif isinstance(v, list | tuple | set): - return [safe_json_value(i) for i in v] - else: - return v + match v: + case datetime(): + tz_name = "UTC" + if isinstance(current_user, Account) and current_user.timezone is not None: + tz_name = current_user.timezone + return v.astimezone(pytz.timezone(tz_name)).isoformat() + case date(): + return v.isoformat() + case UUID(): + return str(v) + case Decimal(): + return float(v) + case bytes(): + try: + return v.decode("utf-8") + except UnicodeDecodeError: + return v.hex() + case memoryview(): + return v.tobytes().hex() + case np.integer(): + return int(v) + case np.floating(): + return float(v) + case np.ndarray(): + return v.tolist() + case dict(): + return safe_json_dict(v) + case list() | tuple() | set(): + return [safe_json_value(i) for i in v] + case _: + return v def safe_json_dict(d: dict[str, Any]): diff --git a/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py b/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py new file mode 100644 index 0000000000..eee58b6310 --- /dev/null +++ b/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py @@ -0,0 +1,26 @@ +"""add recommended app categories + +Revision ID: a4f2d8c9b731 +Revises: 227822d22895 +Create Date: 2026-04-29 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a4f2d8c9b731" +down_revision = "227822d22895" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.add_column(sa.Column("categories", sa.JSON(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.drop_column("categories") diff --git a/api/models/model.py b/api/models/model.py index 25c330b062..f7f90465cf 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -878,6 +878,7 @@ class RecommendedApp(TypeBase): copyright: Mapped[str] = mapped_column(String(255), nullable=False) privacy_policy: Mapped[str] = mapped_column(String(255), nullable=False) category: Mapped[str] = mapped_column(String(255), nullable=False) + categories: Mapped[list[str] | None] = mapped_column(sa.JSON, nullable=True, default=None) custom_disclaimer: Mapped[str] = mapped_column(LongText, default="") position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True) diff --git a/api/services/recommend_app/category_order.py b/api/services/recommend_app/category_order.py new file mode 100644 index 0000000000..be6b112aa4 --- /dev/null +++ b/api/services/recommend_app/category_order.py @@ -0,0 +1,49 @@ +"""Apply Redis-backed category ordering for DB-backed Explore apps.""" + +import json +import logging +from collections.abc import Collection +from typing import Any + +from extensions.ext_redis import redis_client + +logger = logging.getLogger(__name__) + +EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order" + + +def _category_order_key(language: str) -> str: + return f"{EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX}:{language}" + + +def get_explore_app_category_order(language: str) -> list[str]: + try: + raw_categories = redis_client.get(_category_order_key(language)) + except Exception: + logger.exception("Failed to read explore app category order from Redis.") + return [] + + if not raw_categories: + return [] + + if isinstance(raw_categories, bytes): + raw_categories = raw_categories.decode("utf-8") + + try: + categories: Any = json.loads(raw_categories) + except (TypeError, json.JSONDecodeError): + logger.warning("Invalid explore app category order payload for language %s.", language) + return [] + + if not isinstance(categories, list): + return [] + + return [category for category in categories if isinstance(category, str)] + + +def order_categories(categories: Collection[str], language: str) -> list[str]: + configured_order = get_explore_app_category_order(language) + if configured_order: + return configured_order + + return sorted(categories) diff --git a/api/services/recommend_app/database/database_retrieval.py b/api/services/recommend_app/database/database_retrieval.py index 1df5fd13b6..ac870f0700 100644 --- a/api/services/recommend_app/database/database_retrieval.py +++ b/api/services/recommend_app/database/database_retrieval.py @@ -6,6 +6,7 @@ from constants.languages import languages from extensions.ext_database import db from models.model import App, RecommendedApp from services.app_dsl_service import AppDslService +from services.recommend_app.category_order import order_categories from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase from services.recommend_app.recommend_app_type import RecommendAppType @@ -18,7 +19,7 @@ class RecommendedAppItemDict(TypedDict): copyright: Any privacy_policy: Any custom_disclaimer: str - category: str + categories: list[str] position: int is_listed: bool @@ -80,6 +81,7 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): if not site: continue + app_categories = recommended_app.categories or [] recommended_app_result: RecommendedAppItemDict = { "id": recommended_app.id, "app": recommended_app.app, @@ -88,15 +90,18 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): "copyright": site.copyright, "privacy_policy": site.privacy_policy, "custom_disclaimer": site.custom_disclaimer, - "category": recommended_app.category, + "categories": app_categories, "position": recommended_app.position, "is_listed": recommended_app.is_listed, } recommended_apps_result.append(recommended_app_result) - categories.add(recommended_app.category) + categories.update(app_categories) - return RecommendedAppsResultDict(recommended_apps=recommended_apps_result, categories=sorted(categories)) + return RecommendedAppsResultDict( + recommended_apps=recommended_apps_result, + categories=order_categories(categories, language), + ) @classmethod def fetch_recommended_app_detail_from_db(cls, app_id: str) -> RecommendedAppDetailDict | None: diff --git a/api/services/variable_truncator.py b/api/services/variable_truncator.py index 1529c2b98f..5dd5f6873f 100644 --- a/api/services/variable_truncator.py +++ b/api/services/variable_truncator.py @@ -194,14 +194,15 @@ class VariableTruncator(BaseTruncator): result: _PartResult[Any] # Apply type-specific truncation with target size - if isinstance(segment, ArraySegment): - result = self._truncate_array(segment.value, target_size) - elif isinstance(segment, StringSegment): - result = self._truncate_string(segment.value, target_size) - elif isinstance(segment, ObjectSegment): - result = self._truncate_object(segment.value, target_size) - else: - raise AssertionError("this should be unreachable.") + match segment: + case ArraySegment(): + result = self._truncate_array(segment.value, target_size) + case StringSegment(): + result = self._truncate_string(segment.value, target_size) + case ObjectSegment(): + result = self._truncate_object(segment.value, target_size) + case _: + raise AssertionError("this should be unreachable.") return _PartResult( value=segment.model_copy(update={"value": result.value}), @@ -219,40 +220,41 @@ class VariableTruncator(BaseTruncator): return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1) if depth > _MAX_DEPTH: raise MaxDepthExceededError() - if isinstance(value, str): - # Ideally, the size of strings should be calculated based on their utf-8 encoded length. - # However, this adds complexity as we would need to compute encoded sizes consistently - # throughout the code. Therefore, we approximate the size using the string's length. - # Rough estimate: number of characters, plus 2 for quotes - return len(value) + 2 - elif isinstance(value, (int, float)): - return len(str(value)) - elif isinstance(value, bool): - return 4 if value else 5 # "true" or "false" - elif value is None: - return 4 # "null" - elif isinstance(value, list): - # Size = sum of elements + separators + brackets - total = 2 # "[]" - for i, item in enumerate(value): - if i > 0: - total += 1 # "," - total += VariableTruncator.calculate_json_size(item, depth=depth + 1) - return total - elif isinstance(value, dict): - # Size = sum of keys + values + separators + brackets - total = 2 # "{}" - for index, key in enumerate(value.keys()): - if index > 0: - total += 1 # "," - total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string - total += 1 # ":" - total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1) - return total - elif isinstance(value, File): - return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1) - else: - raise UnknownTypeError(f"got unknown type {type(value)}") + match value: + case str(): + # Ideally, the size of strings should be calculated based on their utf-8 encoded length. + # However, this adds complexity as we would need to compute encoded sizes consistently + # throughout the code. Therefore, we approximate the size using the string's length. + # Rough estimate: number of characters, plus 2 for quotes + return len(value) + 2 + case bool(): + return 4 if value else 5 # "true" or "false" + case int() | float(): + return len(str(value)) + case None: + return 4 # "null" + case list(): + # Size = sum of elements + separators + brackets + total = 2 # "[]" + for i, item in enumerate(value): + if i > 0: + total += 1 # "," + total += VariableTruncator.calculate_json_size(item, depth=depth + 1) + return total + case dict(): + # Size = sum of keys + values + separators + brackets + total = 2 # "{}" + for index, key in enumerate(value.keys()): + if index > 0: + total += 1 # "," + total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string + total += 1 # ":" + total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1) + return total + case File(): + return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1) + case _: + raise UnknownTypeError(f"got unknown type {type(value)}") def _truncate_string(self, value: str, target_size: int) -> _PartResult[str]: if (size := self.calculate_json_size(value)) < target_size: @@ -419,22 +421,23 @@ class VariableTruncator(BaseTruncator): target_size: int, ) -> _PartResult[Any]: """Truncate a value within an object to fit within budget.""" - if isinstance(val, UpdatedVariable): - # TODO(Workflow): push UpdatedVariable normalization closer to its producer. - return self._truncate_object(val.model_dump(), target_size) - elif isinstance(val, str): - return self._truncate_string(val, target_size) - elif isinstance(val, list): - return self._truncate_array(val, target_size) - elif isinstance(val, dict): - return self._truncate_object(val, target_size) - elif isinstance(val, File): - # File objects should not be truncated, return as-is - return _PartResult(val, self.calculate_json_size(val), False) - elif val is None or isinstance(val, (bool, int, float)): - return _PartResult(val, self.calculate_json_size(val), False) - else: - raise AssertionError("this statement should be unreachable.") + match val: + case UpdatedVariable(): + # TODO(Workflow): push UpdatedVariable normalization closer to its producer. + return self._truncate_object(val.model_dump(), target_size) + case str(): + return self._truncate_string(val, target_size) + case list(): + return self._truncate_array(val, target_size) + case dict(): + return self._truncate_object(val, target_size) + case File(): + # File objects should not be truncated, return as-is + return _PartResult(val, self.calculate_json_size(val), False) + case None | bool() | int() | float(): + return _PartResult(val, self.calculate_json_size(val), False) + case _: + raise AssertionError("this statement should be unreachable.") class DummyVariableTruncator(BaseTruncator): diff --git a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py index 724dd19f92..11e864176a 100644 --- a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py +++ b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py @@ -47,6 +47,7 @@ def _create_recommended_app( *, app_id: str, category: str = "chat", + categories: list[str] | None = None, language: str = "en-US", is_listed: bool = True, position: int = 1, @@ -57,6 +58,7 @@ def _create_recommended_app( copyright="copy", privacy_policy="pp", category=category, + categories=[category] if categories is None else categories, language=language, is_listed=is_listed, position=position, @@ -113,6 +115,53 @@ class TestFetchRecommendedAppsFromDb: assert "assistant" in result["categories"] assert "writing" in result["categories"] + def test_returns_multiple_categories_for_one_app( + self, flask_app_with_containers, db_session_with_containers: Session + ): + tenant_id = str(uuid4()) + created_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=created_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=created_app.id, + category="writing", + categories=["writing", "assistant"], + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db("en-US") + + recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id) + assert recommended_app["categories"] == ["writing", "assistant"] + assert "writing" in result["categories"] + assert "assistant" in result["categories"] + + def test_ignores_legacy_category_when_categories_are_empty( + self, + flask_app_with_containers, + db_session_with_containers: Session, + ): + legacy_category = f"legacy-empty-{uuid4()}" + tenant_id = str(uuid4()) + created_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=created_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=created_app.id, + category=legacy_category, + categories=[], + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db("en-US") + + recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id) + assert "category" not in recommended_app + assert recommended_app["categories"] == [] + assert legacy_category not in result["categories"] + def test_falls_back_to_default_language_when_empty( self, flask_app_with_containers, db_session_with_containers: Session ): diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index bad246a4bb..57dbf453de 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -114,8 +114,8 @@ def test_flask_configs(monkeypatch: pytest.MonkeyPatch): "pool_recycle": 3600, "pool_size": 30, "pool_use_lifo": False, - "pool_reset_on_return": None, "pool_timeout": 30, + "pool_reset_on_return": "rollback", } assert config["CONSOLE_WEB_URL"] == "https://example.com" diff --git a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py index 557fded37e..89cbea5ddc 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py +++ b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py @@ -126,7 +126,7 @@ class TestRecommendedAppResponseModels: }, "app_id": "app-1", "description": "desc", - "category": "cat", + "categories": ["cat", "other"], "position": 1, "is_listed": True, "can_trial": False, @@ -137,4 +137,5 @@ class TestRecommendedAppResponseModels: ).model_dump(mode="json") assert response["recommended_apps"][0]["app_id"] == "app-1" + assert response["recommended_apps"][0]["categories"] == ["cat", "other"] assert response["categories"] == ["cat"] diff --git a/api/tests/unit_tests/core/ops/test_ops_trace_manager.py b/api/tests/unit_tests/core/ops/test_ops_trace_manager.py index e47df0121e..beb99f92cd 100644 --- a/api/tests/unit_tests/core/ops/test_ops_trace_manager.py +++ b/api/tests/unit_tests/core/ops/test_ops_trace_manager.py @@ -407,18 +407,18 @@ def test_update_app_tracing_config_success(mock_db): def test_get_app_tracing_config_errors_when_missing(mock_db): mock_db.get.return_value = None with pytest.raises(ValueError, match="App not found"): - OpsTraceManager.get_app_tracing_config("app") + OpsTraceManager.get_app_tracing_config("app", mock_db) def test_get_app_tracing_config_returns_defaults(mock_db): mock_db.get.return_value = SimpleNamespace(tracing=None) - assert OpsTraceManager.get_app_tracing_config("app-id") == {"enabled": False, "tracing_provider": None} + assert OpsTraceManager.get_app_tracing_config("app-id", mock_db) == {"enabled": False, "tracing_provider": None} def test_get_app_tracing_config_returns_payload(mock_db): payload = {"enabled": True, "tracing_provider": "dummy"} mock_db.get.return_value = SimpleNamespace(tracing=json.dumps(payload)) - assert OpsTraceManager.get_app_tracing_config("app-id") == payload + assert OpsTraceManager.get_app_tracing_config("app-id", mock_db) == payload def test_check_and_project_helpers(monkeypatch): diff --git a/api/tests/unit_tests/services/recommend_app/test_category_order.py b/api/tests/unit_tests/services/recommend_app/test_category_order.py new file mode 100644 index 0000000000..3b94021f26 --- /dev/null +++ b/api/tests/unit_tests/services/recommend_app/test_category_order.py @@ -0,0 +1,26 @@ +import json +from unittest.mock import patch + +from services.recommend_app.category_order import get_explore_app_category_order, order_categories + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_get_explore_app_category_order_returns_redis_list(mock_get): + mock_get.return_value = json.dumps(["C", "A", "B"]).encode() + + assert get_explore_app_category_order("en-US") == ["C", "A", "B"] + mock_get.assert_called_once_with("explore:apps:category_order:en-US") + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_order_categories_uses_redis_order_as_source_of_truth(mock_get): + mock_get.return_value = json.dumps(["C", "A", "B"]).encode() + + assert order_categories({"A", "B", "C", "D"}, "en-US") == ["C", "A", "B"] + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_order_categories_falls_back_to_sorted_categories_without_redis_order(mock_get): + mock_get.return_value = None + + assert order_categories({"B", "A", "C"}, "en-US") == ["A", "B", "C"] diff --git a/dev/pytest/pytest_config_tests.py b/dev/pytest/pytest_config_tests.py index d56cceff5e..b136f09c61 100644 --- a/dev/pytest/pytest_config_tests.py +++ b/dev/pytest/pytest_config_tests.py @@ -93,10 +93,16 @@ BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF: frozenset[str] = frozenset( API_CONFIG_SET = set(dotenv_values(Path("api") / Path(".env.example")).keys()) DOCKER_CONFIG_SET = set(dotenv_values(Path("docker") / Path(".env.example")).keys()) -DOCKER_COMPOSE_CONFIG_SET = set() +DOCKER_COMPOSE_CONFIG_SET = set(DOCKER_CONFIG_SET) -with open(Path("docker") / Path("docker-compose.yaml")) as f: - DOCKER_COMPOSE_CONFIG_SET = set(yaml.safe_load(f.read())["x-shared-env"].keys()) +# Read environment variables from the split env files used by docker-compose +# Walk through all .env.example files in subdirectories (per-module structure) +envs_dir = Path("docker") / Path("envs") +if envs_dir.exists(): + for env_file_path in envs_dir.rglob("*.env.example"): + env_keys = set(dotenv_values(env_file_path).keys()) + DOCKER_CONFIG_SET.update(env_keys) + DOCKER_COMPOSE_CONFIG_SET.update(env_keys) def test_yaml_config(): diff --git a/docker/.env.default b/docker/.env.default deleted file mode 100644 index 6f6683b9f5..0000000000 --- a/docker/.env.default +++ /dev/null @@ -1,51 +0,0 @@ -# ------------------------------------------------------------------ -# Minimal defaults for Docker Compose deployments. -# -# Keep local changes in .env. Use .env.example as the full reference -# for advanced and service-specific settings. -# ------------------------------------------------------------------ - -# Public URLs used when Dify generates links. Change these together when -# exposing Dify under another hostname, IP address, or port. -CONSOLE_WEB_URL=http://localhost -SERVICE_API_URL=http://localhost -APP_WEB_URL=http://localhost -FILES_URL=http://localhost -INTERNAL_FILES_URL=http://api:5001 -TRIGGER_URL=http://localhost -ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id} -NEXT_PUBLIC_SOCKET_URL=ws://localhost -EXPOSE_PLUGIN_DEBUGGING_HOST=localhost -EXPOSE_PLUGIN_DEBUGGING_PORT=5003 - -# Built-in metadata database defaults. -DB_TYPE=postgresql -DB_USERNAME=postgres -DB_PASSWORD=difyai123456 -DB_HOST=db_postgres -DB_PORT=5432 -DB_DATABASE=dify - -# Built-in Redis defaults. -REDIS_HOST=redis -REDIS_PORT=6379 -REDIS_PASSWORD=difyai123456 - -# Default file storage. -STORAGE_TYPE=opendal -OPENDAL_SCHEME=fs -OPENDAL_FS_ROOT=storage - -# Default vector database. -VECTOR_STORE=weaviate - -# Internal service authentication. Paired values must match. -PLUGIN_DAEMON_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi -PLUGIN_DIFY_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 - -# Host ports. -EXPOSE_NGINX_PORT=80 -EXPOSE_NGINX_SSL_PORT=443 - -# Docker Compose profiles for bundled services. -COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql} diff --git a/docker/.env.example b/docker/.env.example index 122228cdd1..82bd837ffb 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,1249 +1,157 @@ -# ------------------------------ -# Environment Variables for API service & worker -# ------------------------------ +# ------------------------------------------------------------------ +# Essential defaults for Docker Compose deployments. +# +# For a default deployment, copy this file to .env and run: +# docker compose up -d +# +# Optional and provider-specific variables live under docker/envs/. +# Copy an optional *.env.example file beside itself without the +# .example suffix when you need those advanced settings. +# Values in docker/.env take precedence over docker/envs/*.env files. +# ------------------------------------------------------------------ -# ------------------------------ -# Common Variables -# ------------------------------ - -# The backend URL of the console API, -# used to concatenate the authorization callback. -# If empty, it is the same domain. -# Example: https://api.console.dify.ai +# Core service URLs CONSOLE_API_URL= - -# The front-end URL of the console web, -# used to concatenate some front-end addresses and for CORS configuration use. -# If empty, it is the same domain. -# Example: https://console.dify.ai CONSOLE_WEB_URL= - -# Service API Url, -# used to display Service API Base Url to the front-end. -# If empty, it is the same domain. -# Example: https://api.dify.ai SERVICE_API_URL= - -# Trigger external URL -# used to display trigger endpoint API Base URL to the front-end. -# Example: https://api.dify.ai TRIGGER_URL=http://localhost - -# WebApp API backend Url, -# used to declare the back-end URL for the front-end API. -# If empty, it is the same domain. -# Example: https://api.app.dify.ai APP_API_URL= - -# WebApp Url, -# used to display WebAPP API Base Url to the front-end. -# If empty, it is the same domain. -# Example: https://app.dify.ai APP_WEB_URL= - -# File preview or download Url prefix. -# used to display File preview or download Url to the front-end or as Multi-model inputs; -# Url is signed and has expiration time. -# Setting FILES_URL is required for file processing plugins. -# - For https://example.com, use FILES_URL=https://example.com -# - For http://example.com, use FILES_URL=http://example.com -# Recommendation: use a dedicated domain (e.g., https://upload.example.com). -# Alternatively, use http://:5001 or http://api:5001, -# ensuring port 5001 is externally accessible (see docker-compose.yaml). FILES_URL= - -# INTERNAL_FILES_URL is used for plugin daemon communication within Docker network. -# Set this to the internal Docker service URL for proper plugin file access. -# Example: INTERNAL_FILES_URL=http://api:5001 INTERNAL_FILES_URL= +ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id} +NEXT_PUBLIC_SOCKET_URL=ws://localhost -# Ensure UTF-8 encoding +# Runtime and security LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONIOENCODING=utf-8 - -# Set UV cache directory to avoid permission issues with non-existent home directory UV_CACHE_DIR=/tmp/.uv-cache - -# ------------------------------ -# Server Configuration -# ------------------------------ - -# The log level for the application. -# Supported values are `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` -LOG_LEVEL=INFO -# Log output format: text or json -LOG_OUTPUT_FORMAT=text -# Log file path -LOG_FILE=/app/logs/server.log -# Log file max size, the unit is MB -LOG_FILE_MAX_SIZE=20 -# Log file max backup count -LOG_FILE_BACKUP_COUNT=5 -# Log dateformat -LOG_DATEFORMAT=%Y-%m-%d %H:%M:%S -# Log Timezone -LOG_TZ=UTC - -# Debug mode, default is false. -# It is recommended to turn on this configuration for local development -# to prevent some problems caused by monkey patch. -DEBUG=false - -# Flask debug mode, it can output trace information at the interface when turned on, -# which is convenient for debugging. -FLASK_DEBUG=false - -# Enable request logging, which will log the request and response information. -# And the log level is DEBUG -ENABLE_REQUEST_LOGGING=False - -# A secret key that is used for securely signing the session cookie -# and encrypting sensitive information on the database. -# You can generate a strong key using `openssl rand -base64 42`. SECRET_KEY=sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U - -# Password for admin user initialization. -# If left unset, admin user will not be prompted for a password -# when creating the initial admin account. -# The length of the password cannot exceed 30 characters. INIT_PASSWORD= - -# Deployment environment. -# Supported values are `PRODUCTION`, `TESTING`. Default is `PRODUCTION`. -# Testing environment. There will be a distinct color label on the front-end page, -# indicating that this environment is a testing environment. DEPLOY_ENV=PRODUCTION - -# Whether to enable the version check policy. -# If set to empty, https://updates.dify.ai will be called for version check. CHECK_UPDATE_URL=https://updates.dify.ai - -# Used to change the OpenAI base address, default is https://api.openai.com/v1. -# When OpenAI cannot be accessed in China, replace it with a domestic mirror address, -# or when a local model provides OpenAI compatible API, it can be replaced. OPENAI_API_BASE=https://api.openai.com/v1 - -# When enabled, migrations will be executed prior to application startup -# and the application will start after the migrations have completed. MIGRATION_ENABLED=true - -# File Access Time specifies a time interval in seconds for the file to be accessed. -# The default value is 300 seconds. FILES_ACCESS_TIMEOUT=300 - -# Collaboration mode toggle -# To open collaboration features, you also need to set SERVER_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker ENABLE_COLLABORATION_MODE=false -# Access token expiration time in minutes -ACCESS_TOKEN_EXPIRE_MINUTES=60 - -# Refresh token expiration time in days -REFRESH_TOKEN_EXPIRE_DAYS=30 - -# The default number of active requests for the application, where 0 means unlimited, should be a non-negative integer. -APP_DEFAULT_ACTIVE_REQUESTS=0 -# The maximum number of active requests for the application, where 0 means unlimited, should be a non-negative integer. -APP_MAX_ACTIVE_REQUESTS=0 -APP_MAX_EXECUTION_TIME=1200 - -# ------------------------------ -# Container Startup Related Configuration -# Only effective when starting with docker image or docker-compose. -# ------------------------------ - -# API service binding address, default: 0.0.0.0, i.e., all addresses can be accessed. +# Logging and server workers +LOG_LEVEL=INFO +LOG_OUTPUT_FORMAT=text +LOG_FILE=/app/logs/server.log +LOG_FILE_MAX_SIZE=20 +LOG_FILE_BACKUP_COUNT=5 +LOG_DATEFORMAT=%Y-%m-%d %H:%M:%S +LOG_TZ=UTC +DEBUG=false +FLASK_DEBUG=false +ENABLE_REQUEST_LOGGING=False DIFY_BIND_ADDRESS=0.0.0.0 - -# API service binding port number, default 5001. DIFY_PORT=5001 - -# The number of API server workers, i.e., the number of workers. -# Formula: number of cpu cores x 2 + 1 for sync, 1 for Gevent -# Reference: https://docs.gunicorn.org/en/stable/design.html#how-many-workers SERVER_WORKER_AMOUNT=1 - -# Defaults to gevent. If using windows, it can be switched to sync or solo. -# -# Warning: Changing this parameter requires disabling patching for -# psycopg2 and gRPC (see `gunicorn.conf.py` and `celery_entrypoint.py`). -# Modifying it may also decrease throughput. -# -# It is strongly discouraged to change this parameter. -# If enable collaboration mode, it must be set to geventwebsocket.gunicorn.workers.GeventWebSocketWorker SERVER_WORKER_CLASS=gevent - -# Default number of worker connections, the default is 10. SERVER_WORKER_CONNECTIONS=10 - -# Similar to SERVER_WORKER_CLASS. -# If using windows, it can be switched to sync or solo. -# -# Warning: Changing this parameter requires disabling patching for -# psycopg2 and gRPC (see `gunicorn_conf.py` and `celery_entrypoint.py`). -# Modifying it may also decrease throughput. -# -# It is strongly discouraged to change this parameter. -CELERY_WORKER_CLASS= - -# Request handling timeout. The default is 200, -# it is recommended to set it to 360 to support a longer sse connection time. GUNICORN_TIMEOUT=360 - -# The number of Celery workers. The default is 4 for development environments -# to allow parallel processing of workflows, document indexing, and other async tasks. -# Adjust based on your system resources and workload requirements. +CELERY_WORKER_CLASS= CELERY_WORKER_AMOUNT=4 - -# Flag indicating whether to enable autoscaling of Celery workers. -# -# Autoscaling is useful when tasks are CPU intensive and can be dynamically -# allocated and deallocated based on the workload. -# -# When autoscaling is enabled, the maximum and minimum number of workers can -# be specified. The autoscaling algorithm will dynamically adjust the number -# of workers within the specified range. -# -# Default is false (i.e., autoscaling is disabled). -# -# Example: -# CELERY_AUTO_SCALE=true CELERY_AUTO_SCALE=false - -# The maximum number of Celery workers that can be autoscaled. -# This is optional and only used when autoscaling is enabled. -# Default is not set. CELERY_MAX_WORKERS= - -# The minimum number of Celery workers that can be autoscaled. -# This is optional and only used when autoscaling is enabled. -# Default is not set. CELERY_MIN_WORKERS= +COMPOSE_WORKER_HEALTHCHECK_DISABLED=true +COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s +COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s -# API Tool configuration -API_TOOL_DEFAULT_CONNECT_TIMEOUT=10 -API_TOOL_DEFAULT_READ_TIMEOUT=60 - -# ------------------------------- -# Datasource Configuration -# -------------------------------- -ENABLE_WEBSITE_JINAREADER=true -ENABLE_WEBSITE_FIRECRAWL=true -ENABLE_WEBSITE_WATERCRAWL=true - -# Enable inline LaTeX rendering with single dollar signs ($...$) in the web frontend -# Default is false for security reasons to prevent conflicts with regular text -NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false - -# ------------------------------ -# Database Configuration -# The database uses PostgreSQL or MySQL. OceanBase and seekdb are also supported. Please use the public schema. -# It is consistent with the configuration in the database service below. -# You can adjust the database configuration according to your needs. -# ------------------------------ - -# Database type, supported values are `postgresql`, `mysql`, `oceanbase`, `seekdb` +# Database DB_TYPE=postgresql -# For MySQL, only `root` user is supported for now DB_USERNAME=postgres DB_PASSWORD=difyai123456 DB_HOST=db_postgres DB_PORT=5432 DB_DATABASE=dify - -# The size of the database connection pool. -# The default is 30 connections, which can be appropriately increased. SQLALCHEMY_POOL_SIZE=30 -# The default is 10 connections, which allows temporary overflow beyond the pool size. SQLALCHEMY_MAX_OVERFLOW=10 -# Database connection pool recycling time, the default is 3600 seconds. SQLALCHEMY_POOL_RECYCLE=3600 -# Whether to print SQL, default is false. SQLALCHEMY_ECHO=false -# If True, will test connections for liveness upon each checkout SQLALCHEMY_POOL_PRE_PING=false -# Whether to enable the Last in first out option or use default FIFO queue if is false SQLALCHEMY_POOL_USE_LIFO=false -# Number of seconds to wait for a connection from the pool before raising a timeout error. -# Default is 30 SQLALCHEMY_POOL_TIMEOUT=30 - -# Maximum number of connections to the database -# Default is 100 -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS +SQLALCHEMY_POOL_RESET_ON_RETURN=rollback +PGDATA=/var/lib/postgresql/data/pgdata POSTGRES_MAX_CONNECTIONS=200 - -# Sets the amount of shared memory used for postgres's shared buffers. -# Default is 128MB -# Recommended value: 25% of available memory -# Reference: https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS POSTGRES_SHARED_BUFFERS=128MB - -# Sets the amount of memory used by each database worker for working space. -# Default is 4MB -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM POSTGRES_WORK_MEM=4MB - -# Sets the amount of memory reserved for maintenance activities. -# Default is 64MB -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM POSTGRES_MAINTENANCE_WORK_MEM=64MB - -# Sets the planner's assumption about the effective cache size. -# Default is 4096MB -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE POSTGRES_EFFECTIVE_CACHE_SIZE=4096MB - -# Sets the maximum allowed duration of any statement before termination. -# Default is 0 (no timeout). -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT -# A value of 0 prevents the server from timing out statements. POSTGRES_STATEMENT_TIMEOUT=0 - -# Sets the maximum allowed duration of any idle in-transaction session before termination. -# Default is 0 (no timeout). -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT -# A value of 0 prevents the server from terminating idle sessions. POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0 -# MySQL Performance Configuration -# Maximum number of connections to MySQL -# -# Default is 1000 -MYSQL_MAX_CONNECTIONS=1000 - -# InnoDB buffer pool size -# Default is 512M -# Recommended value: 70-80% of available memory for dedicated MySQL server -# Reference: https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_buffer_pool_size -MYSQL_INNODB_BUFFER_POOL_SIZE=512M - -# InnoDB log file size -# Default is 128M -# Reference: https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_log_file_size -MYSQL_INNODB_LOG_FILE_SIZE=128M - -# InnoDB flush log at transaction commit -# Default is 2 (flush to OS cache, sync every second) -# Options: 0 (no flush), 1 (flush and sync), 2 (flush to OS cache) -# Reference: https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit -MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT=2 - -# ------------------------------ -# Redis Configuration -# This Redis configuration is used for caching and for pub/sub during conversation. -# ------------------------------ - +# Redis and Celery REDIS_HOST=redis REDIS_PORT=6379 REDIS_USERNAME= REDIS_PASSWORD=difyai123456 REDIS_USE_SSL=false -# SSL configuration for Redis (when REDIS_USE_SSL=true) REDIS_SSL_CERT_REQS=CERT_NONE -# Options: CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED REDIS_SSL_CA_CERTS= -# Path to CA certificate file for SSL verification REDIS_SSL_CERTFILE= -# Path to client certificate file for SSL authentication REDIS_SSL_KEYFILE= -# Path to client private key file for SSL authentication REDIS_DB=0 -# Optional global prefix for Redis keys, topics, streams, and Celery Redis transport artifacts. -# Leave empty to preserve current unprefixed behavior. REDIS_KEY_PREFIX= -# Optional: limit total Redis connections used by API/Worker (unset for default) -# Align with API's REDIS_MAX_CONNECTIONS in configs REDIS_MAX_CONNECTIONS= - -# Whether to use Redis Sentinel mode. -# If set to true, the application will automatically discover and connect to the master node through Sentinel. -REDIS_USE_SENTINEL=false - -# List of Redis Sentinel nodes. If Sentinel mode is enabled, provide at least one Sentinel IP and port. -# Format: `:,:,:` -REDIS_SENTINELS= -REDIS_SENTINEL_SERVICE_NAME= -REDIS_SENTINEL_USERNAME= -REDIS_SENTINEL_PASSWORD= -REDIS_SENTINEL_SOCKET_TIMEOUT=0.1 - -# List of Redis Cluster nodes. If Cluster mode is enabled, provide at least one Cluster IP and port. -# Format: `:,:,:` -REDIS_USE_CLUSTERS=false -REDIS_CLUSTERS= -REDIS_CLUSTERS_PASSWORD= - -# Redis connection and retry configuration -# max redis retry REDIS_RETRY_RETRIES=3 -# Base delay (in seconds) for exponential backoff on retries REDIS_RETRY_BACKOFF_BASE=1.0 -# Cap (in seconds) for exponential backoff on retries REDIS_RETRY_BACKOFF_CAP=10.0 -# Timeout (in seconds) for Redis socket operations REDIS_SOCKET_TIMEOUT=5.0 -# Timeout (in seconds) for establishing a Redis connection REDIS_SOCKET_CONNECT_TIMEOUT=5.0 -# Interval (in seconds) for Redis health checks REDIS_HEALTH_CHECK_INTERVAL=30 - -# ------------------------------ -# Celery Configuration -# ------------------------------ - -# Use standalone redis as the broker, and redis db 1 for celery broker. (redis_username is usually set by default as empty) -# Format as follows: `redis://:@:/`. -# Example: redis://:difyai123456@redis:6379/1 -# If use Redis Sentinel, format as follows: `sentinel://:@:/` -# For high availability, you can configure multiple Sentinel nodes (if provided) separated by semicolons like below example: -# Example: sentinel://:difyai123456@localhost:26379/1;sentinel://:difyai12345@localhost:26379/1;sentinel://:difyai12345@localhost:26379/1 CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1 CELERY_BACKEND=redis BROKER_USE_SSL=false - -# If you are using Redis Sentinel for high availability, configure the following settings. -CELERY_USE_SENTINEL=false -CELERY_SENTINEL_MASTER_NAME= -CELERY_SENTINEL_PASSWORD= -CELERY_SENTINEL_SOCKET_TIMEOUT=0.1 -# e.g. {"tasks.add": {"rate_limit": "10/s"}} CELERY_TASK_ANNOTATIONS=null +EVENT_BUS_REDIS_URL= +EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub +EVENT_BUS_REDIS_USE_CLUSTERS=false -# ------------------------------ -# CORS Configuration -# Used to set the front-end cross-domain access policy. -# ------------------------------ - -# Specifies the allowed origins for cross-origin requests to the Web API, -# e.g. https://dify.app or * for all origins. +# Web and app limits WEB_API_CORS_ALLOW_ORIGINS=* - -# Specifies the allowed origins for cross-origin requests to the console API, -# e.g. https://cloud.dify.ai or * for all origins. CONSOLE_CORS_ALLOW_ORIGINS=* -# When the frontend and backend run on different subdomains, set COOKIE_DOMAIN to the site's top-level domain (e.g., `example.com`). Leading dots are optional. COOKIE_DOMAIN= -# When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1. NEXT_PUBLIC_COOKIE_DOMAIN= -# WebSocket server URL. -NEXT_PUBLIC_SOCKET_URL=ws://localhost NEXT_PUBLIC_BATCH_CONCURRENCY=5 +API_SENTRY_DSN= +API_SENTRY_TRACES_SAMPLE_RATE=1.0 +API_SENTRY_PROFILES_SAMPLE_RATE=1.0 +WEB_SENTRY_DSN= +AMPLITUDE_API_KEY= +TEXT_GENERATION_TIMEOUT_MS=60000 +CSP_WHITELIST= +ALLOW_EMBED=false +ALLOW_INLINE_STYLES=false +ALLOW_UNSAFE_DATA_SCHEME=false +TOP_K_MAX_VALUE=10 +INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000 +LOOP_NODE_MAX_COUNT=100 +MAX_TOOLS_NUM=10 +MAX_PARALLEL_LIMIT=10 +MAX_ITERATIONS_NUM=99 +MAX_TREE_DEPTH=50 +ENABLE_WEBSITE_JINAREADER=true +ENABLE_WEBSITE_FIRECRAWL=true +ENABLE_WEBSITE_WATERCRAWL=true +NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false +EXPERIMENTAL_ENABLE_VINEXT=false -# ------------------------------ -# File Storage Configuration -# ------------------------------ - -# The type of storage to use for storing user files. +# Storage and default vector store STORAGE_TYPE=opendal - -# Apache OpenDAL Configuration -# The configuration for OpenDAL consists of the following format: OPENDAL__. -# You can find all the service configurations (CONFIG_NAME) in the repository at: https://github.com/apache/opendal/tree/main/core/src/services. -# Dify will scan configurations starting with OPENDAL_ and automatically apply them. -# The scheme name for the OpenDAL storage. OPENDAL_SCHEME=fs -# Configurations for OpenDAL Local File System. OPENDAL_FS_ROOT=storage - -# ClickZetta Volume Configuration (for storage backend) -# To use ClickZetta Volume as storage backend, set STORAGE_TYPE=clickzetta-volume -# Note: ClickZetta Volume will reuse the existing CLICKZETTA_* connection parameters - -# Volume type selection (three types available): -# - user: Personal/small team use, simple config, user-level permissions -# - table: Enterprise multi-tenant, smart routing, table-level + user-level permissions -# - external: Data lake integration, external storage connection, volume-level + storage-level permissions -CLICKZETTA_VOLUME_TYPE=user - -# External Volume name (required only when TYPE=external) -CLICKZETTA_VOLUME_NAME= - -# Table Volume table prefix (used only when TYPE=table) -CLICKZETTA_VOLUME_TABLE_PREFIX=dataset_ - -# Dify file directory prefix (isolates from other apps, recommended to keep default) -CLICKZETTA_VOLUME_DIFY_PREFIX=dify_km - -# S3 Configuration -# -S3_ENDPOINT= -S3_REGION=us-east-1 -S3_BUCKET_NAME=difyai -S3_ACCESS_KEY= -S3_SECRET_KEY= -S3_ADDRESS_STYLE=auto -# Whether to use AWS managed IAM roles for authenticating with the S3 service. -# If set to false, the access key and secret key must be provided. -S3_USE_AWS_MANAGED_IAM=false - -# Workflow run and Conversation archive storage (S3-compatible) -ARCHIVE_STORAGE_ENABLED=false -ARCHIVE_STORAGE_ENDPOINT= -ARCHIVE_STORAGE_ARCHIVE_BUCKET= -ARCHIVE_STORAGE_EXPORT_BUCKET= -ARCHIVE_STORAGE_ACCESS_KEY= -ARCHIVE_STORAGE_SECRET_KEY= -ARCHIVE_STORAGE_REGION=auto - -# Azure Blob Configuration -# -AZURE_BLOB_ACCOUNT_NAME=difyai -AZURE_BLOB_ACCOUNT_KEY=difyai -AZURE_BLOB_CONTAINER_NAME=difyai-container -AZURE_BLOB_ACCOUNT_URL=https://.blob.core.windows.net - -# Google Storage Configuration -# -GOOGLE_STORAGE_BUCKET_NAME=your-bucket-name -GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64= - -# The Alibaba Cloud OSS configurations, -# -ALIYUN_OSS_BUCKET_NAME=your-bucket-name -ALIYUN_OSS_ACCESS_KEY=your-access-key -ALIYUN_OSS_SECRET_KEY=your-secret-key -ALIYUN_OSS_ENDPOINT=https://oss-ap-southeast-1-internal.aliyuncs.com -ALIYUN_OSS_REGION=ap-southeast-1 -ALIYUN_OSS_AUTH_VERSION=v4 -# Don't start with '/'. OSS doesn't support leading slash in object names. -ALIYUN_OSS_PATH=your-path -# Optional CloudBox ID for Aliyun OSS, DO NOT enable it if you are not using CloudBox. -#ALIYUN_CLOUDBOX_ID=your-cloudbox-id - -# Tencent COS Configuration -# -TENCENT_COS_BUCKET_NAME=your-bucket-name -TENCENT_COS_SECRET_KEY=your-secret-key -TENCENT_COS_SECRET_ID=your-secret-id -TENCENT_COS_REGION=your-region -TENCENT_COS_SCHEME=your-scheme -TENCENT_COS_CUSTOM_DOMAIN=your-custom-domain - -# Oracle Storage Configuration -# -OCI_ENDPOINT=https://your-object-storage-namespace.compat.objectstorage.us-ashburn-1.oraclecloud.com -OCI_BUCKET_NAME=your-bucket-name -OCI_ACCESS_KEY=your-access-key -OCI_SECRET_KEY=your-secret-key -OCI_REGION=us-ashburn-1 - -# Huawei OBS Configuration -# -HUAWEI_OBS_BUCKET_NAME=your-bucket-name -HUAWEI_OBS_SECRET_KEY=your-secret-key -HUAWEI_OBS_ACCESS_KEY=your-access-key -HUAWEI_OBS_SERVER=your-server-url -HUAWEI_OBS_PATH_STYLE=false - -# Volcengine TOS Configuration -# -VOLCENGINE_TOS_BUCKET_NAME=your-bucket-name -VOLCENGINE_TOS_SECRET_KEY=your-secret-key -VOLCENGINE_TOS_ACCESS_KEY=your-access-key -VOLCENGINE_TOS_ENDPOINT=your-server-url -VOLCENGINE_TOS_REGION=your-region - -# Baidu OBS Storage Configuration -# -BAIDU_OBS_BUCKET_NAME=your-bucket-name -BAIDU_OBS_SECRET_KEY=your-secret-key -BAIDU_OBS_ACCESS_KEY=your-access-key -BAIDU_OBS_ENDPOINT=your-server-url - -# Supabase Storage Configuration -# -SUPABASE_BUCKET_NAME=your-bucket-name -SUPABASE_API_KEY=your-access-key -SUPABASE_URL=your-server-url - -# ------------------------------ -# Vector Database Configuration -# ------------------------------ - -# The type of vector store to use. -# Supported values are `weaviate`, `oceanbase`, `seekdb`, `qdrant`, `milvus`, `myscale`, `relyt`, `pgvector`, `pgvecto-rs`, `chroma`, `opensearch`, `oracle`, `tencent`, `elasticsearch`, `elasticsearch-ja`, `analyticdb`, `couchbase`, `vikingdb`, `opengauss`, `tablestore`, `vastbase`, `tidb`, `tidb_on_qdrant`, `baidu`, `lindorm`, `huawei_cloud`, `upstash`, `matrixone`, `clickzetta`, `alibabacloud_mysql`, `iris`, `hologres`. VECTOR_STORE=weaviate -# Prefix used to create collection name in vector database VECTOR_INDEX_NAME_PREFIX=Vector_index - -# The Weaviate endpoint URL. Only available when VECTOR_STORE is `weaviate`. WEAVIATE_ENDPOINT=http://weaviate:8080 WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih WEAVIATE_GRPC_ENDPOINT=grpc://weaviate:50051 WEAVIATE_TOKENIZATION=word - -# For OceanBase metadata database configuration, available when `DB_TYPE` is `oceanbase`. -# For OceanBase vector database configuration, available when `VECTOR_STORE` is `oceanbase` -# If you want to use OceanBase as both vector database and metadata database, you need to set both `DB_TYPE` and `VECTOR_STORE` to `oceanbase`, and set Database Configuration is the same as the vector database. -# seekdb is the lite version of OceanBase and shares the connection configuration with OceanBase. -OCEANBASE_VECTOR_HOST=oceanbase -OCEANBASE_VECTOR_PORT=2881 -OCEANBASE_VECTOR_USER=root@test -OCEANBASE_VECTOR_PASSWORD=difyai123456 -OCEANBASE_VECTOR_DATABASE=test -OCEANBASE_CLUSTER_NAME=difyai -OCEANBASE_MEMORY_LIMIT=6G -OCEANBASE_ENABLE_HYBRID_SEARCH=false -# For OceanBase vector database, built-in fulltext parsers are `ngram`, `beng`, `space`, `ngram2`, `ik` -# For OceanBase vector database, external fulltext parsers (require plugin installation) are `japanese_ftparser`, `thai_ftparser` -OCEANBASE_FULLTEXT_PARSER=ik -SEEKDB_MEMORY_LIMIT=2G - -# The Qdrant endpoint URL. Only available when VECTOR_STORE is `qdrant`. -QDRANT_URL=http://qdrant:6333 -QDRANT_API_KEY=difyai123456 -QDRANT_CLIENT_TIMEOUT=20 -QDRANT_GRPC_ENABLED=false -QDRANT_GRPC_PORT=6334 -QDRANT_REPLICATION_FACTOR=1 - -# Milvus configuration. Only available when VECTOR_STORE is `milvus`. -# The milvus uri. -MILVUS_URI=http://host.docker.internal:19530 -MILVUS_DATABASE= -MILVUS_TOKEN= -MILVUS_USER= -MILVUS_PASSWORD= -MILVUS_ENABLE_HYBRID_SEARCH=False -MILVUS_ANALYZER_PARAMS= - -# MyScale configuration, only available when VECTOR_STORE is `myscale` -# For multi-language support, please set MYSCALE_FTS_PARAMS with referring to: -# https://myscale.com/docs/en/text-search/#understanding-fts-index-parameters -MYSCALE_HOST=myscale -MYSCALE_PORT=8123 -MYSCALE_USER=default -MYSCALE_PASSWORD= -MYSCALE_DATABASE=dify -MYSCALE_FTS_PARAMS= - -# Couchbase configurations, only available when VECTOR_STORE is `couchbase` -# The connection string must include hostname defined in the docker-compose file (couchbase-server in this case) -COUCHBASE_CONNECTION_STRING=couchbase://couchbase-server -COUCHBASE_USER=Administrator -COUCHBASE_PASSWORD=password -COUCHBASE_BUCKET_NAME=Embeddings -COUCHBASE_SCOPE_NAME=_default - -# Hologres configurations, only available when VECTOR_STORE is `hologres` -# access_key_id is used as the PG username, access_key_secret is used as the PG password -HOLOGRES_HOST= -HOLOGRES_PORT=80 -HOLOGRES_DATABASE= -HOLOGRES_ACCESS_KEY_ID= -HOLOGRES_ACCESS_KEY_SECRET= -HOLOGRES_SCHEMA=public -HOLOGRES_TOKENIZER=jieba -HOLOGRES_DISTANCE_METHOD=Cosine -HOLOGRES_BASE_QUANTIZATION_TYPE=rabitq -HOLOGRES_MAX_DEGREE=64 -HOLOGRES_EF_CONSTRUCTION=400 - -# pgvector configurations, only available when VECTOR_STORE is `pgvector` -PGVECTOR_HOST=pgvector -PGVECTOR_PORT=5432 -PGVECTOR_USER=postgres -PGVECTOR_PASSWORD=difyai123456 -PGVECTOR_DATABASE=dify -PGVECTOR_MIN_CONNECTION=1 -PGVECTOR_MAX_CONNECTION=5 -PGVECTOR_PG_BIGM=false -PGVECTOR_PG_BIGM_VERSION=1.2-20240606 - -# vastbase configurations, only available when VECTOR_STORE is `vastbase` -VASTBASE_HOST=vastbase -VASTBASE_PORT=5432 -VASTBASE_USER=dify -VASTBASE_PASSWORD=Difyai123456 -VASTBASE_DATABASE=dify -VASTBASE_MIN_CONNECTION=1 -VASTBASE_MAX_CONNECTION=5 - -# pgvecto-rs configurations, only available when VECTOR_STORE is `pgvecto-rs` -PGVECTO_RS_HOST=pgvecto-rs -PGVECTO_RS_PORT=5432 -PGVECTO_RS_USER=postgres -PGVECTO_RS_PASSWORD=difyai123456 -PGVECTO_RS_DATABASE=dify - -# analyticdb configurations, only available when VECTOR_STORE is `analyticdb` -ANALYTICDB_KEY_ID=your-ak -ANALYTICDB_KEY_SECRET=your-sk -ANALYTICDB_REGION_ID=cn-hangzhou -ANALYTICDB_INSTANCE_ID=gp-ab123456 -ANALYTICDB_ACCOUNT=testaccount -ANALYTICDB_PASSWORD=testpassword -ANALYTICDB_NAMESPACE=dify -ANALYTICDB_NAMESPACE_PASSWORD=difypassword -ANALYTICDB_HOST=gp-test.aliyuncs.com -ANALYTICDB_PORT=5432 -ANALYTICDB_MIN_CONNECTION=1 -ANALYTICDB_MAX_CONNECTION=5 - -# TiDB vector configurations, only available when VECTOR_STORE is `tidb_vector` -TIDB_VECTOR_HOST=tidb -TIDB_VECTOR_PORT=4000 -TIDB_VECTOR_USER= -TIDB_VECTOR_PASSWORD= -TIDB_VECTOR_DATABASE=dify - -# Matrixone vector configurations. -MATRIXONE_HOST=matrixone -MATRIXONE_PORT=6001 -MATRIXONE_USER=dump -MATRIXONE_PASSWORD=111 -MATRIXONE_DATABASE=dify - -# Tidb on qdrant configuration, only available when VECTOR_STORE is `tidb_on_qdrant` -TIDB_ON_QDRANT_URL=http://127.0.0.1 -TIDB_ON_QDRANT_API_KEY=dify -TIDB_ON_QDRANT_CLIENT_TIMEOUT=20 -TIDB_ON_QDRANT_GRPC_ENABLED=false -TIDB_ON_QDRANT_GRPC_PORT=6334 -TIDB_PUBLIC_KEY=dify -TIDB_PRIVATE_KEY=dify -TIDB_API_URL=http://127.0.0.1 -TIDB_IAM_API_URL=http://127.0.0.1 -TIDB_REGION=regions/aws-us-east-1 -TIDB_PROJECT_ID=dify -TIDB_SPEND_LIMIT=100 - -# Chroma configuration, only available when VECTOR_STORE is `chroma` -CHROMA_HOST=127.0.0.1 -CHROMA_PORT=8000 -CHROMA_TENANT=default_tenant -CHROMA_DATABASE=default_database -CHROMA_AUTH_PROVIDER=chromadb.auth.token_authn.TokenAuthClientProvider -CHROMA_AUTH_CREDENTIALS= - -# Oracle configuration, only available when VECTOR_STORE is `oracle` -ORACLE_USER=dify -ORACLE_PASSWORD=dify -ORACLE_DSN=oracle:1521/FREEPDB1 -ORACLE_CONFIG_DIR=/app/api/storage/wallet -ORACLE_WALLET_LOCATION=/app/api/storage/wallet -ORACLE_WALLET_PASSWORD=dify -ORACLE_IS_AUTONOMOUS=false - -# AlibabaCloud MySQL configuration, only available when VECTOR_STORE is `alibabcloud_mysql` -ALIBABACLOUD_MYSQL_HOST=127.0.0.1 -ALIBABACLOUD_MYSQL_PORT=3306 -ALIBABACLOUD_MYSQL_USER=root -ALIBABACLOUD_MYSQL_PASSWORD=difyai123456 -ALIBABACLOUD_MYSQL_DATABASE=dify -ALIBABACLOUD_MYSQL_MAX_CONNECTION=5 -ALIBABACLOUD_MYSQL_HNSW_M=6 - -# relyt configurations, only available when VECTOR_STORE is `relyt` -RELYT_HOST=db -RELYT_PORT=5432 -RELYT_USER=postgres -RELYT_PASSWORD=difyai123456 -RELYT_DATABASE=postgres - -# open search configuration, only available when VECTOR_STORE is `opensearch` -OPENSEARCH_HOST=opensearch -OPENSEARCH_PORT=9200 -OPENSEARCH_SECURE=true -OPENSEARCH_VERIFY_CERTS=true -OPENSEARCH_AUTH_METHOD=basic -OPENSEARCH_USER=admin -OPENSEARCH_PASSWORD=admin -# If using AWS managed IAM, e.g. Managed Cluster or OpenSearch Serverless -OPENSEARCH_AWS_REGION=ap-southeast-1 -OPENSEARCH_AWS_SERVICE=aoss - -# tencent vector configurations, only available when VECTOR_STORE is `tencent` -TENCENT_VECTOR_DB_URL=http://127.0.0.1 -TENCENT_VECTOR_DB_API_KEY=dify -TENCENT_VECTOR_DB_TIMEOUT=30 -TENCENT_VECTOR_DB_USERNAME=dify -TENCENT_VECTOR_DB_DATABASE=dify -TENCENT_VECTOR_DB_SHARD=1 -TENCENT_VECTOR_DB_REPLICAS=2 -TENCENT_VECTOR_DB_ENABLE_HYBRID_SEARCH=false - -# ElasticSearch configuration, only available when VECTOR_STORE is `elasticsearch` -ELASTICSEARCH_HOST=0.0.0.0 -ELASTICSEARCH_PORT=9200 -ELASTICSEARCH_USERNAME=elastic -ELASTICSEARCH_PASSWORD=elastic -KIBANA_PORT=5601 - -# Using ElasticSearch Cloud Serverless, or not. -ELASTICSEARCH_USE_CLOUD=false -ELASTICSEARCH_CLOUD_URL=YOUR-ELASTICSEARCH_CLOUD_URL -ELASTICSEARCH_API_KEY=YOUR-ELASTICSEARCH_API_KEY - -ELASTICSEARCH_VERIFY_CERTS=False -ELASTICSEARCH_CA_CERTS= -ELASTICSEARCH_REQUEST_TIMEOUT=100000 -ELASTICSEARCH_RETRY_ON_TIMEOUT=True -ELASTICSEARCH_MAX_RETRIES=10 - -# baidu vector configurations, only available when VECTOR_STORE is `baidu` -BAIDU_VECTOR_DB_ENDPOINT=http://127.0.0.1:5287 -BAIDU_VECTOR_DB_CONNECTION_TIMEOUT_MS=30000 -BAIDU_VECTOR_DB_ACCOUNT=root -BAIDU_VECTOR_DB_API_KEY=dify -BAIDU_VECTOR_DB_DATABASE=dify -BAIDU_VECTOR_DB_SHARD=1 -BAIDU_VECTOR_DB_REPLICAS=3 -BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER=DEFAULT_ANALYZER -BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE=COARSE_MODE -BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT=500 -BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO=0.05 -BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS=300 - -# VikingDB configurations, only available when VECTOR_STORE is `vikingdb` -VIKINGDB_ACCESS_KEY=your-ak -VIKINGDB_SECRET_KEY=your-sk -VIKINGDB_REGION=cn-shanghai -VIKINGDB_HOST=api-vikingdb.xxx.volces.com -VIKINGDB_SCHEMA=http -VIKINGDB_CONNECTION_TIMEOUT=30 -VIKINGDB_SOCKET_TIMEOUT=30 - -# Lindorm configuration, only available when VECTOR_STORE is `lindorm` -LINDORM_URL=http://localhost:30070 -LINDORM_USERNAME=admin -LINDORM_PASSWORD=admin -LINDORM_USING_UGC=True -LINDORM_QUERY_TIMEOUT=1 - -# opengauss configurations, only available when VECTOR_STORE is `opengauss` -OPENGAUSS_HOST=opengauss -OPENGAUSS_PORT=6600 -OPENGAUSS_USER=postgres -OPENGAUSS_PASSWORD=Dify@123 -OPENGAUSS_DATABASE=dify -OPENGAUSS_MIN_CONNECTION=1 -OPENGAUSS_MAX_CONNECTION=5 -OPENGAUSS_ENABLE_PQ=false - -# huawei cloud search service vector configurations, only available when VECTOR_STORE is `huawei_cloud` -HUAWEI_CLOUD_HOSTS=https://127.0.0.1:9200 -HUAWEI_CLOUD_USER=admin -HUAWEI_CLOUD_PASSWORD=admin - -# Upstash Vector configuration, only available when VECTOR_STORE is `upstash` -UPSTASH_VECTOR_URL=https://xxx-vector.upstash.io -UPSTASH_VECTOR_TOKEN=dify - -# TableStore Vector configuration -# (only used when VECTOR_STORE is tablestore) -TABLESTORE_ENDPOINT=https://instance-name.cn-hangzhou.ots.aliyuncs.com -TABLESTORE_INSTANCE_NAME=instance-name -TABLESTORE_ACCESS_KEY_ID=xxx -TABLESTORE_ACCESS_KEY_SECRET=xxx -TABLESTORE_NORMALIZE_FULLTEXT_BM25_SCORE=false - -# Clickzetta configuration, only available when VECTOR_STORE is `clickzetta` -CLICKZETTA_USERNAME= -CLICKZETTA_PASSWORD= -CLICKZETTA_INSTANCE= -CLICKZETTA_SERVICE=api.clickzetta.com -CLICKZETTA_WORKSPACE=quick_start -CLICKZETTA_VCLUSTER=default_ap -CLICKZETTA_SCHEMA=dify -CLICKZETTA_BATCH_SIZE=100 -CLICKZETTA_ENABLE_INVERTED_INDEX=true -CLICKZETTA_ANALYZER_TYPE=chinese -CLICKZETTA_ANALYZER_MODE=smart -CLICKZETTA_VECTOR_DISTANCE_FUNCTION=cosine_distance - -# InterSystems IRIS configuration, only available when VECTOR_STORE is `iris` -IRIS_HOST=iris -IRIS_SUPER_SERVER_PORT=1972 -IRIS_WEB_SERVER_PORT=52773 -IRIS_USER=_SYSTEM -IRIS_PASSWORD=Dify@1234 -IRIS_DATABASE=USER -IRIS_SCHEMA=dify -IRIS_CONNECTION_URL= -IRIS_MIN_CONNECTION=1 -IRIS_MAX_CONNECTION=3 -IRIS_TEXT_INDEX=true -IRIS_TEXT_INDEX_LANGUAGE=en -IRIS_TIMEZONE=UTC - -# ------------------------------ -# Knowledge Configuration -# ------------------------------ - -# Upload file size limit, default 15M. -UPLOAD_FILE_SIZE_LIMIT=15 - -# The maximum number of files that can be uploaded at a time, default 5. -UPLOAD_FILE_BATCH_LIMIT=5 - -# Comma-separated list of file extensions blocked from upload for security reasons. -# Extensions should be lowercase without dots (e.g., exe,bat,sh,dll). -# Empty by default to allow all file types. -# Recommended: exe,bat,cmd,com,scr,vbs,ps1,msi,dll -UPLOAD_FILE_EXTENSION_BLACKLIST= - -# Maximum number of files allowed in a single chunk attachment, default 10. -SINGLE_CHUNK_ATTACHMENT_LIMIT=10 - -# Maximum number of files allowed in a image batch upload operation -IMAGE_FILE_BATCH_LIMIT=10 - -# Maximum allowed image file size for attachments in megabytes, default 2. -ATTACHMENT_IMAGE_FILE_SIZE_LIMIT=2 - -# Timeout for downloading image attachments in seconds, default 60. -ATTACHMENT_IMAGE_DOWNLOAD_TIMEOUT=60 - - -# ETL type, support: `dify`, `Unstructured` -# `dify` Dify's proprietary file extraction scheme -# `Unstructured` Unstructured.io file extraction scheme -ETL_TYPE=dify - -# Unstructured API path and API key, needs to be configured when ETL_TYPE is Unstructured -# Or using Unstructured for document extractor node for pptx. -# For example: http://unstructured:8000/general/v0/general -UNSTRUCTURED_API_URL= -UNSTRUCTURED_API_KEY= -SCARF_NO_ANALYTICS=true - -# ------------------------------ -# Model Configuration -# ------------------------------ - -# The maximum number of tokens allowed for prompt generation. -# This setting controls the upper limit of tokens that can be used by the LLM -# when generating a prompt in the prompt generation tool. -# Default: 512 tokens. -PROMPT_GENERATION_MAX_TOKENS=512 - -# The maximum number of tokens allowed for code generation. -# This setting controls the upper limit of tokens that can be used by the LLM -# when generating code in the code generation tool. -# Default: 1024 tokens. -CODE_GENERATION_MAX_TOKENS=1024 - -# Enable or disable plugin based token counting. If disabled, token counting will return 0. -# This can improve performance by skipping token counting operations. -# Default: false (disabled). -PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false - -# ------------------------------ -# Multi-modal Configuration -# ------------------------------ - -# The format of the image/video/audio/document sent when the multi-modal model is input, -# the default is base64, optional url. -# The delay of the call in url mode will be lower than that in base64 mode. -# It is generally recommended to use the more compatible base64 mode. -# If configured as url, you need to configure FILES_URL as an externally accessible address so that the multi-modal model can access the image/video/audio/document. -MULTIMODAL_SEND_FORMAT=base64 -# Upload image file size limit, default 10M. -UPLOAD_IMAGE_FILE_SIZE_LIMIT=10 -# Upload video file size limit, default 100M. -UPLOAD_VIDEO_FILE_SIZE_LIMIT=100 -# Upload audio file size limit, default 50M. -UPLOAD_AUDIO_FILE_SIZE_LIMIT=50 - -# ------------------------------ -# Sentry Configuration -# Used for application monitoring and error log tracking. -# ------------------------------ -SENTRY_DSN= - -# API Service Sentry DSN address, default is empty, when empty, -# all monitoring information is not reported to Sentry. -# If not set, Sentry error reporting will be disabled. -API_SENTRY_DSN= -# API Service The reporting ratio of Sentry events, if it is 0.01, it is 1%. -API_SENTRY_TRACES_SAMPLE_RATE=1.0 -# API Service The reporting ratio of Sentry profiles, if it is 0.01, it is 1%. -API_SENTRY_PROFILES_SAMPLE_RATE=1.0 - -# Web Service Sentry DSN address, default is empty, when empty, -# all monitoring information is not reported to Sentry. -# If not set, Sentry error reporting will be disabled. -WEB_SENTRY_DSN= - -# Plugin_daemon Service Sentry DSN address, default is empty, when empty, -# all monitoring information is not reported to Sentry. -# If not set, Sentry error reporting will be disabled. -PLUGIN_SENTRY_ENABLED=false -PLUGIN_SENTRY_DSN= - -# ------------------------------ -# Notion Integration Configuration -# Variables can be obtained by applying for Notion integration: https://www.notion.so/my-integrations -# ------------------------------ - -# Configure as "public" or "internal". -# Since Notion's OAuth redirect URL only supports HTTPS, -# if deploying locally, please use Notion's internal integration. -NOTION_INTEGRATION_TYPE=public -# Notion OAuth client secret (used for public integration type) -NOTION_CLIENT_SECRET= -# Notion OAuth client id (used for public integration type) -NOTION_CLIENT_ID= -# Notion internal integration secret. -# If the value of NOTION_INTEGRATION_TYPE is "internal", -# you need to configure this variable. -NOTION_INTERNAL_SECRET= - -# ------------------------------ -# Mail related configuration -# ------------------------------ - -# Mail type, support: resend, smtp, sendgrid -MAIL_TYPE= - -# Default send from email address, if not specified -# If using SendGrid, use the 'from' field for authentication if necessary. -MAIL_DEFAULT_SEND_FROM= - -# API-Key for the Resend email provider, used when MAIL_TYPE is `resend`. -RESEND_API_URL=https://api.resend.com -RESEND_API_KEY= - - -# SMTP server configuration, used when MAIL_TYPE is `smtp` -SMTP_SERVER= -SMTP_PORT=465 -SMTP_USERNAME= -SMTP_PASSWORD= -SMTP_USE_TLS=true -SMTP_OPPORTUNISTIC_TLS=false -# Optional: override the local hostname used for SMTP HELO/EHLO -SMTP_LOCAL_HOSTNAME= - -# Sendgid configuration -SENDGRID_API_KEY= - -# ------------------------------ -# Others Configuration -# ------------------------------ - -# Maximum length of segmentation tokens for indexing -INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000 - -# Member invitation link valid time (hours), -# Default: 72. -INVITE_EXPIRY_HOURS=72 - -# Reset password token valid time (minutes), -RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 -EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 -CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 -OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 - -# The sandbox service endpoint. -CODE_EXECUTION_ENDPOINT=http://sandbox:8194 -CODE_EXECUTION_API_KEY=dify-sandbox -CODE_EXECUTION_SSL_VERIFY=True -CODE_EXECUTION_POOL_MAX_CONNECTIONS=100 -CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS=20 -CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY=5.0 -CODE_MAX_NUMBER=9223372036854775807 -CODE_MIN_NUMBER=-9223372036854775808 -CODE_MAX_DEPTH=5 -CODE_MAX_PRECISION=20 -CODE_MAX_STRING_LENGTH=400000 -CODE_MAX_STRING_ARRAY_LENGTH=30 -CODE_MAX_OBJECT_ARRAY_LENGTH=30 -CODE_MAX_NUMBER_ARRAY_LENGTH=1000 -CODE_EXECUTION_CONNECT_TIMEOUT=10 -CODE_EXECUTION_READ_TIMEOUT=60 -CODE_EXECUTION_WRITE_TIMEOUT=10 -TEMPLATE_TRANSFORM_MAX_LENGTH=400000 - -# Workflow runtime configuration -WORKFLOW_MAX_EXECUTION_STEPS=500 -WORKFLOW_MAX_EXECUTION_TIME=1200 -WORKFLOW_CALL_MAX_DEPTH=5 -MAX_VARIABLE_SIZE=204800 -WORKFLOW_FILE_UPLOAD_LIMIT=10 - -# GraphEngine Worker Pool Configuration -# Minimum number of workers per GraphEngine instance (default: 1) -GRAPH_ENGINE_MIN_WORKERS=1 -# Maximum number of workers per GraphEngine instance (default: 10) -GRAPH_ENGINE_MAX_WORKERS=10 -# Queue depth threshold that triggers worker scale up (default: 3) -GRAPH_ENGINE_SCALE_UP_THRESHOLD=3 -# Seconds of idle time before scaling down workers (default: 5.0) -GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME=5.0 - -# Workflow storage configuration -# Options: rdbms, hybrid -# rdbms: Use only the relational database (default) -# hybrid: Save new data to object storage, read from both object storage and RDBMS -WORKFLOW_NODE_EXECUTION_STORAGE=rdbms - -# Repository configuration -# Core workflow execution repository implementation -# Options: -# - core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository (default) -# - core.repositories.celery_workflow_execution_repository.CeleryWorkflowExecutionRepository -# - extensions.logstore.repositories.logstore_workflow_execution_repository.LogstoreWorkflowExecutionRepository -CORE_WORKFLOW_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository - -# Core workflow node execution repository implementation -# Options: -# - core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository (default) -# - core.repositories.celery_workflow_node_execution_repository.CeleryWorkflowNodeExecutionRepository -# - extensions.logstore.repositories.logstore_workflow_node_execution_repository.LogstoreWorkflowNodeExecutionRepository -CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository - -# API workflow run repository implementation -# Options: -# - repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository (default) -# - extensions.logstore.repositories.logstore_api_workflow_run_repository.LogstoreAPIWorkflowRunRepository -API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository - -# API workflow node execution repository implementation -# Options: -# - repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository (default) -# - extensions.logstore.repositories.logstore_api_workflow_node_execution_repository.LogstoreAPIWorkflowNodeExecutionRepository -API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository - -# Workflow log cleanup configuration -# Enable automatic cleanup of workflow run logs to manage database size -WORKFLOW_LOG_CLEANUP_ENABLED=false -# Number of days to retain workflow run logs (default: 30 days) -WORKFLOW_LOG_RETENTION_DAYS=30 -# Batch size for workflow log cleanup operations (default: 100) -WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100 -# Comma-separated list of workflow IDs to clean logs for -WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS= - -# Aliyun SLS Logstore Configuration -# Aliyun Access Key ID -ALIYUN_SLS_ACCESS_KEY_ID= -# Aliyun Access Key Secret -ALIYUN_SLS_ACCESS_KEY_SECRET= -# Aliyun SLS Endpoint (e.g., cn-hangzhou.log.aliyuncs.com) -ALIYUN_SLS_ENDPOINT= -# Aliyun SLS Region (e.g., cn-hangzhou) -ALIYUN_SLS_REGION= -# Aliyun SLS Project Name -ALIYUN_SLS_PROJECT_NAME= -# Number of days to retain workflow run logs (default: 365 days, 3650 for permanent storage) -ALIYUN_SLS_LOGSTORE_TTL=365 -# Enable dual-write to both SLS LogStore and SQL database (default: false) -LOGSTORE_DUAL_WRITE_ENABLED=false -# Enable dual-read fallback to SQL database when LogStore returns no results (default: true) -# Useful for migration scenarios where historical data exists only in SQL database -LOGSTORE_DUAL_READ_ENABLED=true -# Control flag for whether to write the `graph` field to LogStore. -# If LOGSTORE_ENABLE_PUT_GRAPH_FIELD is "true", write the full `graph` field; -# otherwise write an empty {} instead. Defaults to writing the `graph` field. -LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true - -# HTTP request node in workflow configuration -HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760 -HTTP_REQUEST_NODE_MAX_TEXT_SIZE=1048576 -HTTP_REQUEST_NODE_SSL_VERIFY=True - -# HTTP request node timeout configuration -# Maximum timeout values (in seconds) that users can set in HTTP request nodes -# - Connect timeout: Time to wait for establishing connection (default: 10s) -# - Read timeout: Time to wait for receiving response data (default: 600s, 10 minutes) -# - Write timeout: Time to wait for sending request data (default: 600s, 10 minutes) -HTTP_REQUEST_MAX_CONNECT_TIMEOUT=10 -HTTP_REQUEST_MAX_READ_TIMEOUT=600 -HTTP_REQUEST_MAX_WRITE_TIMEOUT=600 - -# Base64 encoded CA certificate data for custom certificate verification (PEM format, optional) -# HTTP_REQUEST_NODE_SSL_CERT_DATA=LS0tLS1CRUdJTi... -# Base64 encoded client certificate data for mutual TLS authentication (PEM format, optional) -# HTTP_REQUEST_NODE_SSL_CLIENT_CERT_DATA=LS0tLS1CRUdJTi... -# Base64 encoded client private key data for mutual TLS authentication (PEM format, optional) -# HTTP_REQUEST_NODE_SSL_CLIENT_KEY_DATA=LS0tLS1CRUdJTi... - -# Webhook request configuration -WEBHOOK_REQUEST_BODY_MAX_SIZE=10485760 - -# Respect X-* headers to redirect clients -RESPECT_XFORWARD_HEADERS_ENABLED=false - -# SSRF Proxy server HTTP URL -SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128 -# SSRF Proxy server HTTPS URL -SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128 - -# Maximum loop count in the workflow -LOOP_NODE_MAX_COUNT=100 - -# The maximum number of tools that can be used in the agent. -MAX_TOOLS_NUM=10 - -# Maximum number of Parallelism branches in the workflow -MAX_PARALLEL_LIMIT=10 - -# The maximum number of iterations for agent setting -MAX_ITERATIONS_NUM=99 - -# ------------------------------ -# Environment Variables for web Service -# ------------------------------ - -# The timeout for the text generation in millisecond -TEXT_GENERATION_TIMEOUT_MS=60000 - -# Enable the experimental vinext runtime shipped in the image. -EXPERIMENTAL_ENABLE_VINEXT=false - -# Allow inline style attributes in Markdown rendering. -# Enable this if your workflows use Jinja2 templates with styled HTML. -# Only recommended for self-hosted deployments with trusted content. -ALLOW_INLINE_STYLES=false - -# Allow rendering unsafe URLs which have "data:" scheme. -ALLOW_UNSAFE_DATA_SCHEME=false - -# Maximum number of tree depth in the workflow -MAX_TREE_DEPTH=50 - -# ------------------------------ -# Environment Variables for database Service -# ------------------------------ -# Postgres data directory -PGDATA=/var/lib/postgresql/data/pgdata - -# MySQL Default Configuration -MYSQL_HOST_VOLUME=./volumes/mysql/data - -# ------------------------------ -# Environment Variables for sandbox Service -# ------------------------------ - -# The API key for the sandbox service -SANDBOX_API_KEY=dify-sandbox -# The mode in which the Gin framework runs -SANDBOX_GIN_MODE=release -# The timeout for the worker in seconds -SANDBOX_WORKER_TIMEOUT=15 -# Enable network for the sandbox service -SANDBOX_ENABLE_NETWORK=true -# HTTP proxy URL for SSRF protection -SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128 -# HTTPS proxy URL for SSRF protection -SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128 -# The port on which the sandbox service runs -SANDBOX_PORT=8194 - -# ------------------------------ -# Environment Variables for weaviate Service -# (only used when VECTOR_STORE is weaviate) -# ------------------------------ WEAVIATE_PERSISTENCE_DATA_PATH=/var/lib/weaviate WEAVIATE_QUERY_DEFAULTS_LIMIT=25 WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true @@ -1259,118 +167,26 @@ WEAVIATE_ENABLE_TOKENIZER_GSE=false WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA=false WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR=false -# ------------------------------ -# Environment Variables for Chroma -# (only used when VECTOR_STORE is chroma) -# ------------------------------ - -# Authentication credentials for Chroma server -CHROMA_SERVER_AUTHN_CREDENTIALS=difyai123456 -# Authentication provider for Chroma server -CHROMA_SERVER_AUTHN_PROVIDER=chromadb.auth.token_authn.TokenAuthenticationServerProvider -# Persistence setting for Chroma server -CHROMA_IS_PERSISTENT=TRUE - -# ------------------------------ -# Environment Variables for Oracle Service -# (only used when VECTOR_STORE is oracle) -# ------------------------------ -ORACLE_PWD=Dify123456 -ORACLE_CHARACTERSET=AL32UTF8 - -# ------------------------------ -# Environment Variables for milvus Service -# (only used when VECTOR_STORE is milvus) -# ------------------------------ -# ETCD configuration for auto compaction mode -ETCD_AUTO_COMPACTION_MODE=revision -# ETCD configuration for auto compaction retention in terms of number of revisions -ETCD_AUTO_COMPACTION_RETENTION=1000 -# ETCD configuration for backend quota in bytes -ETCD_QUOTA_BACKEND_BYTES=4294967296 -# ETCD configuration for the number of changes before triggering a snapshot -ETCD_SNAPSHOT_COUNT=50000 -# MinIO access key for authentication -MINIO_ACCESS_KEY=minioadmin -# MinIO secret key for authentication -MINIO_SECRET_KEY=minioadmin -# ETCD service endpoints -ETCD_ENDPOINTS=etcd:2379 -# MinIO service address -MINIO_ADDRESS=minio:9000 -# Enable or disable security authorization -MILVUS_AUTHORIZATION_ENABLED=true - -# ------------------------------ -# Environment Variables for pgvector / pgvector-rs Service -# (only used when VECTOR_STORE is pgvector / pgvector-rs) -# ------------------------------ -PGVECTOR_PGUSER=postgres -# The password for the default postgres user. -PGVECTOR_POSTGRES_PASSWORD=difyai123456 -# The name of the default postgres database. -PGVECTOR_POSTGRES_DB=dify -# postgres data directory -PGVECTOR_PGDATA=/var/lib/postgresql/data/pgdata - -# ------------------------------ -# Environment Variables for opensearch -# (only used when VECTOR_STORE is opensearch) -# ------------------------------ -OPENSEARCH_DISCOVERY_TYPE=single-node -OPENSEARCH_BOOTSTRAP_MEMORY_LOCK=true -OPENSEARCH_JAVA_OPTS_MIN=512m -OPENSEARCH_JAVA_OPTS_MAX=1024m -OPENSEARCH_INITIAL_ADMIN_PASSWORD=Qazwsxedc!@#123 -OPENSEARCH_MEMLOCK_SOFT=-1 -OPENSEARCH_MEMLOCK_HARD=-1 -OPENSEARCH_NOFILE_SOFT=65536 -OPENSEARCH_NOFILE_HARD=65536 - -# ------------------------------ -# Environment Variables for Nginx reverse proxy -# ------------------------------ -NGINX_SERVER_NAME=_ -NGINX_HTTPS_ENABLED=false -# HTTP port -NGINX_PORT=80 -# SSL settings are only applied when HTTPS_ENABLED is true -NGINX_SSL_PORT=443 -# if HTTPS_ENABLED is true, you're required to add your own SSL certificates/keys to the `./nginx/ssl` directory -# and modify the env vars below accordingly. -NGINX_SSL_CERT_FILENAME=dify.crt -NGINX_SSL_CERT_KEY_FILENAME=dify.key -NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3 - -# Nginx performance tuning -NGINX_WORKER_PROCESSES=auto -NGINX_CLIENT_MAX_BODY_SIZE=100M -NGINX_KEEPALIVE_TIMEOUT=65 - -# Proxy settings -NGINX_PROXY_READ_TIMEOUT=3600s -NGINX_PROXY_SEND_TIMEOUT=3600s - -# Set true to accept requests for /.well-known/acme-challenge/ -NGINX_ENABLE_CERTBOT_CHALLENGE=false - -# ------------------------------ -# Certbot Configuration -# ------------------------------ - -# Email address (required to get certificates from Let's Encrypt) -CERTBOT_EMAIL= - -# Domain name -CERTBOT_DOMAIN= - -# certbot command options -# i.e: --force-renewal --dry-run --test-cert --debug -CERTBOT_OPTIONS= - -# ------------------------------ -# Environment Variables for SSRF Proxy -# ------------------------------ +# Sandbox and SSRF proxy +CODE_EXECUTION_ENDPOINT=http://sandbox:8194 +CODE_EXECUTION_API_KEY=dify-sandbox +CODE_EXECUTION_SSL_VERIFY=True +CODE_EXECUTION_POOL_MAX_CONNECTIONS=100 +CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS=20 +CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY=5.0 +CODE_EXECUTION_CONNECT_TIMEOUT=10 +CODE_EXECUTION_READ_TIMEOUT=60 +CODE_EXECUTION_WRITE_TIMEOUT=10 +SANDBOX_API_KEY=dify-sandbox +SANDBOX_GIN_MODE=release +SANDBOX_WORKER_TIMEOUT=15 +SANDBOX_ENABLE_NETWORK=true +SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128 +SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128 +SANDBOX_PORT=8194 +PIP_MIRROR_URL= +SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128 +SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128 SSRF_HTTP_PORT=3128 SSRF_COREDUMP_DIR=/var/spool/squid SSRF_REVERSE_PROXY_PORT=8194 @@ -1383,67 +199,7 @@ SSRF_POOL_MAX_CONNECTIONS=100 SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS=20 SSRF_POOL_KEEPALIVE_EXPIRY=5.0 -# ------------------------------ -# docker env var for specifying vector db and metadata db type at startup -# (based on the vector db and metadata db type, the corresponding docker -# compose profile will be used) -# if you want to use unstructured, add ',unstructured' to the end -# ------------------------------ -COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql} - -# ------------------------------ -# Worker health check configuration for worker and worker_beat services. -# Set to false to enable the health check. -# Note: enabling the health check may cause periodic CPU spikes and increased load, -# as it establishes a broker connection and sends a Celery ping on every check interval. -# ------------------------------ -COMPOSE_WORKER_HEALTHCHECK_DISABLED=true -# Interval between health checks (e.g. 30s, 1m) -COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s -# Timeout for each health check (e.g. 30s, 1m) -COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s - -# ------------------------------ -# Docker Compose Service Expose Host Port Configurations -# ------------------------------ -EXPOSE_NGINX_PORT=80 -EXPOSE_NGINX_SSL_PORT=443 - -# ---------------------------------------------------------------------------- -# ModelProvider & Tool Position Configuration -# Used to specify the model providers and tools that can be used in the app. -# ---------------------------------------------------------------------------- - -# Pin, include, and exclude tools -# Use comma-separated values with no spaces between items. -# Example: POSITION_TOOL_PINS=bing,google -POSITION_TOOL_PINS= -POSITION_TOOL_INCLUDES= -POSITION_TOOL_EXCLUDES= - -# Pin, include, and exclude model providers -# Use comma-separated values with no spaces between items. -# Example: POSITION_PROVIDER_PINS=openai,openllm -POSITION_PROVIDER_PINS= -POSITION_PROVIDER_INCLUDES= -POSITION_PROVIDER_EXCLUDES= - -# CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -CSP_WHITELIST= - -# Enable or disable create tidb service job -CREATE_TIDB_SERVICE_JOB_ENABLED=false - -# Maximum number of submitted thread count in a ThreadPool for parallel node execution -MAX_SUBMIT_COUNT=100 - -# The maximum number of top-k value for RAG. -TOP_K_MAX_VALUE=10 - -# ------------------------------ -# Plugin Daemon Configuration -# ------------------------------ - +# Plugin daemon DB_PLUGIN_DATABASE=dify_plugin EXPOSE_PLUGIN_DAEMON_PORT=5002 PLUGIN_DAEMON_PORT=5002 @@ -1452,180 +208,44 @@ PLUGIN_DAEMON_URL=http://plugin_daemon:5002 PLUGIN_MAX_PACKAGE_SIZE=52428800 PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600 PLUGIN_PPROF_ENABLED=false - PLUGIN_DEBUGGING_HOST=0.0.0.0 PLUGIN_DEBUGGING_PORT=5003 EXPOSE_PLUGIN_DEBUGGING_HOST=localhost EXPOSE_PLUGIN_DEBUGGING_PORT=5003 - -# If this key is changed, DIFY_INNER_API_KEY in plugin_daemon service must also be updated or agent node will fail. PLUGIN_DIFY_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 PLUGIN_DIFY_INNER_API_URL=http://api:5001 - -ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id} - -MARKETPLACE_ENABLED=true -MARKETPLACE_API_URL=https://marketplace.dify.ai - -# Creators Platform configuration -CREATORS_PLATFORM_FEATURES_ENABLED=true -CREATORS_PLATFORM_API_URL=https://creators.dify.ai -CREATORS_PLATFORM_OAUTH_CLIENT_ID= - FORCE_VERIFYING_SIGNATURE=true -ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES=true - PLUGIN_STDIO_BUFFER_SIZE=1024 PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880 - PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120 -# Plugin Daemon side timeout (configure to match the API side below) PLUGIN_MAX_EXECUTION_TIMEOUT=600 -# API side timeout (configure to match the Plugin Daemon side above) -PLUGIN_DAEMON_TIMEOUT=600.0 -# PIP_MIRROR_URL=https://pypi.tuna.tsinghua.edu.cn/simple -PIP_MIRROR_URL= - -# https://github.com/langgenius/dify-plugin-daemon/blob/main/.env.example -# Plugin storage type, local aws_s3 tencent_cos azure_blob aliyun_oss volcengine_tos PLUGIN_STORAGE_TYPE=local PLUGIN_STORAGE_LOCAL_ROOT=/app/storage PLUGIN_WORKING_PATH=/app/storage/cwd PLUGIN_INSTALLED_PATH=plugin PLUGIN_PACKAGE_CACHE_PATH=plugin_packages PLUGIN_MEDIA_CACHE_PATH=assets -# Plugin oss bucket PLUGIN_STORAGE_OSS_BUCKET= -# Plugin oss s3 credentials -PLUGIN_S3_USE_AWS=false -PLUGIN_S3_USE_AWS_MANAGED_IAM=false -PLUGIN_S3_ENDPOINT= -PLUGIN_S3_USE_PATH_STYLE=false -PLUGIN_AWS_ACCESS_KEY= -PLUGIN_AWS_SECRET_KEY= -PLUGIN_AWS_REGION= -# Plugin oss azure blob -PLUGIN_AZURE_BLOB_STORAGE_CONTAINER_NAME= -PLUGIN_AZURE_BLOB_STORAGE_CONNECTION_STRING= -# Plugin oss tencent cos -PLUGIN_TENCENT_COS_SECRET_KEY= -PLUGIN_TENCENT_COS_SECRET_ID= -PLUGIN_TENCENT_COS_REGION= -# Plugin oss aliyun oss -PLUGIN_ALIYUN_OSS_REGION= -PLUGIN_ALIYUN_OSS_ENDPOINT= -PLUGIN_ALIYUN_OSS_ACCESS_KEY_ID= -PLUGIN_ALIYUN_OSS_ACCESS_KEY_SECRET= -PLUGIN_ALIYUN_OSS_AUTH_VERSION=v4 -PLUGIN_ALIYUN_OSS_PATH= -# Plugin oss volcengine tos -PLUGIN_VOLCENGINE_TOS_ENDPOINT= -PLUGIN_VOLCENGINE_TOS_ACCESS_KEY= -PLUGIN_VOLCENGINE_TOS_SECRET_KEY= -PLUGIN_VOLCENGINE_TOS_REGION= +PLUGIN_SENTRY_ENABLED=false +PLUGIN_SENTRY_DSN= +MARKETPLACE_ENABLED=true +MARKETPLACE_API_URL=https://marketplace.dify.ai +MARKETPLACE_URL= -# ------------------------------ -# OTLP Collector Configuration -# ------------------------------ -ENABLE_OTEL=false -OTLP_TRACE_ENDPOINT= -OTLP_METRIC_ENDPOINT= -OTLP_BASE_ENDPOINT=http://localhost:4318 -OTLP_API_KEY= -OTEL_EXPORTER_OTLP_PROTOCOL= -OTEL_EXPORTER_TYPE=otlp -OTEL_SAMPLING_RATE=0.1 -OTEL_BATCH_EXPORT_SCHEDULE_DELAY=5000 -OTEL_MAX_QUEUE_SIZE=2048 -OTEL_MAX_EXPORT_BATCH_SIZE=512 -OTEL_METRIC_EXPORT_INTERVAL=60000 -OTEL_BATCH_EXPORT_TIMEOUT=10000 -OTEL_METRIC_EXPORT_TIMEOUT=30000 - -# Prevent Clickjacking -ALLOW_EMBED=false - -# Dataset queue monitor configuration -QUEUE_MONITOR_THRESHOLD=200 -# You can configure multiple ones, separated by commas. eg: test1@dify.ai,test2@dify.ai -QUEUE_MONITOR_ALERT_EMAILS= -# Monitor interval in minutes, default is 30 minutes -QUEUE_MONITOR_INTERVAL=30 - -# Swagger UI configuration -SWAGGER_UI_ENABLED=false -SWAGGER_UI_PATH=/swagger-ui.html - -# Whether to encrypt dataset IDs when exporting DSL files (default: true) -# Set to false to export dataset IDs as plain text for easier cross-environment import -DSL_EXPORT_ENCRYPT_DATASET_ID=true - -# Maximum number of segments for dataset segments API (0 for unlimited) -DATASET_MAX_SEGMENTS_PER_REQUEST=0 - -# Celery schedule tasks configuration -ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false -ENABLE_CLEAN_UNUSED_DATASETS_TASK=false -ENABLE_CREATE_TIDB_SERVERLESS_TASK=false -ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK=false -ENABLE_CLEAN_MESSAGES=false -ENABLE_WORKFLOW_RUN_CLEANUP_TASK=false -ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK=false -ENABLE_DATASETS_QUEUE_MONITOR=false -ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK=true -ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK=true -WORKFLOW_SCHEDULE_POLLER_INTERVAL=1 -WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE=100 -WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK=0 - -# Tenant isolated task queue configuration -TENANT_ISOLATED_TASK_CONCURRENCY=1 - -# Maximum allowed CSV file size for annotation import in megabytes -ANNOTATION_IMPORT_FILE_SIZE_LIMIT=2 -#Maximum number of annotation records allowed in a single import -ANNOTATION_IMPORT_MAX_RECORDS=10000 -# Minimum number of annotation records required in a single import -ANNOTATION_IMPORT_MIN_RECORDS=1 -ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5 -ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20 -# Maximum number of concurrent annotation import tasks per tenant -ANNOTATION_IMPORT_MAX_CONCURRENT=5 - -# The API key of amplitude -AMPLITUDE_API_KEY= - -# Sandbox expired records clean configuration -SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21 -SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000 -SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200 -SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30 - - -# Redis URL used for event bus between API and -# celery worker -# defaults to url constructed from `REDIS_*` -# configurations -EVENT_BUS_REDIS_URL= -# Event transport type. Options are: -# -# - pubsub: normal Pub/Sub (at-most-once) -# - sharded: sharded Pub/Sub (at-most-once) -# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races) -# -# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs. -# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce -# the risk of data loss from Redis auto-eviction under memory pressure. -# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE. -EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub -# Whether to use Redis cluster mode while use redis as event bus. -# It's highly recommended to enable this for large deployments. -EVENT_BUS_REDIS_USE_CLUSTERS=false - -# Whether to Enable human input timeout check task -ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true -# Human input timeout check interval in minutes -HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1 - - -SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000 +# Nginx and Docker Compose +NGINX_SERVER_NAME=_ +NGINX_HTTPS_ENABLED=false +NGINX_PORT=80 +NGINX_SSL_PORT=443 +NGINX_SSL_CERT_FILENAME=dify.crt +NGINX_SSL_CERT_KEY_FILENAME=dify.key +NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3 +NGINX_WORKER_PROCESSES=auto +NGINX_CLIENT_MAX_BODY_SIZE=100M +NGINX_KEEPALIVE_TIMEOUT=65 +NGINX_PROXY_READ_TIMEOUT=3600s +NGINX_PROXY_SEND_TIMEOUT=3600s +NGINX_ENABLE_CERTBOT_CHALLENGE=false +EXPOSE_NGINX_PORT=80 +EXPOSE_NGINX_SSL_PORT=443 +COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql} diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000000..c3a47ad592 --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,3 @@ +# Ignore actual .env files (keep only .env.example files in git) +*.env +!*.env.example diff --git a/docker/README.md b/docker/README.md index 3a7f4c2ad5..a2d9b2eeba 100644 --- a/docker/README.md +++ b/docker/README.md @@ -7,29 +7,31 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T - **Certbot Container**: `docker-compose.yaml` now contains `certbot` for managing SSL certificates. This container automatically renews certificates and ensures secure HTTPS connections.\ For more information, refer `docker/certbot/README.md`. -- **Persistent Environment Variables**: Default environment variables are managed through `.env.default`, while local overrides are stored in `.env`, ensuring that your configurations persist across deployments. +- **Persistent Environment Variables**: Essential startup defaults are provided in `.env.example`, while local values are stored in `.env`, ensuring that your configurations persist across deployments. > What is `.env`?

- > The `.env` file is a local override file. Keep it small by adding only the values that differ from `.env.default`. Use `.env.example` as the full reference when you need advanced configuration. + > The `.env` file is the local startup file. Copy it from `.env.example` for a default deployment. Optional advanced settings live in `envs/*.env.example` files. - **Unified Vector Database Services**: All vector database services are now managed from a single Docker Compose file `docker-compose.yaml`. You can switch between different vector databases by setting the `VECTOR_STORE` environment variable in your `.env` file. -- **Local .env Overrides**: The `dify-compose` and `dify-compose.ps1` wrappers create `.env` if it is missing and generate a persistent `SECRET_KEY` for this deployment. - ### How to Deploy Dify with `docker-compose.yaml` 1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system. 1. **Environment Setup**: - Navigate to the `docker` directory. - - No copy step is required. The `dify-compose` wrappers create `.env` if it is missing and write a generated `SECRET_KEY` to it. - - When prompted on first run, press Enter to use the default deployment, or answer `y` to stop and edit `.env` first. - - Customize `.env` only when you need to override defaults from `.env.default`. Refer to `.env.example` for the full list of available variables. + - Copy `.env.example` to `.env`. + - Customize `.env` when you need to change essential startup defaults. Copy optional files from `envs/` without the `.example` suffix when you need advanced settings. - **Optional (for advanced deployments)**: If you maintain a full `.env` file copied from `.env.example`, you may use the environment synchronization tool to keep it aligned with the latest `.env.example` updates while preserving your custom settings. See the [Environment Variables Synchronization](#environment-variables-synchronization) section below. 1. **Running the Services**: - - Execute `./dify-compose up -d` from the `docker` directory to start the services. On Windows PowerShell, run `.\dify-compose.ps1 up -d`. + - Execute `docker compose up -d` from the `docker` directory to start the services. - To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`. + ```bash + cp .env.example .env + docker compose up -d + ``` + 1. **SSL Certificate Setup**: - Refer `docker/certbot/README.md` to set up SSL certificates using Certbot. 1. **OpenTelemetry Collector Setup**: @@ -41,7 +43,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T 1. **Middleware Setup**: - Use the `docker-compose.middleware.yaml` for setting up essential middleware services like databases and caches. - Navigate to the `docker` directory. - - Ensure the `middleware.env` file is created by running `cp middleware.env.example middleware.env` (refer to the `middleware.env.example` file). + - Ensure the `middleware.env` file is created by running `cp envs/middleware.env.example middleware.env` (refer to the `envs/middleware.env.example` file). 1. **Running Middleware Services**: - Navigate to the `docker` directory. - Execute `docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d` to start PostgreSQL/MySQL (per `DB_TYPE`) plus the bundled Weaviate instance. @@ -58,13 +60,13 @@ For users migrating from the `docker-legacy` setup: 1. **Data Migration**: - Ensure that data from services like databases and caches is backed up and migrated appropriately to the new structure if necessary. -### Overview of `.env.default`, `.env`, and `.env.example` +### Overview of `.env`, `.env.example`, and `envs/` -- `.env.default` contains the minimal default configuration for Docker Compose deployments. -- `.env` contains the generated `SECRET_KEY` plus any local overrides. -- `.env.example` is the full reference for advanced configuration. +- `.env.example` contains the essential default configuration for Docker Compose deployments. +- `.env` contains local startup values copied from `.env.example` and any local changes. +- `envs/*.env.example` files contain optional advanced configuration grouped by theme. -The `dify-compose` wrappers merge `.env.default` and `.env` into a temporary environment file, append paired internal service keys when needed, and remove the temporary file after Docker Compose starts. +Docker Compose reads `envs/*.env` files when present, then reads `.env` last so values in `.env` take precedence. #### Key Modules and Customization @@ -74,7 +76,7 @@ The `dify-compose` wrappers merge `.env.default` and `.env` into a temporary env #### Other notable variables -The `.env.example` file provided in the Docker setup is extensive and covers a wide range of configuration options. It is structured into several sections, each pertaining to different aspects of the application and its services. Here are some of the key sections and variables: +The root `.env.example` file contains the essential startup settings. Optional and provider-specific settings are grouped in `envs/*.env.example` files. Here are some of the key sections and variables: 1. **Common Variables**: @@ -102,7 +104,7 @@ The `.env.example` file provided in the Docker setup is extensive and covers a w 1. **Storage Configuration**: - - `STORAGE_TYPE`, `S3_BUCKET_NAME`, `AZURE_BLOB_ACCOUNT_NAME`: Settings for file storage options like local, S3, Azure Blob, etc. + - `STORAGE_TYPE`, `OPENDAL_SCHEME`, `OPENDAL_FS_ROOT`: Default local file storage settings. Optional storage backends are configured from the files under `envs/`. 1. **Vector Database Configuration**: @@ -124,11 +126,11 @@ The `.env.example` file provided in the Docker setup is extensive and covers a w ### Environment Variables Synchronization -When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.default` or `.env.example`. +When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example` or the optional files under `envs/`. -If you use the default override-only workflow, review `.env.default` and add only the values you need to override to `.env`. +If you use the default workflow, review `.env.example` and keep your `.env` aligned with essential startup values. -If you maintain a full `.env` file copied from `.env.example`, an optional environment variables synchronization tool is provided. +If you maintain a customized `.env` file copied from `.env.example`, an optional environment variables synchronization tool is provided. > This tool performs a **one-way synchronization** from `.env.example` to `.env`. > Existing values in `.env` are never overwritten automatically. diff --git a/docker/dify-compose b/docker/dify-compose deleted file mode 100755 index 16bbd6b538..0000000000 --- a/docker/dify-compose +++ /dev/null @@ -1,334 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" - -DEFAULT_ENV_FILE=".env.default" -USER_ENV_FILE=".env" - -log() { - printf '%s\n' "$*" >&2 -} - -die() { - printf 'Error: %s\n' "$*" >&2 - exit 1 -} - -detect_compose() { - if docker compose version >/dev/null 2>&1; then - COMPOSE_CMD=(docker compose) - return - fi - - if command -v docker-compose >/dev/null 2>&1; then - COMPOSE_CMD=(docker-compose) - return - fi - - die "Docker Compose is not available. Install Docker Compose, then run this command again." -} - -generate_secret_key() { - if command -v openssl >/dev/null 2>&1; then - openssl rand -base64 42 - return - fi - - if command -v dd >/dev/null 2>&1 && command -v base64 >/dev/null 2>&1; then - dd if=/dev/urandom bs=42 count=1 2>/dev/null | base64 | tr -d '\n' - printf '\n' - return - fi - - return 1 -} - -ensure_env_files() { - [[ -f "$DEFAULT_ENV_FILE" ]] || die "$DEFAULT_ENV_FILE is missing." - - if [[ -f "$USER_ENV_FILE" ]]; then - return - fi - - : >"$USER_ENV_FILE" - - if [[ ! -t 0 ]]; then - log "Created $USER_ENV_FILE for local overrides." - return - fi - - printf 'Created %s for local overrides.\n' "$USER_ENV_FILE" - printf 'Do you need a custom deployment now? (Most users can press Enter to skip.) [y/N] ' - read -r answer - - case "${answer:-}" in - y | Y | yes | YES | Yes) - cat <<'EOF' -Edit .env with the settings you want to override, using .env.example as the full reference. -Run ./dify-compose up -d again when you are ready. -EOF - exit 0 - ;; - esac -} - -user_env_value() { - local key="$1" - awk -F= -v target="$key" ' - /^[[:space:]]*#/ || !/=/{ next } - { - key = $1 - gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) - if (key == target) { - value = substr($0, index($0, "=") + 1) - gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) - if ((value ~ /^".*"$/) || (value ~ /^'\''.*'\''$/)) { - value = substr(value, 2, length(value) - 2) - } - result = value - } - } - END { print result } - ' "$USER_ENV_FILE" -} - -set_user_env_value() { - local key="$1" - local value="$2" - local temp_file - - temp_file="$(mktemp "${TMPDIR:-/tmp}/dify-env.XXXXXX")" - awk -F= -v target="$key" -v replacement="$key=$value" ' - BEGIN { replaced = 0 } - /^[[:space:]]*#/ || !/=/{ print; next } - { - key = $1 - gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) - if (key == target) { - if (!replaced) { - print replacement - replaced = 1 - } - next - } - print - } - END { - if (!replaced) { - print replacement - } - } - ' "$USER_ENV_FILE" >"$temp_file" - mv "$temp_file" "$USER_ENV_FILE" -} - -ensure_secret_key() { - local current_secret_key - local secret_key - - current_secret_key="$(user_env_value SECRET_KEY)" - if [[ -n "$current_secret_key" ]]; then - return - fi - - secret_key="$(generate_secret_key)" || die "Unable to generate SECRET_KEY. Install openssl or configure SECRET_KEY in .env." - set_user_env_value SECRET_KEY "$secret_key" - log "Generated SECRET_KEY in $USER_ENV_FILE." -} - -env_value() { - local key="$1" - awk -F= -v target="$key" ' - /^[[:space:]]*#/ || !/=/{ next } - { - key = $1 - gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) - if (key == target) { - value = substr($0, index($0, "=") + 1) - gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) - if ((value ~ /^".*"$/) || (value ~ /^'\''.*'\''$/)) { - value = substr(value, 2, length(value) - 2) - } - result = value - } - } - END { print result } - ' "$DEFAULT_ENV_FILE" "$USER_ENV_FILE" -} - -user_overrides() { - local key="$1" - grep -Eq "^[[:space:]]*${key}[[:space:]]*=" "$USER_ENV_FILE" -} - -write_merged_env() { - awk ' - function trim(s) { - sub(/^[[:space:]]+/, "", s) - sub(/[[:space:]]+$/, "", s) - return s - } - - /^[[:space:]]*#/ || !/=/{ next } - - { - key = $0 - sub(/=.*/, "", key) - key = trim(key) - if (key == "") { - next - } - - value = substr($0, index($0, "=") + 1) - value = trim(value) - - if (!(key in seen)) { - order[++count] = key - seen[key] = 1 - } - - values[key] = value - } - - END { - for (i = 1; i <= count; i++) { - key = order[i] - print key "=" values[key] - } - } - ' "$DEFAULT_ENV_FILE" "$USER_ENV_FILE" >"$MERGED_ENV_FILE" -} - -set_merged_env_value() { - local key="$1" - local value="$2" - local temp_file - - temp_file="$(mktemp "${TMPDIR:-/tmp}/dify-compose-env.XXXXXX")" - awk -F= -v target="$key" -v replacement="$key=$value" ' - BEGIN { replaced = 0 } - /^[[:space:]]*#/ || !/=/{ print; next } - { - key = $1 - gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) - if (key == target) { - if (!replaced) { - print replacement - replaced = 1 - } - next - } - print - } - END { - if (!replaced) { - print replacement - } - } - ' "$MERGED_ENV_FILE" >"$temp_file" - mv "$temp_file" "$MERGED_ENV_FILE" -} - -set_if_not_overridden() { - local key="$1" - local value="$2" - - if user_overrides "$key"; then - return - fi - - set_merged_env_value "$key" "$value" -} - -metadata_db_host() { - case "$1" in - mysql) printf 'db_mysql' ;; - postgresql | '') printf 'db_postgres' ;; - *) printf '%s' "$(env_value DB_HOST)" ;; - esac -} - -metadata_db_port() { - case "$1" in - mysql) printf '3306' ;; - postgresql | '') printf '5432' ;; - *) printf '%s' "$(env_value DB_PORT)" ;; - esac -} - -metadata_db_user() { - case "$1" in - mysql) printf 'root' ;; - postgresql | '') printf 'postgres' ;; - *) printf '%s' "$(env_value DB_USERNAME)" ;; - esac -} - -build_merged_env() { - MERGED_ENV_FILE="$(mktemp "${TMPDIR:-/tmp}/dify-compose.XXXXXX")" - trap 'rm -f "$MERGED_ENV_FILE"' EXIT - - write_merged_env - - local db_type - local redis_host - local redis_port - local redis_username - local redis_password - local redis_auth - local code_execution_api_key - local weaviate_api_key - - db_type="$(env_value DB_TYPE)" - - set_if_not_overridden DB_HOST "$(metadata_db_host "$db_type")" - set_if_not_overridden DB_PORT "$(metadata_db_port "$db_type")" - set_if_not_overridden DB_USERNAME "$(metadata_db_user "$db_type")" - - if ! user_overrides CELERY_BROKER_URL; then - redis_host="$(env_value REDIS_HOST)" - redis_port="$(env_value REDIS_PORT)" - redis_username="$(env_value REDIS_USERNAME)" - redis_password="$(env_value REDIS_PASSWORD)" - redis_auth="" - - if [[ -n "$redis_username" && -n "$redis_password" ]]; then - redis_auth="${redis_username}:${redis_password}@" - elif [[ -n "$redis_password" ]]; then - redis_auth=":${redis_password}@" - elif [[ -n "$redis_username" ]]; then - redis_auth="${redis_username}@" - fi - - set_merged_env_value CELERY_BROKER_URL "redis://${redis_auth}${redis_host:-redis}:${redis_port:-6379}/1" - fi - - if ! user_overrides SANDBOX_API_KEY; then - code_execution_api_key="$(env_value CODE_EXECUTION_API_KEY)" - set_if_not_overridden SANDBOX_API_KEY "${code_execution_api_key:-dify-sandbox}" - fi - - if ! user_overrides WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS; then - weaviate_api_key="$(env_value WEAVIATE_API_KEY)" - set_if_not_overridden WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS \ - "${weaviate_api_key:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih}" - fi -} - -main() { - detect_compose - ensure_env_files - ensure_secret_key - build_merged_env - - if [[ "$#" -eq 0 ]]; then - set -- up -d - fi - - "${COMPOSE_CMD[@]}" --env-file "$MERGED_ENV_FILE" "$@" -} - -main "$@" diff --git a/docker/dify-compose.ps1 b/docker/dify-compose.ps1 deleted file mode 100644 index 851f8b76fe..0000000000 --- a/docker/dify-compose.ps1 +++ /dev/null @@ -1,317 +0,0 @@ -$ErrorActionPreference = "Stop" -Set-StrictMode -Version Latest - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -Set-Location $ScriptDir - -$DefaultEnvFile = ".env.default" -$UserEnvFile = ".env" -$MergedEnvFile = $null -$Utf8NoBom = New-Object System.Text.UTF8Encoding -ArgumentList $false - -function Write-Info { - param([string]$Message) - [Console]::Error.WriteLine($Message) -} - -function Fail { - param([string]$Message) - [Console]::Error.WriteLine("Error: $Message") - exit 1 -} - -function Test-CommandSuccess { - param([string[]]$Command) - - try { - $Executable = $Command[0] - $CommandArgs = @() - if ($Command.Length -gt 1) { - $CommandArgs = @($Command[1..($Command.Length - 1)]) - } - - & $Executable @CommandArgs *> $null - return $LASTEXITCODE -eq 0 - } - catch { - return $false - } -} - -function Get-ComposeCommand { - if (Test-CommandSuccess @("docker", "compose", "version")) { - return @("docker", "compose") - } - - if ((Get-Command "docker-compose" -ErrorAction SilentlyContinue) -and (Test-CommandSuccess @("docker-compose", "version"))) { - return @("docker-compose") - } - - Fail "Docker Compose is not available. Install Docker Compose, then run this command again." -} - -function New-SecretKey { - $Bytes = New-Object byte[] 42 - $Generator = [System.Security.Cryptography.RandomNumberGenerator]::Create() - - try { - $Generator.GetBytes($Bytes) - } - finally { - $Generator.Dispose() - } - - return [Convert]::ToBase64String($Bytes) -} - -function Ensure-EnvFiles { - if (-not (Test-Path $DefaultEnvFile -PathType Leaf)) { - Fail "$DefaultEnvFile is missing." - } - - if (Test-Path $UserEnvFile -PathType Leaf) { - return - } - - New-Item -ItemType File -Path $UserEnvFile | Out-Null - - if ([Console]::IsInputRedirected) { - Write-Info "Created $UserEnvFile for local overrides." - return - } - - Write-Info "Created $UserEnvFile for local overrides." - $Answer = Read-Host "Do you need a custom deployment now? (Most users can press Enter to skip.) [y/N]" - - if ($Answer -match "^(y|yes)$") { - Write-Output "Edit .env with the settings you want to override, using .env.example as the full reference." - Write-Output "Run .\dify-compose.ps1 up -d again when you are ready." - exit 0 - } -} - -function Read-EnvFile { - param([string]$Path) - - $Values = [ordered]@{} - - if (-not (Test-Path $Path -PathType Leaf)) { - return $Values - } - - foreach ($Line in Get-Content -Path $Path) { - if ($Line -match "^\s*#" -or $Line -notmatch "=") { - continue - } - - $SeparatorIndex = $Line.IndexOf("=") - $Key = $Line.Substring(0, $SeparatorIndex).Trim() - $Value = $Line.Substring($SeparatorIndex + 1).Trim() - - if (($Value.StartsWith('"') -and $Value.EndsWith('"')) -or ($Value.StartsWith("'") -and $Value.EndsWith("'"))) { - $Value = $Value.Substring(1, $Value.Length - 2) - } - - if ($Key.Length -gt 0) { - $Values[$Key] = $Value - } - } - - return $Values -} - -function Set-UserEnvValue { - param( - [string]$Key, - [string]$Value - ) - - $Path = [string](Resolve-Path $UserEnvFile) - $Lines = [System.IO.File]::ReadAllLines($Path, [System.Text.Encoding]::UTF8) - $Output = New-Object System.Collections.Generic.List[string] - $Replaced = $false - - foreach ($Line in $Lines) { - if ($Line -match "^\s*#" -or $Line -notmatch "=") { - $Output.Add($Line) - continue - } - - $SeparatorIndex = $Line.IndexOf("=") - $CurrentKey = $Line.Substring(0, $SeparatorIndex).Trim() - - if ($CurrentKey -eq $Key) { - if (-not $Replaced) { - $Output.Add("$Key=$Value") - $Replaced = $true - } - continue - } - - $Output.Add($Line) - } - - if (-not $Replaced) { - $Output.Add("$Key=$Value") - } - - [System.IO.File]::WriteAllLines($Path, $Output, $Utf8NoBom) -} - -function Ensure-SecretKey { - $Values = Read-EnvFile $UserEnvFile - - if ($Values.Contains("SECRET_KEY") -and $Values["SECRET_KEY"]) { - return - } - - Set-UserEnvValue "SECRET_KEY" (New-SecretKey) - Write-Info "Generated SECRET_KEY in $UserEnvFile." -} - -function Merge-EnvValues { - $Values = [ordered]@{} - - foreach ($Entry in (Read-EnvFile $DefaultEnvFile).GetEnumerator()) { - $Values[$Entry.Key] = $Entry.Value - } - - foreach ($Entry in (Read-EnvFile $UserEnvFile).GetEnumerator()) { - $Values[$Entry.Key] = $Entry.Value - } - - return $Values -} - -function User-Overrides { - param([string]$Key) - - if (-not (Test-Path $UserEnvFile -PathType Leaf)) { - return $false - } - - return [bool](Select-String -Path $UserEnvFile -Pattern "^\s*$([regex]::Escape($Key))\s*=" -Quiet) -} - -function Metadata-DbHost { - param([string]$DbType, $Values) - - switch ($DbType) { - "mysql" { return "db_mysql" } - "postgresql" { return "db_postgres" } - "" { return "db_postgres" } - default { return $Values["DB_HOST"] } - } -} - -function Metadata-DbPort { - param([string]$DbType, $Values) - - switch ($DbType) { - "mysql" { return "3306" } - "postgresql" { return "5432" } - "" { return "5432" } - default { return $Values["DB_PORT"] } - } -} - -function Metadata-DbUser { - param([string]$DbType, $Values) - - switch ($DbType) { - "mysql" { return "root" } - "postgresql" { return "postgres" } - "" { return "postgres" } - default { return $Values["DB_USERNAME"] } - } -} - -function Write-MergedEnv { - param($Values) - - $Output = New-Object System.Collections.Generic.List[string] - - foreach ($Entry in $Values.GetEnumerator()) { - $Output.Add("$($Entry.Key)=$($Entry.Value)") - } - - [System.IO.File]::WriteAllLines($MergedEnvFile, $Output, $Utf8NoBom) -} - -function Build-MergedEnv { - $Values = Merge-EnvValues - $script:MergedEnvFile = [System.IO.Path]::GetTempFileName() - - $DbType = if ($Values.Contains("DB_TYPE")) { $Values["DB_TYPE"] } else { "postgresql" } - - if (-not (User-Overrides "DB_HOST")) { - $Values["DB_HOST"] = Metadata-DbHost $DbType $Values - } - - if (-not (User-Overrides "DB_PORT")) { - $Values["DB_PORT"] = Metadata-DbPort $DbType $Values - } - - if (-not (User-Overrides "DB_USERNAME")) { - $Values["DB_USERNAME"] = Metadata-DbUser $DbType $Values - } - - if (-not (User-Overrides "CELERY_BROKER_URL")) { - $RedisHost = if ($Values.Contains("REDIS_HOST") -and $Values["REDIS_HOST"]) { $Values["REDIS_HOST"] } else { "redis" } - $RedisPort = if ($Values.Contains("REDIS_PORT") -and $Values["REDIS_PORT"]) { $Values["REDIS_PORT"] } else { "6379" } - $RedisUsername = if ($Values.Contains("REDIS_USERNAME")) { $Values["REDIS_USERNAME"] } else { "" } - $RedisPassword = if ($Values.Contains("REDIS_PASSWORD")) { $Values["REDIS_PASSWORD"] } else { "" } - $RedisAuth = "" - - if ($RedisUsername -and $RedisPassword) { - $RedisAuth = "${RedisUsername}:${RedisPassword}@" - } - elseif ($RedisPassword) { - $RedisAuth = ":${RedisPassword}@" - } - elseif ($RedisUsername) { - $RedisAuth = "${RedisUsername}@" - } - - $Values["CELERY_BROKER_URL"] = "redis://$RedisAuth${RedisHost}:${RedisPort}/1" - } - - if (-not (User-Overrides "SANDBOX_API_KEY")) { - $CodeExecutionApiKey = if ($Values.Contains("CODE_EXECUTION_API_KEY") -and $Values["CODE_EXECUTION_API_KEY"]) { $Values["CODE_EXECUTION_API_KEY"] } else { "dify-sandbox" } - $Values["SANDBOX_API_KEY"] = $CodeExecutionApiKey - } - - if (-not (User-Overrides "WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS")) { - $WeaviateApiKey = if ($Values.Contains("WEAVIATE_API_KEY") -and $Values["WEAVIATE_API_KEY"]) { $Values["WEAVIATE_API_KEY"] } else { "WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih" } - $Values["WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS"] = $WeaviateApiKey - } - - Write-MergedEnv $Values -} - -$ComposeCommand = Get-ComposeCommand - -try { - Ensure-EnvFiles - Ensure-SecretKey - Build-MergedEnv - - $ComposeArgs = @($args) - if ($ComposeArgs.Count -eq 0) { - $ComposeArgs = @("up", "-d") - } - - $ComposeCommandArgs = @() - if ($ComposeCommand.Length -gt 1) { - $ComposeCommandArgs = @($ComposeCommand[1..($ComposeCommand.Length - 1)]) - } - - $ComposeExecutable = $ComposeCommand[0] - & $ComposeExecutable @ComposeCommandArgs --env-file $MergedEnvFile @ComposeArgs - exit $LASTEXITCODE -} -finally { - if ($MergedEnvFile -and (Test-Path $MergedEnvFile -PathType Leaf)) { - Remove-Item -Force $MergedEnvFile - } -} diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index b2df61ebb2..0f65c38098 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -1,4 +1,202 @@ -x-shared-env: &shared-api-worker-env +# Shared configuration using YAML anchors and env_file +x-shared-api-worker-config: &shared-api-worker-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/api.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always + +x-shared-worker-config: &shared-worker-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/worker.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always + +x-shared-worker-beat-config: &shared-worker-beat-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/worker-beat.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always + services: # Init container to fix permissions init_permissions: @@ -21,12 +219,9 @@ services: # API service api: + <<: *shared-api-worker-config image: langgenius/dify-api:1.14.0 - restart: always environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'api' starts the API server. MODE: api SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} @@ -69,12 +264,9 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: + <<: *shared-worker-config image: langgenius/dify-api:1.14.0 - restart: always environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'worker' starts the Celery worker for processing all queues. MODE: worker SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} @@ -115,12 +307,9 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: + <<: *shared-worker-beat-config image: langgenius/dify-api:1.14.0 - restart: always environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'worker_beat' starts the Celery beat for scheduling periodic tasks. MODE: beat depends_on: init_permissions: @@ -154,6 +343,12 @@ services: web: image: langgenius/dify-web:1.14.0 restart: always + env_file: + - path: ./envs/core-services/web.env + required: false + - path: ./envs/security.env + required: false + - ./.env environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} APP_API_URL: ${APP_API_URL:-} @@ -228,7 +423,7 @@ services: MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456} MYSQL_DATABASE: ${DB_DATABASE:-dify} command: > - --max_connections=1000 + --max_connections=${MYSQL_MAX_CONNECTIONS:-1000} --innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} --innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M} --innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2} @@ -270,6 +465,12 @@ services: sandbox: image: langgenius/dify-sandbox:0.2.15 restart: always + env_file: + - path: ./envs/core-services/sandbox.env + required: false + - path: ./envs/security.env + required: false + - ./.env environment: # The DifySandbox configurations # Make sure you are changing this key for your deployment with a strong key. @@ -294,9 +495,24 @@ services: plugin_daemon: image: langgenius/dify-plugin-daemon:0.6.0-local restart: always + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/plugin-daemon.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default environment: - # Use the shared environment variables. - <<: *shared-api-worker-env DB_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin} DB_SSL_MODE: ${DB_SSL_MODE:-disable} SERVER_PORT: ${PLUGIN_DAEMON_PORT:-5002} diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 23c26c6695..0ad406a63b 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -51,7 +51,7 @@ services: MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456} MYSQL_DATABASE: ${DB_DATABASE:-dify} command: > - --max_connections=1000 + --max_connections=${MYSQL_MAX_CONNECTIONS:-1000} --innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} --innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M} --innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 6dcab4a9fc..0f8458a58f 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -4,724 +4,204 @@ # or docker-compose-template.yaml and regenerate this file. # ================================================================== -x-shared-env: &shared-api-worker-env - CONSOLE_API_URL: ${CONSOLE_API_URL:-} - CONSOLE_WEB_URL: ${CONSOLE_WEB_URL:-} - SERVICE_API_URL: ${SERVICE_API_URL:-} - TRIGGER_URL: ${TRIGGER_URL:-http://localhost} - APP_API_URL: ${APP_API_URL:-} - APP_WEB_URL: ${APP_WEB_URL:-} - FILES_URL: ${FILES_URL:-} - INTERNAL_FILES_URL: ${INTERNAL_FILES_URL:-} - LANG: ${LANG:-C.UTF-8} - LC_ALL: ${LC_ALL:-C.UTF-8} - PYTHONIOENCODING: ${PYTHONIOENCODING:-utf-8} - UV_CACHE_DIR: ${UV_CACHE_DIR:-/tmp/.uv-cache} - LOG_LEVEL: ${LOG_LEVEL:-INFO} - LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text} - LOG_FILE: ${LOG_FILE:-/app/logs/server.log} - LOG_FILE_MAX_SIZE: ${LOG_FILE_MAX_SIZE:-20} - LOG_FILE_BACKUP_COUNT: ${LOG_FILE_BACKUP_COUNT:-5} - LOG_DATEFORMAT: ${LOG_DATEFORMAT:-%Y-%m-%d %H:%M:%S} - LOG_TZ: ${LOG_TZ:-UTC} - DEBUG: ${DEBUG:-false} - FLASK_DEBUG: ${FLASK_DEBUG:-false} - ENABLE_REQUEST_LOGGING: ${ENABLE_REQUEST_LOGGING:-False} - SECRET_KEY: ${SECRET_KEY:-sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U} - INIT_PASSWORD: ${INIT_PASSWORD:-} - DEPLOY_ENV: ${DEPLOY_ENV:-PRODUCTION} - CHECK_UPDATE_URL: ${CHECK_UPDATE_URL:-https://updates.dify.ai} - OPENAI_API_BASE: ${OPENAI_API_BASE:-https://api.openai.com/v1} - MIGRATION_ENABLED: ${MIGRATION_ENABLED:-true} - FILES_ACCESS_TIMEOUT: ${FILES_ACCESS_TIMEOUT:-300} - ENABLE_COLLABORATION_MODE: ${ENABLE_COLLABORATION_MODE:-false} - ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} - REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30} - APP_DEFAULT_ACTIVE_REQUESTS: ${APP_DEFAULT_ACTIVE_REQUESTS:-0} - APP_MAX_ACTIVE_REQUESTS: ${APP_MAX_ACTIVE_REQUESTS:-0} - APP_MAX_EXECUTION_TIME: ${APP_MAX_EXECUTION_TIME:-1200} - DIFY_BIND_ADDRESS: ${DIFY_BIND_ADDRESS:-0.0.0.0} - DIFY_PORT: ${DIFY_PORT:-5001} - SERVER_WORKER_AMOUNT: ${SERVER_WORKER_AMOUNT:-1} - SERVER_WORKER_CLASS: ${SERVER_WORKER_CLASS:-gevent} - SERVER_WORKER_CONNECTIONS: ${SERVER_WORKER_CONNECTIONS:-10} - CELERY_WORKER_CLASS: ${CELERY_WORKER_CLASS:-} - GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-360} - CELERY_WORKER_AMOUNT: ${CELERY_WORKER_AMOUNT:-4} - CELERY_AUTO_SCALE: ${CELERY_AUTO_SCALE:-false} - CELERY_MAX_WORKERS: ${CELERY_MAX_WORKERS:-} - CELERY_MIN_WORKERS: ${CELERY_MIN_WORKERS:-} - API_TOOL_DEFAULT_CONNECT_TIMEOUT: ${API_TOOL_DEFAULT_CONNECT_TIMEOUT:-10} - API_TOOL_DEFAULT_READ_TIMEOUT: ${API_TOOL_DEFAULT_READ_TIMEOUT:-60} - ENABLE_WEBSITE_JINAREADER: ${ENABLE_WEBSITE_JINAREADER:-true} - ENABLE_WEBSITE_FIRECRAWL: ${ENABLE_WEBSITE_FIRECRAWL:-true} - ENABLE_WEBSITE_WATERCRAWL: ${ENABLE_WEBSITE_WATERCRAWL:-true} - NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: ${NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX:-false} - DB_TYPE: ${DB_TYPE:-postgresql} - DB_USERNAME: ${DB_USERNAME:-postgres} - DB_PASSWORD: ${DB_PASSWORD:-difyai123456} - DB_HOST: ${DB_HOST:-db_postgres} - DB_PORT: ${DB_PORT:-5432} - DB_DATABASE: ${DB_DATABASE:-dify} - SQLALCHEMY_POOL_SIZE: ${SQLALCHEMY_POOL_SIZE:-30} - SQLALCHEMY_MAX_OVERFLOW: ${SQLALCHEMY_MAX_OVERFLOW:-10} - SQLALCHEMY_POOL_RECYCLE: ${SQLALCHEMY_POOL_RECYCLE:-3600} - SQLALCHEMY_ECHO: ${SQLALCHEMY_ECHO:-false} - SQLALCHEMY_POOL_PRE_PING: ${SQLALCHEMY_POOL_PRE_PING:-false} - SQLALCHEMY_POOL_USE_LIFO: ${SQLALCHEMY_POOL_USE_LIFO:-false} - SQLALCHEMY_POOL_TIMEOUT: ${SQLALCHEMY_POOL_TIMEOUT:-30} - POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-200} - POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-128MB} - POSTGRES_WORK_MEM: ${POSTGRES_WORK_MEM:-4MB} - POSTGRES_MAINTENANCE_WORK_MEM: ${POSTGRES_MAINTENANCE_WORK_MEM:-64MB} - POSTGRES_EFFECTIVE_CACHE_SIZE: ${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB} - POSTGRES_STATEMENT_TIMEOUT: ${POSTGRES_STATEMENT_TIMEOUT:-0} - POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT: ${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-0} - MYSQL_MAX_CONNECTIONS: ${MYSQL_MAX_CONNECTIONS:-1000} - MYSQL_INNODB_BUFFER_POOL_SIZE: ${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} - MYSQL_INNODB_LOG_FILE_SIZE: ${MYSQL_INNODB_LOG_FILE_SIZE:-128M} - MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT: ${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2} - REDIS_HOST: ${REDIS_HOST:-redis} - REDIS_PORT: ${REDIS_PORT:-6379} - REDIS_USERNAME: ${REDIS_USERNAME:-} - REDIS_PASSWORD: ${REDIS_PASSWORD:-difyai123456} - REDIS_USE_SSL: ${REDIS_USE_SSL:-false} - REDIS_SSL_CERT_REQS: ${REDIS_SSL_CERT_REQS:-CERT_NONE} - REDIS_SSL_CA_CERTS: ${REDIS_SSL_CA_CERTS:-} - REDIS_SSL_CERTFILE: ${REDIS_SSL_CERTFILE:-} - REDIS_SSL_KEYFILE: ${REDIS_SSL_KEYFILE:-} - REDIS_DB: ${REDIS_DB:-0} - REDIS_KEY_PREFIX: ${REDIS_KEY_PREFIX:-} - REDIS_MAX_CONNECTIONS: ${REDIS_MAX_CONNECTIONS:-} - REDIS_USE_SENTINEL: ${REDIS_USE_SENTINEL:-false} - REDIS_SENTINELS: ${REDIS_SENTINELS:-} - REDIS_SENTINEL_SERVICE_NAME: ${REDIS_SENTINEL_SERVICE_NAME:-} - REDIS_SENTINEL_USERNAME: ${REDIS_SENTINEL_USERNAME:-} - REDIS_SENTINEL_PASSWORD: ${REDIS_SENTINEL_PASSWORD:-} - REDIS_SENTINEL_SOCKET_TIMEOUT: ${REDIS_SENTINEL_SOCKET_TIMEOUT:-0.1} - REDIS_USE_CLUSTERS: ${REDIS_USE_CLUSTERS:-false} - REDIS_CLUSTERS: ${REDIS_CLUSTERS:-} - REDIS_CLUSTERS_PASSWORD: ${REDIS_CLUSTERS_PASSWORD:-} - REDIS_RETRY_RETRIES: ${REDIS_RETRY_RETRIES:-3} - REDIS_RETRY_BACKOFF_BASE: ${REDIS_RETRY_BACKOFF_BASE:-1.0} - REDIS_RETRY_BACKOFF_CAP: ${REDIS_RETRY_BACKOFF_CAP:-10.0} - REDIS_SOCKET_TIMEOUT: ${REDIS_SOCKET_TIMEOUT:-5.0} - REDIS_SOCKET_CONNECT_TIMEOUT: ${REDIS_SOCKET_CONNECT_TIMEOUT:-5.0} - REDIS_HEALTH_CHECK_INTERVAL: ${REDIS_HEALTH_CHECK_INTERVAL:-30} - CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://:difyai123456@redis:6379/1} - CELERY_BACKEND: ${CELERY_BACKEND:-redis} - BROKER_USE_SSL: ${BROKER_USE_SSL:-false} - CELERY_USE_SENTINEL: ${CELERY_USE_SENTINEL:-false} - CELERY_SENTINEL_MASTER_NAME: ${CELERY_SENTINEL_MASTER_NAME:-} - CELERY_SENTINEL_PASSWORD: ${CELERY_SENTINEL_PASSWORD:-} - CELERY_SENTINEL_SOCKET_TIMEOUT: ${CELERY_SENTINEL_SOCKET_TIMEOUT:-0.1} - CELERY_TASK_ANNOTATIONS: ${CELERY_TASK_ANNOTATIONS:-null} - WEB_API_CORS_ALLOW_ORIGINS: ${WEB_API_CORS_ALLOW_ORIGINS:-*} - CONSOLE_CORS_ALLOW_ORIGINS: ${CONSOLE_CORS_ALLOW_ORIGINS:-*} - COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} - NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-} - NEXT_PUBLIC_SOCKET_URL: ${NEXT_PUBLIC_SOCKET_URL:-ws://localhost} - NEXT_PUBLIC_BATCH_CONCURRENCY: ${NEXT_PUBLIC_BATCH_CONCURRENCY:-5} - STORAGE_TYPE: ${STORAGE_TYPE:-opendal} - OPENDAL_SCHEME: ${OPENDAL_SCHEME:-fs} - OPENDAL_FS_ROOT: ${OPENDAL_FS_ROOT:-storage} - CLICKZETTA_VOLUME_TYPE: ${CLICKZETTA_VOLUME_TYPE:-user} - CLICKZETTA_VOLUME_NAME: ${CLICKZETTA_VOLUME_NAME:-} - CLICKZETTA_VOLUME_TABLE_PREFIX: ${CLICKZETTA_VOLUME_TABLE_PREFIX:-dataset_} - CLICKZETTA_VOLUME_DIFY_PREFIX: ${CLICKZETTA_VOLUME_DIFY_PREFIX:-dify_km} - S3_ENDPOINT: ${S3_ENDPOINT:-} - S3_REGION: ${S3_REGION:-us-east-1} - S3_BUCKET_NAME: ${S3_BUCKET_NAME:-difyai} - S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} - S3_SECRET_KEY: ${S3_SECRET_KEY:-} - S3_ADDRESS_STYLE: ${S3_ADDRESS_STYLE:-auto} - S3_USE_AWS_MANAGED_IAM: ${S3_USE_AWS_MANAGED_IAM:-false} - ARCHIVE_STORAGE_ENABLED: ${ARCHIVE_STORAGE_ENABLED:-false} - ARCHIVE_STORAGE_ENDPOINT: ${ARCHIVE_STORAGE_ENDPOINT:-} - ARCHIVE_STORAGE_ARCHIVE_BUCKET: ${ARCHIVE_STORAGE_ARCHIVE_BUCKET:-} - ARCHIVE_STORAGE_EXPORT_BUCKET: ${ARCHIVE_STORAGE_EXPORT_BUCKET:-} - ARCHIVE_STORAGE_ACCESS_KEY: ${ARCHIVE_STORAGE_ACCESS_KEY:-} - ARCHIVE_STORAGE_SECRET_KEY: ${ARCHIVE_STORAGE_SECRET_KEY:-} - ARCHIVE_STORAGE_REGION: ${ARCHIVE_STORAGE_REGION:-auto} - AZURE_BLOB_ACCOUNT_NAME: ${AZURE_BLOB_ACCOUNT_NAME:-difyai} - AZURE_BLOB_ACCOUNT_KEY: ${AZURE_BLOB_ACCOUNT_KEY:-difyai} - AZURE_BLOB_CONTAINER_NAME: ${AZURE_BLOB_CONTAINER_NAME:-difyai-container} - AZURE_BLOB_ACCOUNT_URL: ${AZURE_BLOB_ACCOUNT_URL:-https://.blob.core.windows.net} - GOOGLE_STORAGE_BUCKET_NAME: ${GOOGLE_STORAGE_BUCKET_NAME:-your-bucket-name} - GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64: ${GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64:-} - ALIYUN_OSS_BUCKET_NAME: ${ALIYUN_OSS_BUCKET_NAME:-your-bucket-name} - ALIYUN_OSS_ACCESS_KEY: ${ALIYUN_OSS_ACCESS_KEY:-your-access-key} - ALIYUN_OSS_SECRET_KEY: ${ALIYUN_OSS_SECRET_KEY:-your-secret-key} - ALIYUN_OSS_ENDPOINT: ${ALIYUN_OSS_ENDPOINT:-https://oss-ap-southeast-1-internal.aliyuncs.com} - ALIYUN_OSS_REGION: ${ALIYUN_OSS_REGION:-ap-southeast-1} - ALIYUN_OSS_AUTH_VERSION: ${ALIYUN_OSS_AUTH_VERSION:-v4} - ALIYUN_OSS_PATH: ${ALIYUN_OSS_PATH:-your-path} - TENCENT_COS_BUCKET_NAME: ${TENCENT_COS_BUCKET_NAME:-your-bucket-name} - TENCENT_COS_SECRET_KEY: ${TENCENT_COS_SECRET_KEY:-your-secret-key} - TENCENT_COS_SECRET_ID: ${TENCENT_COS_SECRET_ID:-your-secret-id} - TENCENT_COS_REGION: ${TENCENT_COS_REGION:-your-region} - TENCENT_COS_SCHEME: ${TENCENT_COS_SCHEME:-your-scheme} - TENCENT_COS_CUSTOM_DOMAIN: ${TENCENT_COS_CUSTOM_DOMAIN:-your-custom-domain} - OCI_ENDPOINT: ${OCI_ENDPOINT:-https://your-object-storage-namespace.compat.objectstorage.us-ashburn-1.oraclecloud.com} - OCI_BUCKET_NAME: ${OCI_BUCKET_NAME:-your-bucket-name} - OCI_ACCESS_KEY: ${OCI_ACCESS_KEY:-your-access-key} - OCI_SECRET_KEY: ${OCI_SECRET_KEY:-your-secret-key} - OCI_REGION: ${OCI_REGION:-us-ashburn-1} - HUAWEI_OBS_BUCKET_NAME: ${HUAWEI_OBS_BUCKET_NAME:-your-bucket-name} - HUAWEI_OBS_SECRET_KEY: ${HUAWEI_OBS_SECRET_KEY:-your-secret-key} - HUAWEI_OBS_ACCESS_KEY: ${HUAWEI_OBS_ACCESS_KEY:-your-access-key} - HUAWEI_OBS_SERVER: ${HUAWEI_OBS_SERVER:-your-server-url} - HUAWEI_OBS_PATH_STYLE: ${HUAWEI_OBS_PATH_STYLE:-false} - VOLCENGINE_TOS_BUCKET_NAME: ${VOLCENGINE_TOS_BUCKET_NAME:-your-bucket-name} - VOLCENGINE_TOS_SECRET_KEY: ${VOLCENGINE_TOS_SECRET_KEY:-your-secret-key} - VOLCENGINE_TOS_ACCESS_KEY: ${VOLCENGINE_TOS_ACCESS_KEY:-your-access-key} - VOLCENGINE_TOS_ENDPOINT: ${VOLCENGINE_TOS_ENDPOINT:-your-server-url} - VOLCENGINE_TOS_REGION: ${VOLCENGINE_TOS_REGION:-your-region} - BAIDU_OBS_BUCKET_NAME: ${BAIDU_OBS_BUCKET_NAME:-your-bucket-name} - BAIDU_OBS_SECRET_KEY: ${BAIDU_OBS_SECRET_KEY:-your-secret-key} - BAIDU_OBS_ACCESS_KEY: ${BAIDU_OBS_ACCESS_KEY:-your-access-key} - BAIDU_OBS_ENDPOINT: ${BAIDU_OBS_ENDPOINT:-your-server-url} - SUPABASE_BUCKET_NAME: ${SUPABASE_BUCKET_NAME:-your-bucket-name} - SUPABASE_API_KEY: ${SUPABASE_API_KEY:-your-access-key} - SUPABASE_URL: ${SUPABASE_URL:-your-server-url} - VECTOR_STORE: ${VECTOR_STORE:-weaviate} - VECTOR_INDEX_NAME_PREFIX: ${VECTOR_INDEX_NAME_PREFIX:-Vector_index} - WEAVIATE_ENDPOINT: ${WEAVIATE_ENDPOINT:-http://weaviate:8080} - WEAVIATE_API_KEY: ${WEAVIATE_API_KEY:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih} - WEAVIATE_GRPC_ENDPOINT: ${WEAVIATE_GRPC_ENDPOINT:-grpc://weaviate:50051} - WEAVIATE_TOKENIZATION: ${WEAVIATE_TOKENIZATION:-word} - OCEANBASE_VECTOR_HOST: ${OCEANBASE_VECTOR_HOST:-oceanbase} - OCEANBASE_VECTOR_PORT: ${OCEANBASE_VECTOR_PORT:-2881} - OCEANBASE_VECTOR_USER: ${OCEANBASE_VECTOR_USER:-root@test} - OCEANBASE_VECTOR_PASSWORD: ${OCEANBASE_VECTOR_PASSWORD:-difyai123456} - OCEANBASE_VECTOR_DATABASE: ${OCEANBASE_VECTOR_DATABASE:-test} - OCEANBASE_CLUSTER_NAME: ${OCEANBASE_CLUSTER_NAME:-difyai} - OCEANBASE_MEMORY_LIMIT: ${OCEANBASE_MEMORY_LIMIT:-6G} - OCEANBASE_ENABLE_HYBRID_SEARCH: ${OCEANBASE_ENABLE_HYBRID_SEARCH:-false} - OCEANBASE_FULLTEXT_PARSER: ${OCEANBASE_FULLTEXT_PARSER:-ik} - SEEKDB_MEMORY_LIMIT: ${SEEKDB_MEMORY_LIMIT:-2G} - QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333} - QDRANT_API_KEY: ${QDRANT_API_KEY:-difyai123456} - QDRANT_CLIENT_TIMEOUT: ${QDRANT_CLIENT_TIMEOUT:-20} - QDRANT_GRPC_ENABLED: ${QDRANT_GRPC_ENABLED:-false} - QDRANT_GRPC_PORT: ${QDRANT_GRPC_PORT:-6334} - QDRANT_REPLICATION_FACTOR: ${QDRANT_REPLICATION_FACTOR:-1} - MILVUS_URI: ${MILVUS_URI:-http://host.docker.internal:19530} - MILVUS_DATABASE: ${MILVUS_DATABASE:-} - MILVUS_TOKEN: ${MILVUS_TOKEN:-} - MILVUS_USER: ${MILVUS_USER:-} - MILVUS_PASSWORD: ${MILVUS_PASSWORD:-} - MILVUS_ENABLE_HYBRID_SEARCH: ${MILVUS_ENABLE_HYBRID_SEARCH:-False} - MILVUS_ANALYZER_PARAMS: ${MILVUS_ANALYZER_PARAMS:-} - MYSCALE_HOST: ${MYSCALE_HOST:-myscale} - MYSCALE_PORT: ${MYSCALE_PORT:-8123} - MYSCALE_USER: ${MYSCALE_USER:-default} - MYSCALE_PASSWORD: ${MYSCALE_PASSWORD:-} - MYSCALE_DATABASE: ${MYSCALE_DATABASE:-dify} - MYSCALE_FTS_PARAMS: ${MYSCALE_FTS_PARAMS:-} - COUCHBASE_CONNECTION_STRING: ${COUCHBASE_CONNECTION_STRING:-couchbase://couchbase-server} - COUCHBASE_USER: ${COUCHBASE_USER:-Administrator} - COUCHBASE_PASSWORD: ${COUCHBASE_PASSWORD:-password} - COUCHBASE_BUCKET_NAME: ${COUCHBASE_BUCKET_NAME:-Embeddings} - COUCHBASE_SCOPE_NAME: ${COUCHBASE_SCOPE_NAME:-_default} - HOLOGRES_HOST: ${HOLOGRES_HOST:-} - HOLOGRES_PORT: ${HOLOGRES_PORT:-80} - HOLOGRES_DATABASE: ${HOLOGRES_DATABASE:-} - HOLOGRES_ACCESS_KEY_ID: ${HOLOGRES_ACCESS_KEY_ID:-} - HOLOGRES_ACCESS_KEY_SECRET: ${HOLOGRES_ACCESS_KEY_SECRET:-} - HOLOGRES_SCHEMA: ${HOLOGRES_SCHEMA:-public} - HOLOGRES_TOKENIZER: ${HOLOGRES_TOKENIZER:-jieba} - HOLOGRES_DISTANCE_METHOD: ${HOLOGRES_DISTANCE_METHOD:-Cosine} - HOLOGRES_BASE_QUANTIZATION_TYPE: ${HOLOGRES_BASE_QUANTIZATION_TYPE:-rabitq} - HOLOGRES_MAX_DEGREE: ${HOLOGRES_MAX_DEGREE:-64} - HOLOGRES_EF_CONSTRUCTION: ${HOLOGRES_EF_CONSTRUCTION:-400} - PGVECTOR_HOST: ${PGVECTOR_HOST:-pgvector} - PGVECTOR_PORT: ${PGVECTOR_PORT:-5432} - PGVECTOR_USER: ${PGVECTOR_USER:-postgres} - PGVECTOR_PASSWORD: ${PGVECTOR_PASSWORD:-difyai123456} - PGVECTOR_DATABASE: ${PGVECTOR_DATABASE:-dify} - PGVECTOR_MIN_CONNECTION: ${PGVECTOR_MIN_CONNECTION:-1} - PGVECTOR_MAX_CONNECTION: ${PGVECTOR_MAX_CONNECTION:-5} - PGVECTOR_PG_BIGM: ${PGVECTOR_PG_BIGM:-false} - PGVECTOR_PG_BIGM_VERSION: ${PGVECTOR_PG_BIGM_VERSION:-1.2-20240606} - VASTBASE_HOST: ${VASTBASE_HOST:-vastbase} - VASTBASE_PORT: ${VASTBASE_PORT:-5432} - VASTBASE_USER: ${VASTBASE_USER:-dify} - VASTBASE_PASSWORD: ${VASTBASE_PASSWORD:-Difyai123456} - VASTBASE_DATABASE: ${VASTBASE_DATABASE:-dify} - VASTBASE_MIN_CONNECTION: ${VASTBASE_MIN_CONNECTION:-1} - VASTBASE_MAX_CONNECTION: ${VASTBASE_MAX_CONNECTION:-5} - PGVECTO_RS_HOST: ${PGVECTO_RS_HOST:-pgvecto-rs} - PGVECTO_RS_PORT: ${PGVECTO_RS_PORT:-5432} - PGVECTO_RS_USER: ${PGVECTO_RS_USER:-postgres} - PGVECTO_RS_PASSWORD: ${PGVECTO_RS_PASSWORD:-difyai123456} - PGVECTO_RS_DATABASE: ${PGVECTO_RS_DATABASE:-dify} - ANALYTICDB_KEY_ID: ${ANALYTICDB_KEY_ID:-your-ak} - ANALYTICDB_KEY_SECRET: ${ANALYTICDB_KEY_SECRET:-your-sk} - ANALYTICDB_REGION_ID: ${ANALYTICDB_REGION_ID:-cn-hangzhou} - ANALYTICDB_INSTANCE_ID: ${ANALYTICDB_INSTANCE_ID:-gp-ab123456} - ANALYTICDB_ACCOUNT: ${ANALYTICDB_ACCOUNT:-testaccount} - ANALYTICDB_PASSWORD: ${ANALYTICDB_PASSWORD:-testpassword} - ANALYTICDB_NAMESPACE: ${ANALYTICDB_NAMESPACE:-dify} - ANALYTICDB_NAMESPACE_PASSWORD: ${ANALYTICDB_NAMESPACE_PASSWORD:-difypassword} - ANALYTICDB_HOST: ${ANALYTICDB_HOST:-gp-test.aliyuncs.com} - ANALYTICDB_PORT: ${ANALYTICDB_PORT:-5432} - ANALYTICDB_MIN_CONNECTION: ${ANALYTICDB_MIN_CONNECTION:-1} - ANALYTICDB_MAX_CONNECTION: ${ANALYTICDB_MAX_CONNECTION:-5} - TIDB_VECTOR_HOST: ${TIDB_VECTOR_HOST:-tidb} - TIDB_VECTOR_PORT: ${TIDB_VECTOR_PORT:-4000} - TIDB_VECTOR_USER: ${TIDB_VECTOR_USER:-} - TIDB_VECTOR_PASSWORD: ${TIDB_VECTOR_PASSWORD:-} - TIDB_VECTOR_DATABASE: ${TIDB_VECTOR_DATABASE:-dify} - MATRIXONE_HOST: ${MATRIXONE_HOST:-matrixone} - MATRIXONE_PORT: ${MATRIXONE_PORT:-6001} - MATRIXONE_USER: ${MATRIXONE_USER:-dump} - MATRIXONE_PASSWORD: ${MATRIXONE_PASSWORD:-111} - MATRIXONE_DATABASE: ${MATRIXONE_DATABASE:-dify} - TIDB_ON_QDRANT_URL: ${TIDB_ON_QDRANT_URL:-http://127.0.0.1} - TIDB_ON_QDRANT_API_KEY: ${TIDB_ON_QDRANT_API_KEY:-dify} - TIDB_ON_QDRANT_CLIENT_TIMEOUT: ${TIDB_ON_QDRANT_CLIENT_TIMEOUT:-20} - TIDB_ON_QDRANT_GRPC_ENABLED: ${TIDB_ON_QDRANT_GRPC_ENABLED:-false} - TIDB_ON_QDRANT_GRPC_PORT: ${TIDB_ON_QDRANT_GRPC_PORT:-6334} - TIDB_PUBLIC_KEY: ${TIDB_PUBLIC_KEY:-dify} - TIDB_PRIVATE_KEY: ${TIDB_PRIVATE_KEY:-dify} - TIDB_API_URL: ${TIDB_API_URL:-http://127.0.0.1} - TIDB_IAM_API_URL: ${TIDB_IAM_API_URL:-http://127.0.0.1} - TIDB_REGION: ${TIDB_REGION:-regions/aws-us-east-1} - TIDB_PROJECT_ID: ${TIDB_PROJECT_ID:-dify} - TIDB_SPEND_LIMIT: ${TIDB_SPEND_LIMIT:-100} - CHROMA_HOST: ${CHROMA_HOST:-127.0.0.1} - CHROMA_PORT: ${CHROMA_PORT:-8000} - CHROMA_TENANT: ${CHROMA_TENANT:-default_tenant} - CHROMA_DATABASE: ${CHROMA_DATABASE:-default_database} - CHROMA_AUTH_PROVIDER: ${CHROMA_AUTH_PROVIDER:-chromadb.auth.token_authn.TokenAuthClientProvider} - CHROMA_AUTH_CREDENTIALS: ${CHROMA_AUTH_CREDENTIALS:-} - ORACLE_USER: ${ORACLE_USER:-dify} - ORACLE_PASSWORD: ${ORACLE_PASSWORD:-dify} - ORACLE_DSN: ${ORACLE_DSN:-oracle:1521/FREEPDB1} - ORACLE_CONFIG_DIR: ${ORACLE_CONFIG_DIR:-/app/api/storage/wallet} - ORACLE_WALLET_LOCATION: ${ORACLE_WALLET_LOCATION:-/app/api/storage/wallet} - ORACLE_WALLET_PASSWORD: ${ORACLE_WALLET_PASSWORD:-dify} - ORACLE_IS_AUTONOMOUS: ${ORACLE_IS_AUTONOMOUS:-false} - ALIBABACLOUD_MYSQL_HOST: ${ALIBABACLOUD_MYSQL_HOST:-127.0.0.1} - ALIBABACLOUD_MYSQL_PORT: ${ALIBABACLOUD_MYSQL_PORT:-3306} - ALIBABACLOUD_MYSQL_USER: ${ALIBABACLOUD_MYSQL_USER:-root} - ALIBABACLOUD_MYSQL_PASSWORD: ${ALIBABACLOUD_MYSQL_PASSWORD:-difyai123456} - ALIBABACLOUD_MYSQL_DATABASE: ${ALIBABACLOUD_MYSQL_DATABASE:-dify} - ALIBABACLOUD_MYSQL_MAX_CONNECTION: ${ALIBABACLOUD_MYSQL_MAX_CONNECTION:-5} - ALIBABACLOUD_MYSQL_HNSW_M: ${ALIBABACLOUD_MYSQL_HNSW_M:-6} - RELYT_HOST: ${RELYT_HOST:-db} - RELYT_PORT: ${RELYT_PORT:-5432} - RELYT_USER: ${RELYT_USER:-postgres} - RELYT_PASSWORD: ${RELYT_PASSWORD:-difyai123456} - RELYT_DATABASE: ${RELYT_DATABASE:-postgres} - OPENSEARCH_HOST: ${OPENSEARCH_HOST:-opensearch} - OPENSEARCH_PORT: ${OPENSEARCH_PORT:-9200} - OPENSEARCH_SECURE: ${OPENSEARCH_SECURE:-true} - OPENSEARCH_VERIFY_CERTS: ${OPENSEARCH_VERIFY_CERTS:-true} - OPENSEARCH_AUTH_METHOD: ${OPENSEARCH_AUTH_METHOD:-basic} - OPENSEARCH_USER: ${OPENSEARCH_USER:-admin} - OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD:-admin} - OPENSEARCH_AWS_REGION: ${OPENSEARCH_AWS_REGION:-ap-southeast-1} - OPENSEARCH_AWS_SERVICE: ${OPENSEARCH_AWS_SERVICE:-aoss} - TENCENT_VECTOR_DB_URL: ${TENCENT_VECTOR_DB_URL:-http://127.0.0.1} - TENCENT_VECTOR_DB_API_KEY: ${TENCENT_VECTOR_DB_API_KEY:-dify} - TENCENT_VECTOR_DB_TIMEOUT: ${TENCENT_VECTOR_DB_TIMEOUT:-30} - TENCENT_VECTOR_DB_USERNAME: ${TENCENT_VECTOR_DB_USERNAME:-dify} - TENCENT_VECTOR_DB_DATABASE: ${TENCENT_VECTOR_DB_DATABASE:-dify} - TENCENT_VECTOR_DB_SHARD: ${TENCENT_VECTOR_DB_SHARD:-1} - TENCENT_VECTOR_DB_REPLICAS: ${TENCENT_VECTOR_DB_REPLICAS:-2} - TENCENT_VECTOR_DB_ENABLE_HYBRID_SEARCH: ${TENCENT_VECTOR_DB_ENABLE_HYBRID_SEARCH:-false} - ELASTICSEARCH_HOST: ${ELASTICSEARCH_HOST:-0.0.0.0} - ELASTICSEARCH_PORT: ${ELASTICSEARCH_PORT:-9200} - ELASTICSEARCH_USERNAME: ${ELASTICSEARCH_USERNAME:-elastic} - ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD:-elastic} - KIBANA_PORT: ${KIBANA_PORT:-5601} - ELASTICSEARCH_USE_CLOUD: ${ELASTICSEARCH_USE_CLOUD:-false} - ELASTICSEARCH_CLOUD_URL: ${ELASTICSEARCH_CLOUD_URL:-YOUR-ELASTICSEARCH_CLOUD_URL} - ELASTICSEARCH_API_KEY: ${ELASTICSEARCH_API_KEY:-YOUR-ELASTICSEARCH_API_KEY} - ELASTICSEARCH_VERIFY_CERTS: ${ELASTICSEARCH_VERIFY_CERTS:-False} - ELASTICSEARCH_CA_CERTS: ${ELASTICSEARCH_CA_CERTS:-} - ELASTICSEARCH_REQUEST_TIMEOUT: ${ELASTICSEARCH_REQUEST_TIMEOUT:-100000} - ELASTICSEARCH_RETRY_ON_TIMEOUT: ${ELASTICSEARCH_RETRY_ON_TIMEOUT:-True} - ELASTICSEARCH_MAX_RETRIES: ${ELASTICSEARCH_MAX_RETRIES:-10} - BAIDU_VECTOR_DB_ENDPOINT: ${BAIDU_VECTOR_DB_ENDPOINT:-http://127.0.0.1:5287} - BAIDU_VECTOR_DB_CONNECTION_TIMEOUT_MS: ${BAIDU_VECTOR_DB_CONNECTION_TIMEOUT_MS:-30000} - BAIDU_VECTOR_DB_ACCOUNT: ${BAIDU_VECTOR_DB_ACCOUNT:-root} - BAIDU_VECTOR_DB_API_KEY: ${BAIDU_VECTOR_DB_API_KEY:-dify} - BAIDU_VECTOR_DB_DATABASE: ${BAIDU_VECTOR_DB_DATABASE:-dify} - BAIDU_VECTOR_DB_SHARD: ${BAIDU_VECTOR_DB_SHARD:-1} - BAIDU_VECTOR_DB_REPLICAS: ${BAIDU_VECTOR_DB_REPLICAS:-3} - BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER: ${BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER:-DEFAULT_ANALYZER} - BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE: ${BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE:-COARSE_MODE} - BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT: ${BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT:-500} - BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO: ${BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO:-0.05} - BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS: ${BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS:-300} - VIKINGDB_ACCESS_KEY: ${VIKINGDB_ACCESS_KEY:-your-ak} - VIKINGDB_SECRET_KEY: ${VIKINGDB_SECRET_KEY:-your-sk} - VIKINGDB_REGION: ${VIKINGDB_REGION:-cn-shanghai} - VIKINGDB_HOST: ${VIKINGDB_HOST:-api-vikingdb.xxx.volces.com} - VIKINGDB_SCHEMA: ${VIKINGDB_SCHEMA:-http} - VIKINGDB_CONNECTION_TIMEOUT: ${VIKINGDB_CONNECTION_TIMEOUT:-30} - VIKINGDB_SOCKET_TIMEOUT: ${VIKINGDB_SOCKET_TIMEOUT:-30} - LINDORM_URL: ${LINDORM_URL:-http://localhost:30070} - LINDORM_USERNAME: ${LINDORM_USERNAME:-admin} - LINDORM_PASSWORD: ${LINDORM_PASSWORD:-admin} - LINDORM_USING_UGC: ${LINDORM_USING_UGC:-True} - LINDORM_QUERY_TIMEOUT: ${LINDORM_QUERY_TIMEOUT:-1} - OPENGAUSS_HOST: ${OPENGAUSS_HOST:-opengauss} - OPENGAUSS_PORT: ${OPENGAUSS_PORT:-6600} - OPENGAUSS_USER: ${OPENGAUSS_USER:-postgres} - OPENGAUSS_PASSWORD: ${OPENGAUSS_PASSWORD:-Dify@123} - OPENGAUSS_DATABASE: ${OPENGAUSS_DATABASE:-dify} - OPENGAUSS_MIN_CONNECTION: ${OPENGAUSS_MIN_CONNECTION:-1} - OPENGAUSS_MAX_CONNECTION: ${OPENGAUSS_MAX_CONNECTION:-5} - OPENGAUSS_ENABLE_PQ: ${OPENGAUSS_ENABLE_PQ:-false} - HUAWEI_CLOUD_HOSTS: ${HUAWEI_CLOUD_HOSTS:-https://127.0.0.1:9200} - HUAWEI_CLOUD_USER: ${HUAWEI_CLOUD_USER:-admin} - HUAWEI_CLOUD_PASSWORD: ${HUAWEI_CLOUD_PASSWORD:-admin} - UPSTASH_VECTOR_URL: ${UPSTASH_VECTOR_URL:-https://xxx-vector.upstash.io} - UPSTASH_VECTOR_TOKEN: ${UPSTASH_VECTOR_TOKEN:-dify} - TABLESTORE_ENDPOINT: ${TABLESTORE_ENDPOINT:-https://instance-name.cn-hangzhou.ots.aliyuncs.com} - TABLESTORE_INSTANCE_NAME: ${TABLESTORE_INSTANCE_NAME:-instance-name} - TABLESTORE_ACCESS_KEY_ID: ${TABLESTORE_ACCESS_KEY_ID:-xxx} - TABLESTORE_ACCESS_KEY_SECRET: ${TABLESTORE_ACCESS_KEY_SECRET:-xxx} - TABLESTORE_NORMALIZE_FULLTEXT_BM25_SCORE: ${TABLESTORE_NORMALIZE_FULLTEXT_BM25_SCORE:-false} - CLICKZETTA_USERNAME: ${CLICKZETTA_USERNAME:-} - CLICKZETTA_PASSWORD: ${CLICKZETTA_PASSWORD:-} - CLICKZETTA_INSTANCE: ${CLICKZETTA_INSTANCE:-} - CLICKZETTA_SERVICE: ${CLICKZETTA_SERVICE:-api.clickzetta.com} - CLICKZETTA_WORKSPACE: ${CLICKZETTA_WORKSPACE:-quick_start} - CLICKZETTA_VCLUSTER: ${CLICKZETTA_VCLUSTER:-default_ap} - CLICKZETTA_SCHEMA: ${CLICKZETTA_SCHEMA:-dify} - CLICKZETTA_BATCH_SIZE: ${CLICKZETTA_BATCH_SIZE:-100} - CLICKZETTA_ENABLE_INVERTED_INDEX: ${CLICKZETTA_ENABLE_INVERTED_INDEX:-true} - CLICKZETTA_ANALYZER_TYPE: ${CLICKZETTA_ANALYZER_TYPE:-chinese} - CLICKZETTA_ANALYZER_MODE: ${CLICKZETTA_ANALYZER_MODE:-smart} - CLICKZETTA_VECTOR_DISTANCE_FUNCTION: ${CLICKZETTA_VECTOR_DISTANCE_FUNCTION:-cosine_distance} - IRIS_HOST: ${IRIS_HOST:-iris} - IRIS_SUPER_SERVER_PORT: ${IRIS_SUPER_SERVER_PORT:-1972} - IRIS_WEB_SERVER_PORT: ${IRIS_WEB_SERVER_PORT:-52773} - IRIS_USER: ${IRIS_USER:-_SYSTEM} - IRIS_PASSWORD: ${IRIS_PASSWORD:-Dify@1234} - IRIS_DATABASE: ${IRIS_DATABASE:-USER} - IRIS_SCHEMA: ${IRIS_SCHEMA:-dify} - IRIS_CONNECTION_URL: ${IRIS_CONNECTION_URL:-} - IRIS_MIN_CONNECTION: ${IRIS_MIN_CONNECTION:-1} - IRIS_MAX_CONNECTION: ${IRIS_MAX_CONNECTION:-3} - IRIS_TEXT_INDEX: ${IRIS_TEXT_INDEX:-true} - IRIS_TEXT_INDEX_LANGUAGE: ${IRIS_TEXT_INDEX_LANGUAGE:-en} - IRIS_TIMEZONE: ${IRIS_TIMEZONE:-UTC} - UPLOAD_FILE_SIZE_LIMIT: ${UPLOAD_FILE_SIZE_LIMIT:-15} - UPLOAD_FILE_BATCH_LIMIT: ${UPLOAD_FILE_BATCH_LIMIT:-5} - UPLOAD_FILE_EXTENSION_BLACKLIST: ${UPLOAD_FILE_EXTENSION_BLACKLIST:-} - SINGLE_CHUNK_ATTACHMENT_LIMIT: ${SINGLE_CHUNK_ATTACHMENT_LIMIT:-10} - IMAGE_FILE_BATCH_LIMIT: ${IMAGE_FILE_BATCH_LIMIT:-10} - ATTACHMENT_IMAGE_FILE_SIZE_LIMIT: ${ATTACHMENT_IMAGE_FILE_SIZE_LIMIT:-2} - ATTACHMENT_IMAGE_DOWNLOAD_TIMEOUT: ${ATTACHMENT_IMAGE_DOWNLOAD_TIMEOUT:-60} - ETL_TYPE: ${ETL_TYPE:-dify} - UNSTRUCTURED_API_URL: ${UNSTRUCTURED_API_URL:-} - UNSTRUCTURED_API_KEY: ${UNSTRUCTURED_API_KEY:-} - SCARF_NO_ANALYTICS: ${SCARF_NO_ANALYTICS:-true} - PROMPT_GENERATION_MAX_TOKENS: ${PROMPT_GENERATION_MAX_TOKENS:-512} - CODE_GENERATION_MAX_TOKENS: ${CODE_GENERATION_MAX_TOKENS:-1024} - PLUGIN_BASED_TOKEN_COUNTING_ENABLED: ${PLUGIN_BASED_TOKEN_COUNTING_ENABLED:-false} - MULTIMODAL_SEND_FORMAT: ${MULTIMODAL_SEND_FORMAT:-base64} - UPLOAD_IMAGE_FILE_SIZE_LIMIT: ${UPLOAD_IMAGE_FILE_SIZE_LIMIT:-10} - UPLOAD_VIDEO_FILE_SIZE_LIMIT: ${UPLOAD_VIDEO_FILE_SIZE_LIMIT:-100} - UPLOAD_AUDIO_FILE_SIZE_LIMIT: ${UPLOAD_AUDIO_FILE_SIZE_LIMIT:-50} - SENTRY_DSN: ${SENTRY_DSN:-} - API_SENTRY_DSN: ${API_SENTRY_DSN:-} - API_SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} - API_SENTRY_PROFILES_SAMPLE_RATE: ${API_SENTRY_PROFILES_SAMPLE_RATE:-1.0} - WEB_SENTRY_DSN: ${WEB_SENTRY_DSN:-} - PLUGIN_SENTRY_ENABLED: ${PLUGIN_SENTRY_ENABLED:-false} - PLUGIN_SENTRY_DSN: ${PLUGIN_SENTRY_DSN:-} - NOTION_INTEGRATION_TYPE: ${NOTION_INTEGRATION_TYPE:-public} - NOTION_CLIENT_SECRET: ${NOTION_CLIENT_SECRET:-} - NOTION_CLIENT_ID: ${NOTION_CLIENT_ID:-} - NOTION_INTERNAL_SECRET: ${NOTION_INTERNAL_SECRET:-} - MAIL_TYPE: ${MAIL_TYPE:-} - MAIL_DEFAULT_SEND_FROM: ${MAIL_DEFAULT_SEND_FROM:-} - RESEND_API_URL: ${RESEND_API_URL:-https://api.resend.com} - RESEND_API_KEY: ${RESEND_API_KEY:-} - SMTP_SERVER: ${SMTP_SERVER:-} - SMTP_PORT: ${SMTP_PORT:-465} - SMTP_USERNAME: ${SMTP_USERNAME:-} - SMTP_PASSWORD: ${SMTP_PASSWORD:-} - SMTP_USE_TLS: ${SMTP_USE_TLS:-true} - SMTP_OPPORTUNISTIC_TLS: ${SMTP_OPPORTUNISTIC_TLS:-false} - SMTP_LOCAL_HOSTNAME: ${SMTP_LOCAL_HOSTNAME:-} - SENDGRID_API_KEY: ${SENDGRID_API_KEY:-} - INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} - INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72} - RESET_PASSWORD_TOKEN_EXPIRY_MINUTES: ${RESET_PASSWORD_TOKEN_EXPIRY_MINUTES:-5} - EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES: ${EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES:-5} - CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: ${CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES:-5} - OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: ${OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES:-5} - CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194} - CODE_EXECUTION_API_KEY: ${CODE_EXECUTION_API_KEY:-dify-sandbox} - CODE_EXECUTION_SSL_VERIFY: ${CODE_EXECUTION_SSL_VERIFY:-True} - CODE_EXECUTION_POOL_MAX_CONNECTIONS: ${CODE_EXECUTION_POOL_MAX_CONNECTIONS:-100} - CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS: ${CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS:-20} - CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY: ${CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY:-5.0} - CODE_MAX_NUMBER: ${CODE_MAX_NUMBER:-9223372036854775807} - CODE_MIN_NUMBER: ${CODE_MIN_NUMBER:--9223372036854775808} - CODE_MAX_DEPTH: ${CODE_MAX_DEPTH:-5} - CODE_MAX_PRECISION: ${CODE_MAX_PRECISION:-20} - CODE_MAX_STRING_LENGTH: ${CODE_MAX_STRING_LENGTH:-400000} - CODE_MAX_STRING_ARRAY_LENGTH: ${CODE_MAX_STRING_ARRAY_LENGTH:-30} - CODE_MAX_OBJECT_ARRAY_LENGTH: ${CODE_MAX_OBJECT_ARRAY_LENGTH:-30} - CODE_MAX_NUMBER_ARRAY_LENGTH: ${CODE_MAX_NUMBER_ARRAY_LENGTH:-1000} - CODE_EXECUTION_CONNECT_TIMEOUT: ${CODE_EXECUTION_CONNECT_TIMEOUT:-10} - CODE_EXECUTION_READ_TIMEOUT: ${CODE_EXECUTION_READ_TIMEOUT:-60} - CODE_EXECUTION_WRITE_TIMEOUT: ${CODE_EXECUTION_WRITE_TIMEOUT:-10} - TEMPLATE_TRANSFORM_MAX_LENGTH: ${TEMPLATE_TRANSFORM_MAX_LENGTH:-400000} - WORKFLOW_MAX_EXECUTION_STEPS: ${WORKFLOW_MAX_EXECUTION_STEPS:-500} - WORKFLOW_MAX_EXECUTION_TIME: ${WORKFLOW_MAX_EXECUTION_TIME:-1200} - WORKFLOW_CALL_MAX_DEPTH: ${WORKFLOW_CALL_MAX_DEPTH:-5} - MAX_VARIABLE_SIZE: ${MAX_VARIABLE_SIZE:-204800} - WORKFLOW_FILE_UPLOAD_LIMIT: ${WORKFLOW_FILE_UPLOAD_LIMIT:-10} - GRAPH_ENGINE_MIN_WORKERS: ${GRAPH_ENGINE_MIN_WORKERS:-1} - GRAPH_ENGINE_MAX_WORKERS: ${GRAPH_ENGINE_MAX_WORKERS:-10} - GRAPH_ENGINE_SCALE_UP_THRESHOLD: ${GRAPH_ENGINE_SCALE_UP_THRESHOLD:-3} - GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME: ${GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME:-5.0} - WORKFLOW_NODE_EXECUTION_STORAGE: ${WORKFLOW_NODE_EXECUTION_STORAGE:-rdbms} - CORE_WORKFLOW_EXECUTION_REPOSITORY: ${CORE_WORKFLOW_EXECUTION_REPOSITORY:-core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository} - CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY: ${CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY:-core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository} - API_WORKFLOW_RUN_REPOSITORY: ${API_WORKFLOW_RUN_REPOSITORY:-repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository} - API_WORKFLOW_NODE_EXECUTION_REPOSITORY: ${API_WORKFLOW_NODE_EXECUTION_REPOSITORY:-repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository} - WORKFLOW_LOG_CLEANUP_ENABLED: ${WORKFLOW_LOG_CLEANUP_ENABLED:-false} - WORKFLOW_LOG_RETENTION_DAYS: ${WORKFLOW_LOG_RETENTION_DAYS:-30} - WORKFLOW_LOG_CLEANUP_BATCH_SIZE: ${WORKFLOW_LOG_CLEANUP_BATCH_SIZE:-100} - WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS: ${WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS:-} - ALIYUN_SLS_ACCESS_KEY_ID: ${ALIYUN_SLS_ACCESS_KEY_ID:-} - ALIYUN_SLS_ACCESS_KEY_SECRET: ${ALIYUN_SLS_ACCESS_KEY_SECRET:-} - ALIYUN_SLS_ENDPOINT: ${ALIYUN_SLS_ENDPOINT:-} - ALIYUN_SLS_REGION: ${ALIYUN_SLS_REGION:-} - ALIYUN_SLS_PROJECT_NAME: ${ALIYUN_SLS_PROJECT_NAME:-} - ALIYUN_SLS_LOGSTORE_TTL: ${ALIYUN_SLS_LOGSTORE_TTL:-365} - LOGSTORE_DUAL_WRITE_ENABLED: ${LOGSTORE_DUAL_WRITE_ENABLED:-false} - LOGSTORE_DUAL_READ_ENABLED: ${LOGSTORE_DUAL_READ_ENABLED:-true} - LOGSTORE_ENABLE_PUT_GRAPH_FIELD: ${LOGSTORE_ENABLE_PUT_GRAPH_FIELD:-true} - HTTP_REQUEST_NODE_MAX_BINARY_SIZE: ${HTTP_REQUEST_NODE_MAX_BINARY_SIZE:-10485760} - HTTP_REQUEST_NODE_MAX_TEXT_SIZE: ${HTTP_REQUEST_NODE_MAX_TEXT_SIZE:-1048576} - HTTP_REQUEST_NODE_SSL_VERIFY: ${HTTP_REQUEST_NODE_SSL_VERIFY:-True} - HTTP_REQUEST_MAX_CONNECT_TIMEOUT: ${HTTP_REQUEST_MAX_CONNECT_TIMEOUT:-10} - HTTP_REQUEST_MAX_READ_TIMEOUT: ${HTTP_REQUEST_MAX_READ_TIMEOUT:-600} - HTTP_REQUEST_MAX_WRITE_TIMEOUT: ${HTTP_REQUEST_MAX_WRITE_TIMEOUT:-600} - WEBHOOK_REQUEST_BODY_MAX_SIZE: ${WEBHOOK_REQUEST_BODY_MAX_SIZE:-10485760} - RESPECT_XFORWARD_HEADERS_ENABLED: ${RESPECT_XFORWARD_HEADERS_ENABLED:-false} - SSRF_PROXY_HTTP_URL: ${SSRF_PROXY_HTTP_URL:-http://ssrf_proxy:3128} - SSRF_PROXY_HTTPS_URL: ${SSRF_PROXY_HTTPS_URL:-http://ssrf_proxy:3128} - LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100} - MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10} - MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10} - MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99} - TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} - EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false} - ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} - ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} - MAX_TREE_DEPTH: ${MAX_TREE_DEPTH:-50} - PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata} - MYSQL_HOST_VOLUME: ${MYSQL_HOST_VOLUME:-./volumes/mysql/data} - SANDBOX_API_KEY: ${SANDBOX_API_KEY:-dify-sandbox} - SANDBOX_GIN_MODE: ${SANDBOX_GIN_MODE:-release} - SANDBOX_WORKER_TIMEOUT: ${SANDBOX_WORKER_TIMEOUT:-15} - SANDBOX_ENABLE_NETWORK: ${SANDBOX_ENABLE_NETWORK:-true} - SANDBOX_HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128} - SANDBOX_HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128} - SANDBOX_PORT: ${SANDBOX_PORT:-8194} - WEAVIATE_PERSISTENCE_DATA_PATH: ${WEAVIATE_PERSISTENCE_DATA_PATH:-/var/lib/weaviate} - WEAVIATE_QUERY_DEFAULTS_LIMIT: ${WEAVIATE_QUERY_DEFAULTS_LIMIT:-25} - WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: ${WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED:-true} - WEAVIATE_DEFAULT_VECTORIZER_MODULE: ${WEAVIATE_DEFAULT_VECTORIZER_MODULE:-none} - WEAVIATE_CLUSTER_HOSTNAME: ${WEAVIATE_CLUSTER_HOSTNAME:-node1} - WEAVIATE_AUTHENTICATION_APIKEY_ENABLED: ${WEAVIATE_AUTHENTICATION_APIKEY_ENABLED:-true} - WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS: ${WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih} - WEAVIATE_AUTHENTICATION_APIKEY_USERS: ${WEAVIATE_AUTHENTICATION_APIKEY_USERS:-hello@dify.ai} - WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED: ${WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED:-true} - WEAVIATE_AUTHORIZATION_ADMINLIST_USERS: ${WEAVIATE_AUTHORIZATION_ADMINLIST_USERS:-hello@dify.ai} - WEAVIATE_DISABLE_TELEMETRY: ${WEAVIATE_DISABLE_TELEMETRY:-false} - WEAVIATE_ENABLE_TOKENIZER_GSE: ${WEAVIATE_ENABLE_TOKENIZER_GSE:-false} - WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA:-false} - WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR:-false} - CHROMA_SERVER_AUTHN_CREDENTIALS: ${CHROMA_SERVER_AUTHN_CREDENTIALS:-difyai123456} - CHROMA_SERVER_AUTHN_PROVIDER: ${CHROMA_SERVER_AUTHN_PROVIDER:-chromadb.auth.token_authn.TokenAuthenticationServerProvider} - CHROMA_IS_PERSISTENT: ${CHROMA_IS_PERSISTENT:-TRUE} - ORACLE_PWD: ${ORACLE_PWD:-Dify123456} - ORACLE_CHARACTERSET: ${ORACLE_CHARACTERSET:-AL32UTF8} - ETCD_AUTO_COMPACTION_MODE: ${ETCD_AUTO_COMPACTION_MODE:-revision} - ETCD_AUTO_COMPACTION_RETENTION: ${ETCD_AUTO_COMPACTION_RETENTION:-1000} - ETCD_QUOTA_BACKEND_BYTES: ${ETCD_QUOTA_BACKEND_BYTES:-4294967296} - ETCD_SNAPSHOT_COUNT: ${ETCD_SNAPSHOT_COUNT:-50000} - MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} - MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} - ETCD_ENDPOINTS: ${ETCD_ENDPOINTS:-etcd:2379} - MINIO_ADDRESS: ${MINIO_ADDRESS:-minio:9000} - MILVUS_AUTHORIZATION_ENABLED: ${MILVUS_AUTHORIZATION_ENABLED:-true} - PGVECTOR_PGUSER: ${PGVECTOR_PGUSER:-postgres} - PGVECTOR_POSTGRES_PASSWORD: ${PGVECTOR_POSTGRES_PASSWORD:-difyai123456} - PGVECTOR_POSTGRES_DB: ${PGVECTOR_POSTGRES_DB:-dify} - PGVECTOR_PGDATA: ${PGVECTOR_PGDATA:-/var/lib/postgresql/data/pgdata} - OPENSEARCH_DISCOVERY_TYPE: ${OPENSEARCH_DISCOVERY_TYPE:-single-node} - OPENSEARCH_BOOTSTRAP_MEMORY_LOCK: ${OPENSEARCH_BOOTSTRAP_MEMORY_LOCK:-true} - OPENSEARCH_JAVA_OPTS_MIN: ${OPENSEARCH_JAVA_OPTS_MIN:-512m} - OPENSEARCH_JAVA_OPTS_MAX: ${OPENSEARCH_JAVA_OPTS_MAX:-1024m} - OPENSEARCH_INITIAL_ADMIN_PASSWORD: ${OPENSEARCH_INITIAL_ADMIN_PASSWORD:-Qazwsxedc!@#123} - OPENSEARCH_MEMLOCK_SOFT: ${OPENSEARCH_MEMLOCK_SOFT:--1} - OPENSEARCH_MEMLOCK_HARD: ${OPENSEARCH_MEMLOCK_HARD:--1} - OPENSEARCH_NOFILE_SOFT: ${OPENSEARCH_NOFILE_SOFT:-65536} - OPENSEARCH_NOFILE_HARD: ${OPENSEARCH_NOFILE_HARD:-65536} - NGINX_SERVER_NAME: ${NGINX_SERVER_NAME:-_} - NGINX_HTTPS_ENABLED: ${NGINX_HTTPS_ENABLED:-false} - NGINX_PORT: ${NGINX_PORT:-80} - NGINX_SSL_PORT: ${NGINX_SSL_PORT:-443} - NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt} - NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key} - NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3} - NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto} - NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M} - NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65} - NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} - NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} - NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false} - CERTBOT_EMAIL: ${CERTBOT_EMAIL:-} - CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-} - CERTBOT_OPTIONS: ${CERTBOT_OPTIONS:-} - SSRF_HTTP_PORT: ${SSRF_HTTP_PORT:-3128} - SSRF_COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} - SSRF_REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194} - SSRF_SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox} - SSRF_DEFAULT_TIME_OUT: ${SSRF_DEFAULT_TIME_OUT:-5} - SSRF_DEFAULT_CONNECT_TIME_OUT: ${SSRF_DEFAULT_CONNECT_TIME_OUT:-5} - SSRF_DEFAULT_READ_TIME_OUT: ${SSRF_DEFAULT_READ_TIME_OUT:-5} - SSRF_DEFAULT_WRITE_TIME_OUT: ${SSRF_DEFAULT_WRITE_TIME_OUT:-5} - SSRF_POOL_MAX_CONNECTIONS: ${SSRF_POOL_MAX_CONNECTIONS:-100} - SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS: ${SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS:-20} - SSRF_POOL_KEEPALIVE_EXPIRY: ${SSRF_POOL_KEEPALIVE_EXPIRY:-5.0} - EXPOSE_NGINX_PORT: ${EXPOSE_NGINX_PORT:-80} - EXPOSE_NGINX_SSL_PORT: ${EXPOSE_NGINX_SSL_PORT:-443} - POSITION_TOOL_PINS: ${POSITION_TOOL_PINS:-} - POSITION_TOOL_INCLUDES: ${POSITION_TOOL_INCLUDES:-} - POSITION_TOOL_EXCLUDES: ${POSITION_TOOL_EXCLUDES:-} - POSITION_PROVIDER_PINS: ${POSITION_PROVIDER_PINS:-} - POSITION_PROVIDER_INCLUDES: ${POSITION_PROVIDER_INCLUDES:-} - POSITION_PROVIDER_EXCLUDES: ${POSITION_PROVIDER_EXCLUDES:-} - CSP_WHITELIST: ${CSP_WHITELIST:-} - CREATE_TIDB_SERVICE_JOB_ENABLED: ${CREATE_TIDB_SERVICE_JOB_ENABLED:-false} - MAX_SUBMIT_COUNT: ${MAX_SUBMIT_COUNT:-100} - TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-10} - DB_PLUGIN_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin} - EXPOSE_PLUGIN_DAEMON_PORT: ${EXPOSE_PLUGIN_DAEMON_PORT:-5002} - PLUGIN_DAEMON_PORT: ${PLUGIN_DAEMON_PORT:-5002} - PLUGIN_DAEMON_KEY: ${PLUGIN_DAEMON_KEY:-lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi} - PLUGIN_DAEMON_URL: ${PLUGIN_DAEMON_URL:-http://plugin_daemon:5002} - PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800} - PLUGIN_MODEL_SCHEMA_CACHE_TTL: ${PLUGIN_MODEL_SCHEMA_CACHE_TTL:-3600} - PLUGIN_PPROF_ENABLED: ${PLUGIN_PPROF_ENABLED:-false} - PLUGIN_DEBUGGING_HOST: ${PLUGIN_DEBUGGING_HOST:-0.0.0.0} - PLUGIN_DEBUGGING_PORT: ${PLUGIN_DEBUGGING_PORT:-5003} - EXPOSE_PLUGIN_DEBUGGING_HOST: ${EXPOSE_PLUGIN_DEBUGGING_HOST:-localhost} - EXPOSE_PLUGIN_DEBUGGING_PORT: ${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003} - PLUGIN_DIFY_INNER_API_KEY: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1} - PLUGIN_DIFY_INNER_API_URL: ${PLUGIN_DIFY_INNER_API_URL:-http://api:5001} - ENDPOINT_URL_TEMPLATE: ${ENDPOINT_URL_TEMPLATE:-http://localhost/e/{hook_id}} - MARKETPLACE_ENABLED: ${MARKETPLACE_ENABLED:-true} - MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} - CREATORS_PLATFORM_FEATURES_ENABLED: ${CREATORS_PLATFORM_FEATURES_ENABLED:-true} - CREATORS_PLATFORM_API_URL: ${CREATORS_PLATFORM_API_URL:-https://creators.dify.ai} - CREATORS_PLATFORM_OAUTH_CLIENT_ID: ${CREATORS_PLATFORM_OAUTH_CLIENT_ID:-} - FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true} - ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES: ${ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES:-true} - PLUGIN_STDIO_BUFFER_SIZE: ${PLUGIN_STDIO_BUFFER_SIZE:-1024} - PLUGIN_STDIO_MAX_BUFFER_SIZE: ${PLUGIN_STDIO_MAX_BUFFER_SIZE:-5242880} - PLUGIN_PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120} - PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600} - PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0} - PIP_MIRROR_URL: ${PIP_MIRROR_URL:-} - PLUGIN_STORAGE_TYPE: ${PLUGIN_STORAGE_TYPE:-local} - PLUGIN_STORAGE_LOCAL_ROOT: ${PLUGIN_STORAGE_LOCAL_ROOT:-/app/storage} - PLUGIN_WORKING_PATH: ${PLUGIN_WORKING_PATH:-/app/storage/cwd} - PLUGIN_INSTALLED_PATH: ${PLUGIN_INSTALLED_PATH:-plugin} - PLUGIN_PACKAGE_CACHE_PATH: ${PLUGIN_PACKAGE_CACHE_PATH:-plugin_packages} - PLUGIN_MEDIA_CACHE_PATH: ${PLUGIN_MEDIA_CACHE_PATH:-assets} - PLUGIN_STORAGE_OSS_BUCKET: ${PLUGIN_STORAGE_OSS_BUCKET:-} - PLUGIN_S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-false} - PLUGIN_S3_USE_AWS_MANAGED_IAM: ${PLUGIN_S3_USE_AWS_MANAGED_IAM:-false} - PLUGIN_S3_ENDPOINT: ${PLUGIN_S3_ENDPOINT:-} - PLUGIN_S3_USE_PATH_STYLE: ${PLUGIN_S3_USE_PATH_STYLE:-false} - PLUGIN_AWS_ACCESS_KEY: ${PLUGIN_AWS_ACCESS_KEY:-} - PLUGIN_AWS_SECRET_KEY: ${PLUGIN_AWS_SECRET_KEY:-} - PLUGIN_AWS_REGION: ${PLUGIN_AWS_REGION:-} - PLUGIN_AZURE_BLOB_STORAGE_CONTAINER_NAME: ${PLUGIN_AZURE_BLOB_STORAGE_CONTAINER_NAME:-} - PLUGIN_AZURE_BLOB_STORAGE_CONNECTION_STRING: ${PLUGIN_AZURE_BLOB_STORAGE_CONNECTION_STRING:-} - PLUGIN_TENCENT_COS_SECRET_KEY: ${PLUGIN_TENCENT_COS_SECRET_KEY:-} - PLUGIN_TENCENT_COS_SECRET_ID: ${PLUGIN_TENCENT_COS_SECRET_ID:-} - PLUGIN_TENCENT_COS_REGION: ${PLUGIN_TENCENT_COS_REGION:-} - PLUGIN_ALIYUN_OSS_REGION: ${PLUGIN_ALIYUN_OSS_REGION:-} - PLUGIN_ALIYUN_OSS_ENDPOINT: ${PLUGIN_ALIYUN_OSS_ENDPOINT:-} - PLUGIN_ALIYUN_OSS_ACCESS_KEY_ID: ${PLUGIN_ALIYUN_OSS_ACCESS_KEY_ID:-} - PLUGIN_ALIYUN_OSS_ACCESS_KEY_SECRET: ${PLUGIN_ALIYUN_OSS_ACCESS_KEY_SECRET:-} - PLUGIN_ALIYUN_OSS_AUTH_VERSION: ${PLUGIN_ALIYUN_OSS_AUTH_VERSION:-v4} - PLUGIN_ALIYUN_OSS_PATH: ${PLUGIN_ALIYUN_OSS_PATH:-} - PLUGIN_VOLCENGINE_TOS_ENDPOINT: ${PLUGIN_VOLCENGINE_TOS_ENDPOINT:-} - PLUGIN_VOLCENGINE_TOS_ACCESS_KEY: ${PLUGIN_VOLCENGINE_TOS_ACCESS_KEY:-} - PLUGIN_VOLCENGINE_TOS_SECRET_KEY: ${PLUGIN_VOLCENGINE_TOS_SECRET_KEY:-} - PLUGIN_VOLCENGINE_TOS_REGION: ${PLUGIN_VOLCENGINE_TOS_REGION:-} - ENABLE_OTEL: ${ENABLE_OTEL:-false} - OTLP_TRACE_ENDPOINT: ${OTLP_TRACE_ENDPOINT:-} - OTLP_METRIC_ENDPOINT: ${OTLP_METRIC_ENDPOINT:-} - OTLP_BASE_ENDPOINT: ${OTLP_BASE_ENDPOINT:-http://localhost:4318} - OTLP_API_KEY: ${OTLP_API_KEY:-} - OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-} - OTEL_EXPORTER_TYPE: ${OTEL_EXPORTER_TYPE:-otlp} - OTEL_SAMPLING_RATE: ${OTEL_SAMPLING_RATE:-0.1} - OTEL_BATCH_EXPORT_SCHEDULE_DELAY: ${OTEL_BATCH_EXPORT_SCHEDULE_DELAY:-5000} - OTEL_MAX_QUEUE_SIZE: ${OTEL_MAX_QUEUE_SIZE:-2048} - OTEL_MAX_EXPORT_BATCH_SIZE: ${OTEL_MAX_EXPORT_BATCH_SIZE:-512} - OTEL_METRIC_EXPORT_INTERVAL: ${OTEL_METRIC_EXPORT_INTERVAL:-60000} - OTEL_BATCH_EXPORT_TIMEOUT: ${OTEL_BATCH_EXPORT_TIMEOUT:-10000} - OTEL_METRIC_EXPORT_TIMEOUT: ${OTEL_METRIC_EXPORT_TIMEOUT:-30000} - ALLOW_EMBED: ${ALLOW_EMBED:-false} - QUEUE_MONITOR_THRESHOLD: ${QUEUE_MONITOR_THRESHOLD:-200} - QUEUE_MONITOR_ALERT_EMAILS: ${QUEUE_MONITOR_ALERT_EMAILS:-} - QUEUE_MONITOR_INTERVAL: ${QUEUE_MONITOR_INTERVAL:-30} - SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-false} - SWAGGER_UI_PATH: ${SWAGGER_UI_PATH:-/swagger-ui.html} - DSL_EXPORT_ENCRYPT_DATASET_ID: ${DSL_EXPORT_ENCRYPT_DATASET_ID:-true} - DATASET_MAX_SEGMENTS_PER_REQUEST: ${DATASET_MAX_SEGMENTS_PER_REQUEST:-0} - ENABLE_CLEAN_EMBEDDING_CACHE_TASK: ${ENABLE_CLEAN_EMBEDDING_CACHE_TASK:-false} - ENABLE_CLEAN_UNUSED_DATASETS_TASK: ${ENABLE_CLEAN_UNUSED_DATASETS_TASK:-false} - ENABLE_CREATE_TIDB_SERVERLESS_TASK: ${ENABLE_CREATE_TIDB_SERVERLESS_TASK:-false} - ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK: ${ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK:-false} - ENABLE_CLEAN_MESSAGES: ${ENABLE_CLEAN_MESSAGES:-false} - ENABLE_WORKFLOW_RUN_CLEANUP_TASK: ${ENABLE_WORKFLOW_RUN_CLEANUP_TASK:-false} - ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: ${ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK:-false} - ENABLE_DATASETS_QUEUE_MONITOR: ${ENABLE_DATASETS_QUEUE_MONITOR:-false} - ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK: ${ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK:-true} - ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK: ${ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK:-true} - WORKFLOW_SCHEDULE_POLLER_INTERVAL: ${WORKFLOW_SCHEDULE_POLLER_INTERVAL:-1} - WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE: ${WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE:-100} - WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK: ${WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK:-0} - TENANT_ISOLATED_TASK_CONCURRENCY: ${TENANT_ISOLATED_TASK_CONCURRENCY:-1} - ANNOTATION_IMPORT_FILE_SIZE_LIMIT: ${ANNOTATION_IMPORT_FILE_SIZE_LIMIT:-2} - ANNOTATION_IMPORT_MAX_RECORDS: ${ANNOTATION_IMPORT_MAX_RECORDS:-10000} - ANNOTATION_IMPORT_MIN_RECORDS: ${ANNOTATION_IMPORT_MIN_RECORDS:-1} - ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE: ${ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE:-5} - ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR: ${ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR:-20} - ANNOTATION_IMPORT_MAX_CONCURRENT: ${ANNOTATION_IMPORT_MAX_CONCURRENT:-5} - AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-} - SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD: ${SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD:-21} - SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000} - SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL:-200} - SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30} - EVENT_BUS_REDIS_URL: ${EVENT_BUS_REDIS_URL:-} - EVENT_BUS_REDIS_CHANNEL_TYPE: ${EVENT_BUS_REDIS_CHANNEL_TYPE:-pubsub} - EVENT_BUS_REDIS_USE_CLUSTERS: ${EVENT_BUS_REDIS_USE_CLUSTERS:-false} - ENABLE_HUMAN_INPUT_TIMEOUT_TASK: ${ENABLE_HUMAN_INPUT_TIMEOUT_TASK:-true} - HUMAN_INPUT_TIMEOUT_TASK_INTERVAL: ${HUMAN_INPUT_TIMEOUT_TASK_INTERVAL:-1} - SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL:-90000} +# Shared configuration using YAML anchors and env_file +x-shared-api-worker-config: &shared-api-worker-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/api.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always + +x-shared-worker-config: &shared-worker-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/worker.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always + +x-shared-worker-beat-config: &shared-worker-beat-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/worker-beat.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always services: # Init container to fix permissions @@ -745,12 +225,9 @@ services: # API service api: + <<: *shared-api-worker-config image: langgenius/dify-api:1.14.0 - restart: always environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'api' starts the API server. MODE: api SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} @@ -793,12 +270,9 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: + <<: *shared-worker-config image: langgenius/dify-api:1.14.0 - restart: always environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'worker' starts the Celery worker for processing all queues. MODE: worker SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} @@ -839,12 +313,9 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: + <<: *shared-worker-beat-config image: langgenius/dify-api:1.14.0 - restart: always environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'worker_beat' starts the Celery beat for scheduling periodic tasks. MODE: beat depends_on: init_permissions: @@ -878,6 +349,12 @@ services: web: image: langgenius/dify-web:1.14.0 restart: always + env_file: + - path: ./envs/core-services/web.env + required: false + - path: ./envs/security.env + required: false + - ./.env environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} APP_API_URL: ${APP_API_URL:-} @@ -952,7 +429,7 @@ services: MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456} MYSQL_DATABASE: ${DB_DATABASE:-dify} command: > - --max_connections=1000 + --max_connections=${MYSQL_MAX_CONNECTIONS:-1000} --innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} --innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M} --innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2} @@ -994,6 +471,12 @@ services: sandbox: image: langgenius/dify-sandbox:0.2.15 restart: always + env_file: + - path: ./envs/core-services/sandbox.env + required: false + - path: ./envs/security.env + required: false + - ./.env environment: # The DifySandbox configurations # Make sure you are changing this key for your deployment with a strong key. @@ -1018,9 +501,24 @@ services: plugin_daemon: image: langgenius/dify-plugin-daemon:0.6.0-local restart: always + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/plugin-daemon.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default environment: - # Use the shared environment variables. - <<: *shared-api-worker-env DB_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin} DB_SSL_MODE: ${DB_SSL_MODE:-disable} SERVER_PORT: ${PLUGIN_DAEMON_PORT:-5002} diff --git a/docker/envs/core-services/api.env.example b/docker/envs/core-services/api.env.example new file mode 100644 index 0000000000..1a3fc7a4ab --- /dev/null +++ b/docker/envs/core-services/api.env.example @@ -0,0 +1,13 @@ +# ------------------------------ +# Api Configuration +# ------------------------------ + +MODE=api +SENTRY_DSN= +SENTRY_TRACES_SAMPLE_RATE=1.0 +SENTRY_PROFILES_SAMPLE_RATE=1.0 +PLUGIN_REMOTE_INSTALL_HOST=localhost +PLUGIN_REMOTE_INSTALL_PORT=5003 +PLUGIN_MAX_PACKAGE_SIZE=52428800 +PLUGIN_DAEMON_TIMEOUT=600.0 +INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 diff --git a/docker/envs/core-services/plugin-daemon.env.example b/docker/envs/core-services/plugin-daemon.env.example new file mode 100644 index 0000000000..c3b1bef974 --- /dev/null +++ b/docker/envs/core-services/plugin-daemon.env.example @@ -0,0 +1,23 @@ +# ------------------------------ +# Plugin Daemon Configuration +# ------------------------------ + +DB_PLUGIN_DATABASE=dify_plugin +PLUGIN_DAEMON_URL=http://plugin_daemon:5002 +PLUGIN_PPROF_ENABLED=false +PLUGIN_DIFY_INNER_API_URL=http://api:5001 +FORCE_VERIFYING_SIGNATURE=true +PLUGIN_STDIO_BUFFER_SIZE=1024 +PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880 +PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120 +PLUGIN_MAX_EXECUTION_TIMEOUT=600 +PLUGIN_DEBUGGING_HOST=0.0.0.0 +PLUGIN_DEBUGGING_PORT=5003 +PLUGIN_DAEMON_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi +PLUGIN_DIFY_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 +PLUGIN_DAEMON_PORT=5002 +CELERY_WORKER_CLASS= +PLUGIN_STORAGE_TYPE=local +PLUGIN_STORAGE_LOCAL_ROOT=/app/storage +PLUGIN_WORKING_PATH=/app/storage/cwd +PLUGIN_STORAGE_OSS_BUCKET= diff --git a/docker/envs/core-services/sandbox.env.example b/docker/envs/core-services/sandbox.env.example new file mode 100644 index 0000000000..5d4ee6614b --- /dev/null +++ b/docker/envs/core-services/sandbox.env.example @@ -0,0 +1,17 @@ +# ------------------------------ +# Sandbox Configuration +# ------------------------------ + +SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128 +SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128 +SANDBOX_PORT=8194 +PIP_MIRROR_URL= +SANDBOX_API_KEY=dify-sandbox +SANDBOX_GIN_MODE=release +SANDBOX_WORKER_TIMEOUT=15 +SANDBOX_ENABLE_NETWORK=true +SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21 +SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000 +SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200 +SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30 +SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000 diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example new file mode 100644 index 0000000000..2a57f6954a --- /dev/null +++ b/docker/envs/core-services/shared.env.example @@ -0,0 +1,469 @@ +# ------------------------------ +# Shared API/Worker Configuration +# ------------------------------ + +CONSOLE_WEB_URL= +SERVICE_API_URL= +TRIGGER_URL=http://localhost +APP_WEB_URL= +FILES_URL= +INTERNAL_FILES_URL= +LANG=C.UTF-8 +LC_ALL=C.UTF-8 +PYTHONIOENCODING=utf-8 +UV_CACHE_DIR=/tmp/.uv-cache +CHECK_UPDATE_URL=https://updates.dify.ai +OPENAI_API_BASE=https://api.openai.com/v1 +MIGRATION_ENABLED=true +FILES_ACCESS_TIMEOUT=300 +ENABLE_COLLABORATION_MODE=false +CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1 +CELERY_TASK_ANNOTATIONS=null +AZURE_BLOB_ACCOUNT_URL=https://.blob.core.windows.net +SUPABASE_URL=your-server-url +TIDB_ON_QDRANT_URL=http://127.0.0.1 +TIDB_ON_QDRANT_API_KEY=dify +TIDB_API_URL=http://127.0.0.1 +TIDB_IAM_API_URL=http://127.0.0.1 +TIDB_REGION=regions/aws-us-east-1 +TIDB_PROJECT_ID=dify +TIDB_SPEND_LIMIT=100 +TENCENT_VECTOR_DB_URL=http://127.0.0.1 +TENCENT_VECTOR_DB_API_KEY=dify +LINDORM_URL=http://localhost:30070 +LINDORM_USERNAME=admin +UPSTASH_VECTOR_URL=https://xxx-vector.upstash.io +UPLOAD_FILE_SIZE_LIMIT=15 +UPLOAD_FILE_BATCH_LIMIT=5 +UPLOAD_FILE_EXTENSION_BLACKLIST= +SINGLE_CHUNK_ATTACHMENT_LIMIT=10 +IMAGE_FILE_BATCH_LIMIT=10 +ATTACHMENT_IMAGE_FILE_SIZE_LIMIT=2 +ATTACHMENT_IMAGE_DOWNLOAD_TIMEOUT=60 +ETL_TYPE=dify +UNSTRUCTURED_API_URL= +MULTIMODAL_SEND_FORMAT=base64 +UPLOAD_IMAGE_FILE_SIZE_LIMIT=10 +UPLOAD_VIDEO_FILE_SIZE_LIMIT=100 +UPLOAD_AUDIO_FILE_SIZE_LIMIT=50 +API_SENTRY_DSN= +API_SENTRY_TRACES_SAMPLE_RATE=1.0 +API_SENTRY_PROFILES_SAMPLE_RATE=1.0 +WEB_SENTRY_DSN= +PLUGIN_SENTRY_ENABLED=false +PLUGIN_SENTRY_DSN= +NOTION_INTEGRATION_TYPE=public +RESEND_API_URL=https://api.resend.com +SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128 +SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128 +PGDATA=/var/lib/postgresql/data/pgdata +PLUGIN_MAX_PACKAGE_SIZE=52428800 +PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600 +ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id} +LOG_LEVEL=INFO +LOG_OUTPUT_FORMAT=text +LOG_FILE=/app/logs/server.log +LOG_FILE_MAX_SIZE=20 +LOG_FILE_BACKUP_COUNT=5 +LOG_DATEFORMAT=%Y-%m-%d %H:%M:%S +LOG_TZ=UTC +DEBUG=false +FLASK_DEBUG=false +ENABLE_REQUEST_LOGGING=False +WORKFLOW_LOG_CLEANUP_ENABLED=false +WORKFLOW_LOG_RETENTION_DAYS=30 +WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100 +WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS= +EXPOSE_PLUGIN_DEBUGGING_HOST=localhost +EXPOSE_PLUGIN_DEBUGGING_PORT=5003 +DEPLOY_ENV=PRODUCTION +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_DAYS=30 +APP_DEFAULT_ACTIVE_REQUESTS=0 +APP_MAX_ACTIVE_REQUESTS=0 +APP_MAX_EXECUTION_TIME=1200 +DIFY_BIND_ADDRESS=0.0.0.0 +DIFY_PORT=5001 +SERVER_WORKER_AMOUNT=1 +SERVER_WORKER_CLASS=gevent +SERVER_WORKER_CONNECTIONS=10 +CELERY_SENTINEL_PASSWORD= +S3_ACCESS_KEY= +S3_SECRET_KEY= +ARCHIVE_STORAGE_ACCESS_KEY= +ARCHIVE_STORAGE_SECRET_KEY= +AZURE_BLOB_ACCOUNT_KEY=difyai +ALIYUN_OSS_ACCESS_KEY=your-access-key +ALIYUN_OSS_SECRET_KEY=your-secret-key +TENCENT_COS_SECRET_KEY=your-secret-key +TENCENT_COS_SECRET_ID=your-secret-id +OCI_ACCESS_KEY=your-access-key +OCI_SECRET_KEY=your-secret-key +HUAWEI_OBS_SECRET_KEY=your-secret-key +HUAWEI_OBS_ACCESS_KEY=your-access-key +VOLCENGINE_TOS_SECRET_KEY=your-secret-key +VOLCENGINE_TOS_ACCESS_KEY=your-access-key +BAIDU_OBS_SECRET_KEY=your-secret-key +BAIDU_OBS_ACCESS_KEY=your-access-key +SUPABASE_API_KEY=your-access-key +ALIBABACLOUD_MYSQL_PASSWORD=difyai123456 +RELYT_PASSWORD=difyai123456 +LINDORM_PASSWORD=admin +LINDORM_USING_UGC=True +LINDORM_QUERY_TIMEOUT=1 +HUAWEI_CLOUD_PASSWORD=admin +UPSTASH_VECTOR_TOKEN=dify +TABLESTORE_ACCESS_KEY_ID=xxx +TABLESTORE_ACCESS_KEY_SECRET=xxx +TABLESTORE_NORMALIZE_FULLTEXT_BM25_SCORE=false +CLICKZETTA_PASSWORD= +CLICKZETTA_INSTANCE= +CLICKZETTA_SERVICE=api.clickzetta.com +CLICKZETTA_WORKSPACE=quick_start +CLICKZETTA_VCLUSTER=default_ap +CLICKZETTA_SCHEMA=dify +CLICKZETTA_BATCH_SIZE=100 +CLICKZETTA_ENABLE_INVERTED_INDEX=true +CLICKZETTA_ANALYZER_TYPE=chinese +CLICKZETTA_ANALYZER_MODE=smart +UNSTRUCTURED_API_KEY= +SCARF_NO_ANALYTICS=true +PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false +NOTION_CLIENT_SECRET= +NOTION_CLIENT_ID= +NOTION_INTERNAL_SECRET= +MAIL_TYPE=resend +MAIL_DEFAULT_SEND_FROM= +RESEND_API_KEY=your-resend-api-key +SMTP_SERVER= +SMTP_PORT=465 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_USE_TLS=true +SMTP_OPPORTUNISTIC_TLS=false +SMTP_LOCAL_HOSTNAME= +SENDGRID_API_KEY= +INVITE_EXPIRY_HOURS=72 +RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 +EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 +CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 +OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 +CODE_EXECUTION_ENDPOINT=http://sandbox:8194 +CODE_EXECUTION_API_KEY=dify-sandbox +CODE_EXECUTION_SSL_VERIFY=True +CODE_EXECUTION_POOL_MAX_CONNECTIONS=100 +CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS=20 +CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY=5.0 +CODE_MAX_NUMBER=9223372036854775807 +CODE_MIN_NUMBER=-9223372036854775808 +CODE_MAX_DEPTH=5 +CODE_MAX_PRECISION=20 +CODE_MAX_STRING_LENGTH=400000 +CODE_MAX_STRING_ARRAY_LENGTH=30 +CODE_MAX_OBJECT_ARRAY_LENGTH=30 +CODE_MAX_NUMBER_ARRAY_LENGTH=1000 +CODE_EXECUTION_CONNECT_TIMEOUT=10 +CODE_EXECUTION_READ_TIMEOUT=60 +CODE_EXECUTION_WRITE_TIMEOUT=10 +TEMPLATE_TRANSFORM_MAX_LENGTH=400000 +WORKFLOW_MAX_EXECUTION_STEPS=500 +WORKFLOW_MAX_EXECUTION_TIME=1200 +WORKFLOW_CALL_MAX_DEPTH=5 +MAX_VARIABLE_SIZE=204800 +WORKFLOW_FILE_UPLOAD_LIMIT=10 +GRAPH_ENGINE_MIN_WORKERS=1 +GRAPH_ENGINE_MAX_WORKERS=10 +GRAPH_ENGINE_SCALE_UP_THRESHOLD=3 +GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME=5.0 +ALIYUN_SLS_ACCESS_KEY_ID= +ALIYUN_SLS_ACCESS_KEY_SECRET= +WEBHOOK_REQUEST_BODY_MAX_SIZE=10485760 +RESPECT_XFORWARD_HEADERS_ENABLED=false +SSRF_HTTP_PORT=3128 +SSRF_COREDUMP_DIR=/var/spool/squid +SSRF_REVERSE_PROXY_PORT=8194 +SSRF_SANDBOX_HOST=sandbox +SSRF_DEFAULT_TIME_OUT=5 +SSRF_DEFAULT_CONNECT_TIME_OUT=5 +SSRF_DEFAULT_READ_TIME_OUT=5 +SSRF_DEFAULT_WRITE_TIME_OUT=5 +SSRF_POOL_MAX_CONNECTIONS=100 +SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS=20 +SSRF_POOL_KEEPALIVE_EXPIRY=5.0 +PLUGIN_AWS_ACCESS_KEY= +PLUGIN_AWS_SECRET_KEY= +PLUGIN_AWS_REGION= +PLUGIN_TENCENT_COS_SECRET_KEY= +PLUGIN_TENCENT_COS_SECRET_ID= +PLUGIN_ALIYUN_OSS_ACCESS_KEY_ID= +PLUGIN_ALIYUN_OSS_ACCESS_KEY_SECRET= +PLUGIN_VOLCENGINE_TOS_ACCESS_KEY= +PLUGIN_VOLCENGINE_TOS_SECRET_KEY= +OTLP_API_KEY= +OTEL_EXPORTER_OTLP_PROTOCOL= +OTEL_EXPORTER_TYPE=otlp +OTEL_SAMPLING_RATE=0.1 +OTEL_BATCH_EXPORT_SCHEDULE_DELAY=5000 +OTEL_MAX_QUEUE_SIZE=2048 +OTEL_MAX_EXPORT_BATCH_SIZE=512 +OTEL_METRIC_EXPORT_INTERVAL=60000 +OTEL_BATCH_EXPORT_TIMEOUT=10000 +OTEL_METRIC_EXPORT_TIMEOUT=30000 +QUEUE_MONITOR_THRESHOLD=200 +QUEUE_MONITOR_ALERT_EMAILS= +QUEUE_MONITOR_INTERVAL=30 +SWAGGER_UI_ENABLED=false +SWAGGER_UI_PATH=/swagger-ui.html +DSL_EXPORT_ENCRYPT_DATASET_ID=true +DATASET_MAX_SEGMENTS_PER_REQUEST=0 +ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false +ENABLE_CLEAN_UNUSED_DATASETS_TASK=false +ENABLE_CREATE_TIDB_SERVERLESS_TASK=false +ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK=false +ENABLE_CLEAN_MESSAGES=false +ENABLE_WORKFLOW_RUN_CLEANUP_TASK=false +ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK=false +ENABLE_DATASETS_QUEUE_MONITOR=false +ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK=true +ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK=true +WORKFLOW_SCHEDULE_POLLER_INTERVAL=1 +WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE=100 +WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK=0 +TENANT_ISOLATED_TASK_CONCURRENCY=1 +ANNOTATION_IMPORT_FILE_SIZE_LIMIT=2 +ANNOTATION_IMPORT_MAX_RECORDS=10000 +ANNOTATION_IMPORT_MIN_RECORDS=1 +ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5 +ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20 +ANNOTATION_IMPORT_MAX_CONCURRENT=5 +CREATORS_PLATFORM_FEATURES_ENABLED=true +CREATORS_PLATFORM_API_URL=https://creators.dify.ai +CREATORS_PLATFORM_OAUTH_CLIENT_ID= +TIDB_VECTOR_DATABASE=dify +ALIBABACLOUD_MYSQL_HOST=127.0.0.1 +ALIBABACLOUD_MYSQL_PORT=3306 +ALIBABACLOUD_MYSQL_USER=root +ALIBABACLOUD_MYSQL_DATABASE=dify +ALIBABACLOUD_MYSQL_MAX_CONNECTION=5 +ALIBABACLOUD_MYSQL_HNSW_M=6 +RELYT_DATABASE=postgres +TENCENT_VECTOR_DB_DATABASE=dify +BAIDU_VECTOR_DB_DATABASE=dify +EXPOSE_PLUGIN_DAEMON_PORT=5002 +GUNICORN_TIMEOUT=360 +CELERY_WORKER_AMOUNT= +CELERY_AUTO_SCALE=false +CELERY_MAX_WORKERS= +CELERY_MIN_WORKERS= +API_TOOL_DEFAULT_CONNECT_TIMEOUT=10 +API_TOOL_DEFAULT_READ_TIMEOUT=60 +CELERY_BACKEND=redis +CELERY_USE_SENTINEL=false +CELERY_SENTINEL_MASTER_NAME= +CELERY_SENTINEL_SOCKET_TIMEOUT=0.1 +WEB_API_CORS_ALLOW_ORIGINS=* +CONSOLE_CORS_ALLOW_ORIGINS=* +COOKIE_DOMAIN= +OPENDAL_SCHEME=fs +OPENDAL_FS_ROOT=storage +CLICKZETTA_VOLUME_TYPE=user +CLICKZETTA_VOLUME_NAME= +CLICKZETTA_VOLUME_TABLE_PREFIX=dataset_ +CLICKZETTA_VOLUME_DIFY_PREFIX=dify_km +S3_ENDPOINT= +S3_REGION=us-east-1 +S3_BUCKET_NAME=difyai +S3_ADDRESS_STYLE=auto +S3_USE_AWS_MANAGED_IAM=false +ARCHIVE_STORAGE_ENABLED=false +ARCHIVE_STORAGE_ENDPOINT= +ARCHIVE_STORAGE_ARCHIVE_BUCKET= +ARCHIVE_STORAGE_EXPORT_BUCKET= +ARCHIVE_STORAGE_REGION=auto +AZURE_BLOB_ACCOUNT_NAME=difyai +AZURE_BLOB_CONTAINER_NAME=difyai-container +GOOGLE_STORAGE_BUCKET_NAME=your-bucket-name +GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64= +ALIYUN_OSS_BUCKET_NAME=your-bucket-name +ALIYUN_OSS_ENDPOINT=https://oss-ap-southeast-1-internal.aliyuncs.com +ALIYUN_OSS_REGION=ap-southeast-1 +ALIYUN_OSS_AUTH_VERSION=v4 +ALIYUN_OSS_PATH=your-path +ALIYUN_CLOUDBOX_ID=your-cloudbox-id +TENCENT_COS_BUCKET_NAME=your-bucket-name +TENCENT_COS_REGION=your-region +TENCENT_COS_SCHEME=your-scheme +TENCENT_COS_CUSTOM_DOMAIN=your-custom-domain +OCI_ENDPOINT=https://your-object-storage-namespace.compat.objectstorage.us-ashburn-1.oraclecloud.com +OCI_BUCKET_NAME=your-bucket-name +OCI_REGION=us-ashburn-1 +HUAWEI_OBS_BUCKET_NAME=your-bucket-name +HUAWEI_OBS_SERVER=your-server-url +HUAWEI_OBS_PATH_STYLE=false +VOLCENGINE_TOS_BUCKET_NAME=your-bucket-name +VOLCENGINE_TOS_ENDPOINT=your-server-url +VOLCENGINE_TOS_REGION=your-region +BAIDU_OBS_BUCKET_NAME=your-bucket-name +BAIDU_OBS_ENDPOINT=your-server-url +SUPABASE_BUCKET_NAME=your-bucket-name +TENCENT_VECTOR_DB_TIMEOUT=30 +TENCENT_VECTOR_DB_USERNAME=dify +TENCENT_VECTOR_DB_SHARD=1 +TENCENT_VECTOR_DB_REPLICAS=2 +TENCENT_VECTOR_DB_ENABLE_HYBRID_SEARCH=false +BAIDU_VECTOR_DB_ENDPOINT=http://127.0.0.1:5287 +BAIDU_VECTOR_DB_CONNECTION_TIMEOUT_MS=30000 +BAIDU_VECTOR_DB_ACCOUNT=root +BAIDU_VECTOR_DB_API_KEY=dify +BAIDU_VECTOR_DB_SHARD=1 +BAIDU_VECTOR_DB_REPLICAS=3 +BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER=DEFAULT_ANALYZER +BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE=COARSE_MODE +BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT=500 +BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO=0.05 +BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS=300 +HUAWEI_CLOUD_HOSTS=https://127.0.0.1:9200 +HUAWEI_CLOUD_USER=admin +WORKFLOW_NODE_EXECUTION_STORAGE=rdbms +CORE_WORKFLOW_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository +CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository +API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository +API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository +ALIYUN_SLS_ENDPOINT= +ALIYUN_SLS_REGION= +ALIYUN_SLS_PROJECT_NAME= +ALIYUN_SLS_LOGSTORE_TTL=365 +LOGSTORE_DUAL_WRITE_ENABLED=false +LOGSTORE_DUAL_READ_ENABLED=true +LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true +HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760 +HTTP_REQUEST_NODE_MAX_TEXT_SIZE=1048576 +HTTP_REQUEST_NODE_SSL_VERIFY=True +HTTP_REQUEST_MAX_CONNECT_TIMEOUT=10 +HTTP_REQUEST_MAX_READ_TIMEOUT=600 +HTTP_REQUEST_MAX_WRITE_TIMEOUT=600 +PLUGIN_INSTALLED_PATH=plugin +PLUGIN_PACKAGE_CACHE_PATH=plugin_packages +PLUGIN_MEDIA_CACHE_PATH=assets +PLUGIN_S3_USE_AWS=false +PLUGIN_S3_USE_AWS_MANAGED_IAM=false +PLUGIN_S3_ENDPOINT= +PLUGIN_S3_USE_PATH_STYLE=false +PLUGIN_AZURE_BLOB_STORAGE_CONTAINER_NAME= +PLUGIN_AZURE_BLOB_STORAGE_CONNECTION_STRING= +PLUGIN_TENCENT_COS_REGION= +PLUGIN_ALIYUN_OSS_REGION= +PLUGIN_ALIYUN_OSS_ENDPOINT= +PLUGIN_ALIYUN_OSS_AUTH_VERSION=v4 +PLUGIN_ALIYUN_OSS_PATH= +PLUGIN_VOLCENGINE_TOS_ENDPOINT= +PLUGIN_VOLCENGINE_TOS_REGION= +ENABLE_OTEL=false +OTLP_TRACE_ENDPOINT= +OTLP_METRIC_ENDPOINT= +# Prefix used to create collection name in vector database +OTLP_BASE_ENDPOINT=http://localhost:4318 +WEAVIATE_GRPC_ENDPOINT=grpc://weaviate:50051 +ANALYTICDB_KEY_ID=your-ak +ANALYTICDB_KEY_SECRET=your-sk +ANALYTICDB_REGION_ID=cn-hangzhou +ANALYTICDB_INSTANCE_ID=gp-ab123456 +ANALYTICDB_ACCOUNT=testaccount +ANALYTICDB_PASSWORD=testpassword +ANALYTICDB_NAMESPACE=dify +ANALYTICDB_NAMESPACE_PASSWORD=difypassword +ANALYTICDB_HOST=gp-test.aliyuncs.com +ANALYTICDB_PORT=5432 +ANALYTICDB_MIN_CONNECTION=1 +ANALYTICDB_MAX_CONNECTION=5 +TIDB_VECTOR_HOST=tidb +TIDB_VECTOR_PORT=4000 +TIDB_VECTOR_USER= +TIDB_VECTOR_PASSWORD= +TIDB_ON_QDRANT_CLIENT_TIMEOUT=20 +TIDB_ON_QDRANT_GRPC_ENABLED=false +TIDB_ON_QDRANT_GRPC_PORT=6334 +TIDB_PUBLIC_KEY=dify +TIDB_PRIVATE_KEY=dify +RELYT_HOST=db +RELYT_PORT=5432 +RELYT_USER=postgres +VIKINGDB_ACCESS_KEY=your-ak +VIKINGDB_SECRET_KEY=your-sk +VIKINGDB_REGION=cn-shanghai +VIKINGDB_HOST=api-vikingdb.xxx.volces.com +VIKINGDB_SCHEME=http +VIKINGDB_CONNECTION_TIMEOUT=30 +VIKINGDB_SOCKET_TIMEOUT=30 +TABLESTORE_ENDPOINT=https://instance-name.cn-hangzhou.ots.aliyuncs.com +TABLESTORE_INSTANCE_NAME=instance-name +CLICKZETTA_USERNAME= +CLICKZETTA_VECTOR_DISTANCE_FUNCTION=cosine_distance +COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql} +EXPOSE_NGINX_PORT=80 +EXPOSE_NGINX_SSL_PORT=443 +POSITION_TOOL_PINS= +POSITION_TOOL_INCLUDES= +POSITION_TOOL_EXCLUDES= +POSITION_PROVIDER_PINS= +POSITION_PROVIDER_INCLUDES= +POSITION_PROVIDER_EXCLUDES= +CREATE_TIDB_SERVICE_JOB_ENABLED=false +MAX_SUBMIT_COUNT=100 + +# Vector Store Configuration +STORAGE_TYPE=opendal +VECTOR_STORE=weaviate +VECTOR_INDEX_NAME_PREFIX=Vector_index +WEAVIATE_ENDPOINT=http://weaviate:8080 +WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih +WEAVIATE_TOKENIZATION=word +OCEANBASE_VECTOR_HOST=oceanbase +OCEANBASE_VECTOR_PORT=2881 +OCEANBASE_VECTOR_USER=root@test +OCEANBASE_VECTOR_PASSWORD=difyai123456 +OCEANBASE_VECTOR_DATABASE=test +OCEANBASE_ENABLE_HYBRID_SEARCH=false +OCEANBASE_FULLTEXT_PARSER=ik +SEEKDB_MEMORY_LIMIT=2G +QDRANT_URL=http://qdrant:6333 +QDRANT_API_KEY=difyai123456 +QDRANT_CLIENT_TIMEOUT=20 +QDRANT_GRPC_ENABLED=false +QDRANT_GRPC_PORT=6334 +QDRANT_REPLICATION_FACTOR=1 +MILVUS_URI=http://host.docker.internal:19530 +MILVUS_TOKEN= +MILVUS_USER= +MILVUS_PASSWORD= +MILVUS_ANALYZER_PARAMS= +PGVECTOR_HOST=pgvector +PGVECTOR_PORT=5432 +PGVECTOR_USER=postgres +PGVECTOR_PASSWORD=difyai123456 +PGVECTOR_DATABASE=dify +PGVECTOR_MIN_CONNECTION=1 +PGVECTOR_MAX_CONNECTION=5 +PGVECTOR_PG_BIGM=false +PGVECTOR_PG_BIGM_VERSION=1.2-20240606 + +# Hologres Configuration +HOLOGRES_HOST= +HOLOGRES_PORT=80 +HOLOGRES_DATABASE= +HOLOGRES_ACCESS_KEY_ID= +HOLOGRES_ACCESS_KEY_SECRET= +HOLOGRES_SCHEMA=public +HOLOGRES_TOKENIZER=jieba +HOLOGRES_DISTANCE_METHOD=Cosine +HOLOGRES_BASE_QUANTIZATION_TYPE=rabitq +HOLOGRES_MAX_DEGREE=64 +HOLOGRES_EF_CONSTRUCTION=400 + +# Milvus API Configuration +MILVUS_DATABASE= +MILVUS_ENABLE_HYBRID_SEARCH=False + +# Human Input Task Configuration +ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true +HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1 diff --git a/docker/envs/core-services/web.env.example b/docker/envs/core-services/web.env.example new file mode 100644 index 0000000000..d366cd87ba --- /dev/null +++ b/docker/envs/core-services/web.env.example @@ -0,0 +1,30 @@ +# ------------------------------ +# Web Configuration +# ------------------------------ + +CONSOLE_API_URL= +APP_API_URL= +SENTRY_DSN= +NEXT_PUBLIC_SOCKET_URL=ws://localhost +EXPERIMENTAL_ENABLE_VINEXT=false +LOOP_NODE_MAX_COUNT=100 +MAX_TOOLS_NUM=10 +MAX_PARALLEL_LIMIT=10 +MAX_ITERATIONS_NUM=99 +TEXT_GENERATION_TIMEOUT_MS=60000 +ALLOW_INLINE_STYLES=false +ALLOW_UNSAFE_DATA_SCHEME=false +MAX_TREE_DEPTH=50 +MARKETPLACE_ENABLED=true +MARKETPLACE_API_URL=https://marketplace.dify.ai +INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000 +ALLOW_EMBED=false +AMPLITUDE_API_KEY= +ENABLE_WEBSITE_JINAREADER=true +ENABLE_WEBSITE_FIRECRAWL=true +ENABLE_WEBSITE_WATERCRAWL=true +NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false +NEXT_PUBLIC_COOKIE_DOMAIN= +NEXT_PUBLIC_BATCH_CONCURRENCY=5 +CSP_WHITELIST= +TOP_K_MAX_VALUE=10 diff --git a/docker/envs/core-services/worker-beat.env.example b/docker/envs/core-services/worker-beat.env.example new file mode 100644 index 0000000000..380fe02b68 --- /dev/null +++ b/docker/envs/core-services/worker-beat.env.example @@ -0,0 +1,8 @@ +# ------------------------------ +# Worker Beat Configuration +# ------------------------------ + +MODE=beat +COMPOSE_WORKER_HEALTHCHECK_DISABLED=true +COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s +COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s diff --git a/docker/envs/core-services/worker.env.example b/docker/envs/core-services/worker.env.example new file mode 100644 index 0000000000..58cf4ea901 --- /dev/null +++ b/docker/envs/core-services/worker.env.example @@ -0,0 +1,13 @@ +# ------------------------------ +# Worker Configuration +# ------------------------------ + +MODE=worker +SENTRY_DSN= +SENTRY_TRACES_SAMPLE_RATE=1.0 +SENTRY_PROFILES_SAMPLE_RATE=1.0 +PLUGIN_MAX_PACKAGE_SIZE=52428800 +INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 +COMPOSE_WORKER_HEALTHCHECK_DISABLED=true +COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s +COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s diff --git a/docker/envs/databases/db-mysql.env.example b/docker/envs/databases/db-mysql.env.example new file mode 100644 index 0000000000..b3ea6801fe --- /dev/null +++ b/docker/envs/databases/db-mysql.env.example @@ -0,0 +1,9 @@ +# ------------------------------ +# Db Mysql Configuration +# ------------------------------ + +MYSQL_INNODB_LOG_FILE_SIZE=128M +MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT=2 +MYSQL_MAX_CONNECTIONS=1000 +MYSQL_INNODB_BUFFER_POOL_SIZE=512M +MYSQL_HOST_VOLUME=./volumes/mysql/data diff --git a/docker/envs/databases/db-postgres.env.example b/docker/envs/databases/db-postgres.env.example new file mode 100644 index 0000000000..14cefb6bee --- /dev/null +++ b/docker/envs/databases/db-postgres.env.example @@ -0,0 +1,26 @@ +# ------------------------------ +# Db Postgres Configuration +# ------------------------------ + +PGDATA=/var/lib/postgresql/data/pgdata +DB_TYPE=postgresql +DB_USERNAME=postgres +DB_PASSWORD=difyai123456 +DB_HOST=db_postgres +DB_PORT=5432 +DB_DATABASE=dify +SQLALCHEMY_POOL_SIZE=30 +SQLALCHEMY_MAX_OVERFLOW=10 +SQLALCHEMY_POOL_RECYCLE=3600 +SQLALCHEMY_ECHO=false +SQLALCHEMY_POOL_PRE_PING=false +SQLALCHEMY_POOL_USE_LIFO=false +SQLALCHEMY_POOL_TIMEOUT=30 +SQLALCHEMY_POOL_RESET_ON_RETURN=rollback +POSTGRES_MAX_CONNECTIONS=100 +POSTGRES_SHARED_BUFFERS=128MB +POSTGRES_WORK_MEM=4MB +POSTGRES_MAINTENANCE_WORK_MEM=64MB +POSTGRES_EFFECTIVE_CACHE_SIZE=4096MB +POSTGRES_STATEMENT_TIMEOUT=0 +POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0 diff --git a/docker/envs/databases/redis.env.example b/docker/envs/databases/redis.env.example new file mode 100644 index 0000000000..74bcb6525e --- /dev/null +++ b/docker/envs/databases/redis.env.example @@ -0,0 +1,35 @@ +# ------------------------------ +# Redis Configuration +# ------------------------------ + +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_USERNAME= +REDIS_PASSWORD=difyai123456 +REDIS_USE_SSL=false +REDIS_SSL_CERT_REQS=CERT_NONE +REDIS_SSL_CA_CERTS= +REDIS_SSL_CERTFILE= +REDIS_SSL_KEYFILE= +REDIS_DB=0 +REDIS_KEY_PREFIX= +REDIS_MAX_CONNECTIONS= +REDIS_USE_SENTINEL=false +REDIS_SENTINELS= +REDIS_SENTINEL_SERVICE_NAME= +REDIS_SENTINEL_USERNAME= +REDIS_SENTINEL_PASSWORD= +REDIS_SENTINEL_SOCKET_TIMEOUT=0.1 +REDIS_USE_CLUSTERS=false +REDIS_CLUSTERS= +REDIS_CLUSTERS_PASSWORD= +REDIS_RETRY_RETRIES=3 +REDIS_RETRY_BACKOFF_BASE=1.0 +REDIS_RETRY_BACKOFF_CAP=10.0 +REDIS_SOCKET_TIMEOUT=5.0 +REDIS_SOCKET_CONNECT_TIMEOUT=5.0 +REDIS_HEALTH_CHECK_INTERVAL=30 +EVENT_BUS_REDIS_URL= +EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub +EVENT_BUS_REDIS_USE_CLUSTERS=false +BROKER_USE_SSL=false diff --git a/docker/envs/infrastructure/certbot.env.example b/docker/envs/infrastructure/certbot.env.example new file mode 100644 index 0000000000..c654fbe02f --- /dev/null +++ b/docker/envs/infrastructure/certbot.env.example @@ -0,0 +1,7 @@ +# ------------------------------ +# Certbot Configuration +# ------------------------------ + +CERTBOT_EMAIL=your_email@example.com +CERTBOT_DOMAIN=your_domain.com +CERTBOT_OPTIONS= diff --git a/docker/envs/infrastructure/etcd.env.example b/docker/envs/infrastructure/etcd.env.example new file mode 100644 index 0000000000..4dca26671a --- /dev/null +++ b/docker/envs/infrastructure/etcd.env.example @@ -0,0 +1,4 @@ +# ------------------------------ +# Etcd Configuration +# ------------------------------ + diff --git a/docker/envs/infrastructure/milvus-standalone.env.example b/docker/envs/infrastructure/milvus-standalone.env.example new file mode 100644 index 0000000000..7e87ed2648 --- /dev/null +++ b/docker/envs/infrastructure/milvus-standalone.env.example @@ -0,0 +1,4 @@ +# ------------------------------ +# Milvus Standalone Configuration +# ------------------------------ + diff --git a/docker/envs/infrastructure/minio.env.example b/docker/envs/infrastructure/minio.env.example new file mode 100644 index 0000000000..7c8e1fa35a --- /dev/null +++ b/docker/envs/infrastructure/minio.env.example @@ -0,0 +1,4 @@ +# ------------------------------ +# Minio Configuration +# ------------------------------ + diff --git a/docker/envs/infrastructure/nginx.env.example b/docker/envs/infrastructure/nginx.env.example new file mode 100644 index 0000000000..fbe86680ba --- /dev/null +++ b/docker/envs/infrastructure/nginx.env.example @@ -0,0 +1,17 @@ +# ------------------------------ +# Nginx Configuration +# ------------------------------ + +NGINX_SERVER_NAME=_ +NGINX_HTTPS_ENABLED=false +NGINX_PORT=80 +NGINX_SSL_PORT=443 +NGINX_SSL_CERT_FILENAME=dify.crt +NGINX_SSL_CERT_KEY_FILENAME=dify.key +NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3 +NGINX_WORKER_PROCESSES=auto +NGINX_CLIENT_MAX_BODY_SIZE=100M +NGINX_KEEPALIVE_TIMEOUT=65 +NGINX_PROXY_READ_TIMEOUT=3600s +NGINX_PROXY_SEND_TIMEOUT=3600s +NGINX_ENABLE_CERTBOT_CHALLENGE=false diff --git a/docker/envs/infrastructure/ssrf-proxy.env.example b/docker/envs/infrastructure/ssrf-proxy.env.example new file mode 100644 index 0000000000..210a782494 --- /dev/null +++ b/docker/envs/infrastructure/ssrf-proxy.env.example @@ -0,0 +1,17 @@ +# ------------------------------ +# Ssrf Proxy Configuration +# ------------------------------ + +SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128 +SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128 +SSRF_HTTP_PORT=3128 +SSRF_COREDUMP_DIR=/var/spool/squid +SSRF_REVERSE_PROXY_PORT=8194 +SSRF_SANDBOX_HOST=sandbox +SSRF_DEFAULT_TIME_OUT=5 +SSRF_DEFAULT_CONNECT_TIME_OUT=5 +SSRF_DEFAULT_READ_TIME_OUT=5 +SSRF_DEFAULT_WRITE_TIME_OUT=5 +SSRF_POOL_MAX_CONNECTIONS=100 +SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS=20 +SSRF_POOL_KEEPALIVE_EXPIRY=5.0 diff --git a/docker/middleware.env.example b/docker/envs/middleware.env.example similarity index 100% rename from docker/middleware.env.example rename to docker/envs/middleware.env.example diff --git a/docker/envs/security.env.example b/docker/envs/security.env.example new file mode 100644 index 0000000000..787aef2706 --- /dev/null +++ b/docker/envs/security.env.example @@ -0,0 +1,40 @@ +# ------------------------------ +# Security Configuration +# ------------------------------ + +TIDB_ON_QDRANT_API_KEY=dify +TENCENT_VECTOR_DB_API_KEY=dify +ALIBABACLOUD_MYSQL_PASSWORD=difyai123456 +RELYT_PASSWORD=difyai123456 +LINDORM_PASSWORD=admin +HUAWEI_CLOUD_PASSWORD=admin +UPSTASH_VECTOR_TOKEN=dify +TABLESTORE_ACCESS_KEY_ID=xxx +TABLESTORE_ACCESS_KEY_SECRET=xxx +UNSTRUCTURED_API_KEY= +PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false +NOTION_CLIENT_SECRET= +NOTION_INTERNAL_SECRET= +RESEND_API_KEY=your-resend-api-key +SMTP_PASSWORD= +SENDGRID_API_KEY= +RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 +EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 +CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 +OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 +CODE_EXECUTION_API_KEY=dify-sandbox +ALIYUN_SLS_ACCESS_KEY_ID= +ALIYUN_SLS_ACCESS_KEY_SECRET= +OTLP_API_KEY= +BAIDU_VECTOR_DB_API_KEY=dify +ANALYTICDB_KEY_ID=your-ak +ANALYTICDB_KEY_SECRET=your-sk +ANALYTICDB_PASSWORD=testpassword +ANALYTICDB_NAMESPACE_PASSWORD=difypassword +TIDB_VECTOR_PASSWORD= +TIDB_PUBLIC_KEY=dify +TIDB_PRIVATE_KEY=dify +VIKINGDB_ACCESS_KEY=your-ak +VIKINGDB_SECRET_KEY=your-sk +SECRET_KEY=sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U +INIT_PASSWORD= diff --git a/docker/envs/vectorstores/chroma.env.example b/docker/envs/vectorstores/chroma.env.example new file mode 100644 index 0000000000..2a15375a3d --- /dev/null +++ b/docker/envs/vectorstores/chroma.env.example @@ -0,0 +1,13 @@ +# ------------------------------ +# Chroma Configuration +# ------------------------------ + +CHROMA_DATABASE=default_database +CHROMA_AUTH_PROVIDER=chromadb.auth.token_authn.TokenAuthClientProvider +CHROMA_AUTH_CREDENTIALS= +CHROMA_HOST=127.0.0.1 +CHROMA_PORT=8000 +CHROMA_TENANT=default_tenant +CHROMA_SERVER_AUTHN_CREDENTIALS=difyai123456 +CHROMA_SERVER_AUTHN_PROVIDER=chromadb.auth.token_authn.TokenAuthenticationServerProvider +CHROMA_IS_PERSISTENT=TRUE diff --git a/docker/envs/vectorstores/couchbase.env.example b/docker/envs/vectorstores/couchbase.env.example new file mode 100644 index 0000000000..4329d9c723 --- /dev/null +++ b/docker/envs/vectorstores/couchbase.env.example @@ -0,0 +1,9 @@ +# ------------------------------ +# Couchbase Configuration +# ------------------------------ + +COUCHBASE_PASSWORD=password +COUCHBASE_BUCKET_NAME=Embeddings +COUCHBASE_SCOPE_NAME=_default +COUCHBASE_CONNECTION_STRING=couchbase://couchbase-server +COUCHBASE_USER=Administrator diff --git a/docker/envs/vectorstores/elasticsearch.env.example b/docker/envs/vectorstores/elasticsearch.env.example new file mode 100644 index 0000000000..2aaa965cd7 --- /dev/null +++ b/docker/envs/vectorstores/elasticsearch.env.example @@ -0,0 +1,17 @@ +# ------------------------------ +# Elasticsearch Configuration +# ------------------------------ + +ELASTICSEARCH_CLOUD_URL=YOUR-ELASTICSEARCH_CLOUD_URL +ELASTICSEARCH_PASSWORD=elastic +KIBANA_PORT=5601 +ELASTICSEARCH_USE_CLOUD=false +ELASTICSEARCH_API_KEY=YOUR-ELASTICSEARCH_API_KEY +ELASTICSEARCH_VERIFY_CERTS=False +ELASTICSEARCH_CA_CERTS= +ELASTICSEARCH_REQUEST_TIMEOUT=100000 +ELASTICSEARCH_RETRY_ON_TIMEOUT=True +ELASTICSEARCH_MAX_RETRIES=10 +ELASTICSEARCH_HOST=0.0.0.0 +ELASTICSEARCH_PORT=9200 +ELASTICSEARCH_USERNAME=elastic diff --git a/docker/envs/vectorstores/iris.env.example b/docker/envs/vectorstores/iris.env.example new file mode 100644 index 0000000000..b1eb39bff8 --- /dev/null +++ b/docker/envs/vectorstores/iris.env.example @@ -0,0 +1,17 @@ +# ------------------------------ +# Iris Configuration +# ------------------------------ + +IRIS_CONNECTION_URL= +IRIS_MIN_CONNECTION=1 +IRIS_MAX_CONNECTION=3 +IRIS_TEXT_INDEX=true +IRIS_TEXT_INDEX_LANGUAGE=en +IRIS_TIMEZONE=UTC +IRIS_PASSWORD=Dify@1234 +IRIS_DATABASE=USER +IRIS_SCHEMA=dify +IRIS_HOST=iris +IRIS_SUPER_SERVER_PORT=1972 +IRIS_WEB_SERVER_PORT=52773 +IRIS_USER=_SYSTEM diff --git a/docker/envs/vectorstores/matrixone.env.example b/docker/envs/vectorstores/matrixone.env.example new file mode 100644 index 0000000000..931375f8b4 --- /dev/null +++ b/docker/envs/vectorstores/matrixone.env.example @@ -0,0 +1,9 @@ +# ------------------------------ +# Matrixone Configuration +# ------------------------------ + +MATRIXONE_PASSWORD=111 +MATRIXONE_HOST=matrixone +MATRIXONE_PORT=6001 +MATRIXONE_USER=dump +MATRIXONE_DATABASE=dify diff --git a/docker/envs/vectorstores/milvus.env.example b/docker/envs/vectorstores/milvus.env.example new file mode 100644 index 0000000000..d16879ca7b --- /dev/null +++ b/docker/envs/vectorstores/milvus.env.example @@ -0,0 +1,13 @@ +# ------------------------------ +# Milvus Configuration +# ------------------------------ + +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +ETCD_ENDPOINTS=etcd:2379 +MINIO_ADDRESS=minio:9000 +ETCD_AUTO_COMPACTION_MODE=revision +ETCD_AUTO_COMPACTION_RETENTION=1000 +ETCD_QUOTA_BACKEND_BYTES=4294967296 +ETCD_SNAPSHOT_COUNT=50000 +MILVUS_AUTHORIZATION_ENABLED=true diff --git a/docker/envs/vectorstores/myscale.env.example b/docker/envs/vectorstores/myscale.env.example new file mode 100644 index 0000000000..eaa9e88cc0 --- /dev/null +++ b/docker/envs/vectorstores/myscale.env.example @@ -0,0 +1,10 @@ +# ------------------------------ +# Myscale Configuration +# ------------------------------ + +MYSCALE_PASSWORD= +MYSCALE_DATABASE=dify +MYSCALE_FTS_PARAMS= +MYSCALE_HOST=myscale +MYSCALE_PORT=8123 +MYSCALE_USER=default diff --git a/docker/envs/vectorstores/oceanbase.env.example b/docker/envs/vectorstores/oceanbase.env.example new file mode 100644 index 0000000000..42bed8df6a --- /dev/null +++ b/docker/envs/vectorstores/oceanbase.env.example @@ -0,0 +1,6 @@ +# ------------------------------ +# Oceanbase Configuration +# ------------------------------ + +OCEANBASE_CLUSTER_NAME=difyai +OCEANBASE_MEMORY_LIMIT=6G diff --git a/docker/envs/vectorstores/opengauss.env.example b/docker/envs/vectorstores/opengauss.env.example new file mode 100644 index 0000000000..9f58499b64 --- /dev/null +++ b/docker/envs/vectorstores/opengauss.env.example @@ -0,0 +1,12 @@ +# ------------------------------ +# Opengauss Configuration +# ------------------------------ + +OPENGAUSS_PASSWORD=Dify@123 +OPENGAUSS_DATABASE=dify +OPENGAUSS_MIN_CONNECTION=1 +OPENGAUSS_MAX_CONNECTION=5 +OPENGAUSS_ENABLE_PQ=false +OPENGAUSS_HOST=opengauss +OPENGAUSS_PORT=6600 +OPENGAUSS_USER=postgres diff --git a/docker/envs/vectorstores/opensearch.env.example b/docker/envs/vectorstores/opensearch.env.example new file mode 100644 index 0000000000..a6a9283378 --- /dev/null +++ b/docker/envs/vectorstores/opensearch.env.example @@ -0,0 +1,22 @@ +# ------------------------------ +# Opensearch Configuration +# ------------------------------ + +OPENSEARCH_PASSWORD=admin +OPENSEARCH_AWS_REGION=ap-southeast-1 +OPENSEARCH_AWS_SERVICE=aoss +OPENSEARCH_INITIAL_ADMIN_PASSWORD=Qazwsxedc!@#123 +OPENSEARCH_MEMLOCK_SOFT=-1 +OPENSEARCH_MEMLOCK_HARD=-1 +OPENSEARCH_NOFILE_SOFT=65536 +OPENSEARCH_NOFILE_HARD=65536 +OPENSEARCH_HOST=opensearch +OPENSEARCH_PORT=9200 +OPENSEARCH_SECURE=true +OPENSEARCH_VERIFY_CERTS=true +OPENSEARCH_AUTH_METHOD=basic +OPENSEARCH_USER=admin +OPENSEARCH_DISCOVERY_TYPE=single-node +OPENSEARCH_BOOTSTRAP_MEMORY_LOCK=true +OPENSEARCH_JAVA_OPTS_MIN=512m +OPENSEARCH_JAVA_OPTS_MAX=1024m diff --git a/docker/envs/vectorstores/oracle.env.example b/docker/envs/vectorstores/oracle.env.example new file mode 100644 index 0000000000..c8f24db41a --- /dev/null +++ b/docker/envs/vectorstores/oracle.env.example @@ -0,0 +1,13 @@ +# ------------------------------ +# Oracle Configuration +# ------------------------------ + +ORACLE_PASSWORD=dify +ORACLE_DSN=oracle:1521/FREEPDB1 +ORACLE_CONFIG_DIR=/app/api/storage/wallet +ORACLE_WALLET_LOCATION=/app/api/storage/wallet +ORACLE_WALLET_PASSWORD=dify +ORACLE_IS_AUTONOMOUS=false +ORACLE_USER=dify +ORACLE_PWD=Dify123456 +ORACLE_CHARACTERSET=AL32UTF8 diff --git a/docker/envs/vectorstores/pgvecto-rs.env.example b/docker/envs/vectorstores/pgvecto-rs.env.example new file mode 100644 index 0000000000..6428e5dd67 --- /dev/null +++ b/docker/envs/vectorstores/pgvecto-rs.env.example @@ -0,0 +1,9 @@ +# ------------------------------ +# Pgvecto Rs Configuration +# ------------------------------ + +PGVECTO_RS_HOST=pgvecto-rs +PGVECTO_RS_PORT=5432 +PGVECTO_RS_USER=postgres +PGVECTO_RS_PASSWORD=difyai123456 +PGVECTO_RS_DATABASE=dify diff --git a/docker/envs/vectorstores/pgvector.env.example b/docker/envs/vectorstores/pgvector.env.example new file mode 100644 index 0000000000..9fd1dbf962 --- /dev/null +++ b/docker/envs/vectorstores/pgvector.env.example @@ -0,0 +1,8 @@ +# ------------------------------ +# Pgvector Configuration +# ------------------------------ + +PGVECTOR_PGUSER=postgres +PGVECTOR_POSTGRES_PASSWORD=difyai123456 +PGVECTOR_POSTGRES_DB=dify +PGVECTOR_PGDATA=/var/lib/postgresql/data/pgdata diff --git a/docker/envs/vectorstores/qdrant.env.example b/docker/envs/vectorstores/qdrant.env.example new file mode 100644 index 0000000000..a3555fe547 --- /dev/null +++ b/docker/envs/vectorstores/qdrant.env.example @@ -0,0 +1,4 @@ +# ------------------------------ +# Qdrant Configuration +# ------------------------------ + diff --git a/docker/envs/vectorstores/seekdb.env.example b/docker/envs/vectorstores/seekdb.env.example new file mode 100644 index 0000000000..4307fbede2 --- /dev/null +++ b/docker/envs/vectorstores/seekdb.env.example @@ -0,0 +1,4 @@ +# ------------------------------ +# Seekdb Configuration +# ------------------------------ + diff --git a/docker/envs/vectorstores/vastbase.env.example b/docker/envs/vectorstores/vastbase.env.example new file mode 100644 index 0000000000..2c9db50fbe --- /dev/null +++ b/docker/envs/vectorstores/vastbase.env.example @@ -0,0 +1,11 @@ +# ------------------------------ +# Vastbase Configuration +# ------------------------------ + +VASTBASE_PASSWORD=Difyai123456 +VASTBASE_DATABASE=dify +VASTBASE_MIN_CONNECTION=1 +VASTBASE_MAX_CONNECTION=5 +VASTBASE_HOST=vastbase +VASTBASE_PORT=5432 +VASTBASE_USER=dify diff --git a/docker/envs/vectorstores/weaviate.env.example b/docker/envs/vectorstores/weaviate.env.example new file mode 100644 index 0000000000..82a3ccb172 --- /dev/null +++ b/docker/envs/vectorstores/weaviate.env.example @@ -0,0 +1,18 @@ +# ------------------------------ +# Weaviate Configuration +# ------------------------------ + +WEAVIATE_PERSISTENCE_DATA_PATH=/var/lib/weaviate +WEAVIATE_QUERY_DEFAULTS_LIMIT=25 +WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true +WEAVIATE_DEFAULT_VECTORIZER_MODULE=none +WEAVIATE_CLUSTER_HOSTNAME=node1 +WEAVIATE_AUTHENTICATION_APIKEY_ENABLED=true +WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih +WEAVIATE_AUTHENTICATION_APIKEY_USERS=hello@dify.ai +WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED=true +WEAVIATE_AUTHORIZATION_ADMINLIST_USERS=hello@dify.ai +WEAVIATE_DISABLE_TELEMETRY=false +WEAVIATE_ENABLE_TOKENIZER_GSE=false +WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA=false +WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR=false diff --git a/docker/generate_docker_compose b/docker/generate_docker_compose index 46d948f3c1..580091e006 100755 --- a/docker/generate_docker_compose +++ b/docker/generate_docker_compose @@ -64,25 +64,61 @@ def generate_shared_env_block(env_vars, anchor_name="shared-api-worker-env"): return "\n".join(lines) -def insert_shared_env(template_path, output_path, shared_env_block, header_comments): +def create_env_files_from_example(env_example_path): """ - Inserts the shared environment variables block and header comments into the template file, - removing any existing x-shared-env anchors, and generates the final docker-compose.yaml file. - Always writes with LF line endings. + Creates actual env files from .env.example by copying the categorized .env.example files. + This allows docker-compose to use env_file references. + Supports per-module structure with subdirectories. + """ + base_dir = os.path.dirname(os.path.abspath(env_example_path)) + root_env_file = os.path.join(base_dir, ".env") + if not os.path.exists(root_env_file): + with open(env_example_path, "r", encoding="utf-8") as src, open( + root_env_file, "w", encoding="utf-8", newline="\n" + ) as dst: + dst.write(src.read()) + print(f"Created {root_env_file}") + else: + print(f"{root_env_file} already exists, skipping") + + envs_dir = os.path.join(base_dir, "envs") + if not os.path.isdir(envs_dir): + print(f"No envs directory found at {envs_dir}, skipping split env files") + return [] + + created_files = [] + # Walk through all .env.example files in subdirectories + for root, dirs, files in os.walk(envs_dir): + for file in files: + if file.endswith('.env.example'): + example_file = os.path.join(root, file) + env_file = example_file.replace('.env.example', '.env') + + if os.path.exists(env_file): + print(f"{env_file} already exists, skipping") + continue + + # Copy .example to actual file + with open(example_file, "r", encoding="utf-8") as src, open( + env_file, "w", encoding="utf-8", newline="\n" + ) as dst: + dst.write(src.read()) + created_files.append(env_file) + print(f"Created {env_file}") + + return created_files + + +def insert_shared_env(template_path, output_path, header_comments): + """ + Copies the template file to output path with header comments. + The template now uses env_file references instead of a huge YAML anchor. """ with open(template_path, "r", encoding="utf-8") as f: template_content = f.read() - # Remove existing x-shared-env: &shared-api-worker-env lines - template_content = re.sub( - r"^x-shared-env: &shared-api-worker-env\s*\n?", - "", - template_content, - flags=re.MULTILINE, - ) - - # Prepare the final content with header comments and shared env block - final_content = f"{header_comments}\n{shared_env_block}\n\n{template_content}" + # Prepare the final content with header comments + final_content = f"{header_comments}\n{template_content}" with open(output_path, "w", encoding="utf-8", newline="\n") as f: f.write(final_content) @@ -90,10 +126,10 @@ def insert_shared_env(template_path, output_path, shared_env_block, header_comme def main(): - env_example_path = ".env.example" - template_path = "docker-compose-template.yaml" - output_path = "docker-compose.yaml" - anchor_name = "shared-api-worker-env" # Can be modified as needed + base_dir = os.path.dirname(os.path.abspath(__file__)) + env_example_path = os.path.join(base_dir, ".env.example") + template_path = os.path.join(base_dir, "docker-compose-template.yaml") + output_path = os.path.join(base_dir, "docker-compose.yaml") # Define header comments to be added at the top of docker-compose.yaml header_comments = ( @@ -110,17 +146,14 @@ def main(): print(f"Error: File {path} does not exist.") sys.exit(1) - # Parse .env.example file - env_vars = parse_env_example(env_example_path) + # Create env files from categorized .env.example files + # These files are used by docker-compose's env_file directive + # This ensures .env files exist even in CI/CD environments + create_env_files_from_example(env_example_path) - if not env_vars: - print("Warning: No environment variables found in .env.example.") - - # Generate shared environment variables block - shared_env_block = generate_shared_env_block(env_vars, anchor_name) - - # Insert shared environment variables block and header comments into the template - insert_shared_env(template_path, output_path, shared_env_block, header_comments) + # Copy template to output with header comments + # The template now uses env_file references instead of a huge YAML anchor + insert_shared_env(template_path, output_path, header_comments) if __name__ == "__main__": diff --git a/e2e/scripts/common.ts b/e2e/scripts/common.ts index ea6c897b2d..2964892dd0 100644 --- a/e2e/scripts/common.ts +++ b/e2e/scripts/common.ts @@ -36,7 +36,7 @@ export const webDir = path.join(rootDir, 'web') export const middlewareComposeFile = path.join(dockerDir, 'docker-compose.middleware.yaml') export const middlewareEnvFile = path.join(dockerDir, 'middleware.env') -export const middlewareEnvExampleFile = path.join(dockerDir, 'middleware.env.example') +export const middlewareEnvExampleFile = path.join(dockerDir, 'envs', 'middleware.env.example') export const webEnvLocalFile = path.join(webDir, '.env.local') export const webEnvExampleFile = path.join(webDir, '.env.example') export const apiEnvExampleFile = path.join(apiDir, 'tests', 'integration_tests', '.env.example') diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b5e67df509..cb41ef5f83 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -202,6 +202,11 @@ "count": 1 } }, + "web/app/components/app/annotation/add-annotation-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/annotation/batch-add-annotation-modal/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -230,6 +235,11 @@ "count": 1 } }, + "web/app/components/app/annotation/edit-annotation-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/annotation/header-opts/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -252,6 +262,9 @@ "erasable-syntax-only/enums": { "count": 1 }, + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 5 }, @@ -269,11 +282,6 @@ "count": 4 } }, - "web/app/components/app/app-publisher/index.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, "web/app/components/app/app-publisher/version-info-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -344,6 +352,9 @@ } }, "web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/exhaustive-deps": { "count": 1 }, @@ -401,6 +412,16 @@ "count": 2 } }, + "web/app/components/app/configuration/configuration-view.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/app/components/app/configuration/dataset-config/card-item/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/configuration/dataset-config/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -531,6 +552,9 @@ } }, "web/app/components/app/log/list.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 6 }, @@ -580,6 +604,9 @@ } }, "web/app/components/app/workflow-log/list.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 2 } @@ -904,6 +931,11 @@ "count": 1 } }, + "web/app/components/base/drawer-plus/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/base/emoji-picker/index.tsx": { "no-restricted-imports": { "count": 1 @@ -1029,6 +1061,11 @@ "count": 3 } }, + "web/app/components/base/float-right-container/index.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, "web/app/components/base/form/components/base/base-form.tsx": { "ts/no-explicit-any": { "count": 6 @@ -1233,7 +1270,7 @@ }, "web/app/components/base/icons/src/vender/line/development/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 2 + "count": 1 } }, "web/app/components/base/icons/src/vender/line/editor/index.ts": { @@ -2144,14 +2181,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/batch-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx": { "react/set-state-in-effect": { "count": 1 @@ -2162,11 +2191,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/components/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 3 - } - }, "web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": { "ts/no-non-null-asserted-optional-chain": { "count": 1 @@ -2231,14 +2255,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/segment-add/index.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "react-refresh/only-export-components": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": { "ts/no-explicit-any": { "count": 6 @@ -2280,6 +2296,9 @@ } }, "web/app/components/datasets/hit-testing/index.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/unsupported-syntax": { "count": 1 } @@ -2319,7 +2338,7 @@ }, "web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": { "no-restricted-imports": { - "count": 2 + "count": 3 } }, "web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": { @@ -2813,10 +2832,18 @@ } }, "web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 7 } }, + "web/app/components/plugins/plugin-detail-panel/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/plugins/plugin-detail-panel/model-list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2838,6 +2865,9 @@ } }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 2 } @@ -2896,6 +2926,9 @@ } }, "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 5 } @@ -2933,16 +2966,6 @@ "count": 1 } }, - "web/app/components/plugins/readme-panel/index.tsx": { - "react/unsupported-syntax": { - "count": 1 - } - }, - "web/app/components/plugins/readme-panel/store.ts": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -3170,7 +3193,7 @@ }, "web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": { @@ -3179,6 +3202,9 @@ } }, "web/app/components/tools/edit-custom-collection-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 4 }, @@ -3187,6 +3213,9 @@ } }, "web/app/components/tools/edit-custom-collection-modal/test-api.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -3196,6 +3225,11 @@ "count": 1 } }, + "web/app/components/tools/mcp/detail/provider-detail.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/tools/mcp/mcp-server-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -3224,12 +3258,20 @@ "count": 1 } }, + "web/app/components/tools/provider/detail.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/tools/provider/empty.tsx": { "ts/no-explicit-any": { "count": 1 } }, "web/app/components/tools/setting/build-in/config-credentials.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 3 } @@ -4061,6 +4103,11 @@ "count": 1 } }, + "web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": { "ts/no-explicit-any": { "count": 1 diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index bdeeec33cb..2915fe5db7 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -28,6 +28,7 @@ Always import from a **subpath export** — there is no barrel: import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog' +import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import '@langgenius/dify-ui/styles.css' // once, in the app root ``` @@ -36,12 +37,12 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported ## Primitives -| Category | Subpath | Notes | -| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. | -| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. | -| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | -| Media | `./avatar`, `./button` | Button exposes `cva` variants. | +| Category | Subpath | Notes | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. | +| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. | +| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | +| Media | `./avatar`, `./button` | Button exposes `cva` variants. | Utilities: @@ -65,7 +66,7 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s ## Overlay & portal contract -All overlay primitives (`dialog`, `alert-dialog`, `autocomplete`, `combobox`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually. +Overlay primitives render their floating surfaces inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Convenience content components such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portal internally; primitives with explicit portal anatomy such as `Drawer` expose the matching `DrawerPortal` part so consumers can compose the full Base UI structure. ### Root isolation requirement @@ -83,19 +84,19 @@ Equivalent: any root element with `isolation: isolate` in CSS. Without it, overl Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites. -| Layer | z-index | Where | -| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | -| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop | -| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. | +| Layer | z-index | Where | +| ------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | +| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop | +| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. | -Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins. +Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` / `base/drawer` / `base/drawer-plus` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins. See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`. ### Rules - Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated. -- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal. +- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal. - When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites. ## Development diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 20e94c7dee..894e92bfd6 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -37,6 +37,10 @@ "types": "./src/dialog/index.tsx", "import": "./src/dialog/index.tsx" }, + "./drawer": { + "types": "./src/drawer/index.tsx", + "import": "./src/drawer/index.tsx" + }, "./dropdown-menu": { "types": "./src/dropdown-menu/index.tsx", "import": "./src/dropdown-menu/index.tsx" diff --git a/packages/dify-ui/src/drawer/__tests__/index.spec.tsx b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx new file mode 100644 index 0000000000..8c3a93f02c --- /dev/null +++ b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx @@ -0,0 +1,61 @@ +import { render } from 'vitest-browser-react' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerDescription, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerTrigger, + DrawerViewport, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('Drawer wrapper', () => { + describe('User Interactions', () => { + it('should open a portalled drawer and close it with the default close button', async () => { + const screen = await render( + + Open settings + + + + + Settings + Configure the current workspace. + +

Workspace controls

+ +
+
+
+
+
, + ) + + expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument() + + asHTMLElement(screen.getByRole('button', { name: 'Open settings' }).element()).click() + + await vi.waitFor(() => { + expect(document.body.querySelector('[role="dialog"]')).toBeInTheDocument() + }) + + const dialog = asHTMLElement(document.body.querySelector('[role="dialog"]')!) + expect(document.body).toContainElement(dialog) + expect(screen.container).not.toContainElement(dialog) + await expect.element(dialog).toHaveTextContent('Workspace controls') + await expect.element(screen.getByText('Configure the current workspace.')).toBeInTheDocument() + await expect.element(screen.getByTestId('drawer-backdrop')).toHaveClass('z-1002') + + asHTMLElement(screen.getByRole('button', { name: 'Close drawer' }).element()).click() + + await vi.waitFor(() => { + expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/packages/dify-ui/src/drawer/index.tsx b/packages/dify-ui/src/drawer/index.tsx new file mode 100644 index 0000000000..c63bc8174e --- /dev/null +++ b/packages/dify-ui/src/drawer/index.tsx @@ -0,0 +1,116 @@ +'use client' + +import type { ReactNode } from 'react' +import { Drawer as BaseDrawer } from '@base-ui/react/drawer' +import { cn } from '../cn' + +export const Drawer = BaseDrawer.Root +export const DrawerProvider = BaseDrawer.Provider +export const DrawerIndent = BaseDrawer.Indent +export const DrawerIndentBackground = BaseDrawer.IndentBackground +export const DrawerTrigger = BaseDrawer.Trigger +export const DrawerSwipeArea = BaseDrawer.SwipeArea +export const DrawerPortal = BaseDrawer.Portal +export const DrawerTitle = BaseDrawer.Title +export const DrawerDescription = BaseDrawer.Description +export const DrawerClose = BaseDrawer.Close +export const createDrawerHandle = BaseDrawer.createHandle + +export type DrawerRootProps = BaseDrawer.Root.Props +export type DrawerRootActions = BaseDrawer.Root.Actions +export type DrawerRootChangeEventDetails = BaseDrawer.Root.ChangeEventDetails +export type DrawerRootChangeEventReason = BaseDrawer.Root.ChangeEventReason +export type DrawerRootSnapPoint = BaseDrawer.Root.SnapPoint +export type DrawerRootSnapPointChangeEventDetails = BaseDrawer.Root.SnapPointChangeEventDetails +export type DrawerRootSnapPointChangeEventReason = BaseDrawer.Root.SnapPointChangeEventReason +export type DrawerTriggerProps = BaseDrawer.Trigger.Props + +export function DrawerBackdrop({ + className, + ...props +}: BaseDrawer.Backdrop.Props) { + return ( + + ) +} + +export function DrawerViewport({ + className, + ...props +}: BaseDrawer.Viewport.Props) { + return ( + + ) +} + +export function DrawerPopup({ + className, + ...props +}: BaseDrawer.Popup.Props) { + return ( + + ) +} + +export function DrawerContent({ + className, + ...props +}: BaseDrawer.Content.Props) { + return ( + + ) +} + +type DrawerCloseButtonProps = Omit & { + children?: ReactNode +} + +export function DrawerCloseButton({ + className, + children, + type = 'button', + 'aria-label': ariaLabel = 'Close drawer', + ...props +}: DrawerCloseButtonProps) { + return ( + + {children ?? + ) +} diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx index 6af17119be..96798ac6a9 100644 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -127,7 +127,7 @@ const createApp = (overrides: Partial = {}): App => ({ copyright: overrides.copyright ?? '', privacy_policy: overrides.privacy_policy ?? null, custom_disclaimer: overrides.custom_disclaimer ?? null, - category: overrides.category ?? 'Writing', + categories: overrides.categories ?? ['Writing'], position: overrides.position ?? 1, is_listed: overrides.is_listed ?? true, install_count: overrides.install_count ?? 0, @@ -165,9 +165,9 @@ describe('Explore App List Flow', () => { mockExploreData = { categories: ['Writing', 'Translate', 'Programming'], allList: [ - createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, category: 'Writing' }), - createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, category: 'Translate' }), - createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, category: 'Programming' }), + createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, categories: ['Writing'] }), + createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, categories: ['Translate'] }), + createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, categories: ['Programming'] }), ], } }) @@ -190,6 +190,30 @@ describe('Explore App List Flow', () => { expect(screen.queryByText('Code Helper')).not.toBeInTheDocument() }) + it('should only use categories when filtering by selected category', () => { + mockTabValue = 'Writing' + mockExploreData = { + categories: ['Writing', 'Translate'], + allList: [ + createApp({ + app_id: 'app-1', + app: { ...createApp().app, name: 'Active Writer' }, + categories: ['Writing'], + }), + createApp({ + app_id: 'app-2', + app: { ...createApp().app, id: 'app-id-2', name: 'Legacy Writer' }, + categories: [], + }), + ], + } + + renderAppList() + + expect(screen.getByText('Active Writer')).toBeInTheDocument() + expect(screen.queryByText('Legacy Writer')).not.toBeInTheDocument() + }) + it('should filter apps by search keyword', async () => { renderAppList() diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index 9cf4772152..c0dd6da1c5 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -205,7 +205,7 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({ })) vi.mock('@/app/components/tools/workflow-tool', () => ({ - default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => ( + WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index a7b65f33fe..3ee5d52603 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -39,7 +39,9 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { const getSigninUrl = useCallback(() => { const params = new URLSearchParams(searchParams) params.delete('message') - params.set('redirect_url', pathname) + const query = params.toString() + const fullPath = query ? `${pathname}?${query}` : pathname + params.set('redirect_url', fullPath) return `/webapp-signin?${params.toString()}` }, [searchParams, pathname]) diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx index 9afa0063dc..3dabb2a91e 100644 --- a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx @@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index cbfd679ace..0dfb4347e4 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -20,6 +20,7 @@ const mockOpenAsyncWindow = vi.fn() const mockFetchInstalledAppList = vi.fn() const mockFetchAppDetailDirect = vi.fn() const mockToastError = vi.fn() +const mockWindowOpen = vi.fn() const mockInvalidateAppWorkflow = vi.fn() const sectionProps = vi.hoisted(() => ({ @@ -37,6 +38,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), + Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null, })) vi.mock('ahooks', async () => { @@ -91,6 +93,21 @@ vi.mock('@/service/use-workflow', () => ({ useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow, })) +vi.mock('@/service/use-tools', () => ({ + useWorkflowToolDetailByAppID: () => ({ + data: undefined, + isLoading: false, + }), + useInvalidateAllWorkflowTools: () => vi.fn(), + useInvalidateWorkflowToolDetailByAppID: () => vi.fn(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + }), +})) + vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), @@ -121,6 +138,15 @@ vi.mock('../../app-access-control', () => ({ ), })) +vi.mock('@/app/components/tools/workflow-tool', () => ({ + WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => ( +
+ workflow tool drawer + +
+ ), +})) + vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) vi.mock('../sections', () => ({ @@ -143,6 +169,13 @@ vi.mock('../sections', () => ({
+ {props.handleOpenRunConfig && ( + <> + + + + )} +
) }, @@ -175,6 +208,10 @@ describe('AppPublisher', () => { mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise) => { await resolver() }) + Object.defineProperty(window, 'open', { + writable: true, + value: mockWindowOpen, + }) }) it('should open the publish popover and refetch access permission data', async () => { @@ -231,6 +268,94 @@ describe('AppPublisher', () => { expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument() }) + it('should collect hidden inputs before opening published run links from config actions', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-run-config')) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/token-1?secret=${encodeURIComponent('top-secret')}`, + '_blank', + ) + }) + }) + + it('should open batch run config links with the configured hidden inputs', async () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-batch-run-config')) + + fireEvent.change(screen.getByLabelText('Batch Secret'), { + target: { value: 'batch-value' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/token-1?mode=batch&batch_secret=${encodeURIComponent('batch-value')}`, + '_blank', + ) + }) + }) + + it('should keep workflow tool drawer mounted after closing the publish popover', () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-workflow-tool')) + + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + expect(screen.getByTestId('workflow-tool-drawer')).toBeInTheDocument() + }) + it('should close embedded and access control panels through child callbacks', async () => { render( ({ })) vi.mock('../suggested-action', () => ({ - default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => ( - + default: ({ + children, + onClick, + link, + disabled, + actionButton, + }: { + children: ReactNode + onClick?: () => void + link?: string + disabled?: boolean + actionButton?: { ariaLabel: string, onClick: () => void } + }) => ( +
+ + {actionButton && ( + + )} +
), })) @@ -170,9 +194,25 @@ describe('app-publisher sections', () => { expect(render().container).toBeEmptyDOMElement() }) + it('should hide access control content when enabled is false', () => { + render( + , + ) + + expect(screen.queryByText('publishApp.title')).not.toBeInTheDocument() + expect(screen.queryByText('accessControlDialog.accessItems.anyone')).not.toBeInTheDocument() + }) + it('should render workflow actions, batch run links, and workflow tool configuration', () => { const handleOpenInExplore = vi.fn() const handleEmbed = vi.fn() + const handleOpenRunConfig = vi.fn() const { rerender } = render( { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} - inputs={[]} missingStartNode={false} - onRefreshData={vi.fn()} - outputs={[]} - published={true} + published={false} publishedAt={Date.now()} + showBatchRunConfig + showRunConfig toolPublished workflowToolAvailable={false} + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager workflowToolMessage="workflow-disabled" + onConfigureWorkflowTool={vi.fn()} />, ) expect(screen.getByText('common.batchRunApp')).toHaveAttribute('data-link', 'https://example.com/app?mode=batch') + fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[0]!) + expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app') + fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[1]!) + expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app?mode=batch') fireEvent.click(screen.getByText('common.openInExplore')) expect(handleOpenInExplore).toHaveBeenCalled() expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument() @@ -223,17 +271,19 @@ describe('app-publisher sections', () => { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} - inputs={[]} missingStartNode - onRefreshData={vi.fn()} - outputs={[]} published={false} publishedAt={Date.now()} toolPublished={false} workflowToolAvailable + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager + onConfigureWorkflowTool={vi.fn()} />, ) @@ -248,16 +298,19 @@ describe('app-publisher sections', () => { disabledFunctionButton={false} handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode - inputs={[]} missingStartNode={false} - outputs={[]} published={false} publishedAt={undefined} toolPublished={false} workflowToolAvailable + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager + onConfigureWorkflowTool={vi.fn()} />, ) diff --git a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx index ea199dfb78..2ca9e77abf 100644 --- a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx @@ -46,4 +46,47 @@ describe('SuggestedAction', () => { expect(handleClick).toHaveBeenCalledTimes(1) }) + + it('should render and trigger the trailing action button when configured', () => { + const handleActionClick = vi.fn() + + render( + config, + onClick: handleActionClick, + }} + > + Configurable action + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) + + expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute('href', 'https://example.com/docs') + expect(handleActionClick).toHaveBeenCalledTimes(1) + }) + + it('should block action button clicks when disabled', () => { + const handleActionClick = vi.fn() + + render( + config, + onClick: handleActionClick, + }} + > + Disabled with action + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) + expect(handleActionClick).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index fe6fe5806f..f5b2c80ae8 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,28 +1,40 @@ +import type { FormEvent } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' +import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '@/app/components/app/overview/app-card-utils' import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' -import { RiStoreLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { useKeyPress } from 'ahooks' import { + memo, + use, useCallback, - useContext, useEffect, useMemo, useState, } from 'react' import { useTranslation } from 'react-i18next' +import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections' +import { + buildWorkflowLaunchUrl, + createWorkflowLaunchInitialValues, + isWorkflowLaunchInputSupported, + +} from '@/app/components/app/overview/app-card-utils' import EmbeddedModal from '@/app/components/app/overview/embedded' import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' +import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool' +import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { WorkflowContext } from '@/app/components/workflow/context' +import { appDefaultIconBackground } from '@/config' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { AccessMode } from '@/models/access-control' @@ -57,8 +69,8 @@ export type AppPublisherProps = { debugWithMultipleModel?: boolean multipleModelConfigs?: ModelAndParameter[] /** modelAndParameter is passed when debugWithMultipleModel is true */ - onPublish?: (params?: any) => Promise | any - onRestore?: () => Promise | any + onPublish?: AppPublisherPublishHandler + onRestore?: AppPublisherRestoreHandler onToggle?: (state: boolean) => void crossAxisOffset?: number toolPublished?: boolean @@ -74,6 +86,12 @@ export type AppPublisherProps = { const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] +type AppPublisherPublishHandler + = | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise | unknown) + | ((params?: unknown) => Promise | unknown) + +type AppPublisherRestoreHandler = () => Promise | unknown + const AppPublisher = ({ disabled = false, publishDisabled = false, @@ -100,11 +118,15 @@ const AppPublisher = ({ const [published, setPublished] = useState(false) const [open, setOpen] = useState(false) const [showAppAccessControl, setShowAppAccessControl] = useState(false) + const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) + const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false) + const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('') + const [workflowLaunchValues, setWorkflowLaunchValues] = useState>({}) const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) - const workflowStore = useContext(WorkflowContext) + const workflowStore = use(WorkflowContext) const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(s => s.setAppDetail) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -113,6 +135,22 @@ const AppPublisher = ({ const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode }) const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) + const hiddenLaunchVariables = useMemo( + () => (inputs ?? []).filter(input => input.hide === true), + [inputs], + ) + const supportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported), + [hiddenLaunchVariables], + ) + const unsupportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)), + [hiddenLaunchVariables], + ) + const initialWorkflowLaunchValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables), + [supportedWorkflowLaunchVariables], + ) const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) @@ -222,6 +260,31 @@ const AppPublisher = ({ } }, [appDetail, setAppDetail]) + const handleOpenWorkflowLaunchDialog = useCallback((targetUrl: string) => { + setWorkflowLaunchValues(initialWorkflowLaunchValues) + setWorkflowLaunchTargetUrl(targetUrl) + setWorkflowLaunchDialogOpen(true) + }, [initialWorkflowLaunchValues]) + + const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => { + setWorkflowLaunchValues(prev => ({ + ...prev, + [variable]: value, + })) + }, []) + + const handleWorkflowLaunchConfirm = useCallback(async (event: FormEvent) => { + event.preventDefault() + + const targetUrl = await buildWorkflowLaunchUrl({ + accessibleUrl: workflowLaunchTargetUrl, + variables: supportedWorkflowLaunchVariables, + values: workflowLaunchValues, + }) + + window.open(targetUrl, '_blank') + setWorkflowLaunchDialogOpen(false) + }, [supportedWorkflowLaunchVariables, workflowLaunchTargetUrl, workflowLaunchValues]) const handlePublishToMarketplace = useCallback(async () => { if (!appDetail?.id || publishingToMarketplace) return @@ -273,6 +336,31 @@ const AppPublisher = ({ const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined + const workflowToolVisible = appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode + const workflowToolPublished = !!toolPublished + const closeWorkflowToolDrawer = useCallback(() => setWorkflowToolDrawerOpen(false), []) + const workflowToolIcon = useMemo(() => ({ + content: (appDetail?.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖', + background: (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground, + }), [appDetail?.icon, appDetail?.icon_background, appDetail?.icon_type]) + const workflowTool = useConfigureButton({ + enabled: workflowToolVisible, + published: workflowToolPublished, + detailNeedUpdate: workflowToolPublished && published, + workflowAppId: appDetail?.id ?? '', + icon: workflowToolIcon, + name: appDetail?.name ?? '', + description: appDetail?.description ?? '', + inputs, + outputs, + handlePublish, + onRefreshData, + onConfigured: closeWorkflowToolDrawer, + }) + const openWorkflowToolDrawer = useCallback(() => { + handleOpenChange(false) + setWorkflowToolDrawerOpen(true) + }, [handleOpenChange]) const upgradeHighlightStyle = useMemo(() => ({ background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)', WebkitBackgroundClip: 'text', @@ -343,23 +431,27 @@ const AppPublisher = ({ handleOpenChange(false) handleOpenInExplore() }} + handleOpenRunConfig={handleOpenWorkflowLaunchDialog} handlePublish={handlePublish} hasHumanInputNode={hasHumanInputNode} hasTriggerNode={hasTriggerNode} - inputs={inputs} missingStartNode={missingStartNode} - onRefreshData={onRefreshData} - outputs={outputs} published={published} publishedAt={publishedAt} + showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)} + showRunConfig={hiddenLaunchVariables.length > 0} toolPublished={toolPublished} workflowToolAvailable={workflowToolAvailable} + workflowToolIsLoading={workflowTool.isLoading} + workflowToolOutdated={workflowTool.outdated} + workflowToolIsCurrentWorkspaceManager={workflowTool.isCurrentWorkspaceManager} workflowToolMessage={workflowToolMessage} + onConfigureWorkflowTool={openWorkflowToolDrawer} /> {systemFeatures.enable_creators_platform && (
} + icon={} disabled={!publishedAt || publishingToMarketplace} onClick={handlePublishToMarketplace} > @@ -377,9 +469,29 @@ const AppPublisher = ({ onClose={() => setEmbeddingModalOpen(false)} appBaseUrl={appBaseURL} accessToken={accessToken} + hiddenInputs={hiddenLaunchVariables} /> {showAppAccessControl && { setShowAppAccessControl(false) }} />} + + {workflowToolDrawerOpen && ( + + )} ) } diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index 57522095ae..712312b744 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -8,13 +8,12 @@ import { TooltipContent, TooltipTrigger, } from '@langgenius/dify-ui/tooltip' +import { RiSettings2Line } from '@remixicon/react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development' import Loading from '@/app/components/base/loading' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' -import { appDefaultIconBackground } from '@/config' import { AppModeEnum } from '@/types/app' import ShortcutsName from '../../workflow/shortcuts-name' import PublishWithMultipleModel from './publish-with-multiple-model' @@ -46,11 +45,8 @@ type AccessSectionProps = { type ActionsSectionProps = Pick & { appDetail: { @@ -67,9 +63,16 @@ type ActionsSectionProps = Pick void handleOpenInExplore: () => void + handleOpenRunConfig?: (url: string) => void handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise published: boolean + showBatchRunConfig?: boolean + showRunConfig?: boolean + workflowToolIsLoading: boolean + workflowToolOutdated: boolean + workflowToolIsCurrentWorkspaceManager: boolean workflowToolMessage?: string + onConfigureWorkflowTool: () => void } export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => { @@ -256,18 +259,20 @@ export const PublisherActionsSection = ({ disabledFunctionTooltip, handleEmbed, handleOpenInExplore, - handlePublish, + handleOpenRunConfig, hasHumanInputNode = false, hasTriggerNode = false, - inputs, missingStartNode = false, - onRefreshData, - outputs, - published, publishedAt, + showBatchRunConfig = false, + showRunConfig = false, toolPublished, workflowToolAvailable = true, + workflowToolIsLoading, + workflowToolOutdated, + workflowToolIsCurrentWorkspaceManager, workflowToolMessage, + onConfigureWorkflowTool, }: ActionsSectionProps) => { const { t } = useTranslation() @@ -284,6 +289,13 @@ export const PublisherActionsSection = ({ disabled={disabledFunctionButton} link={appURL} icon={} + actionButton={showRunConfig + ? { + ariaLabel: t('operation.config', { ns: 'common' }), + icon: , + onClick: () => handleOpenRunConfig?.(appURL), + } + : undefined} > {t('common.runApp', { ns: 'workflow' })} @@ -296,6 +308,13 @@ export const PublisherActionsSection = ({ disabled={disabledFunctionButton} link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`} icon={} + actionButton={showBatchRunConfig + ? { + ariaLabel: t('operation.config', { ns: 'common' }), + icon: , + onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`), + } + : undefined} > {t('common.batchRunApp', { ns: 'workflow' })} @@ -305,7 +324,7 @@ export const PublisherActionsSection = ({ } + icon={} > {t('common.embedIntoSite', { ns: 'workflow' })} @@ -340,18 +359,10 @@ export const PublisherActionsSection = ({ )} diff --git a/web/app/components/app/app-publisher/suggested-action.tsx b/web/app/components/app/app-publisher/suggested-action.tsx index db13364eb9..c1cec6f819 100644 --- a/web/app/components/app/app-publisher/suggested-action.tsx +++ b/web/app/components/app/app-publisher/suggested-action.tsx @@ -1,33 +1,93 @@ -import type { HTMLProps, PropsWithChildren } from 'react' +import type { HTMLProps, PropsWithChildren, MouseEvent as ReactMouseEvent } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowRightUpLine } from '@remixicon/react' +type SuggestedActionButton = { + ariaLabel: string + icon: React.ReactNode + onClick: (event: ReactMouseEvent) => void +} + type SuggestedActionProps = PropsWithChildren & { icon?: React.ReactNode link?: string disabled?: boolean + actionButton?: SuggestedActionButton }> -const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => { - const handleClick = (e: React.MouseEvent) => { - if (disabled) +const SuggestedAction = ({ + icon, + link, + disabled, + children, + className, + onClick, + actionButton, + ...props +}: SuggestedActionProps) => { + const handleClick = (event: ReactMouseEvent) => { + if (disabled) { + event.preventDefault() return - onClick?.(e) + } + + onClick?.(event) } - return ( + + const handleActionClick = (event: ReactMouseEvent) => { + if (disabled) { + event.preventDefault() + return + } + + actionButton?.onClick(event) + } + + const mainAction = ( -
{icon}
+
{icon}
{children}
- +
) + + if (!actionButton) + return mainAction + + return ( +
+ {mainAction} + +
+ ) } export default SuggestedAction diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx index 7a63df3350..cdb1d17833 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -4,6 +4,29 @@ import { fireEvent, render, screen } from '@testing-library/react' import { InputVarType } from '@/app/components/workflow/types' import ConfigModalFormFields from '../form-fields' +vi.mock('react-i18next', async () => { + const React = await import('react') + return { + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const ns = options?.ns as string | undefined + return ns ? `${ns}.${key}` : key + }, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), + Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record }) => ( + + {i18nKey} + {components?.docLink} + + ), + } +}) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path?: string) => `https://docs.example.com${path || ''}`, +})) + vi.mock('@/app/components/base/file-uploader', () => ({ FileUploaderInAttachmentWrapper: ({ onChange, @@ -74,6 +97,12 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { } }) +vi.mock('@langgenius/dify-ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, +})) + vi.mock('../field', () => ({ default: ({ children, title }: { children: ReactNode, title: string }) => (
@@ -176,7 +205,18 @@ describe('ConfigModalFormFields', () => { expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta') }) - it('should wire file, json schema, and visibility controls', () => { + it('should wire file, json schema, and visibility controls', async () => { + const textInputProps = createBaseProps() + const textInputView = render() + expect(screen.getByText('variableConfig.hidden')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'variableConfig.hiddenDescription' })) + expect(await screen.findByText('variableConfig.hiddenDescription')).toBeInTheDocument() + const docLink = await screen.findByRole('link') + expect(docLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/nodes/user-input#hide-and-pre-fill-input-fields') + expect(docLink).toHaveAttribute('target', '_blank') + expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') + textInputView.unmount() + const singleFileProps = createBaseProps() singleFileProps.tempPayload = { ...singleFileProps.tempPayload, @@ -185,18 +225,20 @@ describe('ConfigModalFormFields', () => { allowed_file_extensions: [], allowed_file_upload_methods: ['remote_url'], } - render() + const singleFileView = render() + expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument() + expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument() fireEvent.click(screen.getByText('single-file-setting')) fireEvent.click(screen.getByText('upload-file')) fireEvent.click(screen.getAllByText('unchecked')[0]!) - fireEvent.click(screen.getAllByText('unchecked')[1]!) expect(singleFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 1 }) expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith(expect.objectContaining({ fileId: 'file-1', })) expect(singleFileProps.payloadChangeHandlers.required).toHaveBeenCalledWith(true) - expect(singleFileProps.payloadChangeHandlers.hide).toHaveBeenCalledWith(true) + expect(singleFileProps.payloadChangeHandlers.hide).not.toHaveBeenCalled() + singleFileView.unmount() const multiFileProps = createBaseProps() multiFileProps.tempPayload = { @@ -207,8 +249,9 @@ describe('ConfigModalFormFields', () => { allowed_file_upload_methods: ['remote_url'], } render() + expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument() fireEvent.click(screen.getByText('multi-file-setting')) - fireEvent.click(screen.getAllByText('upload-file')[1]!) + fireEvent.click(screen.getAllByText('upload-file')[0]!) expect(multiFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 3 }) expect(multiFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith([ expect.objectContaining({ fileId: 'file-1' }), @@ -367,4 +410,23 @@ describe('ConfigModalFormFields', () => { expect(screen.getByRole('spinbutton')).toHaveValue(null) }) + + it('should disable hide checkbox when required is true and disable required when hide is true', () => { + const requiredProps = createBaseProps() + requiredProps.tempPayload = { ...requiredProps.tempPayload, type: InputVarType.textInput, required: true, hide: false } + const { unmount } = render() + + const buttons = screen.getAllByRole('button') + const hideButton = buttons.find(btn => btn.textContent === 'unchecked' && btn !== buttons[0]) + expect(hideButton).toBeDefined() + unmount() + + const hideProps = createBaseProps() + hideProps.tempPayload = { ...hideProps.tempPayload, type: InputVarType.textInput, required: false, hide: true } + render() + + const allButtons = screen.getAllByRole('button') + const checkedHideButton = allButtons.find(btn => btn.textContent === 'checked') + expect(checkedHideButton).toBeDefined() + }) }) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx index e6cb56f490..d32bcec755 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx @@ -25,6 +25,7 @@ vi.mock('../form-fields', () => ({ return (
{String(props.tempPayload.type)}
+
{String(props.tempPayload.hide)}
{String(props.tempPayload.label ?? '')}
{String(props.tempPayload.json_schema ?? '')}
{String(props.tempPayload.default ?? '')}
@@ -115,7 +116,7 @@ describe('ConfigModal logic', () => { }) it('should derive payload fields from mocked form-field callbacks', async () => { - renderConfigModal() + renderConfigModal(createPayload({ hide: true })) fireEvent.click(screen.getByTestId('valid-key-blur')) await waitFor(() => { @@ -138,6 +139,7 @@ describe('ConfigModal logic', () => { fireEvent.click(screen.getByTestId('type-change')) await waitFor(() => { expect(screen.getByTestId('payload-type')).toHaveTextContent(InputVarType.singleFile) + expect(screen.getByTestId('payload-hide')).toHaveTextContent('false') }) fireEvent.click(screen.getByTestId('file-payload-change')) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts index 1c00e1c5b2..2317868004 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts @@ -49,11 +49,13 @@ describe('config-modal utils', () => { const payload = createInputVar({ type: InputVarType.textInput, default: 'hello', + hide: true, }) const nextPayload = createPayloadForType(payload, InputVarType.multiFiles) expect(nextPayload.type).toBe(InputVarType.multiFiles) + expect(nextPayload.hide).toBe(false) expect(nextPayload.max_length).toBe(DEFAULT_FILE_UPLOAD_SETTING.max_length) expect(nextPayload.allowed_file_types).toEqual(DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types) expect(nextPayload.default).toBe('hello') @@ -249,6 +251,24 @@ describe('config-modal utils', () => { }) }) + it('should force file inputs to stay visible when saving', () => { + const result = validateConfigModalPayload({ + tempPayload: createInputVar({ + type: InputVarType.singleFile, + hide: true, + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: [], + }), + payload: createInputVar(), + checkVariableName: () => true, + t, + }) + + expect(result.payloadToSave).toEqual(expect.objectContaining({ + hide: false, + })) + }) + it('should stop validation when the variable name checker rejects the payload', () => { const result = validateConfigModalPayload({ tempPayload: createInputVar({ diff --git a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx index 748108e19a..4bd938c3f6 100644 --- a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx @@ -13,14 +13,17 @@ import { SelectValue, } from '@langgenius/dify-ui/select' import * as React from 'react' +import { Trans } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' +import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { useDocLink } from '@/context/i18n' import { TransferMethod } from '@/types/app' import ConfigSelect from '../config-select' import ConfigString from '../config-string' @@ -68,6 +71,9 @@ const ConfigModalFormFields: FC = ({ t, }) => { const { type, label, variable } = tempPayload + const isFileInput = [InputVarType.singleFile, InputVarType.multiFiles].includes(type) + const docLink = useDocLink() + const hiddenDescriptionAriaLabel = t('variableConfig.hiddenDescription', { ns: 'appDebug' }).replace(/<[^>]+>/g, '') return (
@@ -105,7 +111,7 @@ const ConfigModalFormFields: FC = ({ {type === InputVarType.textInput && ( onPayloadChange('default')(e.target.value || undefined)} placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} /> @@ -126,7 +132,7 @@ const ConfigModalFormFields: FC = ({ onPayloadChange('default')(e.target.value || undefined)} placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} /> @@ -186,7 +192,7 @@ const ConfigModalFormFields: FC = ({ )} - {[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && ( + {isFileInput && ( <> = ({ )}
- onPayloadChange('required')(!tempPayload.required)} /> + onPayloadChange('required')(!tempPayload.required)} /> {t('variableConfig.required', { ns: 'appDebug' })}
-
- onPayloadChange('hide')(!tempPayload.hide)} /> - {t('variableConfig.hide', { ns: 'appDebug' })} -
+ {!isFileInput && ( +
+ onPayloadChange('hide')(!tempPayload.hide)} /> +
+ {t('variableConfig.hidden', { ns: 'appDebug' })} + + + ), + }} + /> + +
+
+ )}
) } diff --git a/web/app/components/app/configuration/config-var/config-modal/utils.ts b/web/app/components/app/configuration/config-var/config-modal/utils.ts index fdc0ac3501..e24e4b6593 100644 --- a/web/app/components/app/configuration/config-var/config-modal/utils.ts +++ b/web/app/components/app/configuration/config-var/config-modal/utils.ts @@ -88,7 +88,9 @@ export const createPayloadForType = (payload: InputVar, type: InputVarType) => { draft.default = undefined if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) { - (Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array).forEach((key) => { + draft.hide = false + const fileUploadSettingKeys = Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array + fileUploadSettingKeys.forEach((key) => { if (key !== 'max_length') draft[key] = DEFAULT_FILE_UPLOAD_SETTING[key] as never }) @@ -158,38 +160,41 @@ export const validateConfigModalPayload = ({ checkVariableName, t, }: ValidateConfigModalPayloadOptions): ValidateConfigModalPayloadResult => { + const normalizedTempPayload = [InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type) + ? { ...tempPayload, hide: false } + : tempPayload const jsonSchemaValue = tempPayload.json_schema const schemaEmpty = isJsonSchemaEmpty(jsonSchemaValue) const normalizedJsonSchema = schemaEmpty ? undefined : jsonSchemaValue - const payloadToSave = tempPayload.type === InputVarType.jsonObject && schemaEmpty - ? { ...tempPayload, json_schema: undefined } - : tempPayload + const payloadToSave = normalizedTempPayload.type === InputVarType.jsonObject && schemaEmpty + ? { ...normalizedTempPayload, json_schema: undefined } + : normalizedTempPayload - const moreInfo = tempPayload.variable === payload?.variable + const moreInfo = normalizedTempPayload.variable === payload?.variable ? undefined : { type: ChangeType.changeVarName, - payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable }, + payload: { beforeKey: payload?.variable || '', afterKey: normalizedTempPayload.variable }, } - if (!checkVariableName(tempPayload.variable)) + if (!checkVariableName(normalizedTempPayload.variable)) return {} - if (!tempPayload.label) { + if (!normalizedTempPayload.label) { return { errorMessage: t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }), } } - if (tempPayload.type === InputVarType.select) { - if (!tempPayload.options?.length) { + if (normalizedTempPayload.type === InputVarType.select) { + if (!normalizedTempPayload.options?.length) { return { errorMessage: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }), } } const duplicated = new Set() - const hasRepeatedItem = tempPayload.options.some((option) => { + const hasRepeatedItem = normalizedTempPayload.options.some((option) => { if (duplicated.has(option)) return true @@ -204,8 +209,8 @@ export const validateConfigModalPayload = ({ } } - if ([InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)) { - if (!tempPayload.allowed_file_types?.length) { + if ([InputVarType.singleFile, InputVarType.multiFiles].includes(normalizedTempPayload.type)) { + if (!normalizedTempPayload.allowed_file_types?.length) { return { errorMessage: t('errorMsg.fieldRequired', { ns: 'workflow', @@ -214,7 +219,7 @@ export const validateConfigModalPayload = ({ } } - if (tempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) { + if (normalizedTempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !normalizedTempPayload.allowed_file_extensions?.length) { return { errorMessage: t('errorMsg.fieldRequired', { ns: 'workflow', @@ -224,7 +229,7 @@ export const validateConfigModalPayload = ({ } } - if (tempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') { + if (normalizedTempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') { try { const schema = JSON.parse(normalizedJsonSchema) if (schema?.type !== 'object') { diff --git a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx index 16971f77d5..d1b7dedac3 100644 --- a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx @@ -35,7 +35,7 @@ const mockApp: App = { copyright: 'Test Corp', privacy_policy: null, custom_disclaimer: null, - category: 'Assistant', + categories: ['Assistant'], position: 1, is_listed: true, install_count: 100, @@ -253,7 +253,7 @@ describe('AppCard', () => { template_id: mockApp.app_id, template_name: mockApp.app.name, template_mode: mockApp.app.mode, - template_category: mockApp.category, + template_categories: mockApp.categories, page: 'studio', }) expect(mockSetShowTryAppPanel).toHaveBeenCalledWith(true, { diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index e710e21436..27232b0350 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -35,7 +35,7 @@ const AppCard = ({ template_id: app.app_id, template_name: appBasicInfo.name, template_mode: appBasicInfo.mode, - template_category: app.category, + template_categories: app.categories, page: 'studio', }) setShowTryAppPanel?.(true, { appId: app.app_id, app }) diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 0c6462c2f9..cd6c6b57eb 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -115,7 +115,7 @@ vi.mock('@/next/navigation', () => ({ const createAppEntry = (name: string, category: string) => ({ app_id: name, - category, + categories: [category], app: { id: name, name, diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 1924de3893..b0f0b8ca59 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -74,7 +74,7 @@ const Apps = ({ const filteredByCategory = allList.filter((item) => { if (currCategory === allCategoriesEn) return true - return item.category === currCategory + return item.categories?.includes(currCategory) ?? false }) if (currentType.length === 0) return filteredByCategory diff --git a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx index 9820e15ad8..d3f83d5d9c 100644 --- a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx @@ -1,8 +1,38 @@ +import type { FormEvent } from 'react' import type { AppDetailResponse } from '@/models/app' import { fireEvent, render, screen, within } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' -import { AppCardAccessControlSection, AppCardOperations, AppCardUrlSection, createAppCardOperations } from '../app-card-sections' +import { AppCardAccessControlSection, AppCardDialogs, AppCardOperations, AppCardUrlSection, createAppCardOperations, WorkflowLaunchDialog } from '../app-card-sections' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, +})) + +vi.mock('../settings', () => ({ + default: () =>
, +})) + +vi.mock('../embedded', () => ({ + default: () =>
, +})) + +vi.mock('../customize', () => ({ + default: () =>
, +})) + +vi.mock('../../app-access-control', () => ({ + default: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => ( +
+ + +
+ ), +})) describe('app-card-sections', () => { const t = (key: string) => key @@ -52,6 +82,7 @@ describe('app-card-sections', () => { it('should render operation buttons and execute enabled actions', () => { const onLaunch = vi.fn() + const onLaunchConfig = vi.fn() const operations = createAppCardOperations({ operationKeys: ['launch', 'embedded'], t: t as never, @@ -68,12 +99,19 @@ describe('app-card-sections', () => { , ) fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i })) + fireEvent.click(screen.getByRole('button', { name: /operation\.config/i })) expect(onLaunch).toHaveBeenCalledTimes(1) + expect(onLaunchConfig).toHaveBeenCalledTimes(1) expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument() }) @@ -127,4 +165,127 @@ describe('app-card-sections', () => { fireEvent.click(within(dialog).getByRole('button', { name: /operation\.confirm/i })) expect(onRegenerate).toHaveBeenCalledTimes(1) }) + + it('should disable all operations when triggerModeDisabled is true', () => { + const operations = createAppCardOperations({ + operationKeys: ['launch', 'settings'], + t: t as never, + runningStatus: true, + triggerModeDisabled: true, + onLaunch: vi.fn(), + onEmbedded: vi.fn(), + onCustomize: vi.fn(), + onSettings: vi.fn(), + onDevelop: vi.fn(), + }) + + expect(operations[0]!.disabled).toBe(true) + expect(operations[1]!.disabled).toBe(true) + }) + + it('should render WorkflowLaunchDialog and submit values', () => { + const onOpenChange = vi.fn() + const onValueChange = vi.fn() + const onSubmit = vi.fn((event: FormEvent) => { + event.preventDefault() + }) + + render( + , + ) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + fireEvent.submit(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }).closest('form')!) + expect(onSubmit).toHaveBeenCalled() + }) + + it('should return null for WorkflowLaunchDialog when no variables are provided', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('should render AppCardDialogs with all modals for web apps', () => { + const appInfo = { + id: 'app-1', + mode: AppModeEnum.CHAT, + enable_site: true, + enable_api: false, + site: { app_base_url: 'https://example.com', access_token: 'token-1' }, + api_base_url: 'https://api.example.com', + } as never + + render( + , + ) + + expect(screen.getByTestId('settings-modal')).toBeInTheDocument() + expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() + expect(screen.getByTestId('customize-modal')).toBeInTheDocument() + expect(screen.getByTestId('access-control')).toBeInTheDocument() + }) + + it('should return null for AppCardDialogs when not an app', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) }) diff --git a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts index fbfcdaf955..0a6d7f7dd7 100644 --- a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts +++ b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts @@ -1,9 +1,22 @@ import type { AppDetailResponse } from '@/models/app' -import { BlockEnum } from '@/app/components/workflow/types' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' -import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils' +import { + buildWorkflowLaunchUrl, + compressAndEncodeBase64, + createWorkflowLaunchInitialValues, + getAppCardDisplayState, + getAppCardOperationKeys, + getAppHiddenLaunchVariables, + getEmbeddedIframeSnippet, + getEmbeddedScriptSnippet, + getWorkflowHiddenStartVariables, + hasWorkflowStartNode, + isAppAccessConfigured, + isWorkflowLaunchInputSupported, +} from '../app-card-utils' describe('app-card-utils', () => { const baseAppInfo = { @@ -33,6 +46,108 @@ describe('app-card-utils', () => { })).toBe(false) }) + it('should return hidden workflow start variables and their initial launch values', () => { + const hiddenVariables = getWorkflowHiddenStartVariables({ + graph: { + nodes: [{ + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'visible', + label: 'Visible', + type: InputVarType.textInput, + hide: false, + required: false, + }, + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + default: 'prefilled', + required: false, + }, + { + variable: 'enabled', + label: 'Enabled', + type: InputVarType.checkbox, + hide: true, + default: true, + required: false, + }, + ], + }, + }], + }, + }) + + expect(hiddenVariables.map(variable => variable.variable)).toEqual(['secret', 'enabled']) + expect(createWorkflowLaunchInitialValues(hiddenVariables)).toEqual({ + secret: 'prefilled', + enabled: true, + }) + }) + + it('should return hidden advanced-chat launch variables from the workflow start node first', () => { + const hiddenVariables = getAppHiddenLaunchVariables({ + appInfo: { + ...baseAppInfo, + mode: AppModeEnum.ADVANCED_CHAT, + model_config: { + user_input_form: [ + { + 'text-input': { + label: 'Visible', + variable: 'visible', + required: true, + max_length: 48, + default: '', + hide: false, + }, + }, + { + checkbox: { + label: 'Hidden Toggle', + variable: 'hidden_toggle', + required: false, + default: true, + hide: true, + }, + }, + ], + }, + } as AppDetailResponse, + currentWorkflow: { + graph: { + nodes: [{ + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'start_secret', + label: 'Start Secret', + type: InputVarType.textInput, + hide: true, + default: 'from-start', + required: false, + }, + ], + }, + }], + }, + }, + }) + + expect(hiddenVariables).toEqual([ + expect.objectContaining({ + variable: 'start_secret', + type: InputVarType.textInput, + default: 'from-start', + }), + ]) + }) + it('should build the display state for a published web app', () => { const state = getAppCardDisplayState({ appInfo: baseAppInfo, @@ -104,4 +219,108 @@ describe('app-card-utils', () => { isCurrentWorkspaceEditor: false, })).toEqual(['launch', 'embedded', 'customize']) }) + + it('should build a workflow launch URL with serialized parameters', async () => { + const url = await buildWorkflowLaunchUrl({ + accessibleUrl: 'https://example.com/app/workflow/token-1', + variables: [ + { variable: 'name', label: 'Name', type: InputVarType.textInput, hide: true, required: false }, + { variable: 'enabled', label: 'Enabled', type: InputVarType.checkbox, hide: true, required: false }, + ], + values: { name: 'Alice', enabled: true }, + }) + + const parsed = new URL(url) + expect(parsed.searchParams.get('name')).toBe('Alice') + expect(parsed.searchParams.get('enabled')).toBe('true') + }) + + it('should serialize checkbox false and empty string values in launch URL', async () => { + const url = await buildWorkflowLaunchUrl({ + accessibleUrl: 'https://example.com/app/workflow/token-1', + variables: [ + { variable: 'flag', label: 'Flag', type: InputVarType.checkbox, hide: true, required: false }, + { variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false }, + ], + values: { flag: false, empty: '' }, + }) + + const parsed = new URL(url) + expect(parsed.searchParams.get('flag')).toBe('false') + expect(parsed.searchParams.get('empty')).toBe('') + }) + + it('should generate an iframe snippet with the provided URL', () => { + const snippet = getEmbeddedIframeSnippet('https://example.com/chatbot/token-1') + expect(snippet).toContain('src="https://example.com/chatbot/token-1"') + expect(snippet).toContain('frameborder="0"') + expect(snippet).toContain('allow="microphone"') + }) + + it('should generate an embedded script snippet with inputs', () => { + const snippet = getEmbeddedScriptSnippet({ + url: 'https://example.com', + token: 'abc123', + primaryColor: '#FF0000', + isTestEnv: true, + inputValues: { name: 'Alice', count: '5' }, + }) + + expect(snippet).toContain('token: \'abc123\'') + expect(snippet).toContain('isDev: true') + expect(snippet).toContain('name: "Alice"') + expect(snippet).toContain('count: "5"') + expect(snippet).toContain('background-color: #FF0000') + }) + + it('should generate an embedded script snippet with empty inputs comment', () => { + const snippet = getEmbeddedScriptSnippet({ + url: 'https://example.com', + token: 'abc123', + primaryColor: '#1C64F2', + inputValues: {}, + }) + + expect(snippet).toContain('// You can define the inputs from the Start node here') + expect(snippet).not.toContain('isDev: true') + }) + + it('should compress and encode base64 using CompressionStream when available', async () => { + const result = await compressAndEncodeBase64('hello') + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) + + it('should fallback to plain base64 when CompressionStream is unavailable', async () => { + const original = globalThis.CompressionStream + // @ts-expect-error remove for test + delete globalThis.CompressionStream + + const result = await compressAndEncodeBase64('hello') + expect(result).toBe(btoa('hello')) + + globalThis.CompressionStream = original + }) + + it('should identify supported workflow launch input types', () => { + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.textInput, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.paragraph, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.select, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.number, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.checkbox, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.json, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.jsonObject, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.url, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.files, hide: true, required: false })).toBe(false) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.singleFile, hide: true, required: false })).toBe(false) + }) + + it('should coerce numeric defaults to string in createWorkflowLaunchInitialValues', () => { + const result = createWorkflowLaunchInitialValues([ + { variable: 'count', label: 'Count', type: InputVarType.number, hide: true, required: false, default: 42 }, + { variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false }, + ]) + + expect(result).toEqual({ count: '42', empty: '' }) + }) }) diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx index 2f730ad278..a6bacce887 100644 --- a/web/app/components/app/overview/__tests__/app-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card.spec.tsx @@ -2,6 +2,7 @@ import type { ReactElement, ReactNode } from 'react' import type { AppDetailResponse } from '@/models/app' import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' @@ -17,7 +18,7 @@ const mockSetAppDetail = vi.fn() const mockOnChangeStatus = vi.fn() const mockOnGenerateCode = vi.fn() -let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string } }> } } | null = null +let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array> } }> } } | null = null let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] } let mockAppDetail: AppDetailResponse | undefined @@ -25,6 +26,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), + Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null, })) vi.mock('@/context/app-context', () => ({ @@ -164,6 +166,182 @@ describe('AppCard', () => { expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com${basePath}/chat/access-token`, '_blank') }) + it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByText('overview.appInfo.launch')) + + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/access-token`, + '_blank', + ) + expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument() + }) + + it('should collect hidden workflow inputs from the config action before launching the workflow web app', async () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.config' })) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/access-token?secret=${encodeURIComponent('top-secret')}`, + '_blank', + ) + }) + }) + + it('should open the chat web app directly when launch is clicked even with hidden inputs', () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByText('overview.appInfo.launch')) + + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/access-token`, + '_blank', + ) + expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument() + }) + + it('should collect hidden chatflow inputs from the config action before launching the chat web app', async () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.config' })) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Chat Secret'), { + target: { value: 'chat-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/access-token?chat_secret=${encodeURIComponent('chat-secret')}`, + '_blank', + ) + }) + }) + it('should show the access-control not-set badge when specific access has no subjects', () => { render( { }) it('should report refresh failures from access control updates', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) mockFetchAppDetailDirect.mockRejectedValueOnce(new Error('refresh failed')) render( diff --git a/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx b/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx new file mode 100644 index 0000000000..309df540a6 --- /dev/null +++ b/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx @@ -0,0 +1,214 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' +import WorkflowHiddenInputFields from '../workflow-hidden-input-fields' + +describe('WorkflowHiddenInputFields', () => { + const onValueChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render a text input with label and placeholder', () => { + render( + , + ) + + const input = screen.getByLabelText('Full Name') + expect(input).toHaveValue('Alice') + + fireEvent.change(input, { target: { value: 'Bob' } }) + expect(onValueChange).toHaveBeenCalledWith('name', 'Bob') + }) + + it('should render a number input for number-typed variables', () => { + render( + , + ) + + const input = screen.getByLabelText('Count') + expect(input).toHaveAttribute('type', 'number') + + fireEvent.change(input, { target: { value: '10' } }) + expect(onValueChange).toHaveBeenCalledWith('count', '10') + }) + + it('should render a checkbox input without a separate label element above', () => { + render( + , + ) + + const checkbox = screen.getByRole('checkbox') + expect(checkbox).toBeChecked() + expect(screen.getByText('Enable Feature')).toBeInTheDocument() + + fireEvent.click(checkbox) + expect(onValueChange).toHaveBeenCalledWith('enabled', false) + }) + + it('should render a select dropdown for select-typed variables', () => { + render( + , + ) + + expect(screen.getByRole('combobox', { name: 'Color' })).toBeInTheDocument() + }) + + it('should render a textarea for paragraph-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Description') + expect(textarea).toHaveValue('Hello world') + + fireEvent.change(textarea, { target: { value: 'Updated' } }) + expect(onValueChange).toHaveBeenCalledWith('description', 'Updated') + }) + + it('should render a textarea for json-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Config JSON') + expect(textarea).toHaveValue('{"key": "value"}') + }) + + it('should render a textarea for jsonObject-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Schema') + expect(textarea).toHaveValue('{}') + }) + + it('should use the variable key as label when label is not a string', () => { + render( + , + ) + + expect(screen.getByText('my_var')).toBeInTheDocument() + }) + + it('should use the custom fieldIdPrefix for element ids', () => { + const { container } = render( + , + ) + + expect(container.querySelector('#custom-prefix-token')).toBeInTheDocument() + }) + + it('should render empty string for non-string fieldValue in text inputs', () => { + render( + , + ) + + const input = screen.getByLabelText('Flag') + expect(input).toHaveValue('') + }) +}) diff --git a/web/app/components/app/overview/app-card-sections.tsx b/web/app/components/app/overview/app-card-sections.tsx index 8fef355f34..8db5193f2d 100644 --- a/web/app/components/app/overview/app-card-sections.tsx +++ b/web/app/components/app/overview/app-card-sections.tsx @@ -1,7 +1,11 @@ /* eslint-disable react-refresh/only-export-components */ import type { TFunction } from 'i18next' -import type { ComponentType, ReactNode } from 'react' -import type { OverviewOperationKey } from './app-card-utils' +import type { ComponentType, FormEvent, ReactNode } from 'react' +import type { + OverviewOperationKey, + WorkflowHiddenStartVariable, + WorkflowLaunchInputValue, +} from './app-card-utils' import type { ConfigParams } from './settings' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' @@ -15,12 +19,19 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger, } from '@langgenius/dify-ui/tooltip' -import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react' +import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiSettings2Line, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react' +import { Trans } from 'react-i18next' import CopyFeedback from '@/app/components/base/copy-feedback' import Divider from '@/app/components/base/divider' import ShareQRCode from '@/app/components/base/qrcode' @@ -31,6 +42,7 @@ import CustomizeModal from './customize' import EmbeddedModal from './embedded' import SettingsModal from './settings' import style from './style.module.css' +import WorkflowHiddenInputFields from './workflow-hidden-input-fields' type AppInfo = AppDetailResponse & Partial @@ -50,6 +62,12 @@ type AppCardOperation = { onClick: () => void } +type LaunchConfigAction = { + label: string + disabled: boolean + onClick: () => void +} + const OPERATION_ICON_MAP: Record = { launch: RiExternalLinkLine, embedded: RiWindowLine, @@ -96,6 +114,65 @@ const MaybeTooltip = ({ ) } +export const WorkflowLaunchDialog = ({ + t, + open, + hiddenVariables, + unsupportedVariables, + values, + onOpenChange, + onValueChange, + onSubmit, +}: { + t: TFunction + open: boolean + hiddenVariables: WorkflowHiddenStartVariable[] + unsupportedVariables: WorkflowHiddenStartVariable[] + values: Record + onOpenChange: (open: boolean) => void + onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void + onSubmit: (event: FormEvent) => void +}) => { + if (!hiddenVariables.length && !unsupportedVariables.length) + return null + + return ( + + +
+ + {t('overview.appInfo.workflowLaunchHiddenInputs.title', { ns: 'appOverview' })} + + + }} + /> + +
+
+
+ +
+
+ + +
+
+
+
+ ) +} + export const createAppCardOperations = ({ operationKeys, t, @@ -251,20 +328,15 @@ export const AppCardAccessControlSection = ({ export const AppCardOperations = ({ t, operations, + launchConfigAction, }: { t: TFunction operations: AppCardOperation[] + launchConfigAction?: LaunchConfigAction }) => ( <> - {operations.map(({ key, label, Icon, disabled, onClick }) => ( -
- - ))} + ) + + if (key === 'launch' && launchConfigAction) { + return ( + + + + ) + } + + return ( + + ) + })} ) @@ -295,6 +431,7 @@ export const AppCardDialogs = ({ onCloseAccessControl, onSaveSiteConfig, onConfirmAccessControl, + hiddenInputs, }: { isApp: boolean appInfo: AppInfo @@ -310,6 +447,7 @@ export const AppCardDialogs = ({ onCloseAccessControl: () => void onSaveSiteConfig?: (params: ConfigParams) => Promise onConfirmAccessControl: () => Promise + hiddenInputs?: WorkflowHiddenStartVariable[] }) => { if (!isApp) return null @@ -329,6 +467,7 @@ export const AppCardDialogs = ({ onClose={onCloseEmbedded} appBaseUrl={appInfo.site?.app_base_url} accessToken={appInfo.site?.access_token} + hiddenInputs={hiddenInputs} /> type AppInfo = AppDetailResponse & Partial @@ -16,6 +23,7 @@ type WorkflowLike = { nodes?: Array<{ data?: { type?: string + variables?: InputVar[] } }> } @@ -42,10 +50,173 @@ const getCardAppMode = (mode: AppModeEnum) => { return (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : mode } +const SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES = new Set([ + InputVarType.textInput, + InputVarType.paragraph, + InputVarType.select, + InputVarType.number, + InputVarType.checkbox, + InputVarType.json, + InputVarType.jsonObject, + InputVarType.url, +]) + +const coerceWorkflowLaunchDefaultValue = (variable: WorkflowHiddenStartVariable): WorkflowLaunchInputValue => { + if (variable.type === InputVarType.checkbox) { + if (typeof variable.default === 'boolean') + return variable.default + + return String(variable.default).toLowerCase() === 'true' + } + + if (typeof variable.default === 'number') + return String(variable.default) + + return String(variable.default ?? '') +} + export const hasWorkflowStartNode = (currentWorkflow: WorkflowLike) => { return currentWorkflow?.graph?.nodes?.some(node => node.data?.type === BlockEnum.Start) ?? false } +export const getWorkflowHiddenStartVariables = (currentWorkflow: WorkflowLike): WorkflowHiddenStartVariable[] => { + const startNode = currentWorkflow?.graph?.nodes?.find(node => node.data?.type === BlockEnum.Start) + return (startNode?.data?.variables ?? []).filter(variable => variable.hide === true) +} + +export const getAppHiddenLaunchVariables = ({ + appInfo, + currentWorkflow, +}: { + appInfo: AppInfo + currentWorkflow: WorkflowLike +}) => { + if ([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT].includes(appInfo.mode)) + return getWorkflowHiddenStartVariables(currentWorkflow) +} + +export const isWorkflowLaunchInputSupported = (variable: WorkflowHiddenStartVariable) => { + return SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES.has(variable.type) +} + +export const createWorkflowLaunchInitialValues = (variables: WorkflowHiddenStartVariable[]) => { + return variables.reduce>((acc, variable) => { + acc[variable.variable] = coerceWorkflowLaunchDefaultValue(variable) + return acc + }, {}) +} + +export const buildWorkflowLaunchUrl = async ({ + accessibleUrl, + variables, + values, +}: { + accessibleUrl: string + variables: WorkflowHiddenStartVariable[] + values: Record +}) => { + const targetUrl = new URL(accessibleUrl, window.location.origin) + variables.forEach((variable) => { + const rawValue = values[variable.variable] + const serializedValue = variable.type === InputVarType.checkbox + ? String(Boolean(rawValue)) + : String(rawValue ?? '') + + targetUrl.searchParams.set(variable.variable, serializedValue) + }) + + return targetUrl.toString() +} + +export const getEmbeddedIframeSnippet = (iframeUrl: string) => + `` + +const getScriptInputsContent = (values: Record) => { + const entries = Object.entries(values) + + if (!entries.length) { + return `{ + // You can define the inputs from the Start node here + // key is the variable name + // e.g. + // name: "NAME" + }` + } + + return `{ +${entries.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join('\n')} + }` +} + +export const getEmbeddedScriptSnippet = ({ + url, + token, + primaryColor, + isTestEnv, + inputValues, +}: { + url: string + token: string + primaryColor: string + isTestEnv?: boolean + inputValues: Record +}) => + ` + +` + +export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${iframeUrl}` + +export const compressAndEncodeBase64 = async (input: string) => { + const uint8Array = new TextEncoder().encode(input) + if (typeof CompressionStream === 'undefined') + return btoa(String.fromCharCode(...uint8Array)) + + const compressedStream = new Response( + new Blob([uint8Array]) + .stream() + .pipeThrough(new CompressionStream('gzip')), + ).arrayBuffer() + const compressedUint8Array = new Uint8Array(await compressedStream) + return btoa(String.fromCharCode(...compressedUint8Array)) +} + export const getAppCardDisplayState = ({ appInfo, cardType, diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index b7ec4a2d81..9b1fc3a032 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -1,4 +1,5 @@ 'use client' +import type { WorkflowLaunchInputValue } from './app-card-utils' import type { ConfigParams } from './settings' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' @@ -28,11 +29,16 @@ import { AppCardOperations, AppCardUrlSection, createAppCardOperations, + WorkflowLaunchDialog, } from './app-card-sections' import { + buildWorkflowLaunchUrl, + createWorkflowLaunchInitialValues, getAppCardDisplayState, getAppCardOperationKeys, + getAppHiddenLaunchVariables, isAppAccessConfigured, + isWorkflowLaunchInputSupported, } from './app-card-utils' export type IAppCardProps = { @@ -63,7 +69,8 @@ function AppCard({ const router = useRouter() const pathname = usePathname() const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() - const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '') + const shouldFetchWorkflow = appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT + const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '') const docLink = useDocLink() const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) @@ -73,6 +80,8 @@ function AppCard({ const [genLoading, setGenLoading] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showAccessControl, setShowAccessControl] = useState(false) + const [showWorkflowLaunchDialog, setShowWorkflowLaunchDialog] = useState(false) + const [workflowLaunchValues, setWorkflowLaunchValues] = useState>({}) const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: appAccessSubjects } = useAppWhiteListSubjects( @@ -98,6 +107,25 @@ function AppCard({ () => isAppAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail], ) + const hiddenLaunchVariables = useMemo( + () => getAppHiddenLaunchVariables({ + appInfo, + currentWorkflow, + }) || [], + [appInfo, currentWorkflow], + ) + const supportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported), + [hiddenLaunchVariables], + ) + const unsupportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)), + [hiddenLaunchVariables], + ) + const initialWorkflowLaunchValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables), + [supportedWorkflowLaunchVariables], + ) const onGenCode = async () => { if (!onGenerateCode) @@ -139,6 +167,31 @@ function AppCard({ window.open(cardState.accessibleUrl, '_blank') }, [cardState.accessibleUrl]) + const handleOpenWorkflowLaunchDialog = useCallback(() => { + setWorkflowLaunchValues(initialWorkflowLaunchValues) + setShowWorkflowLaunchDialog(true) + }, [initialWorkflowLaunchValues]) + + const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => { + setWorkflowLaunchValues(prev => ({ + ...prev, + [variable]: value, + })) + }, []) + + const handleWorkflowLaunchConfirm = useCallback(async (event: React.FormEvent) => { + event.preventDefault() + + const targetUrl = await buildWorkflowLaunchUrl({ + accessibleUrl: cardState.accessibleUrl, + variables: supportedWorkflowLaunchVariables, + values: workflowLaunchValues, + }) + + window.open(targetUrl, '_blank') + setShowWorkflowLaunchDialog(false) + }, [cardState.accessibleUrl, supportedWorkflowLaunchVariables, workflowLaunchValues]) + const handleOpenCustomize = useCallback(() => { setShowCustomizeModal(true) }, []) @@ -304,7 +357,17 @@ function AppCard({ {!cardState.isMinimalState && (
{!isApp && } - + 0 + ? { + label: t('operation.config', { ns: 'common' }), + disabled: triggerModeDisabled || !cardState.runningStatus, + onClick: handleOpenWorkflowLaunchDialog, + } + : undefined} + />
)}
@@ -323,6 +386,17 @@ function AppCard({ onCloseAccessControl={() => setShowAccessControl(false)} onSaveSiteConfig={onSaveSiteConfig} onConfirmAccessControl={handleAccessControlUpdate} + hiddenInputs={hiddenLaunchVariables} + /> +
) diff --git a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx index 0a843c26fd..a6e391cb0e 100644 --- a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx @@ -1,10 +1,11 @@ import type { SiteInfo } from '@/models/share' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import copy from 'copy-to-clipboard' import * as React from 'react' - import { act } from 'react' -import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { InputVarType } from '@/app/components/workflow/types' import Embedded from '../index' vi.mock('../style.module.css', () => ({ @@ -46,6 +47,7 @@ vi.mock('@/context/app-context', () => ({ })) const mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) const mockedCopy = vi.mocked(copy) +const originalCompressionStream = globalThis.CompressionStream const siteInfo: SiteInfo = { title: 'test site', @@ -70,6 +72,22 @@ const getCopyButton = () => { } describe('Embedded', () => { + beforeAll(() => { + class MockCompressionStream { + readable: ReadableStream + writable: WritableStream + + constructor() { + const transformStream = new TransformStream() + this.readable = transformStream.readable + this.writable = transformStream.writable + } + } + + // @ts-expect-error test polyfill + globalThis.CompressionStream = MockCompressionStream + }) + afterEach(() => { vi.clearAllMocks() mockWindowOpen.mockClear() @@ -77,6 +95,7 @@ describe('Embedded', () => { afterAll(() => { mockWindowOpen.mockRestore() + globalThis.CompressionStream = originalCompressionStream }) it('builds theme and copies iframe snippet', async () => { @@ -84,14 +103,20 @@ describe('Embedded', () => { render() }) + await waitFor(() => { + expect(screen.getByText((content, node) => node?.tagName.toLowerCase() === 'pre' && content.includes('/chatbot/token'))).toBeInTheDocument() + }) + const actionButton = getCopyButton() const innerDiv = actionButton.querySelector('div') - act(() => { + await act(async () => { fireEvent.click(innerDiv ?? actionButton) }) expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(siteInfo.chat_color_theme, siteInfo.chat_color_theme_inverted) - expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + }) }) it('opens chrome plugin store link when chrome option selected', async () => { @@ -116,4 +141,106 @@ describe('Embedded', () => { 'noopener,noreferrer', ) }) + + it('keeps hidden inputs collapsed by default and updates iframe and script content when values change', async () => { + render( + , + ) + + expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByText('appOverview.overview.appInfo.embedded.hiddenInputs.title').closest('button')!) + }) + + await waitFor(() => { + expect(screen.getByLabelText('Secret')).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + }) + + expect(document.querySelector('pre')?.textContent ?? '').toContain('/chatbot/token') + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('/chatbot/token?secret=dG9wLXNlY3JldA%3D%3D') + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[1]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('secret: "top-secret"') + }) + }) + + it('copies script content when scripts option is selected', async () => { + await act(async () => { + render() + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[1]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('token: \'token\'') + }) + + const actionButton = getCopyButton() + const innerDiv = actionButton.querySelector('div') + await act(async () => { + fireEvent.click(innerDiv ?? actionButton) + }) + + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('token: \'token\'')) + }) + }) + + it('copies chrome plugin URL (without prefix) when chromePlugin option is selected', async () => { + await act(async () => { + render() + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[2]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('ChatBot URL:') + }) + + const actionButton = getCopyButton() + const innerDiv = actionButton.querySelector('div') + await act(async () => { + fireEvent.click(innerDiv ?? actionButton) + }) + + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + expect(mockedCopy).not.toHaveBeenCalledWith(expect.stringContaining('ChatBot URL:')) + }) + }) }) diff --git a/web/app/components/app/overview/embedded/index.tsx b/web/app/components/app/overview/embedded/index.tsx index 12203178f1..112848760b 100644 --- a/web/app/components/app/overview/embedded/index.tsx +++ b/web/app/components/app/overview/embedded/index.tsx @@ -1,88 +1,46 @@ +import type { MutableRefObject } from 'react' +import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils' import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { + RiArrowDownSLine, + RiArrowRightSLine, +} from '@remixicon/react' import copy from 'copy-to-clipboard' -import * as React from 'react' -import { useState } from 'react' +import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context' -import { IS_CE_EDITION } from '@/config' +import { InputVarType } from '@/app/components/workflow/types' import { useAppContext } from '@/context/app-context' import { basePath } from '@/utils/var' +import { + compressAndEncodeBase64, + createWorkflowLaunchInitialValues, + getChromePluginContent, + getEmbeddedIframeSnippet, + getEmbeddedScriptSnippet, + isWorkflowLaunchInputSupported, +} from '../app-card-utils' +import WorkflowHiddenInputFields from '../workflow-hidden-input-fields' import style from './style.module.css' type Props = { siteInfo?: SiteInfo isShow: boolean onClose: () => void - accessToken: string - appBaseUrl: string + accessToken?: string + appBaseUrl?: string + hiddenInputs?: WorkflowHiddenStartVariable[] className?: string } -const OPTION_MAP = { - iframe: { - getContent: (url: string, token: string) => - ``, - }, - scripts: { - getContent: (url: string, token: string, primaryColor: string, isTestEnv?: boolean) => - ` - -`, - }, - chromePlugin: { - getContent: (url: string, token: string) => `ChatBot URL: ${url}${basePath}/chatbot/${token}`, - }, -} +const OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const const prefixEmbedded = 'overview.appInfo.embedded' -type Option = keyof typeof OPTION_MAP - -const OPTIONS: Option[] = ['iframe', 'scripts', 'chromePlugin'] +type Option = typeof OPTION_KEYS[number] const optionIconClassName: Record = { iframe: style.iframeIcon!, @@ -90,38 +48,274 @@ const optionIconClassName: Record = { chromePlugin: style.chromePluginIcon!, } -const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, className }: Props) => { +const getSerializedHiddenInputValue = ( + variable: WorkflowHiddenStartVariable, + values: Record, +) => { + const rawValue = values[variable.variable] + if (variable.type === InputVarType.checkbox) + return String(Boolean(rawValue)) + + return String(rawValue ?? '') +} + +const buildEmbeddedIframeUrl = async ({ + appBaseUrl, + accessToken, + variables, + values, +}: { + appBaseUrl: string + accessToken: string + variables: WorkflowHiddenStartVariable[] + values: Record +}) => { + const iframeUrl = new URL(`${appBaseUrl}${basePath}/chatbot/${accessToken}`, window.location.origin) + + await Promise.all(variables.map(async (variable) => { + iframeUrl.searchParams.set(variable.variable, await compressAndEncodeBase64(getSerializedHiddenInputValue(variable, values))) + })) + + return iframeUrl.toString() +} + +const AsyncEmbeddedOptionContent = ({ + option, + iframeUrlPromise, + latestResolvedIframeUrlRef, +}: { + option: Option + iframeUrlPromise: Promise + latestResolvedIframeUrlRef: MutableRefObject +}) => { + const iframeUrl = use(iframeUrlPromise) + latestResolvedIframeUrlRef.current = iframeUrl + + if (option === 'chromePlugin') + return getChromePluginContent(iframeUrl) + + return getEmbeddedIframeSnippet(iframeUrl) +} + +const EmbeddedContent = ({ + siteInfo, + appBaseUrl, + accessToken, + hiddenInputs, +}: Required> & Pick) => { const { t } = useTranslation() + const supportedHiddenInputs = useMemo( + () => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported), + [hiddenInputs], + ) + const initialHiddenInputValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedHiddenInputs), + [supportedHiddenInputs], + ) const [option, setOption] = useState