Compare commits

..

4 Commits

Author SHA1 Message Date
79598403b3 fix: CI 2026-05-08 20:43:08 +08:00
c8e0899668 chore: remove dify-compose 2026-05-08 17:56:30 +08:00
yyh
8f93bb36ba feat(dify-ui): add drawer (#35917)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-08 08:53:32 +00:00
82f24b336d fix(workflow): handle file-preview URLs in node output display (#34150) 2026-05-08 07:55:46 +00:00
85 changed files with 5505 additions and 3658 deletions

View File

@ -98,7 +98,7 @@ jobs:
- name: Set up dotenvs - name: Set up dotenvs
run: | run: |
cp docker/.env.example docker/.env ./docker/init-env.sh
cp docker/middleware.env.example docker/middleware.env cp docker/middleware.env.example docker/middleware.env
- name: Expose Service Ports - name: Expose Service Ports

View File

@ -56,7 +56,9 @@ jobs:
- 'api/**' - 'api/**'
- '.github/workflows/api-tests.yml' - '.github/workflows/api-tests.yml'
- '.github/workflows/expose_service_ports.sh' - '.github/workflows/expose_service_ports.sh'
- 'docker/.env.all'
- 'docker/.env.example' - 'docker/.env.example'
- 'docker/init-env.sh'
- 'docker/middleware.env.example' - 'docker/middleware.env.example'
- 'docker/docker-compose.middleware.yaml' - 'docker/docker-compose.middleware.yaml'
- 'docker/docker-compose-template.yaml' - 'docker/docker-compose-template.yaml'
@ -93,7 +95,9 @@ jobs:
- 'api/providers/vdb/*/tests/**' - 'api/providers/vdb/*/tests/**'
- '.github/workflows/vdb-tests.yml' - '.github/workflows/vdb-tests.yml'
- '.github/workflows/expose_service_ports.sh' - '.github/workflows/expose_service_ports.sh'
- 'docker/.env.all'
- 'docker/.env.example' - 'docker/.env.example'
- 'docker/init-env.sh'
- 'docker/middleware.env.example' - 'docker/middleware.env.example'
- 'docker/docker-compose.yaml' - 'docker/docker-compose.yaml'
- 'docker/docker-compose-template.yaml' - 'docker/docker-compose-template.yaml'

View File

@ -50,7 +50,7 @@ jobs:
- name: Set up dotenvs - name: Set up dotenvs
run: | run: |
cp docker/.env.example docker/.env ./docker/init-env.sh
cp docker/middleware.env.example docker/middleware.env cp docker/middleware.env.example docker/middleware.env
- name: Expose Service Ports - name: Expose Service Ports

View File

@ -47,7 +47,7 @@ jobs:
- name: Set up dotenvs - name: Set up dotenvs
run: | run: |
cp docker/.env.example docker/.env ./docker/init-env.sh
cp docker/middleware.env.example docker/middleware.env cp docker/middleware.env.example docker/middleware.env
- name: Expose Service Ports - name: Expose Service Ports

View File

@ -74,8 +74,16 @@ Dify is an open-source LLM app development platform. Its intuitive interface com
The easiest way to start the Dify server is through [Docker Compose](docker/docker-compose.yaml). Before running Dify with the following commands, make sure that [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed on your machine: The easiest way to start the Dify server is through [Docker Compose](docker/docker-compose.yaml). Before running Dify with the following commands, make sure that [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed on your machine:
```bash ```bash
cd dify/docker cd dify
cp .env.example .env cd docker
./init-env.sh
docker compose up -d
```
On Windows PowerShell, initialize `.env`, then run `docker compose up -d` from the `docker` directory.
```powershell
.\init-env.ps1
docker compose up -d docker compose up -d
``` ```
@ -136,7 +144,7 @@ Star Dify on GitHub and be instantly notified of new releases.
### Custom configurations ### Custom configurations
If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. 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` after running the initialization script. The full reference remains in [`docker/.env.all`](docker/.env.all). 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 ### Metrics Monitoring with Grafana

View File

@ -92,7 +92,7 @@ BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF: frozenset[str] = frozenset(
) )
API_CONFIG_SET = set(dotenv_values(Path("api") / Path(".env.example")).keys()) 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_CONFIG_SET = set(dotenv_values(Path("docker") / Path(".env.all")).keys())
DOCKER_COMPOSE_CONFIG_SET = set() DOCKER_COMPOSE_CONFIG_SET = set()
with open(Path("docker") / Path("docker-compose.yaml")) as f: with open(Path("docker") / Path("docker-compose.yaml")) as f:
@ -101,15 +101,23 @@ with open(Path("docker") / Path("docker-compose.yaml")) as f:
def test_yaml_config(): def test_yaml_config():
# python set == operator is used to compare two sets # python set == operator is used to compare two sets
DIFF_API_WITH_DOCKER = API_CONFIG_SET - DOCKER_CONFIG_SET - BASE_API_AND_DOCKER_CONFIG_SET_DIFF DIFF_API_WITH_DOCKER = (
API_CONFIG_SET - DOCKER_CONFIG_SET - BASE_API_AND_DOCKER_CONFIG_SET_DIFF
)
if DIFF_API_WITH_DOCKER: if DIFF_API_WITH_DOCKER:
print(f"API and Docker config sets are different with key: {DIFF_API_WITH_DOCKER}") print(
f"API and Docker config sets are different with key: {DIFF_API_WITH_DOCKER}"
)
raise Exception("API and Docker config sets are different") raise Exception("API and Docker config sets are different")
DIFF_API_WITH_DOCKER_COMPOSE = ( DIFF_API_WITH_DOCKER_COMPOSE = (
API_CONFIG_SET - DOCKER_COMPOSE_CONFIG_SET - BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF API_CONFIG_SET
- DOCKER_COMPOSE_CONFIG_SET
- BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF
) )
if DIFF_API_WITH_DOCKER_COMPOSE: if DIFF_API_WITH_DOCKER_COMPOSE:
print(f"API and Docker Compose config sets are different with key: {DIFF_API_WITH_DOCKER_COMPOSE}") print(
f"API and Docker Compose config sets are different with key: {DIFF_API_WITH_DOCKER_COMPOSE}"
)
raise Exception("API and Docker Compose config sets are different") raise Exception("API and Docker Compose config sets are different")
print("All tests passed!") print("All tests passed!")

View File

@ -14,7 +14,7 @@ export OPENDAL_FS_ROOT=${OPENDAL_FS_ROOT:-/tmp/dify-storage}
mkdir -p "${OPENDAL_FS_ROOT}" mkdir -p "${OPENDAL_FS_ROOT}"
# Prepare env files like CI # Prepare env files like CI
cp -n docker/.env.example docker/.env || true ./docker/init-env.sh
cp -n docker/middleware.env.example docker/middleware.env || true cp -n docker/middleware.env.example docker/middleware.env || true
cp -n api/tests/integration_tests/.env.example api/tests/integration_tests/.env || true cp -n api/tests/integration_tests/.env.example api/tests/integration_tests/.env || true

1631
docker/.env.all Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -7,28 +7,38 @@ 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.\ - **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`. For more information, refer `docker/certbot/README.md`.
- **Persistent Environment Variables**: Environment variables are now managed through a `.env` file, ensuring that your configurations persist across deployments. - **Persistent Environment Variables**: Default deployment values are provided in `.env.example`. Initialize `.env` from it and keep local changes there so your configuration persists across deployments.
> What is `.env`? </br> </br> > What is `.env`? </br> </br>
> The `.env` file is a crucial component in Docker and Docker Compose environments, serving as a centralized configuration file where you can define environment variables that are accessible to the containers at runtime. This file simplifies the management of environment settings across different stages of development, testing, and production, providing consistency and ease of configuration to deployments. > The `.env` file is your local Docker Compose environment file. Start from `.env.example`, then customize it as needed. Use `.env.all` as the full reference when you need advanced configuration.
- **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. - **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.
- **Mandatory .env File**: A `.env` file is now required to run `docker compose up`. This file is crucial for configuring your deployment and for any custom settings to persist through upgrades. - **Full Configuration Reference**: `.env.all` keeps the complete variable list for advanced and service-specific settings, while `.env.example` stays focused on the default self-hosted deployment path.
### How to Deploy Dify with `docker-compose.yaml` ### How to Deploy Dify with `docker-compose.yaml`
1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system. 1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system.
1. **Environment Setup**: 1. **Environment Setup**:
- Navigate to the `docker` directory. - Navigate to the `docker` directory.
- Copy the `.env.example` file to a new file named `.env` by running `cp .env.example .env`. - Create `.env` and generate a deployment-specific `SECRET_KEY`:
- Customize the `.env` file as needed. Refer to the `.env.example` file for detailed configuration options.
- **Optional (Recommended for upgrades)**: ```bash
You may use the environment synchronization tool to help keep your `.env` file aligned with the latest `.env.example` updates, while preserving your custom settings. ./init-env.sh
This is especially useful when upgrading Dify or managing a large, customized `.env` file. ```
On Windows PowerShell:
```powershell
.\init-env.ps1
```
- Customize `.env` only when you need to override defaults. Refer to `.env.all` for the full list of available variables.
- **Optional (for advanced deployments)**:
If you maintain a full `.env` file copied from `.env.all`, you may use the environment synchronization tool to keep it aligned with the latest `.env.all` updates while preserving your custom settings.
See the [Environment Variables Synchronization](#environment-variables-synchronization) section below. See the [Environment Variables Synchronization](#environment-variables-synchronization) section below.
1. **Running the Services**: 1. **Running the Services**:
- Execute `docker compose up` from the `docker` directory to start the services. - 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`. - 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`.
1. **SSL Certificate Setup**: 1. **SSL Certificate Setup**:
- Refer `docker/certbot/README.md` to set up SSL certificates using Certbot. - Refer `docker/certbot/README.md` to set up SSL certificates using Certbot.
@ -58,7 +68,11 @@ For users migrating from the `docker-legacy` setup:
1. **Data Migration**: 1. **Data Migration**:
- Ensure that data from services like databases and caches is backed up and migrated appropriately to the new structure if necessary. - Ensure that data from services like databases and caches is backed up and migrated appropriately to the new structure if necessary.
### Overview of `.env` ### Overview of `.env.example`, `.env`, and `.env.all`
- `.env.example` contains the minimal default configuration for Docker Compose deployments.
- `.env` is your local copy. It contains the generated `SECRET_KEY` plus any local changes.
- `.env.all` is the full reference for advanced configuration.
#### Key Modules and Customization #### Key Modules and Customization
@ -68,7 +82,7 @@ For users migrating from the `docker-legacy` setup:
#### Other notable variables #### 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 `.env.all` 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:
1. **Common Variables**: 1. **Common Variables**:
@ -118,23 +132,25 @@ The `.env.example` file provided in the Docker setup is extensive and covers a w
### Environment Variables Synchronization ### Environment Variables Synchronization
When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example`. When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example` or `.env.all`.
To help keep your existing `.env` file up to date **without losing your custom values**, an optional environment variables synchronization tool is provided. If you use the default workflow, review `.env.example` and add only the values you need to customize to `.env`.
> This tool performs a **one-way synchronization** from `.env.example` to `.env`. If you maintain a full `.env` file copied from `.env.all`, an optional environment variables synchronization tool is provided.
> This tool performs a **one-way synchronization** from `.env.all` to `.env`.
> Existing values in `.env` are never overwritten automatically. > Existing values in `.env` are never overwritten automatically.
#### `dify-env-sync.sh` (Optional) #### `dify-env-sync.sh` (Optional)
This script compares your current `.env` file with the latest `.env.example` template and helps safely apply new or updated environment variables. This script compares your current `.env` file with the latest `.env.all` template and helps safely apply new or updated environment variables.
**What it does** **What it does**
- Creates a backup of the current `.env` file before making any changes - Creates a backup of the current `.env` file before making any changes
- Synchronizes newly added environment variables from `.env.example` - Synchronizes newly added environment variables from `.env.all`
- Preserves all existing custom values in `.env` - Preserves all existing custom values in `.env`
- Displays differences and variables removed from `.env.example` for review - Displays differences and variables removed from `.env.all` for review
**Backup behavior** **Backup behavior**
@ -143,9 +159,9 @@ Before synchronization, the current `.env` file is saved to the `env-backup/` di
**When to use** **When to use**
- After upgrading Dify to a newer version - After upgrading Dify to a newer version with a full `.env` file
- When `.env.example` has been updated with new environment variables - When `.env.all` has been updated with new environment variables
- When managing a large or heavily customized `.env` file - When managing a large or heavily customized `.env` file copied from `.env.all`
**Usage** **Usage**
@ -160,6 +176,6 @@ chmod +x dify-env-sync.sh
### Additional Information ### Additional Information
- **Continuous Improvement Phase**: We are actively seeking feedback from the community to refine and enhance the deployment process. As more users adopt this new method, we will continue to make improvements based on your experiences and suggestions. - **Continuous Improvement Phase**: We are actively seeking feedback from the community to refine and enhance the deployment process. As more users adopt this new method, we will continue to make improvements based on your experiences and suggestions.
- **Support**: For detailed configuration options and environment variable settings, refer to the `.env.example` file and the Docker Compose configuration files in the `docker` directory. - **Support**: For detailed configuration options and environment variable settings, refer to the `.env.all` file and the Docker Compose configuration files in the `docker` directory.
This README aims to guide you through the deployment process using the new Docker Compose setup. For any issues or further assistance, please refer to the official documentation or contact support. This README aims to guide you through the deployment process using the new Docker Compose setup. For any issues or further assistance, please refer to the official documentation or contact support.

View File

@ -4,7 +4,7 @@
# Dify Environment Variables Synchronization Script # Dify Environment Variables Synchronization Script
# #
# Features: # Features:
# - Synchronize latest settings from .env.example to .env # - Synchronize latest settings from .env.all to .env
# - Preserve custom settings in existing .env # - Preserve custom settings in existing .env
# - Add new environment variables # - Add new environment variables
# - Detect removed environment variables # - Detect removed environment variables
@ -93,25 +93,25 @@ def parse_env_file(path: Path) -> dict[str, str]:
def check_files(work_dir: Path) -> None: def check_files(work_dir: Path) -> None:
"""Verify required files exist; create .env from .env.example if absent. """Verify required files exist; create .env from .env.all if absent.
Args: Args:
work_dir: Directory that must contain .env.example (and optionally .env). work_dir: Directory that must contain .env.all (and optionally .env).
Raises: Raises:
SystemExit: If .env.example does not exist. SystemExit: If .env.all does not exist.
""" """
log_info("Checking required files...") log_info("Checking required files...")
example_file = work_dir / ".env.example" example_file = work_dir / ".env.all"
env_file = work_dir / ".env" env_file = work_dir / ".env"
if not example_file.exists(): if not example_file.exists():
log_error(".env.example file not found") log_error(".env.all file not found")
sys.exit(1) sys.exit(1)
if not env_file.exists(): if not env_file.exists():
log_warning(".env file does not exist. Creating from .env.example.") log_warning(".env file does not exist. Creating from .env.all.")
shutil.copy2(example_file, env_file) shutil.copy2(example_file, env_file)
log_success(".env file created") log_success(".env file created")
@ -147,7 +147,7 @@ def analyze_value_change(current: str, recommended: str) -> str | None:
Args: Args:
current: Value currently set in .env. current: Value currently set in .env.
recommended: Value present in .env.example. recommended: Value present in .env.all.
Returns: Returns:
A human-readable description string, or None when no analysis applies. A human-readable description string, or None when no analysis applies.
@ -199,20 +199,20 @@ def analyze_value_change(current: str, recommended: str) -> str | None:
def detect_differences(env_vars: dict[str, str], example_vars: dict[str, str]) -> dict[str, tuple[str, str]]: def detect_differences(env_vars: dict[str, str], example_vars: dict[str, str]) -> dict[str, tuple[str, str]]:
"""Find variables whose values differ between .env and .env.example. """Find variables whose values differ between .env and .env.all.
Only variables present in *both* files are compared; new or removed Only variables present in *both* files are compared; new or removed
variables are handled by separate functions. variables are handled by separate functions.
Args: Args:
env_vars: Parsed key/value pairs from .env. env_vars: Parsed key/value pairs from .env.
example_vars: Parsed key/value pairs from .env.example. example_vars: Parsed key/value pairs from .env.all.
Returns: Returns:
Mapping of key -> (env_value, example_value) for every key whose Mapping of key -> (env_value, example_value) for every key whose
values differ. values differ.
""" """
log_info("Detecting differences between .env and .env.example...") log_info("Detecting differences between .env and .env.all...")
diffs: dict[str, tuple[str, str]] = {} diffs: dict[str, tuple[str, str]] = {}
for key, example_value in example_vars.items(): for key, example_value in example_vars.items():
@ -248,11 +248,11 @@ def show_differences_detail(diffs: dict[str, tuple[str, str]]) -> None:
if use_colors: if use_colors:
print(f"{YELLOW}[{count}] {key}{NC}") print(f"{YELLOW}[{count}] {key}{NC}")
print(f" {GREEN}.env (current){NC} : {env_value}") print(f" {GREEN}.env (current){NC} : {env_value}")
print(f" {BLUE}.env.example (recommended){NC} : {example_value}") print(f" {BLUE}.env.all (recommended){NC} : {example_value}")
else: else:
print(f"[{count}] {key}") print(f"[{count}] {key}")
print(f" .env (current) : {env_value}") print(f" .env (current) : {env_value}")
print(f" .env.example (recommended) : {example_value}") print(f" .env.all (recommended) : {example_value}")
analysis = analyze_value_change(env_value, example_value) analysis = analyze_value_change(env_value, example_value)
if analysis: if analysis:
@ -266,21 +266,21 @@ def show_differences_detail(diffs: dict[str, tuple[str, str]]) -> None:
def detect_removed_variables(env_vars: dict[str, str], example_vars: dict[str, str]) -> list[str]: def detect_removed_variables(env_vars: dict[str, str], example_vars: dict[str, str]) -> list[str]:
"""Identify variables present in .env but absent from .env.example. """Identify variables present in .env but absent from .env.all.
Args: Args:
env_vars: Parsed key/value pairs from .env. env_vars: Parsed key/value pairs from .env.
example_vars: Parsed key/value pairs from .env.example. example_vars: Parsed key/value pairs from .env.all.
Returns: Returns:
Sorted list of variable names that no longer appear in .env.example. Sorted list of variable names that no longer appear in .env.all.
""" """
log_info("Detecting removed environment variables...") log_info("Detecting removed environment variables...")
removed = sorted(set(env_vars) - set(example_vars)) removed = sorted(set(env_vars) - set(example_vars))
if removed: if removed:
log_warning("The following environment variables have been removed from .env.example:") log_warning("The following environment variables have been removed from .env.all:")
for var in removed: for var in removed:
log_warning(f" - {var}") log_warning(f" - {var}")
log_warning("Consider manually removing these variables from .env") log_warning("Consider manually removing these variables from .env")
@ -291,22 +291,22 @@ def detect_removed_variables(env_vars: dict[str, str], example_vars: dict[str, s
def sync_env_file(work_dir: Path, env_vars: dict[str, str], diffs: dict[str, tuple[str, str]]) -> None: def sync_env_file(work_dir: Path, env_vars: dict[str, str], diffs: dict[str, tuple[str, str]]) -> None:
"""Rewrite .env based on .env.example while preserving custom values. """Rewrite .env based on .env.all while preserving custom values.
The output file follows the exact line structure of .env.example The output file follows the exact line structure of .env.all
(preserving comments, blank lines, and ordering). For every variable (preserving comments, blank lines, and ordering). For every variable
that exists in .env with a different value from the example, the that exists in .env with a different value from the example, the
current .env value is kept. Variables that are new in .env.example current .env value is kept. Variables that are new in .env.all
(not present in .env at all) are added with the example's default. (not present in .env at all) are added with the example's default.
Args: Args:
work_dir: Directory containing .env and .env.example. work_dir: Directory containing .env and .env.all.
env_vars: Parsed key/value pairs from the original .env. env_vars: Parsed key/value pairs from the original .env.
diffs: Keys whose .env values differ from .env.example (to preserve). diffs: Keys whose .env values differ from .env.all (to preserve).
""" """
log_info("Starting partial synchronization of .env file...") log_info("Starting partial synchronization of .env file...")
example_file = work_dir / ".env.example" example_file = work_dir / ".env.all"
new_env_file = work_dir / ".env.new" new_env_file = work_dir / ".env.new"
# Keys whose current .env value should override the example default # Keys whose current .env value should override the example default
@ -350,24 +350,24 @@ def sync_env_file(work_dir: Path, env_vars: dict[str, str], diffs: dict[str, tup
log_success("Successfully created new .env file") log_success("Successfully created new .env file")
log_success("Partial synchronization of .env file completed") log_success("Partial synchronization of .env file completed")
log_info(f" Preserved .env values: {preserved_count}") log_info(f" Preserved .env values: {preserved_count}")
log_info(f" Updated to .env.example values: {updated_count}") log_info(f" Updated to .env.all values: {updated_count}")
def show_statistics(work_dir: Path) -> None: def show_statistics(work_dir: Path) -> None:
"""Print a summary of variable counts from both env files. """Print a summary of variable counts from both env files.
Args: Args:
work_dir: Directory containing .env and .env.example. work_dir: Directory containing .env and .env.all.
""" """
log_info("Synchronization statistics:") log_info("Synchronization statistics:")
example_file = work_dir / ".env.example" example_file = work_dir / ".env.all"
env_file = work_dir / ".env" env_file = work_dir / ".env"
example_count = len(parse_env_file(example_file)) if example_file.exists() else 0 example_count = len(parse_env_file(example_file)) if example_file.exists() else 0
env_count = len(parse_env_file(env_file)) if env_file.exists() else 0 env_count = len(parse_env_file(env_file)) if env_file.exists() else 0
log_info(f" .env.example environment variables: {example_count}") log_info(f" .env.all environment variables: {example_count}")
log_info(f" .env environment variables: {env_count}") log_info(f" .env environment variables: {env_count}")
@ -380,7 +380,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="dify-env-sync", prog="dify-env-sync",
description=( description=(
"Synchronize .env with .env.example: add new variables, " "Synchronize .env with .env.all: add new variables, "
"preserve custom values, and report removed variables." "preserve custom values, and report removed variables."
), ),
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
@ -396,7 +396,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
"--dir", "--dir",
metavar="DIRECTORY", metavar="DIRECTORY",
default=".", default=".",
help="Working directory containing .env and .env.example (default: current directory)", help="Working directory containing .env and .env.all (default: current directory)",
) )
parser.add_argument( parser.add_argument(
"--no-backup", "--no-backup",
@ -427,7 +427,7 @@ def main() -> None:
# 3. Parse both files # 3. Parse both files
env_vars = parse_env_file(work_dir / ".env") env_vars = parse_env_file(work_dir / ".env")
example_vars = parse_env_file(work_dir / ".env.example") example_vars = parse_env_file(work_dir / ".env.all")
# 4. Report differences (values that changed in the example) # 4. Report differences (values that changed in the example)
diffs = detect_differences(env_vars, example_vars) diffs = detect_differences(env_vars, example_vars)

View File

@ -4,7 +4,7 @@
# Dify Environment Variables Synchronization Script # Dify Environment Variables Synchronization Script
# #
# Features: # Features:
# - Synchronize latest settings from .env.example to .env # - Synchronize latest settings from .env.all to .env
# - Preserve custom settings in existing .env # - Preserve custom settings in existing .env
# - Add new environment variables # - Add new environment variables
# - Detect removed environment variables # - Detect removed environment variables
@ -61,18 +61,18 @@ log_error() {
} }
# Check for required files and create .env if missing # Check for required files and create .env if missing
# Verifies that .env.example exists and creates .env from template if needed # Verifies that .env.all exists and creates .env from template if needed
check_files() { check_files() {
log_info "Checking required files..." log_info "Checking required files..."
if [[ ! -f ".env.example" ]]; then if [[ ! -f ".env.all" ]]; then
log_error ".env.example file not found" log_error ".env.all file not found"
exit 1 exit 1
fi fi
if [[ ! -f ".env" ]]; then if [[ ! -f ".env" ]]; then
log_warning ".env file does not exist. Creating from .env.example." log_warning ".env file does not exist. Creating from .env.all."
cp ".env.example" ".env" cp ".env.all" ".env"
log_success ".env file created" log_success ".env file created"
fi fi
@ -98,9 +98,9 @@ create_backup() {
fi fi
} }
# Detect differences between .env and .env.example (optimized for large files) # Detect differences between .env and .env.all (optimized for large files)
detect_differences() { detect_differences() {
log_info "Detecting differences between .env and .env.example..." log_info "Detecting differences between .env and .env.all..."
# Create secure temporary directory # Create secure temporary directory
local temp_dir=$(mktemp -d) local temp_dir=$(mktemp -d)
@ -140,7 +140,7 @@ detect_differences() {
} }
} }
END { print diff_count } END { print diff_count }
' .env .env.example) ' .env .env.all)
if [[ $diff_count -gt 0 ]]; then if [[ $diff_count -gt 0 ]]; then
log_success "Detected differences in $diff_count environment variables" log_success "Detected differences in $diff_count environment variables"
@ -201,7 +201,7 @@ show_differences_detail() {
echo "" echo ""
echo -e "${YELLOW}[$count] $key${NC}" echo -e "${YELLOW}[$count] $key${NC}"
echo -e " ${GREEN}.env (current)${NC} : ${env_value}" echo -e " ${GREEN}.env (current)${NC} : ${env_value}"
echo -e " ${BLUE}.env.example (recommended)${NC}: ${example_value}" echo -e " ${BLUE}.env.all (recommended)${NC}: ${example_value}"
# Analyze value changes # Analyze value changes
analyze_value_change "$env_value" "$example_value" analyze_value_change "$env_value" "$example_value"
@ -261,8 +261,8 @@ analyze_value_change() {
fi fi
} }
# Synchronize .env file with .env.example while preserving custom values # Synchronize .env file with .env.all while preserving custom values
# Creates a new .env file based on .env.example structure, preserving existing custom values # Creates a new .env file based on .env.all structure, preserving existing custom values
# Global variables used: DIFF_FILE, TEMP_DIR # Global variables used: DIFF_FILE, TEMP_DIR
sync_env_file() { sync_env_file() {
log_info "Starting partial synchronization of .env file..." log_info "Starting partial synchronization of .env file..."
@ -281,7 +281,7 @@ sync_env_file() {
fi fi
# Use AWK for efficient processing (much faster than bash loop for large files) # Use AWK for efficient processing (much faster than bash loop for large files)
log_info "Processing $(wc -l < .env.example) lines with AWK..." log_info "Processing $(wc -l < .env.all) lines with AWK..."
local preserved_keys_file="${TEMP_DIR}/preserved_keys" local preserved_keys_file="${TEMP_DIR}/preserved_keys"
local awk_preserved_count_file="${TEMP_DIR}/awk_preserved_count" local awk_preserved_count_file="${TEMP_DIR}/awk_preserved_count"
@ -332,7 +332,7 @@ sync_env_file() {
print preserved_count > preserved_count_file print preserved_count > preserved_count_file
print updated_count > updated_count_file print updated_count > updated_count_file
} }
' .env.example > "$new_env_file" ' .env.all > "$new_env_file"
# Read counters and preserved keys # Read counters and preserved keys
if [[ -f "$awk_preserved_count_file" ]]; then if [[ -f "$awk_preserved_count_file" ]]; then
@ -372,7 +372,7 @@ sync_env_file() {
log_success "Partial synchronization of .env file completed" log_success "Partial synchronization of .env file completed"
log_info " Preserved .env values: $preserved_count" log_info " Preserved .env values: $preserved_count"
log_info " Updated to .env.example values: $updated_count" log_info " Updated to .env.all values: $updated_count"
} }
# Detect removed environment variables # Detect removed environment variables
@ -394,8 +394,8 @@ detect_removed_variables() {
cleanup_temp_dir="$temp_dir" cleanup_temp_dir="$temp_dir"
fi fi
# Get keys from .env.example and .env, sorted for comm # Get keys from .env.all and .env, sorted for comm
awk -F= '!/^[[:space:]]*#/ && /=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1}' .env.example | sort > "$temp_example_keys" awk -F= '!/^[[:space:]]*#/ && /=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1}' .env.all | sort > "$temp_example_keys"
awk -F= '!/^[[:space:]]*#/ && /=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1}' .env | sort > "$temp_current_keys" awk -F= '!/^[[:space:]]*#/ && /=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1}' .env | sort > "$temp_current_keys"
# Get keys from existing .env and check for removals # Get keys from existing .env and check for removals
@ -410,7 +410,7 @@ detect_removed_variables() {
fi fi
if [[ ${#removed_vars[@]} -gt 0 ]]; then if [[ ${#removed_vars[@]} -gt 0 ]]; then
log_warning "The following environment variables have been removed from .env.example:" log_warning "The following environment variables have been removed from .env.all:"
for var in "${removed_vars[@]}"; do for var in "${removed_vars[@]}"; do
log_warning " - $var" log_warning " - $var"
done done
@ -424,10 +424,10 @@ detect_removed_variables() {
show_statistics() { show_statistics() {
log_info "Synchronization statistics:" log_info "Synchronization statistics:"
local total_example=$(grep -c "^[^#]*=" .env.example 2>/dev/null || echo "0") local total_example=$(grep -c "^[^#]*=" .env.all 2>/dev/null || echo "0")
local total_env=$(grep -c "^[^#]*=" .env 2>/dev/null || echo "0") local total_env=$(grep -c "^[^#]*=" .env 2>/dev/null || echo "0")
log_info " .env.example environment variables: $total_example" log_info " .env.all environment variables: $total_example"
log_info " .env environment variables: $total_env" log_info " .env environment variables: $total_env"
} }

View File

@ -0,0 +1,953 @@
x-shared-env: &shared-api-worker-env
services:
# Init container to fix permissions
init_permissions:
image: busybox:latest
command:
- sh
- -c
- |
FLAG_FILE="/app/api/storage/.init_permissions"
if [ -f "$${FLAG_FILE}" ]; then
echo "Permissions already initialized. Exiting."
exit 0
fi
echo "Initializing permissions for /app/api/storage"
chown -R 1001:1001 /app/api/storage && touch "$${FLAG_FILE}"
echo "Permissions initialized. Exiting."
volumes:
- ./volumes/app/storage:/app/api/storage
restart: "no"
# API service
api:
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}
SENTRY_PROFILES_SAMPLE_RATE: ${API_SENTRY_PROFILES_SAMPLE_RATE:-1.0}
PLUGIN_REMOTE_INSTALL_HOST: ${EXPOSE_PLUGIN_DEBUGGING_HOST:-localhost}
PLUGIN_REMOTE_INSTALL_PORT: ${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
depends_on:
init_permissions:
condition: service_completed_successfully
db_postgres:
condition: service_healthy
required: false
db_mysql:
condition: service_healthy
required: false
oceanbase:
condition: service_healthy
required: false
seekdb:
condition: service_healthy
required: false
redis:
condition: service_started
volumes:
# Mount the storage directory to the container, for storing user files.
- ./volumes/app/storage:/app/api/storage
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5001/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- ssrf_proxy_network
- default
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
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}
SENTRY_PROFILES_SAMPLE_RATE: ${API_SENTRY_PROFILES_SAMPLE_RATE:-1.0}
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
depends_on:
init_permissions:
condition: service_completed_successfully
db_postgres:
condition: service_healthy
required: false
db_mysql:
condition: service_healthy
required: false
oceanbase:
condition: service_healthy
required: false
seekdb:
condition: service_healthy
required: false
redis:
condition: service_started
volumes:
# Mount the storage directory to the container, for storing user files.
- ./volumes/app/storage:/app/api/storage
healthcheck:
test: ["CMD-SHELL", "celery -A celery_healthcheck.celery inspect ping"]
interval: ${COMPOSE_WORKER_HEALTHCHECK_INTERVAL:-30s}
timeout: ${COMPOSE_WORKER_HEALTHCHECK_TIMEOUT:-30s}
retries: 3
start_period: 60s
disable: ${COMPOSE_WORKER_HEALTHCHECK_DISABLED:-true}
networks:
- ssrf_proxy_network
- default
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
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:
condition: service_completed_successfully
db_postgres:
condition: service_healthy
required: false
db_mysql:
condition: service_healthy
required: false
oceanbase:
condition: service_healthy
required: false
seekdb:
condition: service_healthy
required: false
redis:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "celery -A celery_healthcheck.celery inspect ping"]
interval: ${COMPOSE_WORKER_HEALTHCHECK_INTERVAL:-30s}
timeout: ${COMPOSE_WORKER_HEALTHCHECK_TIMEOUT:-30s}
retries: 3
start_period: 60s
disable: ${COMPOSE_WORKER_HEALTHCHECK_DISABLED:-true}
networks:
- ssrf_proxy_network
- default
# Frontend web application.
web:
image: langgenius/dify-web:1.14.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
APP_API_URL: ${APP_API_URL:-}
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
NEXT_PUBLIC_SOCKET_URL: ${NEXT_PUBLIC_SOCKET_URL:-ws://localhost}
SENTRY_DSN: ${WEB_SENTRY_DSN:-}
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}
EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false}
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
CSP_WHITELIST: ${CSP_WHITELIST:-}
ALLOW_EMBED: ${ALLOW_EMBED:-false}
ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false}
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-10}
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
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}
MAX_TREE_DEPTH: ${MAX_TREE_DEPTH:-50}
ENABLE_WEBSITE_JINAREADER: ${ENABLE_WEBSITE_JINAREADER:-true}
ENABLE_WEBSITE_FIRECRAWL: ${ENABLE_WEBSITE_FIRECRAWL:-true}
ENABLE_WEBSITE_WATERCRAWL: ${ENABLE_WEBSITE_WATERCRAWL:-true}
# The PostgreSQL database.
db_postgres:
image: postgres:15-alpine
profiles:
- postgresql
restart: always
environment:
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456}
POSTGRES_DB: ${DB_DATABASE:-dify}
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
command: >
postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}'
-c 'shared_buffers=${POSTGRES_SHARED_BUFFERS:-128MB}'
-c 'work_mem=${POSTGRES_WORK_MEM:-4MB}'
-c 'maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}'
-c 'effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB}'
-c 'statement_timeout=${POSTGRES_STATEMENT_TIMEOUT:-0}'
-c 'idle_in_transaction_session_timeout=${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-0}'
volumes:
- ./volumes/db/data:/var/lib/postgresql/data
healthcheck:
test:
[
"CMD",
"pg_isready",
"-h",
"db_postgres",
"-U",
"${DB_USERNAME:-postgres}",
"-d",
"${DB_DATABASE:-dify}",
]
interval: 1s
timeout: 3s
retries: 60
# The mysql database.
db_mysql:
image: mysql:8.0
profiles:
- mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
MYSQL_DATABASE: ${DB_DATABASE:-dify}
command: >
--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}
volumes:
- ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}:/var/lib/mysql
healthcheck:
test:
[
"CMD",
"mysqladmin",
"ping",
"-u",
"root",
"-p${DB_PASSWORD:-difyai123456}",
]
interval: 1s
timeout: 3s
retries: 30
# The redis cache.
redis:
image: redis:6-alpine
restart: always
environment:
REDISCLI_AUTH: ${REDIS_PASSWORD:-difyai123456}
volumes:
# Mount the redis data directory to the container.
- ./volumes/redis/data:/data
# Set the redis password when startup redis server.
command: redis-server --requirepass ${REDIS_PASSWORD:-difyai123456}
healthcheck:
test:
[
"CMD-SHELL",
"redis-cli -a ${REDIS_PASSWORD:-difyai123456} ping | grep -q PONG",
]
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.15
restart: always
environment:
# The DifySandbox configurations
# Make sure you are changing this key for your deployment with a strong key.
# You can generate a strong key using `openssl rand -base64 42`.
API_KEY: ${SANDBOX_API_KEY:-dify-sandbox}
GIN_MODE: ${SANDBOX_GIN_MODE:-release}
WORKER_TIMEOUT: ${SANDBOX_WORKER_TIMEOUT:-15}
ENABLE_NETWORK: ${SANDBOX_ENABLE_NETWORK:-true}
HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128}
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
volumes:
- ./volumes/sandbox/dependencies:/dependencies
- ./volumes/sandbox/conf:/conf
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8194/health"]
networks:
- ssrf_proxy_network
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.6.0-local
restart: always
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}
SERVER_KEY: ${PLUGIN_DAEMON_KEY:-lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi}
MAX_PLUGIN_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
PPROF_ENABLED: ${PLUGIN_PPROF_ENABLED:-false}
DIFY_INNER_API_URL: ${PLUGIN_DIFY_INNER_API_URL:-http://api:5001}
DIFY_INNER_API_KEY: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
PLUGIN_REMOTE_INSTALLING_HOST: ${PLUGIN_DEBUGGING_HOST:-0.0.0.0}
PLUGIN_REMOTE_INSTALLING_PORT: ${PLUGIN_DEBUGGING_PORT:-5003}
PLUGIN_WORKING_PATH: ${PLUGIN_WORKING_PATH:-/app/storage/cwd}
FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true}
PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
PLUGIN_STDIO_BUFFER_SIZE: ${PLUGIN_STDIO_BUFFER_SIZE:-1024}
PLUGIN_STDIO_MAX_BUFFER_SIZE: ${PLUGIN_STDIO_MAX_BUFFER_SIZE:-5242880}
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
PLUGIN_STORAGE_TYPE: ${PLUGIN_STORAGE_TYPE:-local}
PLUGIN_STORAGE_LOCAL_ROOT: ${PLUGIN_STORAGE_LOCAL_ROOT:-/app/storage}
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:-}
S3_USE_AWS_MANAGED_IAM: ${PLUGIN_S3_USE_AWS_MANAGED_IAM:-false}
S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-false}
S3_ENDPOINT: ${PLUGIN_S3_ENDPOINT:-}
S3_USE_PATH_STYLE: ${PLUGIN_S3_USE_PATH_STYLE:-false}
AWS_ACCESS_KEY: ${PLUGIN_AWS_ACCESS_KEY:-}
AWS_SECRET_KEY: ${PLUGIN_AWS_SECRET_KEY:-}
AWS_REGION: ${PLUGIN_AWS_REGION:-}
AZURE_BLOB_STORAGE_CONNECTION_STRING: ${PLUGIN_AZURE_BLOB_STORAGE_CONNECTION_STRING:-}
AZURE_BLOB_STORAGE_CONTAINER_NAME: ${PLUGIN_AZURE_BLOB_STORAGE_CONTAINER_NAME:-}
TENCENT_COS_SECRET_KEY: ${PLUGIN_TENCENT_COS_SECRET_KEY:-}
TENCENT_COS_SECRET_ID: ${PLUGIN_TENCENT_COS_SECRET_ID:-}
TENCENT_COS_REGION: ${PLUGIN_TENCENT_COS_REGION:-}
ALIYUN_OSS_REGION: ${PLUGIN_ALIYUN_OSS_REGION:-}
ALIYUN_OSS_ENDPOINT: ${PLUGIN_ALIYUN_OSS_ENDPOINT:-}
ALIYUN_OSS_ACCESS_KEY_ID: ${PLUGIN_ALIYUN_OSS_ACCESS_KEY_ID:-}
ALIYUN_OSS_ACCESS_KEY_SECRET: ${PLUGIN_ALIYUN_OSS_ACCESS_KEY_SECRET:-}
ALIYUN_OSS_AUTH_VERSION: ${PLUGIN_ALIYUN_OSS_AUTH_VERSION:-v4}
ALIYUN_OSS_PATH: ${PLUGIN_ALIYUN_OSS_PATH:-}
VOLCENGINE_TOS_ENDPOINT: ${PLUGIN_VOLCENGINE_TOS_ENDPOINT:-}
VOLCENGINE_TOS_ACCESS_KEY: ${PLUGIN_VOLCENGINE_TOS_ACCESS_KEY:-}
VOLCENGINE_TOS_SECRET_KEY: ${PLUGIN_VOLCENGINE_TOS_SECRET_KEY:-}
VOLCENGINE_TOS_REGION: ${PLUGIN_VOLCENGINE_TOS_REGION:-}
SENTRY_ENABLED: ${PLUGIN_SENTRY_ENABLED:-false}
SENTRY_DSN: ${PLUGIN_SENTRY_DSN:-}
ports:
- "${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}:${PLUGIN_DEBUGGING_PORT:-5003}"
volumes:
- ./volumes/plugin_daemon:/app/storage
depends_on:
db_postgres:
condition: service_healthy
required: false
db_mysql:
condition: service_healthy
required: false
oceanbase:
condition: service_healthy
required: false
seekdb:
condition: service_healthy
required: false
# ssrf_proxy server
# for more information, please refer to
# https://docs.dify.ai/learn-more/faq/install-faq#18-why-is-ssrf-proxy-needed%3F
ssrf_proxy:
image: ubuntu/squid:latest
restart: always
volumes:
- ./ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template
- ./ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh
entrypoint:
[
"sh",
"-c",
"cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh",
]
environment:
# pls clearly modify the squid env vars to fit your network environment.
HTTP_PORT: ${SSRF_HTTP_PORT:-3128}
COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194}
SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
networks:
- ssrf_proxy_network
- default
# Certbot service
# use `docker-compose --profile certbot up` to start the certbot service.
certbot:
image: certbot/certbot
profiles:
- certbot
volumes:
- ./volumes/certbot/conf:/etc/letsencrypt
- ./volumes/certbot/www:/var/www/html
- ./volumes/certbot/logs:/var/log/letsencrypt
- ./volumes/certbot/conf/live:/etc/letsencrypt/live
- ./certbot/update-cert.template.txt:/update-cert.template.txt
- ./certbot/docker-entrypoint.sh:/docker-entrypoint.sh
environment:
- CERTBOT_EMAIL=${CERTBOT_EMAIL:-}
- CERTBOT_DOMAIN=${CERTBOT_DOMAIN:-}
- CERTBOT_OPTIONS=${CERTBOT_OPTIONS:-}
entrypoint: ["/docker-entrypoint.sh"]
command: ["tail", "-f", "/dev/null"]
# The nginx reverse proxy.
# used for reverse proxying the API service and Web service.
nginx:
image: nginx:latest
restart: always
volumes:
- ./nginx/nginx.conf.template:/etc/nginx/nginx.conf.template
- ./nginx/proxy.conf.template:/etc/nginx/proxy.conf.template
- ./nginx/https.conf.template:/etc/nginx/https.conf.template
- ./nginx/conf.d:/etc/nginx/conf.d
- ./nginx/docker-entrypoint.sh:/docker-entrypoint-mount.sh
- ./nginx/ssl:/etc/ssl # cert dir (legacy)
- ./volumes/certbot/conf/live:/etc/letsencrypt/live # cert dir (with certbot container)
- ./volumes/certbot/conf:/etc/letsencrypt
- ./volumes/certbot/www:/var/www/html
entrypoint:
[
"sh",
"-c",
"cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh",
]
environment:
NGINX_SERVER_NAME: ${NGINX_SERVER_NAME:-_}
NGINX_HTTPS_ENABLED: ${NGINX_HTTPS_ENABLED:-false}
NGINX_SSL_PORT: ${NGINX_SSL_PORT:-443}
NGINX_PORT: ${NGINX_PORT:-80}
# You're required to add your own SSL certificates/keys to the `./nginx/ssl` directory
# and modify the env vars below in .env if HTTPS_ENABLED is true.
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_DOMAIN: ${CERTBOT_DOMAIN:-}
depends_on:
- api
- web
ports:
- "${EXPOSE_NGINX_PORT:-80}:${NGINX_PORT:-80}"
- "${EXPOSE_NGINX_SSL_PORT:-443}:${NGINX_SSL_PORT:-443}"
# The Weaviate vector store.
weaviate:
image: semitechnologies/weaviate:1.27.0
profiles:
- weaviate
restart: always
volumes:
# Mount the Weaviate data directory to the con tainer.
- ./volumes/weaviate:/var/lib/weaviate
environment:
# The Weaviate configurations
# You can refer to the [Weaviate](https://weaviate.io/developers/weaviate/config-refs/env-vars) documentation for more information.
PERSISTENCE_DATA_PATH: ${WEAVIATE_PERSISTENCE_DATA_PATH:-/var/lib/weaviate}
QUERY_DEFAULTS_LIMIT: ${WEAVIATE_QUERY_DEFAULTS_LIMIT:-25}
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: ${WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED:-false}
DEFAULT_VECTORIZER_MODULE: ${WEAVIATE_DEFAULT_VECTORIZER_MODULE:-none}
CLUSTER_HOSTNAME: ${WEAVIATE_CLUSTER_HOSTNAME:-node1}
AUTHENTICATION_APIKEY_ENABLED: ${WEAVIATE_AUTHENTICATION_APIKEY_ENABLED:-true}
AUTHENTICATION_APIKEY_ALLOWED_KEYS: ${WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih}
AUTHENTICATION_APIKEY_USERS: ${WEAVIATE_AUTHENTICATION_APIKEY_USERS:-hello@dify.ai}
AUTHORIZATION_ADMINLIST_ENABLED: ${WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED:-true}
AUTHORIZATION_ADMINLIST_USERS: ${WEAVIATE_AUTHORIZATION_ADMINLIST_USERS:-hello@dify.ai}
DISABLE_TELEMETRY: ${WEAVIATE_DISABLE_TELEMETRY:-false}
ENABLE_TOKENIZER_GSE: ${WEAVIATE_ENABLE_TOKENIZER_GSE:-false}
ENABLE_TOKENIZER_KAGOME_JA: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA:-false}
ENABLE_TOKENIZER_KAGOME_KR: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR:-false}
# OceanBase vector database
oceanbase:
image: oceanbase/oceanbase-ce:4.3.5-lts
container_name: oceanbase
profiles:
- oceanbase
restart: always
volumes:
- ./volumes/oceanbase/data:/root/ob
- ./volumes/oceanbase/conf:/root/.obd/cluster
- ./volumes/oceanbase/init.d:/root/boot/init.d
environment:
OB_MEMORY_LIMIT: ${OCEANBASE_MEMORY_LIMIT:-6G}
OB_SYS_PASSWORD: ${OCEANBASE_VECTOR_PASSWORD:-difyai123456}
OB_TENANT_PASSWORD: ${OCEANBASE_VECTOR_PASSWORD:-difyai123456}
OB_CLUSTER_NAME: ${OCEANBASE_CLUSTER_NAME:-difyai}
OB_SERVER_IP: 127.0.0.1
MODE: mini
LANG: C.UTF-8
LC_ALL: C.UTF-8
ports:
- "${OCEANBASE_VECTOR_PORT:-2881}:2881"
healthcheck:
test:
[
"CMD-SHELL",
'obclient -h127.0.0.1 -P2881 -uroot@test -p${OCEANBASE_VECTOR_PASSWORD:-difyai123456} -e "SELECT 1;"',
]
interval: 10s
retries: 30
start_period: 30s
timeout: 10s
# seekdb vector database
seekdb:
image: oceanbase/seekdb:latest
container_name: seekdb
profiles:
- seekdb
restart: always
volumes:
- ./volumes/seekdb:/var/lib/oceanbase
environment:
ROOT_PASSWORD: ${OCEANBASE_VECTOR_PASSWORD:-difyai123456}
MEMORY_LIMIT: ${SEEKDB_MEMORY_LIMIT:-2G}
REPORTER: dify-ai-seekdb
ports:
- "${OCEANBASE_VECTOR_PORT:-2881}:2881"
healthcheck:
test:
[
"CMD-SHELL",
'mysql -h127.0.0.1 -P2881 -uroot -p${OCEANBASE_VECTOR_PASSWORD:-difyai123456} -e "SELECT 1;"',
]
interval: 5s
retries: 60
timeout: 5s
# Qdrant vector store.
# (if used, you need to set VECTOR_STORE to qdrant in the api & worker service.)
qdrant:
image: langgenius/qdrant:v1.8.3
profiles:
- qdrant
restart: always
volumes:
- ./volumes/qdrant:/qdrant/storage
environment:
QDRANT_API_KEY: ${QDRANT_API_KEY:-difyai123456}
# The Couchbase vector store.
couchbase-server:
build: ./couchbase-server
profiles:
- couchbase
restart: always
environment:
- CLUSTER_NAME=dify_search
- COUCHBASE_ADMINISTRATOR_USERNAME=${COUCHBASE_USER:-Administrator}
- COUCHBASE_ADMINISTRATOR_PASSWORD=${COUCHBASE_PASSWORD:-password}
- COUCHBASE_BUCKET=${COUCHBASE_BUCKET_NAME:-Embeddings}
- COUCHBASE_BUCKET_RAMSIZE=512
- COUCHBASE_RAM_SIZE=2048
- COUCHBASE_EVENTING_RAM_SIZE=512
- COUCHBASE_INDEX_RAM_SIZE=512
- COUCHBASE_FTS_RAM_SIZE=1024
hostname: couchbase-server
container_name: couchbase-server
working_dir: /opt/couchbase
stdin_open: true
tty: true
entrypoint: [""]
command: sh -c "/opt/couchbase/init/init-cbserver.sh"
volumes:
- ./volumes/couchbase/data:/opt/couchbase/var/lib/couchbase/data
healthcheck:
# ensure bucket was created before proceeding
test:
[
"CMD-SHELL",
"curl -s -f -u Administrator:password http://localhost:8091/pools/default/buckets | grep -q '\\[{' || exit 1",
]
interval: 10s
retries: 10
start_period: 30s
timeout: 10s
# The pgvector vector database.
pgvector:
image: pgvector/pgvector:pg16
profiles:
- pgvector
restart: always
environment:
PGUSER: ${PGVECTOR_PGUSER:-postgres}
# The password for the default postgres user.
POSTGRES_PASSWORD: ${PGVECTOR_POSTGRES_PASSWORD:-difyai123456}
# The name of the default postgres database.
POSTGRES_DB: ${PGVECTOR_POSTGRES_DB:-dify}
# postgres data directory
PGDATA: ${PGVECTOR_PGDATA:-/var/lib/postgresql/data/pgdata}
# pg_bigm module for full text search
PG_BIGM: ${PGVECTOR_PG_BIGM:-false}
PG_BIGM_VERSION: ${PGVECTOR_PG_BIGM_VERSION:-1.2-20240606}
volumes:
- ./volumes/pgvector/data:/var/lib/postgresql/data
- ./pgvector/docker-entrypoint.sh:/docker-entrypoint.sh
entrypoint: ["/docker-entrypoint.sh"]
healthcheck:
test: ["CMD", "pg_isready"]
interval: 1s
timeout: 3s
retries: 30
# get image from https://www.vastdata.com.cn/
vastbase:
image: vastdata/vastbase-vector
profiles:
- vastbase
restart: always
environment:
- VB_DBCOMPATIBILITY=PG
- VB_DB=dify
- VB_USERNAME=dify
- VB_PASSWORD=Difyai123456
ports:
- "5434:5432"
volumes:
- ./vastbase/lic:/home/vastbase/vastbase/lic
- ./vastbase/data:/home/vastbase/data
- ./vastbase/backup:/home/vastbase/backup
- ./vastbase/backup_log:/home/vastbase/backup_log
healthcheck:
test: ["CMD", "pg_isready"]
interval: 1s
timeout: 3s
retries: 30
# pgvecto-rs vector store
pgvecto-rs:
image: tensorchord/pgvecto-rs:pg16-v0.3.0
profiles:
- pgvecto-rs
restart: always
environment:
PGUSER: ${PGVECTOR_PGUSER:-postgres}
# The password for the default postgres user.
POSTGRES_PASSWORD: ${PGVECTOR_POSTGRES_PASSWORD:-difyai123456}
# The name of the default postgres database.
POSTGRES_DB: ${PGVECTOR_POSTGRES_DB:-dify}
# postgres data directory
PGDATA: ${PGVECTOR_PGDATA:-/var/lib/postgresql/data/pgdata}
volumes:
- ./volumes/pgvecto_rs/data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready"]
interval: 1s
timeout: 3s
retries: 30
# Chroma vector database
chroma:
image: ghcr.io/chroma-core/chroma:0.5.20
profiles:
- chroma
restart: always
volumes:
- ./volumes/chroma:/chroma/chroma
environment:
CHROMA_SERVER_AUTHN_CREDENTIALS: ${CHROMA_SERVER_AUTHN_CREDENTIALS:-difyai123456}
CHROMA_SERVER_AUTHN_PROVIDER: ${CHROMA_SERVER_AUTHN_PROVIDER:-chromadb.auth.token_authn.TokenAuthenticationServerProvider}
IS_PERSISTENT: ${CHROMA_IS_PERSISTENT:-TRUE}
# InterSystems IRIS vector database
iris:
image: containers.intersystems.com/intersystems/iris-community:2025.3
profiles:
- iris
container_name: iris
restart: always
init: true
ports:
- "${IRIS_SUPER_SERVER_PORT:-1972}:1972"
- "${IRIS_WEB_SERVER_PORT:-52773}:52773"
volumes:
- ./volumes/iris:/durable
- ./iris/iris-init.script:/iris-init.script
- ./iris/docker-entrypoint.sh:/custom-entrypoint.sh
entrypoint: ["/custom-entrypoint.sh"]
tty: true
environment:
TZ: ${IRIS_TIMEZONE:-UTC}
ISC_DATA_DIRECTORY: /durable/iris
# Oracle vector database
oracle:
image: container-registry.oracle.com/database/free:latest
profiles:
- oracle
restart: always
volumes:
- source: oradata
type: volume
target: /opt/oracle/oradata
- ./startupscripts:/opt/oracle/scripts/startup
environment:
ORACLE_PWD: ${ORACLE_PWD:-Dify123456}
ORACLE_CHARACTERSET: ${ORACLE_CHARACTERSET:-AL32UTF8}
# Milvus vector database services
etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.5
profiles:
- milvus
environment:
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}
volumes:
- ./volumes/milvus/etcd:/etcd
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 30s
timeout: 20s
retries: 3
networks:
- milvus
minio:
container_name: milvus-minio
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
profiles:
- milvus
environment:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
volumes:
- ./volumes/milvus/minio:/minio_data
command: minio server /minio_data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
networks:
- milvus
milvus-standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v2.6.3
profiles:
- milvus
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: ${ETCD_ENDPOINTS:-etcd:2379}
MINIO_ADDRESS: ${MINIO_ADDRESS:-minio:9000}
common.security.authorizationEnabled: ${MILVUS_AUTHORIZATION_ENABLED:-true}
volumes:
- ./volumes/milvus/milvus:/var/lib/milvus
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
interval: 30s
start_period: 90s
timeout: 20s
retries: 3
depends_on:
- etcd
- minio
ports:
- 19530:19530
- 9091:9091
networks:
- milvus
# Opensearch vector database
opensearch:
container_name: opensearch
image: opensearchproject/opensearch:latest
profiles:
- opensearch
environment:
discovery.type: ${OPENSEARCH_DISCOVERY_TYPE:-single-node}
bootstrap.memory_lock: ${OPENSEARCH_BOOTSTRAP_MEMORY_LOCK:-true}
OPENSEARCH_JAVA_OPTS: -Xms${OPENSEARCH_JAVA_OPTS_MIN:-512m} -Xmx${OPENSEARCH_JAVA_OPTS_MAX:-1024m}
OPENSEARCH_INITIAL_ADMIN_PASSWORD: ${OPENSEARCH_INITIAL_ADMIN_PASSWORD:-Qazwsxedc!@#123}
ulimits:
memlock:
soft: ${OPENSEARCH_MEMLOCK_SOFT:--1}
hard: ${OPENSEARCH_MEMLOCK_HARD:--1}
nofile:
soft: ${OPENSEARCH_NOFILE_SOFT:-65536}
hard: ${OPENSEARCH_NOFILE_HARD:-65536}
volumes:
- ./volumes/opensearch/data:/usr/share/opensearch/data
networks:
- opensearch-net
opensearch-dashboards:
container_name: opensearch-dashboards
image: opensearchproject/opensearch-dashboards:latest
profiles:
- opensearch
environment:
OPENSEARCH_HOSTS: '["https://opensearch:9200"]'
volumes:
- ./volumes/opensearch/opensearch_dashboards.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml
networks:
- opensearch-net
depends_on:
- opensearch
# opengauss vector database.
opengauss:
image: opengauss/opengauss:7.0.0-RC1
profiles:
- opengauss
privileged: true
restart: always
environment:
GS_USERNAME: ${OPENGAUSS_USER:-postgres}
GS_PASSWORD: ${OPENGAUSS_PASSWORD:-Dify@123}
GS_PORT: ${OPENGAUSS_PORT:-6600}
GS_DB: ${OPENGAUSS_DATABASE:-dify}
volumes:
- ./volumes/opengauss/data:/var/lib/opengauss/data
healthcheck:
test: ["CMD-SHELL", "netstat -lntp | grep tcp6 > /dev/null 2>&1"]
interval: 10s
timeout: 10s
retries: 10
ports:
- ${OPENGAUSS_PORT:-6600}:${OPENGAUSS_PORT:-6600}
# MyScale vector database
myscale:
container_name: myscale
image: myscale/myscaledb:1.6.4
profiles:
- myscale
restart: always
tty: true
volumes:
- ./volumes/myscale/data:/var/lib/clickhouse
- ./volumes/myscale/log:/var/log/clickhouse-server
- ./volumes/myscale/config/users.d/custom_users_config.xml:/etc/clickhouse-server/users.d/custom_users_config.xml
ports:
- ${MYSCALE_PORT:-8123}:${MYSCALE_PORT:-8123}
# Matrixone vector store.
matrixone:
hostname: matrixone
image: matrixorigin/matrixone:2.1.1
profiles:
- matrixone
restart: always
volumes:
- ./volumes/matrixone/data:/mo-data
ports:
- ${MATRIXONE_PORT:-6001}:${MATRIXONE_PORT:-6001}
# https://www.elastic.co/guide/en/elasticsearch/reference/current/settings.html
# https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#docker-prod-prerequisites
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.14.3
container_name: elasticsearch
profiles:
- elasticsearch
- elasticsearch-ja
restart: always
volumes:
- ./elasticsearch/docker-entrypoint.sh:/docker-entrypoint-mount.sh
- dify_es01_data:/usr/share/elasticsearch/data
environment:
ELASTIC_PASSWORD: ${ELASTICSEARCH_PASSWORD:-elastic}
VECTOR_STORE: ${VECTOR_STORE:-}
cluster.name: dify-es-cluster
node.name: dify-es0
discovery.type: single-node
xpack.license.self_generated.type: basic
xpack.security.enabled: "true"
xpack.security.enrollment.enabled: "false"
xpack.security.http.ssl.enabled: "false"
ports:
- ${ELASTICSEARCH_PORT:-9200}:9200
deploy:
resources:
limits:
memory: 2g
entrypoint: ["sh", "-c", "sh /docker-entrypoint-mount.sh"]
healthcheck:
test:
["CMD", "curl", "-s", "http://localhost:9200/_cluster/health?pretty"]
interval: 30s
timeout: 10s
retries: 50
# https://www.elastic.co/guide/en/kibana/current/docker.html
# https://www.elastic.co/guide/en/kibana/current/settings.html
kibana:
image: docker.elastic.co/kibana/kibana:8.14.3
container_name: kibana
profiles:
- elasticsearch
depends_on:
- elasticsearch
restart: always
environment:
XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY: d1a66dfd-c4d3-4a0a-8290-2abcb83ab3aa
NO_PROXY: localhost,127.0.0.1,elasticsearch,kibana
XPACK_SECURITY_ENABLED: "true"
XPACK_SECURITY_ENROLLMENT_ENABLED: "false"
XPACK_SECURITY_HTTP_SSL_ENABLED: "false"
XPACK_FLEET_ISAIRGAPPED: "true"
I18N_LOCALE: zh-CN
SERVER_PORT: "5601"
ELASTICSEARCH_HOSTS: http://elasticsearch:9200
ports:
- ${KIBANA_PORT:-5601}:5601
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:5601 >/dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
# unstructured .
# (if used, you need to set ETL_TYPE to Unstructured in the api & worker service.)
unstructured:
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest
profiles:
- unstructured
restart: always
volumes:
- ./volumes/unstructured:/app/data
networks:
# create a network between sandbox, api and ssrf_proxy, and can not access outside.
ssrf_proxy_network:
driver: bridge
internal: true
milvus:
driver: bridge
opensearch-net:
driver: bridge
internal: true
volumes:
oradata:
dify_es01_data:

View File

@ -1,3 +1,728 @@
# ==================================================================
# WARNING: This file is auto-generated by generate_docker_compose
# Do not modify this file directly. Instead, update the .env.all
# 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:?SECRET_KEY must be set. Run ./init-env.sh, or .\\init-env.ps1 on Windows, to generate one in .env.}
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://<your_account_name>.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}
services: services:
# Init container to fix permissions # Init container to fix permissions
init_permissions: init_permissions:
@ -22,12 +747,9 @@ services:
api: api:
image: langgenius/dify-api:1.14.0 image: langgenius/dify-api:1.14.0
restart: always restart: always
env_file:
# Defaults checked into git; user overrides go in .env.
# `cp .env.example .env` once before `docker compose up`.
- .env.example
- .env
environment: environment:
# Use the shared environment variables.
<<: *shared-api-worker-env
# Startup mode, 'api' starts the API server. # Startup mode, 'api' starts the API server.
MODE: api MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_DSN: ${API_SENTRY_DSN:-}
@ -73,10 +795,9 @@ services:
worker: worker:
image: langgenius/dify-api:1.14.0 image: langgenius/dify-api:1.14.0
restart: always restart: always
env_file:
- .env.example
- .env
environment: environment:
# Use the shared environment variables.
<<: *shared-api-worker-env
# Startup mode, 'worker' starts the Celery worker for processing all queues. # Startup mode, 'worker' starts the Celery worker for processing all queues.
MODE: worker MODE: worker
SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_DSN: ${API_SENTRY_DSN:-}
@ -120,10 +841,9 @@ services:
worker_beat: worker_beat:
image: langgenius/dify-api:1.14.0 image: langgenius/dify-api:1.14.0
restart: always restart: always
env_file:
- .env.example
- .env
environment: environment:
# Use the shared environment variables.
<<: *shared-api-worker-env
# Startup mode, 'worker_beat' starts the Celery beat for scheduling periodic tasks. # Startup mode, 'worker_beat' starts the Celery beat for scheduling periodic tasks.
MODE: beat MODE: beat
depends_on: depends_on:
@ -298,10 +1018,9 @@ services:
plugin_daemon: plugin_daemon:
image: langgenius/dify-plugin-daemon:0.6.0-local image: langgenius/dify-plugin-daemon:0.6.0-local
restart: always restart: always
env_file:
- .env.example
- .env
environment: environment:
# Use the shared environment variables.
<<: *shared-api-worker-env
DB_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin} DB_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin}
DB_SSL_MODE: ${DB_SSL_MODE:-disable} DB_SSL_MODE: ${DB_SSL_MODE:-disable}
SERVER_PORT: ${PLUGIN_DAEMON_PORT:-5002} SERVER_PORT: ${PLUGIN_DAEMON_PORT:-5002}

132
docker/generate_docker_compose Executable file
View File

@ -0,0 +1,132 @@
#!/usr/bin/env python3
import os
import re
import sys
# Variables that exist only for Docker Compose orchestration and must NOT be
# injected into containers as environment variables.
SHARED_ENV_EXCLUDE = frozenset(
[
# Docker Compose profile selection
"COMPOSE_PROFILES",
# Worker health check orchestration flags (consumed by docker-compose,
# not by the application running inside the container)
"COMPOSE_WORKER_HEALTHCHECK_DISABLED",
"COMPOSE_WORKER_HEALTHCHECK_INTERVAL",
"COMPOSE_WORKER_HEALTHCHECK_TIMEOUT",
]
)
def parse_env_all(file_path):
"""
Parses the .env.all file and returns a dictionary with variable names as keys and default values as values.
"""
env_vars = {}
with open(file_path, "r", encoding="utf-8") as f:
for line_number, line in enumerate(f, 1):
line = line.strip()
# Ignore empty lines and comments
if not line or line.startswith("#"):
continue
# Use regex to parse KEY=VALUE
match = re.match(r"^([^=]+)=(.*)$", line)
if match:
key = match.group(1).strip()
value = match.group(2).strip()
# Remove possible quotes around the value
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1]
env_vars[key] = value
else:
print(f"Warning: Unable to parse line {line_number}: {line}")
return env_vars
def generate_shared_env_block(env_vars, anchor_name="shared-api-worker-env"):
"""
Generates a shared environment variables block as a YAML string.
"""
lines = [f"x-shared-env: &{anchor_name}"]
for key, default in env_vars.items():
if key in SHARED_ENV_EXCLUDE:
continue
if key == "SECRET_KEY":
lines.append(
" SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set. Run ./init-env.sh, or .\\\\init-env.ps1 on Windows, to generate one in .env.}"
)
continue
# If default value is empty, use ${KEY:-}
if default == "":
lines.append(f" {key}: ${{{key}:-}}")
else:
# If default value contains special characters, wrap it in quotes
if re.search(r"[:\s]", default):
default = f"{default}"
lines.append(f" {key}: ${{{key}:-{default}}}")
return "\n".join(lines)
def insert_shared_env(template_path, output_path, shared_env_block, header_comments):
"""
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.
"""
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}"
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write(final_content)
print(f"Generated {output_path}")
def main():
env_all_path = ".env.all"
template_path = "docker-compose-template.yaml"
output_path = "docker-compose.yaml"
anchor_name = "shared-api-worker-env" # Can be modified as needed
# Define header comments to be added at the top of docker-compose.yaml
header_comments = (
"# ==================================================================\n"
"# WARNING: This file is auto-generated by generate_docker_compose\n"
"# Do not modify this file directly. Instead, update the .env.all\n"
"# or docker-compose-template.yaml and regenerate this file.\n"
"# ==================================================================\n"
)
# Check if required files exist
for path in [env_all_path, template_path]:
if not os.path.isfile(path):
print(f"Error: File {path} does not exist.")
sys.exit(1)
# Parse .env.all file
env_vars = parse_env_all(env_all_path)
if not env_vars:
print("Warning: No environment variables found in .env.all.")
# 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)
if __name__ == "__main__":
main()

101
docker/init-env.ps1 Normal file
View File

@ -0,0 +1,101 @@
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
$EnvExampleFile = ".env.example"
$EnvFile = ".env"
function New-SecretKey {
$bytes = New-Object byte[] 42
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
[Convert]::ToBase64String($bytes)
}
function Get-EnvValue {
param([string]$Key)
if (-not (Test-Path $EnvFile)) {
return ""
}
$result = ""
foreach ($line in Get-Content $EnvFile) {
if ($line -match "^\s*#" -or $line -notmatch "=") {
continue
}
$parts = $line.Split("=", 2)
if ($parts[0].Trim() -eq $Key) {
$value = $parts[1].Trim()
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) {
$value = $value.Substring(1, $value.Length - 2)
}
$result = $value
}
}
$result
}
function Set-EnvValue {
param(
[string]$Key,
[string]$Value
)
$output = New-Object System.Collections.Generic.List[string]
$replaced = $false
if (Test-Path $EnvFile) {
foreach ($line in Get-Content $EnvFile) {
if ($line -match "^\s*#" -or $line -notmatch "=") {
$output.Add($line)
continue
}
$parts = $line.Split("=", 2)
if ($parts[0].Trim() -eq $Key) {
if (-not $replaced) {
$output.Add("$Key=$Value")
$replaced = $true
}
continue
}
$output.Add($line)
}
}
if (-not $replaced) {
$output.Add("$Key=$Value")
}
$fullPath = Join-Path $ScriptDir $EnvFile
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllLines($fullPath, [string[]]$output, $utf8NoBom)
}
if (Test-Path $EnvFile) {
Write-Output "Using existing $EnvFile."
}
else {
if (-not (Test-Path $EnvExampleFile)) {
Write-Error "$EnvExampleFile is missing."
exit 1
}
Copy-Item $EnvExampleFile $EnvFile
Write-Output "Created $EnvFile from $EnvExampleFile."
}
$currentSecretKey = Get-EnvValue "SECRET_KEY"
if ($currentSecretKey) {
Write-Output "SECRET_KEY already exists in $EnvFile."
}
else {
Set-EnvValue "SECRET_KEY" (New-SecretKey)
Write-Output "Generated SECRET_KEY in $EnvFile."
}
Write-Output "Environment is ready. Run docker compose up -d to start Dify."

117
docker/init-env.sh Executable file
View File

@ -0,0 +1,117 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ENV_EXAMPLE_FILE=".env.example"
ENV_FILE=".env"
log() {
printf '%s\n' "$*"
}
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
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
}
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 }
' "$ENV_FILE"
}
set_env_value() {
local key="$1"
local value="$2"
local temp_file
temp_file="$(mktemp "${TMPDIR:-/tmp}/dify-env.XXXXXX")"
if 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
}
}
' "$ENV_FILE" >"$temp_file"; then
mv "$temp_file" "$ENV_FILE"
else
rm -f "$temp_file"
return 1
fi
}
ensure_env_file() {
if [[ -f "$ENV_FILE" ]]; then
log "Using existing $ENV_FILE."
return
fi
[[ -f "$ENV_EXAMPLE_FILE" ]] || die "$ENV_EXAMPLE_FILE is missing."
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
log "Created $ENV_FILE from $ENV_EXAMPLE_FILE."
}
ensure_secret_key() {
local current_secret_key
local secret_key
current_secret_key="$(env_value SECRET_KEY)"
if [[ -n "$current_secret_key" ]]; then
log "SECRET_KEY already exists in $ENV_FILE."
return
fi
secret_key="$(generate_secret_key)" || die "Unable to generate SECRET_KEY. Install openssl or set SECRET_KEY in $ENV_FILE."
set_env_value SECRET_KEY "$secret_key"
log "Generated SECRET_KEY in $ENV_FILE."
}
ensure_env_file
ensure_secret_key
log "Environment is ready. Run docker compose up -d to start Dify."

View File

@ -202,6 +202,11 @@
"count": 1 "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": { "web/app/components/app/annotation/batch-add-annotation-modal/index.tsx": {
"erasable-syntax-only/enums": { "erasable-syntax-only/enums": {
"count": 1 "count": 1
@ -230,6 +235,11 @@
"count": 1 "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": { "web/app/components/app/annotation/header-opts/index.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 1 "count": 1
@ -252,6 +262,9 @@
"erasable-syntax-only/enums": { "erasable-syntax-only/enums": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": { "react/set-state-in-effect": {
"count": 5 "count": 5
}, },
@ -269,11 +282,6 @@
"count": 4 "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": { "web/app/components/app/app-publisher/version-info-modal.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
@ -344,6 +352,9 @@
} }
}, },
"web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx": { "web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/exhaustive-deps": { "react-hooks/exhaustive-deps": {
"count": 1 "count": 1
}, },
@ -401,6 +412,16 @@
"count": 2 "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": { "web/app/components/app/configuration/dataset-config/index.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 1 "count": 1
@ -531,6 +552,9 @@
} }
}, },
"web/app/components/app/log/list.tsx": { "web/app/components/app/log/list.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": { "react/set-state-in-effect": {
"count": 6 "count": 6
}, },
@ -580,6 +604,9 @@
} }
}, },
"web/app/components/app/workflow-log/list.tsx": { "web/app/components/app/workflow-log/list.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": { "react/set-state-in-effect": {
"count": 2 "count": 2
} }
@ -904,6 +931,11 @@
"count": 1 "count": 1
} }
}, },
"web/app/components/base/drawer-plus/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/emoji-picker/index.tsx": { "web/app/components/base/emoji-picker/index.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
@ -1029,6 +1061,11 @@
"count": 3 "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": { "web/app/components/base/form/components/base/base-form.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 6 "count": 6
@ -1233,7 +1270,7 @@
}, },
"web/app/components/base/icons/src/vender/line/development/index.ts": { "web/app/components/base/icons/src/vender/line/development/index.ts": {
"no-barrel-files/no-barrel-files": { "no-barrel-files/no-barrel-files": {
"count": 2 "count": 1
} }
}, },
"web/app/components/base/icons/src/vender/line/editor/index.ts": { "web/app/components/base/icons/src/vender/line/editor/index.ts": {
@ -2144,14 +2181,6 @@
"count": 1 "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": { "web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx": {
"react/set-state-in-effect": { "react/set-state-in-effect": {
"count": 1 "count": 1
@ -2162,11 +2191,6 @@
"count": 1 "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": { "web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": {
"ts/no-non-null-asserted-optional-chain": { "ts/no-non-null-asserted-optional-chain": {
"count": 1 "count": 1
@ -2231,14 +2255,6 @@
"count": 1 "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": { "web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 6 "count": 6
@ -2280,6 +2296,9 @@
} }
}, },
"web/app/components/datasets/hit-testing/index.tsx": { "web/app/components/datasets/hit-testing/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/unsupported-syntax": { "react/unsupported-syntax": {
"count": 1 "count": 1
} }
@ -2319,7 +2338,7 @@
}, },
"web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": { "web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 2 "count": 3
} }
}, },
"web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": { "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": { "web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 7 "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": { "web/app/components/plugins/plugin-detail-panel/model-list.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 1 "count": 1
@ -2838,6 +2865,9 @@
} }
}, },
"web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 2 "count": 2
} }
@ -2896,6 +2926,9 @@
} }
}, },
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 5 "count": 5
} }
@ -2933,16 +2966,6 @@
"count": 1 "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": { "web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
"erasable-syntax-only/enums": { "erasable-syntax-only/enums": {
"count": 2 "count": 2
@ -3170,7 +3193,7 @@
}, },
"web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": { "web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 2
} }
}, },
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": { "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": { "web/app/components/tools/edit-custom-collection-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": { "react/set-state-in-effect": {
"count": 4 "count": 4
}, },
@ -3187,6 +3213,9 @@
} }
}, },
"web/app/components/tools/edit-custom-collection-modal/test-api.tsx": { "web/app/components/tools/edit-custom-collection-modal/test-api.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 1 "count": 1
} }
@ -3196,6 +3225,11 @@
"count": 1 "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": { "web/app/components/tools/mcp/mcp-server-modal.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
@ -3224,12 +3258,20 @@
"count": 1 "count": 1
} }
}, },
"web/app/components/tools/provider/detail.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/provider/empty.tsx": { "web/app/components/tools/provider/empty.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 1 "count": 1
} }
}, },
"web/app/components/tools/setting/build-in/config-credentials.tsx": { "web/app/components/tools/setting/build-in/config-credentials.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 3 "count": 3
} }
@ -4061,6 +4103,11 @@
"count": 1 "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": { "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 1 "count": 1

View File

@ -28,6 +28,7 @@ Always import from a **subpath export** — there is no barrel:
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog' 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 { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import '@langgenius/dify-ui/styles.css' // once, in the app root 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 ## Primitives
| Category | Subpath | Notes | | Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. | | 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. | | Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | | Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. | | Media | `./avatar`, `./button` | Button exposes `cva` variants. |
Utilities: Utilities:
@ -65,7 +66,7 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s
## Overlay & portal contract ## 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 ### 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. Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites.
| Layer | z-index | Where | | Layer | z-index | Where |
| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | | ------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop | | 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. | | 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`. 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 ### 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 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. - When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites.
## Development ## Development

View File

@ -37,6 +37,10 @@
"types": "./src/dialog/index.tsx", "types": "./src/dialog/index.tsx",
"import": "./src/dialog/index.tsx" "import": "./src/dialog/index.tsx"
}, },
"./drawer": {
"types": "./src/drawer/index.tsx",
"import": "./src/drawer/index.tsx"
},
"./dropdown-menu": { "./dropdown-menu": {
"types": "./src/dropdown-menu/index.tsx", "types": "./src/dropdown-menu/index.tsx",
"import": "./src/dropdown-menu/index.tsx" "import": "./src/dropdown-menu/index.tsx"

View File

@ -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(
<Drawer>
<DrawerTrigger>Open settings</DrawerTrigger>
<DrawerPortal>
<DrawerBackdrop data-testid="drawer-backdrop" />
<DrawerViewport>
<DrawerPopup>
<DrawerTitle>Settings</DrawerTitle>
<DrawerDescription>Configure the current workspace.</DrawerDescription>
<DrawerContent>
<p>Workspace controls</p>
<DrawerCloseButton />
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>,
)
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()
})
})
})
})

View File

@ -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<Payload = unknown> = BaseDrawer.Root.Props<Payload>
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<Payload = unknown> = BaseDrawer.Trigger.Props<Payload>
export function DrawerBackdrop({
className,
...props
}: BaseDrawer.Backdrop.Props) {
return (
<BaseDrawer.Backdrop
className={cn(
'fixed inset-0 z-1002 bg-background-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))]',
'transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 motion-reduce:transition-none',
className,
)}
{...props}
/>
)
}
export function DrawerViewport({
className,
...props
}: BaseDrawer.Viewport.Props) {
return (
<BaseDrawer.Viewport
className={cn('fixed inset-0 z-1002 touch-none overflow-hidden overscroll-contain outline-hidden', className)}
{...props}
/>
)
}
export function DrawerPopup({
className,
...props
}: BaseDrawer.Popup.Props) {
return (
<BaseDrawer.Popup
className={cn(
'fixed z-1002 flex min-h-0 flex-col overflow-hidden border-[0.5px] border-components-panel-border bg-components-panel-bg text-text-primary shadow-xl outline-hidden touch-none',
'transition-[transform,opacity,box-shadow] duration-200 data-swiping:select-none data-swiping:duration-0 motion-reduce:transition-none',
'data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:h-dvh data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-2rem)] data-[swipe-direction=right]:rounded-l-2xl data-[swipe-direction=right]:border-r-0 data-[swipe-direction=right]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]',
'data-starting-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))] data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))]',
'data-[swipe-direction=left]:inset-y-0 data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:h-dvh data-[swipe-direction=left]:w-120 data-[swipe-direction=left]:max-w-[calc(100vw-2rem)] data-[swipe-direction=left]:rounded-r-2xl data-[swipe-direction=left]:border-l-0 data-[swipe-direction=left]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]',
'data-starting-style:data-[swipe-direction=left]:transform-[translateX(calc(-100%-2px))] data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(-100%-2px))]',
'data-[swipe-direction=down]:inset-x-0 data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:max-h-[calc(100dvh-2rem)] data-[swipe-direction=down]:w-full data-[swipe-direction=down]:rounded-t-2xl data-[swipe-direction=down]:border-b-0 data-[swipe-direction=down]:transform-[translateY(calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)))]',
'data-starting-style:data-[swipe-direction=down]:transform-[translateY(calc(100%+2px))] data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(100%+2px))]',
'data-[swipe-direction=up]:inset-x-0 data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:max-h-[calc(100dvh-2rem)] data-[swipe-direction=up]:w-full data-[swipe-direction=up]:rounded-b-2xl data-[swipe-direction=up]:border-t-0 data-[swipe-direction=up]:transform-[translateY(var(--drawer-swipe-movement-y,0px))]',
'data-starting-style:data-[swipe-direction=up]:transform-[translateY(calc(-100%-2px))] data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(-100%-2px))]',
className,
)}
{...props}
/>
)
}
export function DrawerContent({
className,
...props
}: BaseDrawer.Content.Props) {
return (
<BaseDrawer.Content
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain p-6 pb-[calc(1.5rem+env(safe-area-inset-bottom,0))]', className)}
{...props}
/>
)
}
type DrawerCloseButtonProps = Omit<BaseDrawer.Close.Props, 'children'> & {
children?: ReactNode
}
export function DrawerCloseButton({
className,
children,
type = 'button',
'aria-label': ariaLabel = 'Close drawer',
...props
}: DrawerCloseButtonProps) {
return (
<BaseDrawer.Close
type={type}
aria-label={ariaLabel}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
{children ?? <span aria-hidden="true" className="i-ri-close-line h-4 w-4" />}
</BaseDrawer.Close>
)
}

View File

@ -205,7 +205,7 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
})) }))
vi.mock('@/app/components/tools/workflow-tool', () => ({ 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 }) => (
<div data-testid="workflow-tool-modal"> <div data-testid="workflow-tool-modal">
<button data-testid="wf-modal-hide" onClick={onHide}>Hide</button> <button data-testid="wf-modal-hide" onClick={onHide}>Hide</button>
<button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>Save</button> <button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>Save</button>

View File

@ -91,6 +91,21 @@ vi.mock('@/service/use-workflow', () => ({
useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow, 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', () => ({ vi.mock('@langgenius/dify-ui/toast', () => ({
toast: { toast: {
error: (...args: unknown[]) => mockToastError(...args), error: (...args: unknown[]) => mockToastError(...args),
@ -121,6 +136,15 @@ vi.mock('../../app-access-control', () => ({
), ),
})) }))
vi.mock('@/app/components/tools/workflow-tool', () => ({
WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => (
<div data-testid="workflow-tool-drawer">
workflow tool drawer
<button onClick={onHide}>close-workflow-tool-drawer</button>
</div>
),
}))
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('../sections', () => ({ vi.mock('../sections', () => ({
@ -143,6 +167,7 @@ vi.mock('../sections', () => ({
<div> <div>
<button onClick={props.handleEmbed}>publisher-embed</button> <button onClick={props.handleEmbed}>publisher-embed</button>
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button> <button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
<button onClick={props.onConfigureWorkflowTool}>publisher-workflow-tool</button>
</div> </div>
) )
}, },
@ -231,6 +256,25 @@ describe('AppPublisher', () => {
expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument() expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument()
}) })
it('should keep workflow tool drawer mounted after closing the publish popover', () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
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 () => { it('should close embedded and access control panels through child callbacks', async () => {
render( render(
<AppPublisher <AppPublisher

View File

@ -190,18 +190,17 @@ describe('app-publisher sections', () => {
disabledFunctionTooltip="disabled" disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed} handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore} handleOpenInExplore={handleOpenInExplore}
handlePublish={vi.fn()}
hasHumanInputNode={false} hasHumanInputNode={false}
hasTriggerNode={false} hasTriggerNode={false}
inputs={[]}
missingStartNode={false} missingStartNode={false}
onRefreshData={vi.fn()}
outputs={[]}
published={true}
publishedAt={Date.now()} publishedAt={Date.now()}
toolPublished toolPublished
workflowToolAvailable={false} workflowToolAvailable={false}
workflowToolIsLoading={false}
workflowToolOutdated={false}
workflowToolIsCurrentWorkspaceManager
workflowToolMessage="workflow-disabled" workflowToolMessage="workflow-disabled"
onConfigureWorkflowTool={vi.fn()}
/>, />,
) )
@ -223,17 +222,16 @@ describe('app-publisher sections', () => {
disabledFunctionTooltip="disabled" disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed} handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore} handleOpenInExplore={handleOpenInExplore}
handlePublish={vi.fn()}
hasHumanInputNode={false} hasHumanInputNode={false}
hasTriggerNode={false} hasTriggerNode={false}
inputs={[]}
missingStartNode missingStartNode
onRefreshData={vi.fn()}
outputs={[]}
published={false}
publishedAt={Date.now()} publishedAt={Date.now()}
toolPublished={false} toolPublished={false}
workflowToolAvailable workflowToolAvailable
workflowToolIsLoading={false}
workflowToolOutdated={false}
workflowToolIsCurrentWorkspaceManager
onConfigureWorkflowTool={vi.fn()}
/>, />,
) )
@ -248,16 +246,16 @@ describe('app-publisher sections', () => {
disabledFunctionButton={false} disabledFunctionButton={false}
handleEmbed={handleEmbed} handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore} handleOpenInExplore={handleOpenInExplore}
handlePublish={vi.fn()}
hasHumanInputNode={false} hasHumanInputNode={false}
hasTriggerNode hasTriggerNode
inputs={[]}
missingStartNode={false} missingStartNode={false}
outputs={[]}
published={false}
publishedAt={undefined} publishedAt={undefined}
toolPublished={false} toolPublished={false}
workflowToolAvailable workflowToolAvailable
workflowToolIsLoading={false}
workflowToolOutdated={false}
workflowToolIsCurrentWorkspaceManager
onConfigureWorkflowTool={vi.fn()}
/>, />,
) )

View File

@ -5,13 +5,12 @@ import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { RiStoreLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query' import { useSuspenseQuery } from '@tanstack/react-query'
import { useKeyPress } from 'ahooks' import { useKeyPress } from 'ahooks'
import { import {
memo, memo,
use,
useCallback, useCallback,
useContext,
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
@ -20,9 +19,12 @@ import { useTranslation } from 'react-i18next'
import EmbeddedModal from '@/app/components/app/overview/embedded' import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude' 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 { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { WorkflowContext } from '@/app/components/workflow/context' import { WorkflowContext } from '@/app/components/workflow/context'
import { appDefaultIconBackground } from '@/config'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control' import { AccessMode } from '@/models/access-control'
@ -57,8 +59,8 @@ export type AppPublisherProps = {
debugWithMultipleModel?: boolean debugWithMultipleModel?: boolean
multipleModelConfigs?: ModelAndParameter[] multipleModelConfigs?: ModelAndParameter[]
/** modelAndParameter is passed when debugWithMultipleModel is true */ /** modelAndParameter is passed when debugWithMultipleModel is true */
onPublish?: (params?: any) => Promise<any> | any onPublish?: AppPublisherPublishHandler
onRestore?: () => Promise<any> | any onRestore?: AppPublisherRestoreHandler
onToggle?: (state: boolean) => void onToggle?: (state: boolean) => void
crossAxisOffset?: number crossAxisOffset?: number
toolPublished?: boolean toolPublished?: boolean
@ -74,6 +76,12 @@ export type AppPublisherProps = {
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
type AppPublisherPublishHandler
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
| ((params?: unknown) => Promise<unknown> | unknown)
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown
const AppPublisher = ({ const AppPublisher = ({
disabled = false, disabled = false,
publishDisabled = false, publishDisabled = false,
@ -100,11 +108,12 @@ const AppPublisher = ({
const [published, setPublished] = useState(false) const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [showAppAccessControl, setShowAppAccessControl] = useState(false) const [showAppAccessControl, setShowAppAccessControl] = useState(false)
const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) const [publishingToMarketplace, setPublishingToMarketplace] = useState(false)
const workflowStore = useContext(WorkflowContext) const workflowStore = use(WorkflowContext)
const appDetail = useAppStore(state => state.appDetail) const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail) const setAppDetail = useAppStore(s => s.setAppDetail)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -273,6 +282,31 @@ const AppPublisher = ({
const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable
? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' })
: undefined : 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(() => ({ 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%)', 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', WebkitBackgroundClip: 'text',
@ -343,23 +377,22 @@ const AppPublisher = ({
handleOpenChange(false) handleOpenChange(false)
handleOpenInExplore() handleOpenInExplore()
}} }}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode} hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode} hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode} missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt} publishedAt={publishedAt}
toolPublished={toolPublished} toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable} workflowToolAvailable={workflowToolAvailable}
workflowToolIsLoading={workflowTool.isLoading}
workflowToolOutdated={workflowTool.outdated}
workflowToolIsCurrentWorkspaceManager={workflowTool.isCurrentWorkspaceManager}
workflowToolMessage={workflowToolMessage} workflowToolMessage={workflowToolMessage}
onConfigureWorkflowTool={openWorkflowToolDrawer}
/> />
{systemFeatures.enable_creators_platform && ( {systemFeatures.enable_creators_platform && (
<div className="border-t border-divider-subtle p-4"> <div className="border-t border-divider-subtle p-4">
<SuggestedAction <SuggestedAction
icon={<RiStoreLine className="h-4 w-4" />} icon={<span className="i-ri-store-line h-4 w-4" />}
disabled={!publishedAt || publishingToMarketplace} disabled={!publishedAt || publishingToMarketplace}
onClick={handlePublishToMarketplace} onClick={handlePublishToMarketplace}
> >
@ -380,6 +413,15 @@ const AppPublisher = ({
/> />
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />} {showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
</Popover> </Popover>
{workflowToolDrawerOpen && (
<WorkflowToolDrawer
isAdd={!workflowToolPublished}
payload={workflowTool.payload}
onHide={closeWorkflowToolDrawer}
onCreate={workflowTool.handleCreate}
onSave={workflowTool.handleUpdate}
/>
)}
</> </>
) )
} }

View File

@ -10,11 +10,9 @@ import {
} from '@langgenius/dify-ui/tooltip' } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider' 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 Loading from '@/app/components/base/loading'
import UpgradeBtn from '@/app/components/billing/upgrade-btn' import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { appDefaultIconBackground } from '@/config'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import ShortcutsName from '../../workflow/shortcuts-name' import ShortcutsName from '../../workflow/shortcuts-name'
import PublishWithMultipleModel from './publish-with-multiple-model' import PublishWithMultipleModel from './publish-with-multiple-model'
@ -46,11 +44,8 @@ type AccessSectionProps = {
type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode' type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
| 'hasTriggerNode' | 'hasTriggerNode'
| 'inputs'
| 'missingStartNode' | 'missingStartNode'
| 'onRefreshData'
| 'toolPublished' | 'toolPublished'
| 'outputs'
| 'publishedAt' | 'publishedAt'
| 'workflowToolAvailable'> & { | 'workflowToolAvailable'> & {
appDetail: { appDetail: {
@ -67,9 +62,11 @@ type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
disabledFunctionTooltip?: string disabledFunctionTooltip?: string
handleEmbed: () => void handleEmbed: () => void
handleOpenInExplore: () => void handleOpenInExplore: () => void
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void> workflowToolIsLoading: boolean
published: boolean workflowToolOutdated: boolean
workflowToolIsCurrentWorkspaceManager: boolean
workflowToolMessage?: string workflowToolMessage?: string
onConfigureWorkflowTool: () => void
} }
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => { export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
@ -256,18 +253,17 @@ export const PublisherActionsSection = ({
disabledFunctionTooltip, disabledFunctionTooltip,
handleEmbed, handleEmbed,
handleOpenInExplore, handleOpenInExplore,
handlePublish,
hasHumanInputNode = false, hasHumanInputNode = false,
hasTriggerNode = false, hasTriggerNode = false,
inputs,
missingStartNode = false, missingStartNode = false,
onRefreshData,
outputs,
published,
publishedAt, publishedAt,
toolPublished, toolPublished,
workflowToolAvailable = true, workflowToolAvailable = true,
workflowToolIsLoading,
workflowToolOutdated,
workflowToolIsCurrentWorkspaceManager,
workflowToolMessage, workflowToolMessage,
onConfigureWorkflowTool,
}: ActionsSectionProps) => { }: ActionsSectionProps) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -305,7 +301,7 @@ export const PublisherActionsSection = ({
<SuggestedAction <SuggestedAction
onClick={handleEmbed} onClick={handleEmbed}
disabled={!publishedAt} disabled={!publishedAt}
icon={<CodeBrowser className="h-4 w-4" />} icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
> >
{t('common.embedIntoSite', { ns: 'workflow' })} {t('common.embedIntoSite', { ns: 'workflow' })}
</SuggestedAction> </SuggestedAction>
@ -340,18 +336,10 @@ export const PublisherActionsSection = ({
<WorkflowToolConfigureButton <WorkflowToolConfigureButton
disabled={workflowToolDisabled} disabled={workflowToolDisabled}
published={!!toolPublished} published={!!toolPublished}
detailNeedUpdate={!!toolPublished && published} isLoading={workflowToolIsLoading}
workflowAppId={appDetail?.id ?? ''} outdated={workflowToolOutdated}
icon={{ isCurrentWorkspaceManager={workflowToolIsCurrentWorkspaceManager}
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖', onConfigure={onConfigureWorkflowTool}
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
}}
name={appDetail?.name ?? ''}
description={appDetail?.description ?? ''}
inputs={inputs}
outputs={outputs}
handlePublish={handlePublish}
onRefreshData={onRefreshData}
disabledReason={workflowToolMessage} disabledReason={workflowToolMessage}
/> />
)} )}

View File

@ -54,22 +54,22 @@ const Operation: FC<Props> = ({
onOpenChange={setOpen} onOpenChange={setOpen}
> >
<DropdownMenuTrigger <DropdownMenuTrigger
render={<div />} render={(
<ActionButton
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
state={
isActive
? ActionButtonState.Active
: open
? ActionButtonState.Hover
: ActionButtonState.Default
}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</ActionButton>
)}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> />
<ActionButton
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
state={
isActive
? ActionButtonState.Active
: open
? ActionButtonState.Hover
: ActionButtonState.Default
}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
placement="bottom-end" placement="bottom-end"
sideOffset={4} sideOffset={4}

View File

@ -182,11 +182,13 @@ describe('useChatLayout', () => {
act(() => { act(() => {
capturedResizeCallbacks[0]?.([makeResizeEntry(80, 400)], {} as ResizeObserver) capturedResizeCallbacks[0]?.([makeResizeEntry(80, 400)], {} as ResizeObserver)
flushAnimationFrames()
}) })
expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px') expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px')
act(() => { act(() => {
capturedResizeCallbacks[1]?.([makeResizeEntry(50, 560)], {} as ResizeObserver) capturedResizeCallbacks[1]?.([makeResizeEntry(50, 560)], {} as ResizeObserver)
flushAnimationFrames()
}) })
expect(screen.getByTestId('chat-footer').style.width).toBe('560px') expect(screen.getByTestId('chat-footer').style.width).toBe('560px')

View File

@ -12,6 +12,11 @@ type UseChatLayoutOptions = {
sidebarCollapseState?: boolean sidebarCollapseState?: boolean
} }
const setStyleValue = (element: HTMLElement, property: 'paddingBottom' | 'width', value: string) => {
if (element.style[property] !== value)
element.style[property] = value
}
export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => { export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => {
const [width, setWidth] = useState(0) const [width, setWidth] = useState(0)
const chatContainerRef = useRef<HTMLDivElement>(null) const chatContainerRef = useRef<HTMLDivElement>(null)
@ -21,6 +26,9 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
const userScrolledRef = useRef(false) const userScrolledRef = useRef(false)
const isAutoScrollingRef = useRef(false) const isAutoScrollingRef = useRef(false)
const prevFirstMessageIdRef = useRef<string | undefined>(undefined) const prevFirstMessageIdRef = useRef<string | undefined>(undefined)
const resizeObserverFrameRef = useRef<number | null>(null)
const pendingFooterBlockSizeRef = useRef<number | null>(null)
const pendingContainerInlineSizeRef = useRef<number | null>(null)
const handleScrollToBottom = useCallback(() => { const handleScrollToBottom = useCallback(() => {
if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) { if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) {
@ -34,16 +42,39 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
}, [chatList.length]) }, [chatList.length])
const handleWindowResize = useCallback(() => { const handleWindowResize = useCallback(() => {
if (chatContainerRef.current) if (chatContainerRef.current) {
setWidth(document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8) const nextWidth = document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8
setWidth(currentWidth => currentWidth === nextWidth ? currentWidth : nextWidth)
}
if (chatContainerRef.current && chatFooterRef.current) if (chatContainerRef.current && chatFooterRef.current)
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px` setStyleValue(chatFooterRef.current, 'width', `${chatContainerRef.current.clientWidth}px`)
if (chatContainerInnerRef.current && chatFooterInnerRef.current) if (chatContainerInnerRef.current && chatFooterInnerRef.current)
chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px` setStyleValue(chatFooterInnerRef.current, 'width', `${chatContainerInnerRef.current.clientWidth}px`)
}, []) }, [])
const scheduleResizeObserverUpdate = useCallback(() => {
if (resizeObserverFrameRef.current !== null)
return
resizeObserverFrameRef.current = requestAnimationFrame(() => {
resizeObserverFrameRef.current = null
const footerBlockSize = pendingFooterBlockSizeRef.current
pendingFooterBlockSizeRef.current = null
if (footerBlockSize !== null && chatContainerRef.current) {
setStyleValue(chatContainerRef.current, 'paddingBottom', `${footerBlockSize}px`)
handleScrollToBottom()
}
const containerInlineSize = pendingContainerInlineSizeRef.current
pendingContainerInlineSizeRef.current = null
if (containerInlineSize !== null && chatFooterRef.current)
setStyleValue(chatFooterRef.current, 'width', `${containerInlineSize}px`)
})
}, [handleScrollToBottom])
useEffect(() => { useEffect(() => {
handleScrollToBottom() handleScrollToBottom()
const animationFrame = requestAnimationFrame(handleWindowResize) const animationFrame = requestAnimationFrame(handleWindowResize)
@ -77,26 +108,31 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
const resizeContainerObserver = new ResizeObserver((entries) => { const resizeContainerObserver = new ResizeObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
const { blockSize } = entry.borderBoxSize[0]! const { blockSize } = entry.borderBoxSize[0]!
chatContainerRef.current!.style.paddingBottom = `${blockSize}px` pendingFooterBlockSizeRef.current = blockSize
handleScrollToBottom()
} }
scheduleResizeObserverUpdate()
}) })
resizeContainerObserver.observe(chatFooterRef.current) resizeContainerObserver.observe(chatFooterRef.current)
const resizeFooterObserver = new ResizeObserver((entries) => { const resizeFooterObserver = new ResizeObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
const { inlineSize } = entry.borderBoxSize[0]! const { inlineSize } = entry.borderBoxSize[0]!
chatFooterRef.current!.style.width = `${inlineSize}px` pendingContainerInlineSizeRef.current = inlineSize
} }
scheduleResizeObserverUpdate()
}) })
resizeFooterObserver.observe(chatContainerRef.current) resizeFooterObserver.observe(chatContainerRef.current)
return () => { return () => {
if (resizeObserverFrameRef.current !== null) {
cancelAnimationFrame(resizeObserverFrameRef.current)
resizeObserverFrameRef.current = null
}
resizeContainerObserver.disconnect() resizeContainerObserver.disconnect()
resizeFooterObserver.disconnect() resizeFooterObserver.disconnect()
} }
} }
}, [handleScrollToBottom]) }, [scheduleResizeObserverUpdate])
useEffect(() => { useEffect(() => {
const setUserScrolled = () => { const setUserScrolled = () => {

View File

@ -1,2 +1 @@
export { default as BracketsX } from './BracketsX' export { default as BracketsX } from './BracketsX'
export { default as CodeBrowser } from './CodeBrowser'

View File

@ -120,18 +120,12 @@ vi.mock('../document-title', () => ({
})) }))
vi.mock('../segment-add', () => ({ vi.mock('../segment-add', () => ({
default: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => ( SegmentAdd: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => (
<div data-testid="segment-add" data-embedding={embedding}> <div data-testid="segment-add" data-embedding={embedding}>
<button data-testid="new-segment-btn" onClick={showNewSegmentModal}>New Segment</button> <button data-testid="new-segment-btn" onClick={showNewSegmentModal}>New Segment</button>
<button data-testid="batch-btn" onClick={showBatchModal}>Batch Import</button> <button data-testid="batch-btn" onClick={showBatchModal}>Batch Import</button>
</div> </div>
), ),
ProcessStatus: {
WAITING: 'waiting',
PROCESSING: 'processing',
ERROR: 'error',
COMPLETED: 'completed',
},
})) }))
vi.mock('../../components/operations', () => ({ vi.mock('../../components/operations', () => ({

View File

@ -2,12 +2,15 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { ChunkingMode, FileItem } from '@/models/datasets' import type { ChunkingMode, FileItem } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { RiCloseLine } from '@remixicon/react' import {
import { noop } from 'es-toolkit/function' Dialog,
DialogCloseButton,
DialogContent,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import * as React from 'react' import * as React from 'react'
import { useEffect, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import CSVDownloader from './csv-downloader' import CSVDownloader from './csv-downloader'
import CSVUploader from './csv-uploader' import CSVUploader from './csv-uploader'
@ -18,8 +21,9 @@ type IBatchModalProps = {
onConfirm: (file: FileItem) => void onConfirm: (file: FileItem) => void
} }
const BatchModal: FC<IBatchModalProps> = ({ type BatchModalContentProps = Omit<IBatchModalProps, 'isShow'>
isShow,
const BatchModalContent: FC<BatchModalContentProps> = ({
docForm, docForm,
onCancel, onCancel,
onConfirm, onConfirm,
@ -35,17 +39,13 @@ const BatchModal: FC<IBatchModalProps> = ({
onConfirm(currentCSV) onConfirm(currentCSV)
} }
useEffect(() => {
if (!isShow)
setCurrentCSV(undefined)
}, [isShow])
return ( return (
<Modal isShow={isShow} onClose={noop} className="max-w-[520px]! rounded-xl! px-8 py-6"> <DialogContent className="w-[520px]! overflow-hidden! rounded-xl! border-0! px-8 py-6">
<div className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary">{t('list.batchModal.title', { ns: 'datasetDocuments' })}</div> <DialogTitle className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary">{t('list.batchModal.title', { ns: 'datasetDocuments' })}</DialogTitle>
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onCancel}> <DialogCloseButton
<RiCloseLine className="h-4 w-4 text-text-secondary" /> className="top-4 right-4"
</div> aria-label={t('list.batchModal.cancel', { ns: 'datasetDocuments' })}
/>
<CSVUploader <CSVUploader
file={currentCSV} file={currentCSV}
updateFile={handleFile} updateFile={handleFile}
@ -61,7 +61,33 @@ const BatchModal: FC<IBatchModalProps> = ({
{t('list.batchModal.run', { ns: 'datasetDocuments' })} {t('list.batchModal.run', { ns: 'datasetDocuments' })}
</Button> </Button>
</div> </div>
</Modal> </DialogContent>
) )
} }
const BatchModal: FC<IBatchModalProps> = ({
isShow,
docForm,
onCancel,
onConfirm,
}) => {
return (
<Dialog
open={isShow}
onOpenChange={open => !open && onCancel()}
disablePointerDismissal
>
{isShow
? (
<BatchModalContent
docForm={docForm}
onCancel={onCancel}
onConfirm={onConfirm}
/>
)
: null}
</Dialog>
)
}
export default React.memo(BatchModal) export default React.memo(BatchModal)

View File

@ -137,9 +137,8 @@ vi.mock('../hooks/use-child-segment-data', () => ({
}, },
})) }))
// Mock child components to simplify testing vi.mock('../components/menu-bar', () => ({
vi.mock('../components', () => ({ default: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
MenuBar: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
totalText: string totalText: string
onInputChange: (value: string) => void onInputChange: (value: string) => void
inputValue: string inputValue: string
@ -167,7 +166,13 @@ vi.mock('../components', () => ({
)} )}
</div> </div>
), ),
}))
vi.mock('../components/drawer-group', () => ({
DrawerGroup: () => <div data-testid="drawer-group" />, DrawerGroup: () => <div data-testid="drawer-group" />,
}))
vi.mock('../components/segment-list-content', () => ({
FullDocModeContent: () => <div data-testid="full-doc-mode-content" />, FullDocModeContent: () => <div data-testid="full-doc-mode-content" />,
GeneralModeContent: () => <div data-testid="general-mode-content" />, GeneralModeContent: () => <div data-testid="general-mode-content" />,
})) }))
@ -563,7 +568,7 @@ describe('Edge Cases', () => {
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument() expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()
}) })
it('should handle ProcessStatus.COMPLETED importStatus', () => { it('should handle completed importStatus', () => {
render(<Completed {...defaultProps} importStatus="completed" />, { wrapper: createWrapper() }) render(<Completed {...defaultProps} importStatus="completed" />, { wrapper: createWrapper() })
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument() expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()

View File

@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two' import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode } from '@/models/datasets' import { ChunkingMode } from '@/models/datasets'
import SegmentDetail from '../segment-detail' import { SegmentDetail } from '../segment-detail'
// Mock dataset detail context // Mock dataset detail context
let mockIndexingTechnique = IndexingType.QUALIFIED let mockIndexingTechnique = IndexingType.QUALIFIED
@ -167,7 +167,6 @@ describe('SegmentDetail', () => {
onCancel: vi.fn(), onCancel: vi.fn(),
isEditMode: false, isEditMode: false,
docForm: ChunkingMode.text, docForm: ChunkingMode.text,
onModalStateChange: vi.fn(),
} }
describe('Rendering', () => { describe('Rendering', () => {
@ -352,35 +351,12 @@ describe('SegmentDetail', () => {
expect(screen.getByTestId('regeneration-modal'))!.toBeInTheDocument() expect(screen.getByTestId('regeneration-modal'))!.toBeInTheDocument()
}) })
it('should call onModalStateChange when regeneration modal opens', () => {
const mockOnModalStateChange = vi.fn()
render(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onModalStateChange={mockOnModalStateChange}
/>,
)
fireEvent.click(screen.getByTestId('regenerate-btn'))
expect(mockOnModalStateChange).toHaveBeenCalledWith(true)
})
it('should close modal when cancel is clicked', () => { it('should close modal when cancel is clicked', () => {
const mockOnModalStateChange = vi.fn() render(<SegmentDetail {...defaultProps} isEditMode={true} />)
render(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onModalStateChange={mockOnModalStateChange}
/>,
)
fireEvent.click(screen.getByTestId('regenerate-btn')) fireEvent.click(screen.getByTestId('regenerate-btn'))
fireEvent.click(screen.getByTestId('cancel-regeneration')) fireEvent.click(screen.getByTestId('cancel-regeneration'))
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument() expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument()
}) })
}) })
@ -504,22 +480,18 @@ describe('SegmentDetail', () => {
it('should close modal and edit drawer when close after regeneration is clicked', () => { it('should close modal and edit drawer when close after regeneration is clicked', () => {
const mockOnCancel = vi.fn() const mockOnCancel = vi.fn()
const mockOnModalStateChange = vi.fn()
render( render(
<SegmentDetail <SegmentDetail
{...defaultProps} {...defaultProps}
isEditMode={true} isEditMode={true}
onCancel={mockOnCancel} onCancel={mockOnCancel}
onModalStateChange={mockOnModalStateChange}
/>, />,
) )
// Open regeneration modal
fireEvent.click(screen.getByTestId('regenerate-btn')) fireEvent.click(screen.getByTestId('regenerate-btn'))
fireEvent.click(screen.getByTestId('close-regeneration')) fireEvent.click(screen.getByTestId('close-regeneration'))
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
expect(mockOnCancel).toHaveBeenCalled() expect(mockOnCancel).toHaveBeenCalled()
}) })
}) })

View File

@ -1,27 +1,16 @@
import { render, screen } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import Drawer from '../drawer' import { CompletedDrawer } from '../drawer'
let capturedKeyPressCallback: ((e: KeyboardEvent) => void) | undefined (
globalThis as typeof globalThis & {
BASE_UI_ANIMATIONS_DISABLED: boolean
}
).BASE_UI_ANIMATIONS_DISABLED = true
// Mock useKeyPress: required because tests capture the registered callback const getOverlay = () =>
// and invoke it directly to verify ESC key handling behavior. Array.from(document.querySelectorAll<HTMLElement>('[class]'))
vi.mock('ahooks', () => ({ .find(element => element.className.includes('bg-background-overlay'))
useKeyPress: vi.fn((_key: string, cb: (e: KeyboardEvent) => void) => {
capturedKeyPressCallback = cb
}),
}))
vi.mock('../..', () => ({
useSegmentListContext: (selector: (state: {
currSegment: { showModal: boolean }
currChildChunk: { showModal: boolean }
}) => unknown) =>
selector({
currSegment: { showModal: false },
currChildChunk: { showModal: false },
}),
}))
describe('Drawer', () => { describe('Drawer', () => {
const defaultProps = { const defaultProps = {
@ -31,103 +20,109 @@ describe('Drawer', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
capturedKeyPressCallback = undefined
}) })
describe('Rendering', () => { describe('Rendering', () => {
it('should return null when open is false', () => { it('should return null when open is false', () => {
const { container } = render( const { container } = render(
<Drawer open={false} onClose={vi.fn()}> <CompletedDrawer open={false} onClose={vi.fn()}>
<span>Content</span> <span>Content</span>
</Drawer>, </CompletedDrawer>,
) )
expect(container.innerHTML).toBe('') expect(container.innerHTML).toBe('')
expect(screen.queryByText('Content')).not.toBeInTheDocument() expect(screen.queryByText('Content')).not.toBeInTheDocument()
}) })
it('should render children in portal when open is true', () => { it('should render children in the drawer portal when open is true', () => {
render( render(
<Drawer {...defaultProps}> <CompletedDrawer {...defaultProps}>
<span>Drawer content</span> <span>Drawer content</span>
</Drawer>, </CompletedDrawer>,
) )
expect(screen.getByText('Drawer content')).toBeInTheDocument() expect(screen.getByText('Drawer content')).toBeInTheDocument()
})
it('should render dialog with role="dialog"', () => {
render(
<Drawer {...defaultProps}>
<span>Content</span>
</Drawer>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument() expect(screen.getByRole('dialog')).toBeInTheDocument()
}) })
}) })
// Overlay visibility describe('Variant', () => {
describe('Overlay', () => { it('should render a panel drawer without overlay by default', () => {
it('should show overlay when showOverlay is true', () => {
render( render(
<Drawer {...defaultProps} showOverlay={true}> <CompletedDrawer {...defaultProps}>
<span>Content</span> <span>Content</span>
</Drawer>, </CompletedDrawer>,
)
const overlay = document.querySelector('[aria-hidden="true"]')
expect(overlay).toBeInTheDocument()
})
it('should hide overlay when showOverlay is false', () => {
render(
<Drawer {...defaultProps} showOverlay={false}>
<span>Content</span>
</Drawer>,
)
const overlay = document.querySelector('[aria-hidden="true"]')
expect(overlay).not.toBeInTheDocument()
})
})
// aria-modal attribute
describe('aria-modal', () => {
it('should set aria-modal="true" when modal is true', () => {
render(
<Drawer {...defaultProps} modal={true}>
<span>Content</span>
</Drawer>,
)
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
})
it('should set aria-modal="false" when modal is false', () => {
render(
<Drawer {...defaultProps} modal={false}>
<span>Content</span>
</Drawer>,
) )
expect(getOverlay()).toBeUndefined()
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false') expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false')
}) })
})
// ESC key handling it('should render a modal drawer with overlay', () => {
describe('ESC Key', () => {
it('should call onClose when ESC is pressed and drawer is open', () => {
const onClose = vi.fn()
render( render(
<Drawer open={true} onClose={onClose}> <CompletedDrawer {...defaultProps} modal>
<span>Content</span> <span>Content</span>
</Drawer>, </CompletedDrawer>,
) )
expect(capturedKeyPressCallback).toBeDefined() expect(getOverlay()).toBeInTheDocument()
const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
capturedKeyPressCallback!(fakeEvent) })
})
describe('Dismissal', () => {
it('should call onClose when Escape is pressed', async () => {
const onClose = vi.fn()
render(
<CompletedDrawer open={true} onClose={onClose}>
<span>Content</span>
</CompletedDrawer>,
)
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1)
})
})
it('should keep a panel drawer open when the underlying page is clicked', () => {
const onClose = vi.fn()
render(
<>
<button type="button">Outside</button>
<CompletedDrawer open={true} onClose={onClose}>
<span>Content</span>
</CompletedDrawer>
</>,
)
fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' }))
expect(onClose).not.toHaveBeenCalled()
})
it('should keep a panel drawer open when the pointer down starts inside content', () => {
const onClose = vi.fn()
render(
<CompletedDrawer open={true} onClose={onClose}>
<button type="button">Inside</button>
</CompletedDrawer>,
)
fireEvent.pointerDown(screen.getByRole('button', { name: 'Inside' }))
expect(onClose).not.toHaveBeenCalled()
})
it('should close a modal drawer when the overlay is clicked', () => {
const onClose = vi.fn()
render(
<CompletedDrawer open={true} onClose={onClose} modal>
<span>Content</span>
</CompletedDrawer>,
)
fireEvent.click(getOverlay()!)
expect(onClose).toHaveBeenCalledTimes(1) expect(onClose).toHaveBeenCalledTimes(1)
}) })

View File

@ -1,11 +1,11 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import FullScreenDrawer from '../full-screen-drawer' import { DocumentDetailDrawer } from '../full-screen-drawer'
// Mock the Drawer component since it has high complexity // Mock the Drawer component since it has high complexity
vi.mock('../drawer', () => ({ vi.mock('../drawer', () => ({
default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => { CompletedDrawer: ({ children, open, panelClassName, panelContentClassName, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, modal: boolean }) => {
if (!open) if (!open)
return null return null
return ( return (
@ -13,8 +13,6 @@ vi.mock('../drawer', () => ({
data-testid="drawer-mock" data-testid="drawer-mock"
data-panel-class={panelClassName} data-panel-class={panelClassName}
data-panel-content-class={panelContentClassName} data-panel-content-class={panelContentClassName}
data-show-overlay={showOverlay}
data-need-check-chunks={needCheckChunks}
data-modal={modal} data-modal={modal}
> >
{children} {children}
@ -23,7 +21,7 @@ vi.mock('../drawer', () => ({
}, },
})) }))
describe('FullScreenDrawer', () => { describe('DocumentDetailDrawer', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
@ -31,9 +29,9 @@ describe('FullScreenDrawer', () => {
describe('Rendering', () => { describe('Rendering', () => {
it('should render without crashing when open', () => { it('should render without crashing when open', () => {
render( render(
<FullScreenDrawer isOpen={true} fullScreen={false}> <DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument() expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
@ -41,9 +39,9 @@ describe('FullScreenDrawer', () => {
it('should not render when closed', () => { it('should not render when closed', () => {
render( render(
<FullScreenDrawer isOpen={false} fullScreen={false}> <DocumentDetailDrawer open={false} fullScreen={false}>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument() expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
@ -51,9 +49,9 @@ describe('FullScreenDrawer', () => {
it('should render children content', () => { it('should render children content', () => {
render( render(
<FullScreenDrawer isOpen={true} fullScreen={false}> <DocumentDetailDrawer open={true} fullScreen={false}>
<div>Test Content</div> <div>Test Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
expect(screen.getByText('Test Content')).toBeInTheDocument() expect(screen.getByText('Test Content')).toBeInTheDocument()
@ -63,86 +61,46 @@ describe('FullScreenDrawer', () => {
describe('Props', () => { describe('Props', () => {
it('should pass fullScreen=true to Drawer with full width class', () => { it('should pass fullScreen=true to Drawer with full width class', () => {
render( render(
<FullScreenDrawer isOpen={true} fullScreen={true}> <DocumentDetailDrawer open={true} fullScreen={true}>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
const drawer = screen.getByTestId('drawer-mock') const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-panel-class')).toContain('w-full') expect(drawer.getAttribute('data-panel-class')).toContain('w-full')
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-full')
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-full')
}) })
it('should pass fullScreen=false to Drawer with fixed width class', () => { it('should pass fullScreen=false to Drawer with fixed width class', () => {
render( render(
<FullScreenDrawer isOpen={true} fullScreen={false}> <DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
const drawer = screen.getByTestId('drawer-mock') const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]') expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]')
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-[568px]')
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-[568px]')
}) })
it('should pass showOverlay prop with default true', () => { it('should render as non-modal by default', () => {
render( render(
<FullScreenDrawer isOpen={true} fullScreen={false}> <DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-show-overlay')).toBe('true')
})
it('should pass showOverlay=false when specified', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false} showOverlay={false}>
<div>Content</div>
</FullScreenDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-show-overlay')).toBe('false')
})
it('should pass needCheckChunks prop with default false', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-need-check-chunks')).toBe('false')
})
it('should pass needCheckChunks=true when specified', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false} needCheckChunks={true}>
<div>Content</div>
</FullScreenDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-need-check-chunks')).toBe('true')
})
it('should pass modal prop with default false', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
) )
const drawer = screen.getByTestId('drawer-mock') const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-modal')).toBe('false') expect(drawer.getAttribute('data-modal')).toBe('false')
}) })
it('should pass modal=true when specified', () => { it('should pass modal when specified', () => {
render( render(
<FullScreenDrawer isOpen={true} fullScreen={false} modal={true}> <DocumentDetailDrawer open={true} fullScreen={false} modal>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
const drawer = screen.getByTestId('drawer-mock') const drawer = screen.getByTestId('drawer-mock')
@ -154,9 +112,9 @@ describe('FullScreenDrawer', () => {
describe('Styling', () => { describe('Styling', () => {
it('should apply panel content classes for non-fullScreen mode', () => { it('should apply panel content classes for non-fullScreen mode', () => {
render( render(
<FullScreenDrawer isOpen={true} fullScreen={false}> <DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
const drawer = screen.getByTestId('drawer-mock') const drawer = screen.getByTestId('drawer-mock')
@ -167,9 +125,9 @@ describe('FullScreenDrawer', () => {
it('should apply panel content classes without border for fullScreen mode', () => { it('should apply panel content classes without border for fullScreen mode', () => {
render( render(
<FullScreenDrawer isOpen={true} fullScreen={true}> <DocumentDetailDrawer open={true} fullScreen={true}>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
const drawer = screen.getByTestId('drawer-mock') const drawer = screen.getByTestId('drawer-mock')
@ -184,24 +142,24 @@ describe('FullScreenDrawer', () => {
// Arrange & Act & Assert - should not throw // Arrange & Act & Assert - should not throw
expect(() => { expect(() => {
render( render(
<FullScreenDrawer isOpen={true} fullScreen={false}> <DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
}).not.toThrow() }).not.toThrow()
}) })
it('should maintain structure when rerendered', () => { it('should maintain structure when rerendered', () => {
const { rerender } = render( const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}> <DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
rerender( rerender(
<FullScreenDrawer isOpen={true} fullScreen={true}> <DocumentDetailDrawer open={true} fullScreen={true}>
<div>Updated Content</div> <div>Updated Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
expect(screen.getByText('Updated Content')).toBeInTheDocument() expect(screen.getByText('Updated Content')).toBeInTheDocument()
@ -209,16 +167,16 @@ describe('FullScreenDrawer', () => {
it('should handle toggle between open and closed states', () => { it('should handle toggle between open and closed states', () => {
const { rerender } = render( const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}> <DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument() expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
rerender( rerender(
<FullScreenDrawer isOpen={false} fullScreen={false}> <DocumentDetailDrawer open={false} fullScreen={false}>
<div>Content</div> <div>Content</div>
</FullScreenDrawer>, </DocumentDetailDrawer>,
) )
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument() expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()

View File

@ -1,143 +1,92 @@
import type { ComponentProps, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useKeyPress } from 'ahooks' import {
import * as React from 'react' Drawer,
import { useCallback, useEffect, useRef } from 'react' DrawerBackdrop,
import { createPortal } from 'react-dom' DrawerContent,
import { useSegmentListContext } from '..' DrawerPopup,
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
type DrawerProps = { type DrawerSide = 'right' | 'left' | 'bottom' | 'top'
type DrawerSwipeDirection = 'right' | 'left' | 'down' | 'up'
type DrawerOpenChange = NonNullable<ComponentProps<typeof Drawer>['onOpenChange']>
type CompletedDrawerProps = {
open: boolean open: boolean
onClose: () => void onClose: () => void
side?: 'right' | 'left' | 'bottom' | 'top' side?: DrawerSide
showOverlay?: boolean
modal?: boolean // click outside event can pass through if modal is false
closeOnOutsideClick?: boolean
panelClassName?: string panelClassName?: string
panelContentClassName?: string panelContentClassName?: string
needCheckChunks?: boolean modal?: boolean
children: ReactNode
} }
const SIDE_POSITION_CLASS = { const SIDE_TO_SWIPE_DIRECTION: Record<DrawerSide, DrawerSwipeDirection> = {
right: 'right-0', right: 'right',
left: 'left-0', left: 'left',
bottom: 'bottom-0', bottom: 'down',
top: 'top-0', top: 'up',
} as const
function containsTarget(selector: string, target: Node | null): boolean {
const elements = document.querySelectorAll(selector)
return Array.from(elements).some(el => el?.contains(target))
} }
function shouldReopenChunkDetail( const DRAWER_POPUP_CLASS_NAME = [
isClickOnChunk: boolean, 'pointer-events-auto overflow-visible border-0 bg-transparent shadow-none',
isClickOnChildChunk: boolean, 'data-[swipe-direction=right]:h-screen data-[swipe-direction=right]:max-w-none data-[swipe-direction=right]:rounded-none data-[swipe-direction=right]:border-0',
segmentModalOpen: boolean, 'data-[swipe-direction=left]:h-screen data-[swipe-direction=left]:max-w-none data-[swipe-direction=left]:rounded-none data-[swipe-direction=left]:border-0',
childChunkModalOpen: boolean, 'data-[swipe-direction=down]:max-h-none data-[swipe-direction=down]:rounded-none data-[swipe-direction=down]:border-0',
): boolean { 'data-[swipe-direction=up]:max-h-none data-[swipe-direction=up]:rounded-none data-[swipe-direction=up]:border-0',
if (segmentModalOpen && isClickOnChildChunk) ].join(' ')
return true
if (childChunkModalOpen && isClickOnChunk && !isClickOnChildChunk)
return true
return !isClickOnChunk && !isClickOnChildChunk
}
const Drawer = ({ export function CompletedDrawer({
open, open,
onClose, onClose,
side = 'right', side = 'right',
showOverlay = true,
modal = false,
needCheckChunks = false,
children, children,
panelClassName, panelClassName,
panelContentClassName, panelContentClassName,
}: React.PropsWithChildren<DrawerProps>) => { modal = false,
const panelContentRef = useRef<HTMLDivElement>(null) }: CompletedDrawerProps) {
const currSegment = useSegmentListContext(s => s.currSegment) const handleOpenChange: DrawerOpenChange = (nextOpen, eventDetails) => {
const currChildChunk = useSegmentListContext(s => s.currChildChunk) if (nextOpen)
useKeyPress('esc', (e) => {
if (!open)
return return
e.preventDefault()
if (eventDetails.reason === 'focus-out' || eventDetails.reason === 'outside-press')
return
onClose() onClose()
}, { exactMatch: true, useCapture: true }) }
const shouldCloseDrawer = useCallback((target: Node | null) => {
const panelContent = panelContentRef.current
if (!panelContent || !target)
return false
if (panelContent.contains(target))
return false
if (containsTarget('.image-previewer', target))
return false
if (!needCheckChunks)
return true
const isClickOnChunk = containsTarget('.chunk-card', target)
const isClickOnChildChunk = containsTarget('.child-chunk', target)
return shouldReopenChunkDetail(isClickOnChunk, isClickOnChildChunk, currSegment.showModal, currChildChunk.showModal)
}, [currSegment.showModal, currChildChunk.showModal, needCheckChunks])
const onDownCapture = useCallback((e: PointerEvent) => {
if (!open || modal)
return
const panelContent = panelContentRef.current
if (!panelContent)
return
const target = e.target as Node | null
if (shouldCloseDrawer(target))
queueMicrotask(onClose)
}, [shouldCloseDrawer, onClose, open, modal])
useEffect(() => {
window.addEventListener('pointerdown', onDownCapture, { capture: true })
return () =>
window.removeEventListener('pointerdown', onDownCapture, { capture: true })
}, [onDownCapture])
const isHorizontal = side === 'left' || side === 'right'
const overlayPointerEvents = modal && open ? 'pointer-events-auto' : 'pointer-events-none'
const content = (
<div className="pointer-events-none fixed inset-0 z-9999">
{showOverlay && (
<div
onClick={modal ? onClose : undefined}
aria-hidden="true"
className={cn(
'fixed inset-0 bg-black/30 opacity-0 transition-opacity duration-200 ease-in',
open && 'opacity-100',
overlayPointerEvents,
)}
/>
)}
<div
role="dialog"
aria-modal={modal ? 'true' : 'false'}
className={cn(
'pointer-events-auto fixed flex flex-col',
SIDE_POSITION_CLASS[side],
isHorizontal ? 'h-screen' : 'w-screen',
panelClassName,
)}
>
<div ref={panelContentRef} className={cn('flex grow flex-col', panelContentClassName)}>
{children}
</div>
</div>
</div>
)
if (!open) if (!open)
return null return null
return createPortal(content, document.body) return (
<Drawer
open={open}
modal={modal}
swipeDirection={SIDE_TO_SWIPE_DIRECTION[side]}
disablePointerDismissal
onOpenChange={handleOpenChange}
>
<DrawerPortal>
{modal && (
<DrawerBackdrop
onClick={onClose}
/>
)}
<DrawerViewport className="pointer-events-none">
<DrawerPopup
aria-modal={modal ? 'true' : 'false'}
className={cn(DRAWER_POPUP_CLASS_NAME, panelClassName)}
>
<DrawerContent
className={cn('flex grow flex-col overflow-visible p-0 pb-0', panelContentClassName)}
>
{children}
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
} }
export default Drawer

View File

@ -1,46 +1,39 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { noop } from 'es-toolkit/function' import { noop } from 'es-toolkit/function'
import * as React from 'react' import { CompletedDrawer } from './drawer'
import Drawer from './drawer'
type IFullScreenDrawerProps = { type DocumentDetailDrawerProps = {
isOpen: boolean open: boolean
onClose?: () => void onClose?: () => void
fullScreen: boolean fullScreen: boolean
showOverlay?: boolean
needCheckChunks?: boolean
modal?: boolean modal?: boolean
children: ReactNode
} }
const FullScreenDrawer = ({ export function DocumentDetailDrawer({
isOpen, open,
onClose = noop, onClose = noop,
fullScreen, fullScreen,
children, children,
showOverlay = true,
needCheckChunks = false,
modal = false, modal = false,
}: React.PropsWithChildren<IFullScreenDrawerProps>) => { }: DocumentDetailDrawerProps) {
return ( return (
<Drawer <CompletedDrawer
open={isOpen} open={open}
onClose={onClose} onClose={onClose}
panelClassName={cn( panelClassName={cn(
fullScreen fullScreen
? 'w-full' ? 'w-full data-[swipe-direction=left]:w-full data-[swipe-direction=right]:w-full'
: 'w-[568px] pt-16 pr-2 pb-2', : 'w-[568px] pt-16 pr-2 pb-2 data-[swipe-direction=left]:w-[568px] data-[swipe-direction=right]:w-[568px]',
)} )}
panelContentClassName={cn( panelContentClassName={cn(
'bg-components-panel-bg', 'bg-components-panel-bg',
!fullScreen && 'rounded-xl border-[0.5px] border-components-panel-border', !fullScreen && 'rounded-xl border-[0.5px] border-components-panel-border',
)} )}
showOverlay={showOverlay}
needCheckChunks={needCheckChunks}
modal={modal} modal={modal}
> >
{children} {children}
</Drawer> </CompletedDrawer>
) )
} }
export default FullScreenDrawer

View File

@ -2,16 +2,16 @@ import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets' import { ChunkingMode } from '@/models/datasets'
import DrawerGroup from '../drawer-group' import { DrawerGroup } from '../drawer-group'
vi.mock('../../common/full-screen-drawer', () => ({ vi.mock('../../common/full-screen-drawer', () => ({
default: ({ isOpen, children }: { isOpen: boolean, children: React.ReactNode }) => ( DocumentDetailDrawer: ({ open, children, modal = false }: { open: boolean, children: React.ReactNode, modal?: boolean }) => (
isOpen ? <div data-testid="full-screen-drawer">{children}</div> : null open ? <div data-testid="document-detail-drawer" data-modal={modal}>{children}</div> : null
), ),
})) }))
vi.mock('../../segment-detail', () => ({ vi.mock('../../segment-detail', () => ({
default: () => <div data-testid="segment-detail" />, SegmentDetail: () => <div data-testid="segment-detail" />,
})) }))
vi.mock('../../child-segment-detail', () => ({ vi.mock('../../child-segment-detail', () => ({
@ -31,8 +31,6 @@ describe('DrawerGroup', () => {
currSegment: { segInfo: undefined, showModal: false, isEditMode: false }, currSegment: { segInfo: undefined, showModal: false, isEditMode: false },
onCloseSegmentDetail: vi.fn(), onCloseSegmentDetail: vi.fn(),
onUpdateSegment: vi.fn(), onUpdateSegment: vi.fn(),
isRegenerationModalOpen: false,
setIsRegenerationModalOpen: vi.fn(),
showNewSegmentModal: false, showNewSegmentModal: false,
onCloseNewSegmentModal: vi.fn(), onCloseNewSegmentModal: vi.fn(),
onSaveNewSegment: vi.fn(), onSaveNewSegment: vi.fn(),
@ -55,7 +53,7 @@ describe('DrawerGroup', () => {
it('should render nothing when all modals are closed', () => { it('should render nothing when all modals are closed', () => {
const { container } = render(<DrawerGroup {...defaultProps} />) const { container } = render(<DrawerGroup {...defaultProps} />)
expect(container.querySelector('[data-testid="full-screen-drawer"]')).toBeNull() expect(container.querySelector('[data-testid="document-detail-drawer"]')).toBeNull()
}) })
it('should render segment detail when segment modal is open', () => { it('should render segment detail when segment modal is open', () => {
@ -66,6 +64,7 @@ describe('DrawerGroup', () => {
/>, />,
) )
expect(screen.getByTestId('segment-detail')).toBeInTheDocument() expect(screen.getByTestId('segment-detail')).toBeInTheDocument()
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false')
}) })
it('should render new segment modal when showNewSegmentModal is true', () => { it('should render new segment modal when showNewSegmentModal is true', () => {
@ -73,6 +72,7 @@ describe('DrawerGroup', () => {
<DrawerGroup {...defaultProps} showNewSegmentModal={true} />, <DrawerGroup {...defaultProps} showNewSegmentModal={true} />,
) )
expect(screen.getByTestId('new-segment')).toBeInTheDocument() expect(screen.getByTestId('new-segment')).toBeInTheDocument()
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true')
}) })
it('should render child segment detail when child chunk modal is open', () => { it('should render child segment detail when child chunk modal is open', () => {
@ -83,6 +83,7 @@ describe('DrawerGroup', () => {
/>, />,
) )
expect(screen.getByTestId('child-segment-detail')).toBeInTheDocument() expect(screen.getByTestId('child-segment-detail')).toBeInTheDocument()
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false')
}) })
it('should render new child segment modal when showNewChildSegmentModal is true', () => { it('should render new child segment modal when showNewChildSegmentModal is true', () => {
@ -90,6 +91,7 @@ describe('DrawerGroup', () => {
<DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />, <DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />,
) )
expect(screen.getByTestId('new-child-segment')).toBeInTheDocument() expect(screen.getByTestId('new-child-segment')).toBeInTheDocument()
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true')
}) })
it('should render multiple drawers simultaneously', () => { it('should render multiple drawers simultaneously', () => {

View File

@ -1,15 +1,13 @@
'use client' 'use client'
import type { FC } from 'react'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, ChunkingMode, SegmentDetailModel } from '@/models/datasets' import type { ChildChunkDetail, ChunkingMode, SegmentDetailModel } from '@/models/datasets'
import NewSegment from '@/app/components/datasets/documents/detail/new-segment' import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
import ChildSegmentDetail from '../child-segment-detail' import ChildSegmentDetail from '../child-segment-detail'
import FullScreenDrawer from '../common/full-screen-drawer' import { DocumentDetailDrawer } from '../common/full-screen-drawer'
import NewChildSegment from '../new-child-segment' import NewChildSegment from '../new-child-segment'
import SegmentDetail from '../segment-detail' import { SegmentDetail } from '../segment-detail'
type DrawerGroupProps = { type DrawerGroupProps = {
// Segment detail drawer
currSegment: { currSegment: {
segInfo?: SegmentDetailModel segInfo?: SegmentDetailModel
showModal: boolean showModal: boolean
@ -25,14 +23,10 @@ type DrawerGroupProps = {
summary?: string, summary?: string,
needRegenerate?: boolean, needRegenerate?: boolean,
) => Promise<void> ) => Promise<void>
isRegenerationModalOpen: boolean
setIsRegenerationModalOpen: (open: boolean) => void
// New segment drawer
showNewSegmentModal: boolean showNewSegmentModal: boolean
onCloseNewSegmentModal: () => void onCloseNewSegmentModal: () => void
onSaveNewSegment: () => void onSaveNewSegment: () => void
viewNewlyAddedChunk: () => void viewNewlyAddedChunk: () => void
// Child segment detail drawer
currChildChunk: { currChildChunk: {
childChunkInfo?: ChildChunkDetail childChunkInfo?: ChildChunkDetail
showModal: boolean showModal: boolean
@ -40,52 +34,39 @@ type DrawerGroupProps = {
currChunkId: string currChunkId: string
onCloseChildSegmentDetail: () => void onCloseChildSegmentDetail: () => void
onUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void> onUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void>
// New child segment drawer
showNewChildSegmentModal: boolean showNewChildSegmentModal: boolean
onCloseNewChildChunkModal: () => void onCloseNewChildChunkModal: () => void
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
viewNewlyAddedChildChunk: () => void viewNewlyAddedChildChunk: () => void
// Common props
fullScreen: boolean fullScreen: boolean
docForm: ChunkingMode docForm: ChunkingMode
} }
const DrawerGroup: FC<DrawerGroupProps> = ({ export function DrawerGroup({
// Segment detail drawer
currSegment, currSegment,
onCloseSegmentDetail, onCloseSegmentDetail,
onUpdateSegment, onUpdateSegment,
isRegenerationModalOpen,
setIsRegenerationModalOpen,
// New segment drawer
showNewSegmentModal, showNewSegmentModal,
onCloseNewSegmentModal, onCloseNewSegmentModal,
onSaveNewSegment, onSaveNewSegment,
viewNewlyAddedChunk, viewNewlyAddedChunk,
// Child segment detail drawer
currChildChunk, currChildChunk,
currChunkId, currChunkId,
onCloseChildSegmentDetail, onCloseChildSegmentDetail,
onUpdateChildChunk, onUpdateChildChunk,
// New child segment drawer
showNewChildSegmentModal, showNewChildSegmentModal,
onCloseNewChildChunkModal, onCloseNewChildChunkModal,
onSaveNewChildChunk, onSaveNewChildChunk,
viewNewlyAddedChildChunk, viewNewlyAddedChildChunk,
// Common props
fullScreen, fullScreen,
docForm, docForm,
}) => { }: DrawerGroupProps) {
return ( return (
<> <>
{/* Edit or view segment detail */} <DocumentDetailDrawer
<FullScreenDrawer open={currSegment.showModal}
isOpen={currSegment.showModal}
fullScreen={fullScreen} fullScreen={fullScreen}
onClose={onCloseSegmentDetail} onClose={onCloseSegmentDetail}
showOverlay={false}
needCheckChunks
modal={isRegenerationModalOpen}
> >
<SegmentDetail <SegmentDetail
key={currSegment.segInfo?.id} key={currSegment.segInfo?.id}
@ -94,13 +75,11 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
isEditMode={currSegment.isEditMode} isEditMode={currSegment.isEditMode}
onUpdate={onUpdateSegment} onUpdate={onUpdateSegment}
onCancel={onCloseSegmentDetail} onCancel={onCloseSegmentDetail}
onModalStateChange={setIsRegenerationModalOpen}
/> />
</FullScreenDrawer> </DocumentDetailDrawer>
{/* Create New Segment */} <DocumentDetailDrawer
<FullScreenDrawer open={showNewSegmentModal}
isOpen={showNewSegmentModal}
fullScreen={fullScreen} fullScreen={fullScreen}
onClose={onCloseNewSegmentModal} onClose={onCloseNewSegmentModal}
modal modal
@ -111,15 +90,12 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onSave={onSaveNewSegment} onSave={onSaveNewSegment}
viewNewlyAddedChunk={viewNewlyAddedChunk} viewNewlyAddedChunk={viewNewlyAddedChunk}
/> />
</FullScreenDrawer> </DocumentDetailDrawer>
{/* Edit or view child segment detail */} <DocumentDetailDrawer
<FullScreenDrawer open={currChildChunk.showModal}
isOpen={currChildChunk.showModal}
fullScreen={fullScreen} fullScreen={fullScreen}
onClose={onCloseChildSegmentDetail} onClose={onCloseChildSegmentDetail}
showOverlay={false}
needCheckChunks
> >
<ChildSegmentDetail <ChildSegmentDetail
key={currChildChunk.childChunkInfo?.id} key={currChildChunk.childChunkInfo?.id}
@ -129,11 +105,10 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onUpdate={onUpdateChildChunk} onUpdate={onUpdateChildChunk}
onCancel={onCloseChildSegmentDetail} onCancel={onCloseChildSegmentDetail}
/> />
</FullScreenDrawer> </DocumentDetailDrawer>
{/* Create New Child Segment */} <DocumentDetailDrawer
<FullScreenDrawer open={showNewChildSegmentModal}
isOpen={showNewChildSegmentModal}
fullScreen={fullScreen} fullScreen={fullScreen}
onClose={onCloseNewChildChunkModal} onClose={onCloseNewChildChunkModal}
modal modal
@ -144,9 +119,7 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onSave={onSaveNewChildChunk} onSave={onSaveNewChildChunk}
viewNewlyAddedChildChunk={viewNewlyAddedChildChunk} viewNewlyAddedChildChunk={viewNewlyAddedChildChunk}
/> />
</FullScreenDrawer> </DocumentDetailDrawer>
</> </>
) )
} }
export default DrawerGroup

View File

@ -1,3 +0,0 @@
export { default as DrawerGroup } from './drawer-group'
export { default as MenuBar } from './menu-bar'
export { FullDocModeContent, GeneralModeContent } from './segment-list-content'

View File

@ -1,7 +1,9 @@
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets' import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react' import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useModalState } from '../use-modal-state' import * as modalStateHooks from '../use-modal-state'
const renderDatasetModalState = modalStateHooks.useModalState
describe('useModalState', () => { describe('useModalState', () => {
const onNewSegmentModalChange = vi.fn() const onNewSegmentModalChange = vi.fn()
@ -10,22 +12,21 @@ describe('useModalState', () => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
const renderUseModalState = () => const renderModalState = () =>
renderHook(() => useModalState({ onNewSegmentModalChange })) renderHook(() => renderDatasetModalState({ onNewSegmentModalChange }))
it('should initialize with all modals closed', () => { it('should initialize with all modals closed', () => {
const { result } = renderUseModalState() const { result } = renderModalState()
expect(result.current.currSegment.showModal).toBe(false) expect(result.current.currSegment.showModal).toBe(false)
expect(result.current.currChildChunk.showModal).toBe(false) expect(result.current.currChildChunk.showModal).toBe(false)
expect(result.current.showNewChildSegmentModal).toBe(false) expect(result.current.showNewChildSegmentModal).toBe(false)
expect(result.current.isRegenerationModalOpen).toBe(false)
expect(result.current.fullScreen).toBe(false) expect(result.current.fullScreen).toBe(false)
expect(result.current.isCollapsed).toBe(true) expect(result.current.isCollapsed).toBe(true)
}) })
it('should open segment detail on card click', () => { it('should open segment detail on card click', () => {
const { result } = renderUseModalState() const { result } = renderModalState()
const detail = { id: 'seg-1', content: 'test' } as unknown as SegmentDetailModel const detail = { id: 'seg-1', content: 'test' } as unknown as SegmentDetailModel
act(() => { act(() => {
@ -37,8 +38,25 @@ describe('useModalState', () => {
expect(result.current.currSegment.isEditMode).toBe(true) expect(result.current.currSegment.isEditMode).toBe(true)
}) })
it('should close child detail when opening segment detail', () => {
const { result } = renderModalState()
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel
act(() => {
result.current.onClickSlice(childDetail)
})
act(() => {
result.current.onClickCard(segmentDetail)
})
expect(result.current.currSegment.showModal).toBe(true)
expect(result.current.currSegment.segInfo).toBe(segmentDetail)
expect(result.current.currChildChunk.showModal).toBe(false)
})
it('should close segment detail and reset fullscreen', () => { it('should close segment detail and reset fullscreen', () => {
const { result } = renderUseModalState() const { result } = renderModalState()
act(() => { act(() => {
result.current.onClickCard({ id: 'seg-1' } as unknown as SegmentDetailModel) result.current.onClickCard({ id: 'seg-1' } as unknown as SegmentDetailModel)
@ -55,7 +73,7 @@ describe('useModalState', () => {
}) })
it('should open child segment detail on slice click', () => { it('should open child segment detail on slice click', () => {
const { result } = renderUseModalState() const { result } = renderModalState()
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
act(() => { act(() => {
@ -67,8 +85,25 @@ describe('useModalState', () => {
expect(result.current.currChunkId).toBe('seg-1') expect(result.current.currChunkId).toBe('seg-1')
}) })
it('should close segment detail when opening child detail', () => {
const { result } = renderModalState()
const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
act(() => {
result.current.onClickCard(segmentDetail)
})
act(() => {
result.current.onClickSlice(childDetail)
})
expect(result.current.currSegment.showModal).toBe(false)
expect(result.current.currChildChunk.showModal).toBe(true)
expect(result.current.currChildChunk.childChunkInfo).toBe(childDetail)
})
it('should close child segment detail', () => { it('should close child segment detail', () => {
const { result } = renderUseModalState() const { result } = renderModalState()
act(() => { act(() => {
result.current.onClickSlice({ id: 'c1', segment_id: 's1' } as unknown as ChildChunkDetail) result.current.onClickSlice({ id: 'c1', segment_id: 's1' } as unknown as ChildChunkDetail)
@ -81,7 +116,7 @@ describe('useModalState', () => {
}) })
it('should handle new child chunk modal', () => { it('should handle new child chunk modal', () => {
const { result } = renderUseModalState() const { result } = renderModalState()
act(() => { act(() => {
result.current.handleAddNewChildChunk('parent-chunk-1') result.current.handleAddNewChildChunk('parent-chunk-1')
@ -98,7 +133,7 @@ describe('useModalState', () => {
}) })
it('should close new segment modal and notify parent', () => { it('should close new segment modal and notify parent', () => {
const { result } = renderUseModalState() const { result } = renderModalState()
act(() => { act(() => {
result.current.onCloseNewSegmentModal() result.current.onCloseNewSegmentModal()
@ -108,7 +143,7 @@ describe('useModalState', () => {
}) })
it('should toggle full screen', () => { it('should toggle full screen', () => {
const { result } = renderUseModalState() const { result } = renderModalState()
act(() => { act(() => {
result.current.toggleFullScreen() result.current.toggleFullScreen()
@ -122,7 +157,7 @@ describe('useModalState', () => {
}) })
it('should toggle collapsed', () => { it('should toggle collapsed', () => {
const { result } = renderUseModalState() const { result } = renderModalState()
act(() => { act(() => {
result.current.toggleCollapsed() result.current.toggleCollapsed()
@ -134,13 +169,4 @@ describe('useModalState', () => {
}) })
expect(result.current.isCollapsed).toBe(true) expect(result.current.isCollapsed).toBe(true)
}) })
it('should set regeneration modal state', () => {
const { result } = renderUseModalState()
act(() => {
result.current.setIsRegenerationModalOpen(true)
})
expect(result.current.isRegenerationModalOpen).toBe(true)
})
}) })

View File

@ -1,11 +1,12 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets' import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets'
import type { SegmentImportStatus } from '@/types/dataset'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react' import { act, renderHook } from '@testing-library/react'
import * as React from 'react' import * as React from 'react'
import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets' import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets'
import { ProcessStatus } from '../../../segment-add' import { segmentImportStatus } from '@/types/dataset'
import { useSegmentListData } from '../use-segment-list-data' import { useSegmentListData } from '../use-segment-list-data'
// Type for mutation callbacks // Type for mutation callbacks
@ -176,7 +177,7 @@ const defaultOptions = {
searchValue: '', searchValue: '',
selectedStatus: 'all' as boolean | 'all', selectedStatus: 'all' as boolean | 'all',
selectedSegmentIds: [] as string[], selectedSegmentIds: [] as string[],
importStatus: undefined as ProcessStatus | string | undefined, importStatus: undefined as SegmentImportStatus | undefined,
currentPage: 1, currentPage: 1,
limit: 10, limit: 10,
onCloseSegmentDetail: vi.fn(), onCloseSegmentDetail: vi.fn(),
@ -689,7 +690,7 @@ describe('useSegmentListData', () => {
renderHook(() => useSegmentListData({ renderHook(() => useSegmentListData({
...defaultOptions, ...defaultOptions,
importStatus: ProcessStatus.COMPLETED, importStatus: segmentImportStatus.completed,
clearSelection, clearSelection,
}), { }), {
wrapper: createWrapper(), wrapper: createWrapper(),

View File

@ -13,29 +13,20 @@ type CurrChildChunkType = {
} }
type UseModalStateReturn = { type UseModalStateReturn = {
// Segment detail modal
currSegment: CurrSegmentType currSegment: CurrSegmentType
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
onCloseSegmentDetail: () => void onCloseSegmentDetail: () => void
// Child segment detail modal
currChildChunk: CurrChildChunkType currChildChunk: CurrChildChunkType
currChunkId: string currChunkId: string
onClickSlice: (detail: ChildChunkDetail) => void onClickSlice: (detail: ChildChunkDetail) => void
onCloseChildSegmentDetail: () => void onCloseChildSegmentDetail: () => void
// New segment modal
onCloseNewSegmentModal: () => void onCloseNewSegmentModal: () => void
// New child segment modal
showNewChildSegmentModal: boolean showNewChildSegmentModal: boolean
handleAddNewChildChunk: (parentChunkId: string) => void handleAddNewChildChunk: (parentChunkId: string) => void
onCloseNewChildChunkModal: () => void onCloseNewChildChunkModal: () => void
// Regeneration modal
isRegenerationModalOpen: boolean
setIsRegenerationModalOpen: (open: boolean) => void
// Full screen
fullScreen: boolean fullScreen: boolean
toggleFullScreen: () => void toggleFullScreen: () => void
setFullScreen: (fullScreen: boolean) => void setFullScreen: (fullScreen: boolean) => void
// Collapsed state
isCollapsed: boolean isCollapsed: boolean
toggleCollapsed: () => void toggleCollapsed: () => void
} }
@ -47,25 +38,15 @@ type UseModalStateOptions = {
export const useModalState = (options: UseModalStateOptions): UseModalStateReturn => { export const useModalState = (options: UseModalStateOptions): UseModalStateReturn => {
const { onNewSegmentModalChange } = options const { onNewSegmentModalChange } = options
// Segment detail modal state
const [currSegment, setCurrSegment] = useState<CurrSegmentType>({ showModal: false }) const [currSegment, setCurrSegment] = useState<CurrSegmentType>({ showModal: false })
// Child segment detail modal state
const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ showModal: false }) const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ showModal: false })
const [currChunkId, setCurrChunkId] = useState('') const [currChunkId, setCurrChunkId] = useState('')
// New child segment modal state
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false) const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
// Regeneration modal state
const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
// Display state
const [fullScreen, setFullScreen] = useState(false) const [fullScreen, setFullScreen] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(true) const [isCollapsed, setIsCollapsed] = useState(true)
// Segment detail handlers
const onClickCard = useCallback((detail: SegmentDetailModel, isEditMode = false) => { const onClickCard = useCallback((detail: SegmentDetailModel, isEditMode = false) => {
setCurrChildChunk({ showModal: false })
setCurrSegment({ segInfo: detail, showModal: true, isEditMode }) setCurrSegment({ segInfo: detail, showModal: true, isEditMode })
}, []) }, [])
@ -74,8 +55,8 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
setFullScreen(false) setFullScreen(false)
}, []) }, [])
// Child segment detail handlers
const onClickSlice = useCallback((detail: ChildChunkDetail) => { const onClickSlice = useCallback((detail: ChildChunkDetail) => {
setCurrSegment({ showModal: false })
setCurrChildChunk({ childChunkInfo: detail, showModal: true }) setCurrChildChunk({ childChunkInfo: detail, showModal: true })
setCurrChunkId(detail.segment_id) setCurrChunkId(detail.segment_id)
}, []) }, [])
@ -85,13 +66,11 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
setFullScreen(false) setFullScreen(false)
}, []) }, [])
// New segment modal handlers
const onCloseNewSegmentModal = useCallback(() => { const onCloseNewSegmentModal = useCallback(() => {
onNewSegmentModalChange(false) onNewSegmentModalChange(false)
setFullScreen(false) setFullScreen(false)
}, [onNewSegmentModalChange]) }, [onNewSegmentModalChange])
// New child segment modal handlers
const handleAddNewChildChunk = useCallback((parentChunkId: string) => { const handleAddNewChildChunk = useCallback((parentChunkId: string) => {
setShowNewChildSegmentModal(true) setShowNewChildSegmentModal(true)
setCurrChunkId(parentChunkId) setCurrChunkId(parentChunkId)
@ -102,7 +81,6 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
setFullScreen(false) setFullScreen(false)
}, []) }, [])
// Display handlers - handles both direct calls and click events
const toggleFullScreen = useCallback(() => { const toggleFullScreen = useCallback(() => {
setFullScreen(prev => !prev) setFullScreen(prev => !prev)
}, []) }, [])
@ -112,29 +90,20 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
}, []) }, [])
return { return {
// Segment detail modal
currSegment, currSegment,
onClickCard, onClickCard,
onCloseSegmentDetail, onCloseSegmentDetail,
// Child segment detail modal
currChildChunk, currChildChunk,
currChunkId, currChunkId,
onClickSlice, onClickSlice,
onCloseChildSegmentDetail, onCloseChildSegmentDetail,
// New segment modal
onCloseNewSegmentModal, onCloseNewSegmentModal,
// New child segment modal
showNewChildSegmentModal, showNewChildSegmentModal,
handleAddNewChildChunk, handleAddNewChildChunk,
onCloseNewChildChunkModal, onCloseNewChildChunkModal,
// Regeneration modal
isRegenerationModalOpen,
setIsRegenerationModalOpen,
// Full screen
fullScreen, fullScreen,
toggleFullScreen, toggleFullScreen,
setFullScreen, setFullScreen,
// Collapsed state
isCollapsed, isCollapsed,
toggleCollapsed, toggleCollapsed,
} }

View File

@ -1,5 +1,6 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets' import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets'
import type { SegmentImportStatus } from '@/types/dataset'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@ -9,16 +10,16 @@ import { ChunkingMode } from '@/models/datasets'
import { usePathname } from '@/next/navigation' import { usePathname } from '@/next/navigation'
import { useChunkListAllKey, useChunkListDisabledKey, useChunkListEnabledKey, useDeleteSegment, useDisableSegment, useEnableSegment, useSegmentList, useSegmentListKey, useUpdateSegment } from '@/service/knowledge/use-segment' import { useChunkListAllKey, useChunkListDisabledKey, useChunkListEnabledKey, useDeleteSegment, useDisableSegment, useEnableSegment, useSegmentList, useSegmentListKey, useUpdateSegment } from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base' import { useInvalid } from '@/service/use-base'
import { segmentImportStatus } from '@/types/dataset'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../../context' import { useDocumentContext } from '../../context'
import { ProcessStatus } from '../../segment-add'
const DEFAULT_LIMIT = 10 const DEFAULT_LIMIT = 10
type UseSegmentListDataOptions = { type UseSegmentListDataOptions = {
searchValue: string searchValue: string
selectedStatus: boolean | 'all' selectedStatus: boolean | 'all'
selectedSegmentIds: string[] selectedSegmentIds: string[]
importStatus: ProcessStatus | string | undefined importStatus: SegmentImportStatus | undefined
currentPage: number currentPage: number
limit: number limit: number
onCloseSegmentDetail: () => void onCloseSegmentDetail: () => void
@ -92,7 +93,7 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme
}, [pathname]) }, [pathname])
// Reset list on import completion // Reset list on import completion
useEffect(() => { useEffect(() => {
if (importStatus === ProcessStatus.COMPLETED) { if (importStatus === segmentImportStatus.completed) {
clearSelection() clearSelection()
invalidSegmentList() invalidSegmentList()
} }

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import type { ProcessStatus } from '../segment-add'
import type { SegmentListContextValue } from './segment-list-context' import type { SegmentListContextValue } from './segment-list-context'
import type { SegmentImportStatus } from '@/types/dataset'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import Pagination from '@/app/components/base/pagination' import Pagination from '@/app/components/base/pagination'
@ -13,7 +13,9 @@ import {
import { useInvalid } from '@/service/use-base' import { useInvalid } from '@/service/use-base'
import { useDocumentContext } from '../context' import { useDocumentContext } from '../context'
import BatchAction from './common/batch-action' import BatchAction from './common/batch-action'
import { DrawerGroup, FullDocModeContent, GeneralModeContent, MenuBar } from './components' import { DrawerGroup } from './components/drawer-group'
import MenuBar from './components/menu-bar'
import { FullDocModeContent, GeneralModeContent } from './components/segment-list-content'
import { import {
useChildSegmentData, useChildSegmentData,
useModalState, useModalState,
@ -32,7 +34,7 @@ type ICompletedProps = {
embeddingAvailable: boolean embeddingAvailable: boolean
showNewSegmentModal: boolean showNewSegmentModal: boolean
onNewSegmentModalChange: (state: boolean) => void onNewSegmentModalChange: (state: boolean) => void
importStatus: ProcessStatus | string | undefined importStatus: SegmentImportStatus | undefined
archived?: boolean archived?: boolean
} }
@ -225,8 +227,6 @@ const Completed: FC<ICompletedProps> = ({
currSegment={modalState.currSegment} currSegment={modalState.currSegment}
onCloseSegmentDetail={modalState.onCloseSegmentDetail} onCloseSegmentDetail={modalState.onCloseSegmentDetail}
onUpdateSegment={segmentListDataHook.handleUpdateSegment} onUpdateSegment={segmentListDataHook.handleUpdateSegment}
isRegenerationModalOpen={modalState.isRegenerationModalOpen}
setIsRegenerationModalOpen={modalState.setIsRegenerationModalOpen}
showNewSegmentModal={showNewSegmentModal} showNewSegmentModal={showNewSegmentModal}
onCloseNewSegmentModal={modalState.onCloseNewSegmentModal} onCloseNewSegmentModal={modalState.onCloseNewSegmentModal}
onSaveNewSegment={segmentListDataHook.resetList} onSaveNewSegment={segmentListDataHook.resetList}

View File

@ -1,4 +1,3 @@
import type { FC } from 'react'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { SegmentDetailModel } from '@/models/datasets' import type { SegmentDetailModel } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
@ -7,7 +6,6 @@ import {
RiCollapseDiagonalLine, RiCollapseDiagonalLine,
RiExpandDiagonalLine, RiExpandDiagonalLine,
} from '@remixicon/react' } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { v4 as uuid4 } from 'uuid' import { v4 as uuid4 } from 'uuid'
@ -42,20 +40,15 @@ type ISegmentDetailProps = {
onCancel: () => void onCancel: () => void
isEditMode?: boolean isEditMode?: boolean
docForm: ChunkingMode docForm: ChunkingMode
onModalStateChange?: (isOpen: boolean) => void
} }
/** export function SegmentDetail({
* Show all the contents of the segment
*/
const SegmentDetail: FC<ISegmentDetailProps> = ({
segInfo, segInfo,
onUpdate, onUpdate,
onCancel, onCancel,
isEditMode, isEditMode,
docForm, docForm,
onModalStateChange, }: ISegmentDetailProps) {
}) => {
const { t } = useTranslation() const { t } = useTranslation()
const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '') const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '')
const [answer, setAnswer] = useState(segInfo?.answer || '') const [answer, setAnswer] = useState(segInfo?.answer || '')
@ -99,19 +92,16 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
const handleRegeneration = useCallback(() => { const handleRegeneration = useCallback(() => {
setShowRegenerationModal(true) setShowRegenerationModal(true)
onModalStateChange?.(true) }, [])
}, [onModalStateChange])
const onCancelRegeneration = useCallback(() => { const onCancelRegeneration = useCallback(() => {
setShowRegenerationModal(false) setShowRegenerationModal(false)
onModalStateChange?.(false) }, [])
}, [onModalStateChange])
const onCloseAfterRegeneration = useCallback(() => { const onCloseAfterRegeneration = useCallback(() => {
setShowRegenerationModal(false) setShowRegenerationModal(false)
onModalStateChange?.(false) onCancel()
onCancel() // Close the edit drawer }, [onCancel])
}, [onCancel, onModalStateChange])
const onConfirmRegeneration = useCallback(() => { const onConfirmRegeneration = useCallback(() => {
onUpdate(segInfo?.id || '', question, answer, keywords, attachments, summary, true) onUpdate(segInfo?.id || '', question, answer, keywords, attachments, summary, true)
@ -241,5 +231,3 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
</div> </div>
) )
} }
export default React.memo(SegmentDetail)

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import type { DataSourceInfo, DocumentDisplayStatus, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets' import type { DataSourceInfo, DocumentDisplayStatus, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
import type { SegmentImportStatus } from '@/types/dataset'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react' import * as React from 'react'
@ -17,6 +18,7 @@ import { useRouter, useSearchParams } from '@/next/navigation'
import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document' import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document'
import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment' import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base' import { useInvalid } from '@/service/use-base'
import { segmentImportStatus } from '@/types/dataset'
import Operations from '../components/operations' import Operations from '../components/operations'
import StatusItem from '../status-item' import StatusItem from '../status-item'
import BatchModal from './batch-modal' import BatchModal from './batch-modal'
@ -24,7 +26,7 @@ import Completed from './completed'
import { DocumentContext } from './context' import { DocumentContext } from './context'
import { DocumentTitle } from './document-title' import { DocumentTitle } from './document-title'
import Embedding from './embedding' import Embedding from './embedding'
import SegmentAdd, { ProcessStatus } from './segment-add' import { SegmentAdd } from './segment-add'
import style from './style.module.css' import style from './style.module.css'
type DocumentDetailProps = { type DocumentDetailProps = {
@ -53,20 +55,20 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
const [showMetadata, setShowMetadata] = useState(!isMobile) const [showMetadata, setShowMetadata] = useState(!isMobile)
const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false) const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false)
const [batchModalVisible, setBatchModalVisible] = useState(false) const [batchModalVisible, setBatchModalVisible] = useState(false)
const [importStatus, setImportStatus] = useState<ProcessStatus | string>() const [importStatus, setImportStatus] = useState<SegmentImportStatus>()
const showNewSegmentModal = () => setNewSegmentModalVisible(true) const showNewSegmentModal = () => setNewSegmentModalVisible(true)
const showBatchModal = () => setBatchModalVisible(true) const showBatchModal = () => setBatchModalVisible(true)
const hideBatchModal = () => setBatchModalVisible(false) const hideBatchModal = () => setBatchModalVisible(false)
const resetProcessStatus = () => setImportStatus('') const resetImportStatus = () => setImportStatus(undefined)
const { mutateAsync: checkSegmentBatchImportProgress } = useCheckSegmentBatchImportProgress() const { mutateAsync: checkSegmentBatchImportProgress } = useCheckSegmentBatchImportProgress()
const checkProcess = async (jobID: string) => { const checkProcess = async (jobID: string) => {
await checkSegmentBatchImportProgress({ jobID }, { await checkSegmentBatchImportProgress({ jobID }, {
onSuccess: (res) => { onSuccess: (res) => {
setImportStatus(res.job_status) setImportStatus(res.job_status)
if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING) if (res.job_status === segmentImportStatus.waiting || res.job_status === segmentImportStatus.processing)
setTimeout(() => checkProcess(res.job_id), 2500) setTimeout(() => checkProcess(res.job_id), 2500)
if (res.job_status === ProcessStatus.ERROR) if (res.job_status === segmentImportStatus.error)
toast.error(`${t('list.batchModal.runError', { ns: 'datasetDocuments' })}`) toast.error(`${t('list.batchModal.runError', { ns: 'datasetDocuments' })}`)
}, },
onError: (e) => { onError: (e) => {
@ -222,7 +224,7 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
<> <>
<SegmentAdd <SegmentAdd
importStatus={importStatus} importStatus={importStatus}
clearProcessStatus={resetProcessStatus} clearImportStatus={resetImportStatus}
showNewSegmentModal={showNewSegmentModal} showNewSegmentModal={showNewSegmentModal}
showBatchModal={showBatchModal} showBatchModal={showBatchModal}
embedding={embedding} embedding={embedding}

View File

@ -1,8 +1,10 @@
import type { SegmentImportStatus } from '@/types/dataset'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { segmentImportStatus } from '@/types/dataset'
import SegmentAdd, { ProcessStatus } from '../index' import { SegmentAdd } from '../index'
// Mock provider context // Mock provider context
let mockPlan = { type: Plan.professional } let mockPlan = { type: Plan.professional }
@ -22,8 +24,8 @@ describe('SegmentAdd', () => {
}) })
const defaultProps = { const defaultProps = {
importStatus: undefined as ProcessStatus | string | undefined, importStatus: undefined as SegmentImportStatus | undefined,
clearProcessStatus: vi.fn(), clearImportStatus: vi.fn(),
showNewSegmentModal: vi.fn(), showNewSegmentModal: vi.fn(),
showBatchModal: vi.fn(), showBatchModal: vi.fn(),
embedding: false, embedding: false,
@ -52,33 +54,33 @@ describe('SegmentAdd', () => {
// Import Status displays // Import Status displays
describe('Import Status Display', () => { describe('Import Status Display', () => {
it('should show processing indicator when status is WAITING', () => { it('should show processing indicator when status is WAITING', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />) render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />)
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
}) })
it('should show processing indicator when status is PROCESSING', () => { it('should show processing indicator when status is PROCESSING', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />) render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
}) })
it('should show completed status with ok button', () => { it('should show completed status with ok button', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.COMPLETED} />) render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.completed} />)
expect(screen.getByText(/list\.batchModal\.completed/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.completed/i)).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
}) })
it('should show error status with ok button', () => { it('should show error status with ok button', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.ERROR} />) render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.error} />)
expect(screen.getByText(/list\.batchModal\.error/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.error/i)).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
}) })
it('should not show add button when importStatus is set', () => { it('should not show add button when importStatus is set', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />) render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument() expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument()
}) })
@ -94,34 +96,34 @@ describe('SegmentAdd', () => {
expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1) expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1)
}) })
it('should call clearProcessStatus when ok is clicked on completed status', () => { it('should call clearImportStatus when ok is clicked on completed status', () => {
const mockClearProcessStatus = vi.fn() const mockClearImportStatus = vi.fn()
render( render(
<SegmentAdd <SegmentAdd
{...defaultProps} {...defaultProps}
importStatus={ProcessStatus.COMPLETED} importStatus={segmentImportStatus.completed}
clearProcessStatus={mockClearProcessStatus} clearImportStatus={mockClearImportStatus}
/>, />,
) )
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1) expect(mockClearImportStatus).toHaveBeenCalledTimes(1)
}) })
it('should call clearProcessStatus when ok is clicked on error status', () => { it('should call clearImportStatus when ok is clicked on error status', () => {
const mockClearProcessStatus = vi.fn() const mockClearImportStatus = vi.fn()
render( render(
<SegmentAdd <SegmentAdd
{...defaultProps} {...defaultProps}
importStatus={ProcessStatus.ERROR} importStatus={segmentImportStatus.error}
clearProcessStatus={mockClearProcessStatus} clearImportStatus={mockClearImportStatus}
/>, />,
) )
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1) expect(mockClearImportStatus).toHaveBeenCalledTimes(1)
}) })
it('should render batch add option in dropdown', async () => { it('should render batch add option in dropdown', async () => {
@ -215,14 +217,14 @@ describe('SegmentAdd', () => {
// Progress bar width tests // Progress bar width tests
describe('Progress Bar', () => { describe('Progress Bar', () => {
it('should show 3/12 width progress bar for WAITING status', () => { it('should show 3/12 width progress bar for WAITING status', () => {
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />) const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />)
const progressBar = container.querySelector('.w-3\\/12') const progressBar = container.querySelector('.w-3\\/12')
expect(progressBar).toBeInTheDocument() expect(progressBar).toBeInTheDocument()
}) })
it('should show 2/3 width progress bar for PROCESSING status', () => { it('should show 2/3 width progress bar for PROCESSING status', () => {
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />) const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
const progressBar = container.querySelector('.w-2\\/3') const progressBar = container.querySelector('.w-2\\/3')
expect(progressBar).toBeInTheDocument() expect(progressBar).toBeInTheDocument()
@ -230,15 +232,6 @@ describe('SegmentAdd', () => {
}) })
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should handle unknown importStatus string', () => {
// Arrange & Act - pass unknown status
const { container } = render(<SegmentAdd {...defaultProps} importStatus="unknown" />)
// Assert - empty fragment is rendered for unknown status (container exists but has no visible content)
expect(container).toBeInTheDocument()
expect(container.textContent).toBe('')
})
it('should maintain structure when rerendered', () => { it('should maintain structure when rerendered', () => {
const { rerender } = render(<SegmentAdd {...defaultProps} />) const { rerender } = render(<SegmentAdd {...defaultProps} />)

View File

@ -1,5 +1,5 @@
'use client' 'use client'
import type { FC } from 'react' import type { SegmentImportStatus } from '@/types/dataset'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { import {
DropdownMenu, DropdownMenu,
@ -7,95 +7,92 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu' } from '@langgenius/dify-ui/dropdown-menu'
import { useBoolean } from 'ahooks' import { useRef, useState } from 'react'
import * as React from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { segmentImportStatus } from '@/types/dataset'
type ISegmentAddProps = { type SegmentAddProps = {
importStatus: ProcessStatus | string | undefined importStatus: SegmentImportStatus | undefined
clearProcessStatus: () => void clearImportStatus: () => void
showNewSegmentModal: () => void showNewSegmentModal: () => void
showBatchModal: () => void showBatchModal: () => void
embedding: boolean embedding: boolean
} }
export enum ProcessStatus { export function SegmentAdd({
WAITING = 'waiting',
PROCESSING = 'processing',
COMPLETED = 'completed',
ERROR = 'error',
}
const SegmentAdd: FC<ISegmentAddProps> = ({
importStatus, importStatus,
clearProcessStatus, clearImportStatus,
showNewSegmentModal, showNewSegmentModal,
showBatchModal, showBatchModal,
embedding, embedding,
}) => { }: SegmentAddProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [isShowPlanUpgradeModal, {
setTrue: showPlanUpgradeModal,
setFalse: hidePlanUpgradeModal,
}] = useBoolean(false)
const { plan, enableBilling } = useProviderContext()
const { type } = plan
const canAdd = enableBilling ? type !== Plan.sandbox : true
const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false) const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false)
const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false)
const batchMenuAnchorRef = useRef<HTMLDivElement>(null) const batchMenuAnchorRef = useRef<HTMLDivElement>(null)
const { plan, enableBilling } = useProviderContext()
const canAddChunks = !enableBilling || plan.type !== Plan.sandbox
const withNeedUpgradeCheck = useCallback((fn: () => void) => { const textColor = embedding
return () => { ? 'text-components-button-secondary-accent-text-disabled'
if (!canAdd) { : 'text-components-button-secondary-accent-text'
showPlanUpgradeModal()
return const handleAddClick = () => {
} if (!canAddChunks) {
fn() setIsPlanUpgradeModalOpen(true)
return
} }
}, [canAdd, showPlanUpgradeModal])
const textColor = useMemo(() => { showNewSegmentModal()
return embedding }
? 'text-components-button-secondary-accent-text-disabled'
: 'text-components-button-secondary-accent-text' const handleBatchAddClick = () => {
}, [embedding]) setIsBatchMenuOpen(false)
if (!canAddChunks) {
setIsPlanUpgradeModalOpen(true)
return
}
showBatchModal()
}
if (importStatus) { if (importStatus) {
return ( return (
<> <>
{(importStatus === ProcessStatus.WAITING || importStatus === ProcessStatus.PROCESSING) && ( {(importStatus === segmentImportStatus.waiting || importStatus === segmentImportStatus.processing) && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-progress-bar-border <div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-progress-bar-border
bg-components-progress-bar-border px-2.5 py-2 text-components-button-secondary-accent-text bg-components-progress-bar-border px-2.5 py-2 text-components-button-secondary-accent-text
shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]" shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"
> >
<div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === ProcessStatus.WAITING ? 'w-3/12' : 'w-2/3')} /> <div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === segmentImportStatus.waiting ? 'w-3/12' : 'w-2/3')} />
<span aria-hidden className="mr-1 i-ri-loader-2-line h-4 w-4 animate-spin" /> <span aria-hidden className="mr-1 i-ri-loader-2-line h-4 w-4 animate-spin" />
<span className="z-10 pr-0.5 system-sm-medium">{t('list.batchModal.processing', { ns: 'datasetDocuments' })}</span> <span className="z-10 pr-0.5 system-sm-medium">{t('list.batchModal.processing', { ns: 'datasetDocuments' })}</span>
</div> </div>
)} )}
{importStatus === ProcessStatus.COMPLETED && ( {importStatus === segmentImportStatus.completed && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"> <div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-success"> <div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-success">
<span aria-hidden className="mr-1 i-custom-vender-solid-general-check-circle h-4 w-4" /> <span aria-hidden className="mr-1 i-custom-vender-solid-general-check-circle h-4 w-4" />
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.completed', { ns: 'datasetDocuments' })}</span> <span className="pr-0.5 system-sm-medium">{t('list.batchModal.completed', { ns: 'datasetDocuments' })}</span>
</div> </div>
<div className="m-1 inline-flex items-center"> <div className="m-1 inline-flex items-center">
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearProcessStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span> <span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
</div> </div>
<div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-success-bg opacity-40" /> <div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-success-bg opacity-40" />
</div> </div>
)} )}
{importStatus === ProcessStatus.ERROR && ( {importStatus === segmentImportStatus.error && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"> <div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-destructive"> <div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-destructive">
<span aria-hidden className="mr-1 i-ri-error-warning-fill h-4 w-4" /> <span aria-hidden className="mr-1 i-ri-error-warning-fill h-4 w-4" />
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.error', { ns: 'datasetDocuments' })}</span> <span className="pr-0.5 system-sm-medium">{t('list.batchModal.error', { ns: 'datasetDocuments' })}</span>
</div> </div>
<div className="m-1 inline-flex items-center"> <div className="m-1 inline-flex items-center">
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearProcessStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span> <span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
</div> </div>
<div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-error-bg opacity-40" /> <div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-error-bg opacity-40" />
</div> </div>
@ -116,7 +113,7 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
type="button" type="button"
className={`inline-flex items-center rounded-l-lg border-r border-r-divider-subtle px-2.5 py-2 className={`inline-flex items-center rounded-l-lg border-r border-r-divider-subtle px-2.5 py-2
hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`} hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`}
onClick={withNeedUpgradeCheck(showNewSegmentModal)} onClick={handleAddClick}
disabled={embedding} disabled={embedding}
> >
<span aria-hidden className={cn('i-ri-add-line h-4 w-4', textColor)} /> <span aria-hidden className={cn('i-ri-add-line h-4 w-4', textColor)} />
@ -142,25 +139,20 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
placement="bottom-start" placement="bottom-start"
sideOffset={4} sideOffset={4}
positionerProps={{ anchor: batchMenuAnchorRef }} positionerProps={{ anchor: batchMenuAnchorRef }}
popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-0 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]" popupClassName="w-[var(--anchor-width)]"
> >
<div className="w-full p-1"> <DropdownMenuItem
<DropdownMenuItem className="system-md-regular"
className="h-auto w-full px-2 py-1.5 system-md-regular" onClick={handleBatchAddClick}
onClick={() => { >
setIsBatchMenuOpen(false) {t('list.action.batchAdd', { ns: 'datasetDocuments' })}
withNeedUpgradeCheck(showBatchModal)() </DropdownMenuItem>
}}
>
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
</DropdownMenuItem>
</div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{isShowPlanUpgradeModal && ( {isPlanUpgradeModalOpen && (
<PlanUpgradeModal <PlanUpgradeModal
show show
onClose={hidePlanUpgradeModal} onClose={() => setIsPlanUpgradeModalOpen(false)}
title={t('upgrade.addChunks.title', { ns: 'billing' })!} title={t('upgrade.addChunks.title', { ns: 'billing' })!}
description={t('upgrade.addChunks.description', { ns: 'billing' })!} description={t('upgrade.addChunks.description', { ns: 'billing' })!}
/> />
@ -169,4 +161,3 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
) )
} }
export default React.memo(SegmentAdd)

View File

@ -55,10 +55,6 @@ vi.mock('../../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />, ReadmeEntrance: () => <div data-testid="readme-entrance" />,
})) }))
vi.mock('../../../readme-panel/store', () => ({
ReadmeShowType: { modal: 'modal' },
}))
vi.mock('@/app/components/base/encrypted-bottom', () => ({ vi.mock('@/app/components/base/encrypted-bottom', () => ({
EncryptedBottom: () => <div data-testid="encrypted-bottom" />, EncryptedBottom: () => <div data-testid="encrypted-bottom" />,
})) }))

View File

@ -41,10 +41,6 @@ vi.mock('../../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />, ReadmeEntrance: () => <div data-testid="readme-entrance" />,
})) }))
vi.mock('../../../readme-panel/store', () => ({
ReadmeShowType: { modal: 'modal' },
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', () => { vi.mock('@/app/components/base/form/form-scenarios/auth', () => {
const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => { const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => {
mockAuthFormProps = props mockAuthFormProps = props

View File

@ -19,7 +19,6 @@ import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import { FormTypeEnum } from '@/app/components/base/form/types' import { FormTypeEnum } from '@/app/components/base/form/types'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { ReadmeEntrance } from '../../readme-panel/entrance' import { ReadmeEntrance } from '../../readme-panel/entrance'
import { ReadmeShowType } from '../../readme-panel/store'
import { import {
useAddPluginCredentialHook, useAddPluginCredentialHook,
useGetPluginCredentialSchemaHook, useGetPluginCredentialSchemaHook,
@ -159,7 +158,7 @@ const ApiKeyModal = ({
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginPayload.detail && ( {pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} /> <ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
)} )}
{ {
isLoading && ( isLoading && (

View File

@ -19,7 +19,6 @@ import {
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AuthForm from '@/app/components/base/form/form-scenarios/auth' import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import { ReadmeEntrance } from '../../readme-panel/entrance' import { ReadmeEntrance } from '../../readme-panel/entrance'
import { ReadmeShowType } from '../../readme-panel/store'
import { import {
useDeletePluginOAuthCustomClientHook, useDeletePluginOAuthCustomClientHook,
useInvalidPluginOAuthClientSchemaHook, useInvalidPluginOAuthClientSchemaHook,
@ -157,7 +156,7 @@ const OAuthClientSettings = ({
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0"> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0">
{pluginPayload.detail && ( {pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} /> <ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
)} )}
<AuthForm <AuthForm
formFromProps={form} formFromProps={form}

View File

@ -14,7 +14,6 @@ import { FormTypeEnum } from '@/app/components/base/form/types'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers' import { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser' import { parsePluginErrorMessage } from '@/utils/error-parser'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store' import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list' import { useSubscriptionList } from '../use-subscription-list'
@ -318,7 +317,7 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props)
</div> </div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> <div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && ( {pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} /> <ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)} )}
<MultiSteps currentStep={currentStep} onStepClick={handleBack} /> <MultiSteps currentStep={currentStep} onStepClick={handleBack} />

View File

@ -12,7 +12,6 @@ import { BaseForm } from '@/app/components/base/form/components/base'
import { FormTypeEnum } from '@/app/components/base/form/types' import { FormTypeEnum } from '@/app/components/base/form/types'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription } from '@/service/use-triggers' import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store' import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list' import { useSubscriptionList } from '../use-subscription-list'
@ -159,7 +158,7 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props)
</div> </div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> <div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && ( {pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} /> <ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)} )}
<BaseForm <BaseForm
formSchemas={formSchemas} formSchemas={formSchemas}

View File

@ -12,7 +12,6 @@ import { BaseForm } from '@/app/components/base/form/components/base'
import { FormTypeEnum } from '@/app/components/base/form/types' import { FormTypeEnum } from '@/app/components/base/form/types'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription } from '@/service/use-triggers' import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store' import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list' import { useSubscriptionList } from '../use-subscription-list'
@ -173,7 +172,7 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) =
</div> </div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3"> <div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && ( {pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} /> <ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)} )}
<BaseForm <BaseForm
formSchemas={formSchemas} formSchemas={formSchemas}

View File

@ -1,31 +1,11 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react' import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { ReadmeEntrance } from '../entrance'
import { useReadmePanelStore } from '../store'
vi.mock('@langgenius/dify-ui/cn', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))
const mockSetCurrentPluginDetail = vi.fn()
vi.mock('../store', () => ({
ReadmeShowType: { drawer: 'drawer', side: 'side', modal: 'modal' },
useReadmePanelStore: () => ({
setCurrentPluginDetail: mockSetCurrentPluginDetail,
}),
}))
vi.mock('../constants', () => ({
BUILTIN_TOOLS_ARRAY: ['google_search', 'bing_search'],
}))
describe('ReadmeEntrance', () => { describe('ReadmeEntrance', () => {
let ReadmeEntrance: (typeof import('../entrance'))['ReadmeEntrance'] beforeEach(() => {
useReadmePanelStore.setState({ currentPanel: undefined })
beforeEach(async () => {
vi.clearAllMocks()
const mod = await import('../entrance')
ReadmeEntrance = mod.ReadmeEntrance
}) })
it('should render readme button for non-builtin plugin with unique identifier', () => { it('should render readme button for non-builtin plugin with unique identifier', () => {
@ -35,18 +15,31 @@ describe('ReadmeEntrance', () => {
expect(screen.getByRole('button')).toBeInTheDocument() expect(screen.getByRole('button')).toBeInTheDocument()
}) })
it('should call setCurrentPluginDetail on button click', () => { it('should open drawer presentation by default', () => {
const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never
render(<ReadmeEntrance pluginDetail={pluginDetail} />) render(<ReadmeEntrance pluginDetail={pluginDetail} />)
const button = screen.getByRole('button') const button = screen.getByRole('button')
fireEvent.click(button) fireEvent.click(button)
expect(mockSetCurrentPluginDetail).toHaveBeenCalledWith(pluginDetail, 'drawer') expect(useReadmePanelStore.getState().currentPanel).toEqual({
detail: pluginDetail,
presentation: 'drawer',
triggerId: button.id,
})
})
it('should open dialog presentation when requested', () => {
const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never
render(<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />)
fireEvent.click(screen.getByRole('button'))
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
}) })
it('should return null for builtin tools', () => { it('should return null for builtin tools', () => {
const pluginDetail = { id: 'google_search', name: 'Google Search', plugin_unique_identifier: 'org/google' } as never const pluginDetail = { id: 'code', name: 'Code', plugin_unique_identifier: 'org/code' } as never
const { container } = render(<ReadmeEntrance pluginDetail={pluginDetail} />) const { container } = render(<ReadmeEntrance pluginDetail={pluginDetail} />)
expect(container.innerHTML).toBe('') expect(container.innerHTML).toBe('')

View File

@ -1,29 +1,29 @@
import type { ReactElement } from 'react'
import type { PluginDetail } from '../../types' import type { PluginDetail } from '../../types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum, PluginSource } from '../../types' import { PluginCategoryEnum, PluginSource } from '../../types'
import { ReadmeEntrance } from '../entrance' import { ReadmeEntrance } from '../entrance'
import ReadmePanel from '../index' import ReadmePanel from '../index'
import { ReadmeShowType, useReadmePanelStore } from '../store' import { useReadmePanelStore } from '../store'
// ================================ (
// Mock external dependencies only globalThis as typeof globalThis & {
// ================================ BASE_UI_ANIMATIONS_DISABLED: boolean
}
).BASE_UI_ANIMATIONS_DISABLED = true
// Mock usePluginReadme hook
const mockUsePluginReadme = vi.fn() const mockUsePluginReadme = vi.fn()
vi.mock('@/service/use-plugins', () => ({ vi.mock('@/service/use-plugins', () => ({
usePluginReadme: (params: { plugin_unique_identifier: string, language?: string }) => mockUsePluginReadme(params), usePluginReadme: (params: { plugin_unique_identifier: string, language?: string }) => mockUsePluginReadme(params),
})) }))
// Mock useLanguage hook
let mockLanguage = 'en-US' let mockLanguage = 'en-US'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => mockLanguage, useLanguage: () => mockLanguage,
})) }))
// Mock DetailHeader component (complex component with many dependencies)
vi.mock('../../plugin-detail-panel/detail-header', () => ({ vi.mock('../../plugin-detail-panel/detail-header', () => ({
default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => ( default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => (
<div data-testid="detail-header" data-is-readme-view={isReadmeView}> <div data-testid="detail-header" data-is-readme-view={isReadmeView}>
@ -32,10 +32,6 @@ vi.mock('../../plugin-detail-panel/detail-header', () => ({
), ),
})) }))
// ================================
// Test Data Factories
// ================================
const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({ const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-plugin-id', id: 'test-plugin-id',
created_at: '2024-01-01T00:00:00Z', created_at: '2024-01-01T00:00:00Z',
@ -93,10 +89,6 @@ const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDe
...overrides, ...overrides,
}) })
// ================================
// Test Utilities
// ================================
const createQueryClient = () => new QueryClient({ const createQueryClient = () => new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@ -105,7 +97,7 @@ const createQueryClient = () => new QueryClient({
}, },
}) })
const renderWithQueryClient = (ui: React.ReactElement) => { const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = createQueryClient() const queryClient = createQueryClient()
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@ -114,15 +106,23 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
) )
} }
// Constants (BUILTIN_TOOLS_ARRAY) tests moved to constants.spec.ts const openReadmePanel = (
// Store (useReadmePanelStore) tests moved to store.spec.ts detail = createMockPluginDetail(),
// Entrance (ReadmeEntrance) tests moved to entrance.spec.tsx presentation: 'drawer' | 'dialog' = 'drawer',
) => {
useReadmePanelStore.getState().openReadmePanel({
detail,
presentation,
triggerId: 'readme-trigger',
})
return detail
}
// ================================
// ReadmePanel Component Tests
// ================================
describe('ReadmePanel', () => { describe('ReadmePanel', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en-US'
useReadmePanelStore.setState({ currentPanel: undefined })
mockUsePluginReadme.mockReturnValue({ mockUsePluginReadme.mockReturnValue({
data: null, data: null,
isLoading: false, isLoading: false,
@ -130,487 +130,114 @@ describe('ReadmePanel', () => {
}) })
}) })
// ================================ it('should return null when no readme panel is open', () => {
// Rendering Tests const { container } = renderWithQueryClient(<ReadmePanel />)
// ================================
describe('Rendering', () => {
it('should return null when no plugin detail is set', () => {
const { container } = renderWithQueryClient(<ReadmePanel />)
expect(container.firstChild).toBeNull() expect(container.firstChild).toBeNull()
})
it('should render drawer presentation with plugin header content', () => {
openReadmePanel()
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true')
expect(screen.getByRole('dialog')).toHaveClass('data-[swipe-direction=left]:w-150')
})
it('should render dialog presentation when requested', () => {
openReadmePanel(createMockPluginDetail(), 'dialog')
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByRole('dialog')).toHaveClass('max-w-200')
})
it('should close the active panel when close button is clicked', () => {
openReadmePanel()
renderWithQueryClient(<ReadmePanel />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(useReadmePanelStore.getState().currentPanel).toBeUndefined()
})
it('should render loading, error, empty, and readme states from the readme query', () => {
openReadmePanel()
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: true,
error: null,
}) })
const { rerender } = renderWithQueryClient(<ReadmePanel />)
expect(screen.getByRole('status')).toBeInTheDocument()
it('should render portal content when plugin detail is set', () => { mockUsePluginReadme.mockReturnValue({
const mockDetail = createMockPluginDetail() data: null,
const { setCurrentPluginDetail } = useReadmePanelStore.getState() isLoading: false,
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) error: new Error('Failed to fetch'),
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
}) })
rerender(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument()
it('should render DetailHeader component', () => { mockUsePluginReadme.mockReturnValue({
const mockDetail = createMockPluginDetail() data: { readme: '' },
const { setCurrentPluginDetail } = useReadmePanelStore.getState() isLoading: false,
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) error: null,
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByTestId('detail-header')).toBeInTheDocument()
expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true')
}) })
rerender(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
it('should render close button', () => { mockUsePluginReadme.mockReturnValue({
const mockDetail = createMockPluginDetail() data: { readme: '# Test Readme Content' },
const { setCurrentPluginDetail } = useReadmePanelStore.getState() isLoading: false,
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) error: null,
})
rerender(<ReadmePanel />)
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
})
renderWithQueryClient(<ReadmePanel />) it('should call usePluginReadme with the plugin identifier and selected language', () => {
openReadmePanel(createMockPluginDetail({
plugin_unique_identifier: 'custom-plugin@2.0.0',
}))
// ActionButton wraps the close icon renderWithQueryClient(<ReadmePanel />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'custom-plugin@2.0.0',
language: 'en-US',
}) })
}) })
// ================================ it('should pass undefined language for zh-Hans locale', () => {
// Loading State Tests mockLanguage = 'zh-Hans'
// ================================ openReadmePanel(createMockPluginDetail({
describe('Loading State', () => { plugin_unique_identifier: 'zh-plugin@1.0.0',
it('should show loading indicator when isLoading is true', () => { }))
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: true,
error: null,
})
const mockDetail = createMockPluginDetail() renderWithQueryClient(<ReadmePanel />)
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />) expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'zh-plugin@1.0.0',
// Loading component should be rendered with role="status" language: undefined,
expect(screen.getByRole('status')).toBeInTheDocument()
}) })
}) })
// ================================ it('should open correctly from ReadmeEntrance through the global host', () => {
// Error State Tests const detail = createMockPluginDetail()
// ================================
describe('Error State', () => {
it('should show error message when error occurs', () => {
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
error: new Error('Failed to fetch'),
})
const mockDetail = createMockPluginDetail() renderWithQueryClient(
const { setCurrentPluginDetail } = useReadmePanelStore.getState() <>
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer) <ReadmeEntrance pluginDetail={detail} />
<ReadmePanel />
</>,
)
renderWithQueryClient(<ReadmePanel />) fireEvent.click(screen.getByRole('button', { name: /plugin\.readmeInfo\.needHelpCheckReadme/ }))
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument() expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
// ================================
// No Readme Available State Tests
// ================================
describe('No Readme Available', () => {
it('should show no readme message when readme is empty', () => {
mockUsePluginReadme.mockReturnValue({
data: { readme: '' },
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
})
it('should show no readme message when data is null', () => {
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
})
})
// ================================
// Markdown Content Tests
// ================================
describe('Markdown Content', () => {
it('should render markdown container when readme is available', () => {
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Test Readme Content' },
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Markdown component container should be rendered
// Note: The Markdown component uses dynamic import, so content may load asynchronously
const markdownContainer = document.querySelector('.markdown-body')
expect(markdownContainer).toBeInTheDocument()
})
it('should not show error or no-readme message when readme is available', () => {
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Test Readme Content' },
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Should not show error or no-readme message
expect(screen.queryByText('plugin.readmeInfo.failedToFetch')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.readmeInfo.noReadmeAvailable')).not.toBeInTheDocument()
})
})
// ================================
// Portal Rendering Tests (Drawer Mode)
// ================================
describe('Portal Rendering - Drawer Mode', () => {
it('should render drawer styled container in drawer mode', () => {
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Test' },
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Drawer mode has specific max-width
const drawerContainer = document.querySelector('.max-w-\\[600px\\]')
expect(drawerContainer).toBeInTheDocument()
})
it('should have correct drawer positioning classes', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Check for drawer-specific classes
const backdrop = document.querySelector('.justify-start')
expect(backdrop).toBeInTheDocument()
})
})
// ================================
// Portal Rendering Tests (Modal Mode)
// ================================
describe('Portal Rendering - Modal Mode', () => {
it('should render modal styled container in modal mode', () => {
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Test' },
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
renderWithQueryClient(<ReadmePanel />)
// Modal mode has different max-width
const modalContainer = document.querySelector('.max-w-\\[800px\\]')
expect(modalContainer).toBeInTheDocument()
})
it('should have correct modal positioning classes', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
renderWithQueryClient(<ReadmePanel />)
// Check for modal-specific classes
const backdrop = document.querySelector('.items-center.justify-center')
expect(backdrop).toBeInTheDocument()
})
})
// ================================
// User Interactions / Event Handlers
// ================================
describe('User Interactions', () => {
it('should close panel when close button is clicked', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
fireEvent.click(screen.getByRole('button'))
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeUndefined()
})
it('should close panel when backdrop is clicked', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Click on the backdrop (outer div)
const backdrop = document.querySelector('.fixed.inset-0')
fireEvent.click(backdrop!)
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeUndefined()
})
it('should not close panel when content area is clicked', async () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Click on the content container (should stop propagation)
const contentContainer = document.querySelector('.pointer-events-auto')
fireEvent.click(contentContainer!)
await waitFor(() => {
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeDefined()
})
})
it('should not close panel when content area is clicked in modal mode', async () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
renderWithQueryClient(<ReadmePanel />)
// Click on the content container in modal mode (should stop propagation)
const contentContainer = document.querySelector('.pointer-events-auto')
fireEvent.click(contentContainer!)
await waitFor(() => {
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeDefined()
})
})
})
// ================================
// API Call Tests
// ================================
describe('API Calls', () => {
it('should call usePluginReadme with correct parameters', () => {
const mockDetail = createMockPluginDetail({
plugin_unique_identifier: 'custom-plugin@2.0.0',
})
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'custom-plugin@2.0.0',
language: 'en-US',
})
})
it('should pass undefined language for zh-Hans locale', () => {
// Set language to zh-Hans
mockLanguage = 'zh-Hans'
const mockDetail = createMockPluginDetail({
plugin_unique_identifier: 'zh-plugin@1.0.0',
})
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// The component should pass undefined for language when zh-Hans
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'zh-plugin@1.0.0',
language: undefined,
})
// Reset language
mockLanguage = 'en-US'
})
it('should handle empty plugin_unique_identifier', () => {
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail({
plugin_unique_identifier: '',
})
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: '',
language: 'en-US',
})
})
})
// ================================
// Edge Cases
// ================================
describe('Edge Cases', () => {
it('should handle detail with missing declaration', () => {
const mockDetail = createMockPluginDetail()
// Simulate missing fields
delete (mockDetail as Partial<PluginDetail>).declaration
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
// This should not throw
expect(() => setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)).not.toThrow()
})
it('should handle rapid open/close operations', async () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
// Rapidly toggle the panel
act(() => {
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
setCurrentPluginDetail()
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
})
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
})
it('should handle switching between drawer and modal modes', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
// Start with drawer
act(() => {
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
})
let state = useReadmePanelStore.getState()
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.drawer)
// Switch to modal
act(() => {
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
})
state = useReadmePanelStore.getState()
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
})
it('should handle undefined detail gracefully', () => {
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
// Set to undefined explicitly
act(() => {
setCurrentPluginDetail(undefined, ReadmeShowType.drawer)
})
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeUndefined()
})
})
// ================================
// Integration Tests
// ================================
describe('Integration', () => {
it('should work correctly when opened from ReadmeEntrance', () => {
const mockDetail = createMockPluginDetail()
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Integration Test' },
isLoading: false,
error: null,
})
// Render both components
const { rerender } = renderWithQueryClient(
<>
<ReadmeEntrance pluginDetail={mockDetail} />
<ReadmePanel />
</>,
)
// Initially panel should not show content
expect(screen.queryByTestId('detail-header')).not.toBeInTheDocument()
// Click the entrance button
fireEvent.click(screen.getByRole('button'))
// Re-render to pick up store changes
rerender(
<QueryClientProvider client={createQueryClient()}>
<ReadmeEntrance pluginDetail={mockDetail} />
<ReadmePanel />
</QueryClientProvider>,
)
// Panel should now show content
expect(screen.getByTestId('detail-header')).toBeInTheDocument()
// Markdown content renders in a container (dynamic import may not render content synchronously)
expect(document.querySelector('.markdown-body')).toBeInTheDocument()
})
it('should display correct plugin information in header', () => {
const mockDetail = createMockPluginDetail({
name: 'my-awesome-plugin',
})
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument()
})
}) })
}) })

View File

@ -1,54 +1,52 @@
import type { PluginDetail } from '@/app/components/plugins/types' import type { PluginDetail } from '@/app/components/plugins/types'
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { ReadmeShowType, useReadmePanelStore } from '../store' import { useReadmePanelStore } from '../store'
describe('readme-panel/store', () => { describe('readme-panel/store', () => {
beforeEach(() => { beforeEach(() => {
useReadmePanelStore.setState({ currentPluginDetail: undefined }) useReadmePanelStore.setState({ currentPanel: undefined })
}) })
it('initializes with undefined currentPluginDetail', () => { it('initializes without an active panel', () => {
const state = useReadmePanelStore.getState() const state = useReadmePanelStore.getState()
expect(state.currentPluginDetail).toBeUndefined() expect(state.currentPanel).toBeUndefined()
}) })
it('sets current plugin detail with drawer showType by default', () => { it('opens drawer presentation by default', () => {
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail) useReadmePanelStore.getState().openReadmePanel({ detail, triggerId: 'readme-trigger' })
const state = useReadmePanelStore.getState() expect(useReadmePanelStore.getState().currentPanel).toEqual({
expect(state.currentPluginDetail).toEqual({ detail,
detail: mockDetail, presentation: 'drawer',
showType: ReadmeShowType.drawer, triggerId: 'readme-trigger',
}) })
}) })
it('sets current plugin detail with modal showType', () => { it('opens dialog presentation when requested', () => {
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail, ReadmeShowType.modal) useReadmePanelStore.getState().openReadmePanel({ detail, presentation: 'dialog' })
const state = useReadmePanelStore.getState() expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
}) })
it('clears current plugin detail when called with undefined', () => { it('closes the active panel', () => {
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail) useReadmePanelStore.getState().openReadmePanel({ detail })
expect(useReadmePanelStore.getState().currentPluginDetail).toBeDefined() expect(useReadmePanelStore.getState().currentPanel).toBeDefined()
useReadmePanelStore.getState().setCurrentPluginDetail(undefined) useReadmePanelStore.getState().closeReadmePanel()
expect(useReadmePanelStore.getState().currentPluginDetail).toBeUndefined() expect(useReadmePanelStore.getState().currentPanel).toBeUndefined()
}) })
it('replaces previous detail with new one', () => { it('replaces the active panel with the latest request', () => {
const detail1 = { id: 'plugin-1', plugin_unique_identifier: 'uid-1' } as PluginDetail const detail1 = { id: 'plugin-1', plugin_unique_identifier: 'uid-1' } as PluginDetail
const detail2 = { id: 'plugin-2', plugin_unique_identifier: 'uid-2' } as PluginDetail const detail2 = { id: 'plugin-2', plugin_unique_identifier: 'uid-2' } as PluginDetail
useReadmePanelStore.getState().setCurrentPluginDetail(detail1) useReadmePanelStore.getState().openReadmePanel({ detail: detail1 })
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-1') useReadmePanelStore.getState().openReadmePanel({ detail: detail2, presentation: 'dialog' })
useReadmePanelStore.getState().setCurrentPluginDetail(detail2, ReadmeShowType.modal) expect(useReadmePanelStore.getState().currentPanel?.detail.id).toBe('plugin-2')
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-2') expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
expect(useReadmePanelStore.getState().currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
}) })
}) })

View File

@ -0,0 +1,81 @@
'use client'
import type { ReactNode } from 'react'
import type { PluginDetail } from '../types'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { Markdown } from '@/app/components/base/markdown'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { usePluginReadme } from '@/service/use-plugins'
import DetailHeader from '../plugin-detail-panel/detail-header'
type ReadmePanelContentProps = {
detail: PluginDetail
title: ReactNode
closeButton: ReactNode
}
export function ReadmePanelContent({
detail,
title,
closeButton,
}: ReadmePanelContentProps) {
const { t } = useTranslation()
const language = useLanguage()
const pluginUniqueIdentifier = detail.plugin_unique_identifier || ''
const { data: readmeData, isLoading, error } = usePluginReadme({
plugin_unique_identifier: pluginUniqueIdentifier,
language: language === 'zh-Hans' ? undefined : language,
})
let readmeContent: ReactNode
if (isLoading) {
readmeContent = (
<div className="flex h-40 items-center justify-center">
<Loading type="area" />
</div>
)
}
else if (error) {
readmeContent = (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.failedToFetch', { ns: 'plugin' })}</p>
</div>
)
}
else if (readmeData?.readme) {
readmeContent = (
<Markdown
content={readmeData.readme}
pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }}
/>
)
}
else {
readmeContent = (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.noReadmeAvailable', { ns: 'plugin' })}</p>
</div>
)
}
return (
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden">
<div className="shrink-0 rounded-t-xl bg-background-body px-4 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<span aria-hidden="true" className="i-ri-book-read-line h-3 w-3 shrink-0 text-text-tertiary" />
{title}
</div>
{closeButton}
</div>
<DetailHeader detail={detail} isReadmeView={true} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
{readmeContent}
</div>
</div>
)
}

View File

@ -0,0 +1,52 @@
'use client'
import type { PluginDetail } from '../types'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { useTranslation } from 'react-i18next'
import { ReadmePanelContent } from './content'
type ReadmeDialogProps = {
detail: PluginDetail
open: boolean
onOpenChange: (open: boolean) => void
triggerId?: string
}
export function ReadmeDialog({
detail,
open,
onOpenChange,
triggerId,
}: ReadmeDialogProps) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
triggerId={triggerId}
>
<DialogContent className="h-[calc(100dvh-16px)] w-full max-w-200 overflow-hidden p-0">
<ReadmePanelContent
detail={detail}
title={(
<DialogTitle className="truncate text-xs font-medium text-text-tertiary uppercase">
{t('readmeInfo.title', { ns: 'plugin' })}
</DialogTitle>
)}
closeButton={(
<DialogCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="static h-8 w-8 rounded-lg"
/>
)}
/>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,62 @@
'use client'
import type { PluginDetail } from '../types'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { useTranslation } from 'react-i18next'
import { ReadmePanelContent } from './content'
type ReadmeDrawerProps = {
detail: PluginDetail
open: boolean
onOpenChange: (open: boolean) => void
triggerId?: string
}
export function ReadmeDrawer({
detail,
open,
onOpenChange,
triggerId,
}: ReadmeDrawerProps) {
const { t } = useTranslation()
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
triggerId={triggerId}
modal
swipeDirection="left"
>
<DrawerPortal>
<DrawerBackdrop className="bg-transparent" />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=left]:top-16 data-[swipe-direction=left]:bottom-2 data-[swipe-direction=left]:left-2 data-[swipe-direction=left]:h-auto data-[swipe-direction=left]:w-150 data-[swipe-direction=left]:max-w-[calc(100vw-1rem)] data-[swipe-direction=left]:rounded-2xl data-[swipe-direction=left]:border-l-[0.5px]">
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0">
<ReadmePanelContent
detail={detail}
title={(
<DrawerTitle className="truncate text-xs font-medium text-text-tertiary uppercase">
{t('readmeInfo.title', { ns: 'plugin' })}
</DrawerTitle>
)}
closeButton={(
<DrawerCloseButton aria-label={t('operation.close', { ns: 'common' })} />
)}
/>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}

View File

@ -1,34 +1,40 @@
import type { PluginDetail } from '../types' import type { PluginDetail } from '../types'
import type { ReadmePanelPresentation } from './store'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { RiBookReadLine } from '@remixicon/react' import { useId } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BUILTIN_TOOLS_ARRAY } from './constants' import { BUILTIN_TOOLS_ARRAY } from './constants'
import { ReadmeShowType, useReadmePanelStore } from './store' import { useReadmePanelStore } from './store'
export const ReadmeEntrance = ({ export const ReadmeEntrance = ({
pluginDetail, pluginDetail,
showType = ReadmeShowType.drawer, presentation = 'drawer',
className, className,
showShortTip = false, showShortTip = false,
}: { }: {
pluginDetail: PluginDetail pluginDetail: PluginDetail
showType?: ReadmeShowType presentation?: ReadmePanelPresentation
className?: string className?: string
showShortTip?: boolean showShortTip?: boolean
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { setCurrentPluginDetail } = useReadmePanelStore() const triggerId = useId()
const openReadmePanel = useReadmePanelStore(s => s.openReadmePanel)
const handleReadmeClick = () => { const handleReadmeClick = () => {
if (pluginDetail) if (pluginDetail) {
setCurrentPluginDetail(pluginDetail, showType) openReadmePanel({
detail: pluginDetail,
presentation,
triggerId,
})
}
} }
if (!pluginDetail || !pluginDetail?.plugin_unique_identifier || BUILTIN_TOOLS_ARRAY.includes(pluginDetail.id)) if (!pluginDetail || !pluginDetail?.plugin_unique_identifier || BUILTIN_TOOLS_ARRAY.includes(pluginDetail.id))
return null return null
return ( return (
<div className={cn('flex flex-col items-start justify-center gap-2 pt-0 pb-4', showType === ReadmeShowType.drawer && 'px-4', className)}> <div className={cn('flex flex-col items-start justify-center gap-2 pt-0 pb-4', presentation === 'drawer' && 'px-4', className)}>
{!showShortTip && ( {!showShortTip && (
<div className="relative h-1 w-8 shrink-0"> <div className="relative h-1 w-8 shrink-0">
<div className="h-px w-full bg-divider-regular"></div> <div className="h-px w-full bg-divider-regular"></div>
@ -36,11 +42,13 @@ export const ReadmeEntrance = ({
)} )}
<button <button
id={triggerId}
type="button"
onClick={handleReadmeClick} onClick={handleReadmeClick}
className="flex w-full items-center justify-start gap-1 text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only" className="flex w-full items-center justify-start gap-1 rounded-sm text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden"
> >
<div className="relative flex h-3 w-3 items-center justify-center overflow-hidden"> <div className="relative flex h-3 w-3 items-center justify-center overflow-hidden">
<RiBookReadLine className="h-3 w-3" /> <span aria-hidden="true" className="i-ri-book-read-line h-3 w-3" />
</div> </div>
<span className="text-xs leading-4 font-normal"> <span className="text-xs leading-4 font-normal">
{!showShortTip ? t('readmeInfo.needHelpCheckReadme', { ns: 'plugin' }) : t('readmeInfo.title', { ns: 'plugin' })} {!showShortTip ? t('readmeInfo.needHelpCheckReadme', { ns: 'plugin' }) : t('readmeInfo.title', { ns: 'plugin' })}

View File

@ -1,124 +1,38 @@
'use client' 'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiBookReadLine, RiCloseLine } from '@remixicon/react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Loading from '@/app/components/base/loading'
import { Markdown } from '@/app/components/base/markdown'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { usePluginReadme } from '@/service/use-plugins'
import DetailHeader from '../plugin-detail-panel/detail-header'
import { ReadmeShowType, useReadmePanelStore } from './store'
const ReadmePanel: FC = () => { import { ReadmeDialog } from './dialog'
const { currentPluginDetail, setCurrentPluginDetail } = useReadmePanelStore() import { ReadmeDrawer } from './drawer'
const { detail, showType } = currentPluginDetail || {} import { useReadmePanelStore } from './store'
const { t } = useTranslation()
const language = useLanguage()
const pluginUniqueIdentifier = detail?.plugin_unique_identifier || '' export default function ReadmePanel() {
const currentPanel = useReadmePanelStore(s => s.currentPanel)
const closeReadmePanel = useReadmePanelStore(s => s.closeReadmePanel)
const { data: readmeData, isLoading, error } = usePluginReadme( if (!currentPanel)
{ plugin_unique_identifier: pluginUniqueIdentifier, language: language === 'zh-Hans' ? undefined : language },
)
const onClose = () => {
setCurrentPluginDetail()
}
if (!detail)
return null return null
const children = ( const onOpenChange = (open: boolean) => {
<div className="flex h-full w-full flex-col overflow-hidden"> if (!open)
<div className="rounded-t-xl bg-background-body px-4 py-4"> closeReadmePanel()
<div className="mb-3 flex items-center justify-between"> }
<div className="flex items-center gap-1">
<RiBookReadLine className="h-3 w-3 text-text-tertiary" />
<span className="text-xs font-medium text-text-tertiary uppercase">
{t('readmeInfo.title', { ns: 'plugin' })}
</span>
</div>
<ActionButton onClick={onClose}>
<RiCloseLine className="h-4 w-4" />
</ActionButton>
</div>
<DetailHeader detail={detail} isReadmeView={true} />
</div>
<div className="flex-1 overflow-y-auto px-4 py-3"> if (currentPanel.presentation === 'dialog') {
{(() => { return (
if (isLoading) { <ReadmeDialog
return ( detail={currentPanel.detail}
<div className="flex h-40 items-center justify-center"> open
<Loading type="area" /> onOpenChange={onOpenChange}
</div> triggerId={currentPanel.triggerId}
) />
} )
}
if (error) { return (
return ( <ReadmeDrawer
<div className="py-8 text-center text-text-tertiary"> detail={currentPanel.detail}
<p>{t('readmeInfo.failedToFetch', { ns: 'plugin' })}</p> open
</div> onOpenChange={onOpenChange}
) triggerId={currentPanel.triggerId}
} />
if (readmeData?.readme) {
return (
<Markdown
content={readmeData.readme}
pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }}
/>
)
}
return (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.noReadmeAvailable', { ns: 'plugin' })}</p>
</div>
)
})()}
</div>
</div>
)
const portalContent = showType === ReadmeShowType.drawer
? (
<div className="fixed inset-0 z-1002 flex justify-start" onClick={onClose}>
<div
className={cn(
'pointer-events-auto mt-16 mr-2 mb-2 ml-2 w-[600px] max-w-[600px] justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl',
)}
onClick={(event) => {
event.stopPropagation()
}}
>
{children}
</div>
</div>
)
: (
<div className="fixed inset-0 z-1002 flex items-center justify-center p-2" onClick={onClose}>
<div
className={cn(
'pointer-events-auto relative h-[calc(100vh-16px)] w-full max-w-[800px] rounded-2xl bg-components-panel-bg p-0 shadow-xl',
)}
onClick={(event) => {
event.stopPropagation()
}}
>
{children}
</div>
</div>
)
return createPortal(
portalContent,
document.body,
) )
} }
export default ReadmePanel

View File

@ -1,27 +1,34 @@
import type { PluginDetail } from '@/app/components/plugins/types' import type { PluginDetail } from '@/app/components/plugins/types'
import { create } from 'zustand' import { create } from 'zustand'
export enum ReadmeShowType { export type ReadmePanelPresentation = 'drawer' | 'dialog'
drawer = 'drawer',
modal = 'modal', type ReadmePanelState = {
detail: PluginDetail
presentation: ReadmePanelPresentation
triggerId?: string
}
type OpenReadmePanelPayload = {
detail: PluginDetail
presentation?: ReadmePanelPresentation
triggerId?: string
} }
type Shape = { type Shape = {
currentPluginDetail?: { currentPanel?: ReadmePanelState
detail: PluginDetail openReadmePanel: (payload: OpenReadmePanelPayload) => void
showType: ReadmeShowType closeReadmePanel: () => void
}
setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => void
} }
export const useReadmePanelStore = create<Shape>(set => ({ export const useReadmePanelStore = create<Shape>(set => ({
currentPluginDetail: undefined, currentPanel: undefined,
setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => set({ openReadmePanel: ({ detail, presentation = 'drawer', triggerId }) => set({
currentPluginDetail: !detail currentPanel: {
? undefined detail,
: { presentation,
detail, triggerId,
showType: showType ?? ReadmeShowType.drawer, },
},
}), }),
closeReadmePanel: () => set({ currentPanel: undefined }),
})) }))

View File

@ -68,13 +68,13 @@ const MenuDropdown: FC<Props> = ({
onOpenChange={setOpen} onOpenChange={setOpen}
> >
<DropdownMenuTrigger <DropdownMenuTrigger
render={<div />} render={(
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
<span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" />
</ActionButton>
)}
aria-label={t('operation.more', { ns: 'common' })} aria-label={t('operation.more', { ns: 'common' })}
> />
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
<span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
placement={placement || 'bottom-end'} placement={placement || 'bottom-end'}
sideOffset={4} sideOffset={4}

View File

@ -34,12 +34,28 @@ vi.mock('@langgenius/dify-ui/popover', async () => {
) )
} }
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { const PopoverTrigger = ({
children,
className,
render,
}: {
children?: React.ReactNode
className?: string
render?: React.ReactNode
}) => {
const { open, setOpen } = React.useContext(PopoverContext) const { open, setOpen } = React.useContext(PopoverContext)
if (render) {
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
return ( return (
<div onClick={() => setOpen(!open)}> <button type="button" className={className} onClick={() => setOpen(!open)}>
{render} {children}
</div> </button>
) )
} }
@ -119,6 +135,12 @@ describe('LabelSelector', () => {
expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument() expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument()
}) })
it('should render the trigger as a native button', () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
expect(screen.getByRole('button', { name: 'tools.createTool.toolInput.labelPlaceholder' })).toHaveAttribute('type', 'button')
})
it('should display selected labels as comma-separated list', () => { it('should display selected labels as comma-separated list', () => {
render(<LabelSelector value={['agent', 'rag']} onChange={mockOnChange} />) render(<LabelSelector value={['agent', 'rag']} onChange={mockOnChange} />)

View File

@ -6,7 +6,6 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@langgenius/dify-ui/popover' } from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/function' import { noop } from 'es-toolkit/function'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@ -60,22 +59,19 @@ const LabelSelector: FC<LabelSelectorProps> = ({
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<div className="relative"> <div className="relative">
<PopoverTrigger <PopoverTrigger
render={( className={cn(
<div className={cn( 'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 text-left hover:bg-components-input-bg-hover',
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 hover:bg-components-input-bg-hover', open && 'bg-components-input-bg-hover hover:bg-components-input-bg-hover',
open && '!hover:bg-components-input-bg-hover hover:bg-components-input-bg-hover',
)}
>
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && 'text-text-quaternary!')}>
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
{!!value.length && selectedLabels}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<RiArrowDownSLine className="h-4 w-4" />
</div>
</div>
)} )}
/> >
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-4.5 text-text-secondary', !value.length && 'text-text-quaternary!')}>
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
{!!value.length && selectedLabels}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<span className="i-ri-arrow-down-s-line h-4 w-4" />
</div>
</PopoverTrigger>
<PopoverContent <PopoverContent
placement="bottom-start" placement="bottom-start"
sideOffset={4} sideOffset={4}

View File

@ -133,8 +133,8 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
})) }))
vi.mock('@/app/components/tools/workflow-tool', () => ({ vi.mock('@/app/components/tools/workflow-tool', () => ({
default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => ( WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
<div data-testid="workflow-tool-modal"> <div data-testid="workflow-tool-drawer">
<button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}>Save</button> <button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}>Save</button>
<button data-testid="wf-remove" onClick={onRemove}>Remove</button> <button data-testid="wf-remove" onClick={onRemove}>Remove</button>
<button data-testid="wf-close" onClick={onHide}>Close</button> <button data-testid="wf-close" onClick={onHide}>Close</button>
@ -581,7 +581,7 @@ describe('ProviderDetail', () => {
}) })
}) })
it('saves workflow tool via workflow modal', async () => { it('saves workflow tool via workflow drawer', async () => {
render( render(
<ProviderDetail <ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })} collection={createMockCollection({ type: CollectionType.workflow })}
@ -593,7 +593,7 @@ describe('ProviderDetail', () => {
expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument() expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument()
}) })
fireEvent.click(screen.getByText('tools.createTool.editAction')) fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument() expect(screen.getByTestId('workflow-tool-drawer'))!.toBeInTheDocument()
await act(async () => { await act(async () => {
fireEvent.click(screen.getByTestId('wf-save')) fireEvent.click(screen.getByTestId('wf-save'))
}) })
@ -627,7 +627,7 @@ describe('ProviderDetail', () => {
}) })
}) })
describe('Modal Close Actions', () => { describe('Overlay Close Actions', () => {
it('closes ConfigCredential when cancel is clicked', async () => { it('closes ConfigCredential when cancel is clicked', async () => {
render( render(
<ProviderDetail <ProviderDetail
@ -665,7 +665,7 @@ describe('ProviderDetail', () => {
expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument() expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument()
}) })
it('closes WorkflowToolModal via onHide', async () => { it('closes WorkflowToolDrawer via onHide', async () => {
render( render(
<ProviderDetail <ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })} collection={createMockCollection({ type: CollectionType.workflow })}
@ -677,9 +677,9 @@ describe('ProviderDetail', () => {
expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument() expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument()
}) })
fireEvent.click(screen.getByText('tools.createTool.editAction')) fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument() expect(screen.getByTestId('workflow-tool-drawer'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('wf-close')) fireEvent.click(screen.getByTestId('wf-close'))
expect(screen.queryByTestId('workflow-tool-modal')).not.toBeInTheDocument() expect(screen.queryByTestId('workflow-tool-drawer')).not.toBeInTheDocument()
}) })
}) })

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types' import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types'
import type { WorkflowToolModalPayload } from '@/app/components/tools/workflow-tool' import type { WorkflowToolDrawerPayload } from '@/app/components/tools/workflow-tool'
import { import {
AlertDialog, AlertDialog,
AlertDialogActions, AlertDialogActions,
@ -31,7 +31,7 @@ import OrgInfo from '@/app/components/plugins/card/base/org-info'
import Title from '@/app/components/plugins/card/base/title' import Title from '@/app/components/plugins/card/base/title'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal' import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import WorkflowToolModal from '@/app/components/tools/workflow-tool' import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useLocale } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
@ -140,7 +140,7 @@ const ProviderDetail = ({
setIsShowEditCustomCollectionModal(false) setIsShowEditCustomCollectionModal(false)
} }
// workflow provider // workflow provider
const [isShowEditWorkflowToolModal, setIsShowEditWorkflowToolModal] = useState(false) const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
const getWorkflowToolProvider = useCallback(async () => { const getWorkflowToolProvider = useCallback(async () => {
setIsDetailLoading(true) setIsDetailLoading(true)
const res = await fetchWorkflowToolDetail(collection.id) const res = await fetchWorkflowToolDetail(collection.id)
@ -164,7 +164,7 @@ const ProviderDetail = ({
await deleteWorkflowTool(collection.id) await deleteWorkflowTool(collection.id)
onRefreshData() onRefreshData()
toast.success(t('api.actionSuccess', { ns: 'common' })) toast.success(t('api.actionSuccess', { ns: 'common' }))
setIsShowEditWorkflowToolModal(false) setWorkflowToolDrawerOpen(false)
} }
const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{ const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string workflow_app_id: string
@ -175,7 +175,7 @@ const ProviderDetail = ({
onRefreshData() onRefreshData()
getWorkflowToolProvider() getWorkflowToolProvider()
toast.success(t('api.actionSuccess', { ns: 'common' })) toast.success(t('api.actionSuccess', { ns: 'common' }))
setIsShowEditWorkflowToolModal(false) setWorkflowToolDrawerOpen(false)
} }
const onClickCustomToolDelete = () => { const onClickCustomToolDelete = () => {
setDeleteAction('customTool') setDeleteAction('customTool')
@ -287,7 +287,7 @@ const ProviderDetail = ({
</Button> </Button>
<Button <Button
className={cn('my-3 w-[183px] shrink-0')} className={cn('my-3 w-[183px] shrink-0')}
onClick={() => setIsShowEditWorkflowToolModal(true)} onClick={() => setWorkflowToolDrawerOpen(true)}
disabled={!isCurrentWorkspaceManager} disabled={!isCurrentWorkspaceManager}
> >
<div className="system-sm-medium text-text-secondary">{t('createTool.editAction', { ns: 'tools' })}</div> <div className="system-sm-medium text-text-secondary">{t('createTool.editAction', { ns: 'tools' })}</div>
@ -401,10 +401,10 @@ const ProviderDetail = ({
onRemove={onClickCustomToolDelete} onRemove={onClickCustomToolDelete}
/> />
)} )}
{isShowEditWorkflowToolModal && ( {workflowToolDrawerOpen && (
<WorkflowToolModal <WorkflowToolDrawer
payload={customCollection as unknown as WorkflowToolModalPayload} payload={customCollection as unknown as WorkflowToolDrawerPayload}
onHide={() => setIsShowEditWorkflowToolModal(false)} onHide={() => setWorkflowToolDrawerOpen(false)}
onRemove={onClickWorkflowToolDelete} onRemove={onClickWorkflowToolDelete}
onSave={updateWorkflowToolProvider} onSave={updateWorkflowToolProvider}
/> />

View File

@ -1,22 +1,9 @@
import type { WorkflowToolModalPayload } from '../index' import type { ReactNode } from 'react'
import type { WorkflowToolDrawerPayload } from '../index'
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import WorkflowToolAsModal from '../index' import { WorkflowToolDrawer } from '../index'
vi.mock('@/app/components/base/drawer-plus', () => ({
default: ({ isShow, onHide, title, body }: { isShow: boolean, onHide: () => void, title: string, body: React.ReactNode }) => (
isShow
? (
<div data-testid="drawer" role="dialog">
<span>{title}</span>
<button data-testid="drawer-close" onClick={onHide}>Close</button>
{body}
</div>
)
: null
),
}))
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({ vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => ( default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
@ -46,8 +33,8 @@ vi.mock('@/app/components/base/tooltip', () => ({
children, children,
popupContent, popupContent,
}: { }: {
children?: React.ReactNode children?: ReactNode
popupContent?: React.ReactNode popupContent?: ReactNode
}) => ( }) => (
<div> <div>
{children} {children}
@ -86,7 +73,7 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}), }),
})) }))
const createPayload = (overrides: Partial<WorkflowToolModalPayload> = {}): WorkflowToolModalPayload => ({ const createPayload = (overrides: Partial<WorkflowToolDrawerPayload> = {}): WorkflowToolDrawerPayload => ({
icon: { content: '🔧', background: '#ffffff' }, icon: { content: '🔧', background: '#ffffff' },
label: 'My Tool', label: 'My Tool',
name: 'my_tool', name: 'my_tool',
@ -105,7 +92,7 @@ const createPayload = (overrides: Partial<WorkflowToolModalPayload> = {}): Workf
...overrides, ...overrides,
}) })
describe('WorkflowToolAsModal', () => { describe('WorkflowToolDrawer', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
@ -115,7 +102,7 @@ describe('WorkflowToolAsModal', () => {
const onCreate = vi.fn() const onCreate = vi.fn()
render( render(
<WorkflowToolAsModal <WorkflowToolDrawer
isAdd isAdd
payload={createPayload()} payload={createPayload()}
onHide={vi.fn()} onHide={vi.fn()}
@ -144,7 +131,7 @@ describe('WorkflowToolAsModal', () => {
const onCreate = vi.fn() const onCreate = vi.fn()
render( render(
<WorkflowToolAsModal <WorkflowToolDrawer
isAdd isAdd
payload={createPayload({ name: 'bad-name' })} payload={createPayload({ name: 'bad-name' })}
onHide={vi.fn()} onHide={vi.fn()}
@ -165,7 +152,7 @@ describe('WorkflowToolAsModal', () => {
const onSave = vi.fn() const onSave = vi.fn()
render( render(
<WorkflowToolAsModal <WorkflowToolDrawer
payload={createPayload()} payload={createPayload()}
onHide={vi.fn()} onHide={vi.fn()}
onSave={onSave} onSave={onSave}
@ -187,7 +174,7 @@ describe('WorkflowToolAsModal', () => {
it('should show duplicate reserved output warnings', () => { it('should show duplicate reserved output warnings', () => {
render( render(
<WorkflowToolAsModal <WorkflowToolDrawer
isAdd isAdd
payload={createPayload()} payload={createPayload()}
onHide={vi.fn()} onHide={vi.fn()}

View File

@ -1,70 +1,33 @@
'use client' 'use client'
import type { Emoji } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import Indicator from '@/app/components/header/indicator' import Indicator from '@/app/components/header/indicator'
import WorkflowToolModal from '@/app/components/tools/workflow-tool' import { useRouter } from '@/next/navigation'
import Divider from '../../base/divider' import Divider from '../../base/divider'
import { useConfigureButton } from './hooks/use-configure-button'
type Props = { type Props = {
disabled: boolean disabled: boolean
published: boolean published: boolean
detailNeedUpdate: boolean isLoading: boolean
workflowAppId: string outdated: boolean
icon: Emoji isCurrentWorkspaceManager: boolean
name: string onConfigure: () => void
description: string
inputs?: InputVar[]
outputs?: Variable[]
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
onRefreshData?: () => void
disabledReason?: string disabledReason?: string
} }
const WorkflowToolConfigureButton = ({ const WorkflowToolConfigureButton = ({
disabled, disabled,
published, published,
detailNeedUpdate, isLoading,
workflowAppId, outdated,
icon, isCurrentWorkspaceManager,
name, onConfigure,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
disabledReason, disabledReason,
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { const router = useRouter()
showModal,
isLoading,
outdated,
payload,
isCurrentWorkspaceManager,
openModal,
closeModal,
handleCreate,
handleUpdate,
navigateToTools,
} = useConfigureButton({
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
})
return ( return (
<> <>
@ -80,9 +43,12 @@ const WorkflowToolConfigureButton = ({
? ( ? (
<div <div
className="flex items-center justify-start gap-2 p-2 pl-2.5" className="flex items-center justify-start gap-2 p-2 pl-2.5"
onClick={() => !disabled && !published && openModal()} onClick={() => {
if (!disabled && !published)
onConfigure()
}}
> >
<RiHammerLine className={cn('relative h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} /> <span className={cn('relative i-ri-hammer-line h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
<div <div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''} title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className={cn('shrink grow basis-0 truncate system-sm-medium text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} className={cn('shrink grow basis-0 truncate system-sm-medium text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')}
@ -100,7 +66,7 @@ const WorkflowToolConfigureButton = ({
<div <div
className="flex items-center justify-start gap-2 p-2 pl-2.5" className="flex items-center justify-start gap-2 p-2 pl-2.5"
> >
<RiHammerLine className="h-4 w-4 text-text-tertiary" /> <span className="i-ri-hammer-line h-4 w-4 text-text-tertiary" />
<div <div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''} title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className="shrink grow basis-0 truncate system-sm-medium text-text-tertiary" className="shrink grow basis-0 truncate system-sm-medium text-text-tertiary"
@ -120,7 +86,7 @@ const WorkflowToolConfigureButton = ({
<Button <Button
size="small" size="small"
className="w-[140px]" className="w-[140px]"
onClick={openModal} onClick={onConfigure}
disabled={!isCurrentWorkspaceManager || disabled} disabled={!isCurrentWorkspaceManager || disabled}
> >
{t('common.configure', { ns: 'workflow' })} {t('common.configure', { ns: 'workflow' })}
@ -129,11 +95,11 @@ const WorkflowToolConfigureButton = ({
<Button <Button
size="small" size="small"
className="w-[140px]" className="w-[140px]"
onClick={navigateToTools} onClick={() => router.push('/tools?category=workflow')}
disabled={disabled} disabled={disabled}
> >
{t('common.manageInTools', { ns: 'workflow' })} {t('common.manageInTools', { ns: 'workflow' })}
<RiArrowRightUpLine className="ml-1 h-4 w-4" /> <span className="ml-1 i-ri-arrow-right-up-line h-4 w-4" />
</Button> </Button>
</div> </div>
{outdated && ( {outdated && (
@ -146,15 +112,6 @@ const WorkflowToolConfigureButton = ({
</div> </div>
)} )}
{published && isLoading && <div className="pt-2"><Loading type="app" /></div>} {published && isLoading && <div className="pt-2"><Loading type="app" /></div>}
{showModal && (
<WorkflowToolModal
isAdd={!published}
payload={payload}
onHide={closeModal}
onCreate={handleCreate}
onSave={handleUpdate}
/>
)}
</> </>
) )
} }

View File

@ -4,11 +4,6 @@ import { act, renderHook } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types' import { InputVarType } from '@/app/components/workflow/types'
import { isParametersOutdated, useConfigureButton } from '../use-configure-button' import { isParametersOutdated, useConfigureButton } from '../use-configure-button'
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
const mockIsCurrentWorkspaceManager = vi.fn(() => true) const mockIsCurrentWorkspaceManager = vi.fn(() => true)
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ useAppContext: () => ({
@ -98,6 +93,7 @@ const createMockDetail = (overrides: Partial<WorkflowToolProviderResponse> = {})
}) })
const createDefaultOptions = (overrides = {}) => ({ const createDefaultOptions = (overrides = {}) => ({
enabled: true,
published: false, published: false,
detailNeedUpdate: false, detailNeedUpdate: false,
workflowAppId: 'app-123', workflowAppId: 'app-123',
@ -213,9 +209,9 @@ describe('useConfigureButton', () => {
}) })
describe('Initialization', () => { describe('Initialization', () => {
it('should return showModal as false by default', () => { it('should return workflow tool state without owning drawer visibility', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions())) const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
expect(result.current.showModal).toBe(false) expect(result.current.payload).toMatchObject({ workflow_app_id: 'app-123' })
}) })
it('should forward isCurrentWorkspaceManager from context', () => { it('should forward isCurrentWorkspaceManager from context', () => {
@ -239,6 +235,11 @@ describe('useConfigureButton', () => {
renderHook(() => useConfigureButton(createDefaultOptions({ published: false }))) renderHook(() => useConfigureButton(createDefaultOptions({ published: false })))
expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false) expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false)
}) })
it('should call query hook with enabled=false when controller is disabled', () => {
renderHook(() => useConfigureButton(createDefaultOptions({ enabled: false, published: true })))
expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false)
})
}) })
// Computed values // Computed values
@ -348,46 +349,13 @@ describe('useConfigureButton', () => {
}) })
}) })
// Modal controls
describe('Modal Controls', () => {
it('should open modal via openModal', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
act(() => {
result.current.openModal()
})
expect(result.current.showModal).toBe(true)
})
it('should close modal via closeModal', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
act(() => {
result.current.openModal()
})
act(() => {
result.current.closeModal()
})
expect(result.current.showModal).toBe(false)
})
it('should navigate to tools page', () => {
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
act(() => {
result.current.navigateToTools()
})
expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow')
})
})
// Mutation handlers // Mutation handlers
describe('handleCreate', () => { describe('handleCreate', () => {
it('should create provider, invalidate caches, refresh, and close modal', async () => { it('should create provider, invalidate caches, refresh, and notify configured', async () => {
mockCreateWorkflowToolProvider.mockResolvedValue({}) mockCreateWorkflowToolProvider.mockResolvedValue({})
const onRefreshData = vi.fn() const onRefreshData = vi.fn()
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData }))) const onConfigured = vi.fn()
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData, onConfigured })))
act(() => {
result.current.openModal()
})
await act(async () => { await act(async () => {
await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string }) await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string })
@ -398,7 +366,7 @@ describe('useConfigureButton', () => {
expect(onRefreshData).toHaveBeenCalled() expect(onRefreshData).toHaveBeenCalled()
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) }) expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) })
expect(result.current.showModal).toBe(false) expect(onConfigured).toHaveBeenCalled()
}) })
it('should show error toast on failure', async () => { it('should show error toast on failure', async () => {
@ -414,20 +382,18 @@ describe('useConfigureButton', () => {
}) })
describe('handleUpdate', () => { describe('handleUpdate', () => {
it('should publish, save, invalidate caches, and close modal', async () => { it('should publish, save, invalidate caches, and notify configured', async () => {
mockSaveWorkflowToolProvider.mockResolvedValue({}) mockSaveWorkflowToolProvider.mockResolvedValue({})
const handlePublish = vi.fn().mockResolvedValue(undefined) const handlePublish = vi.fn().mockResolvedValue(undefined)
const onRefreshData = vi.fn() const onRefreshData = vi.fn()
const onConfigured = vi.fn()
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
published: true, published: true,
handlePublish, handlePublish,
onRefreshData, onRefreshData,
onConfigured,
}))) })))
act(() => {
result.current.openModal()
})
await act(async () => { await act(async () => {
await result.current.handleUpdate(createMockRequest({ workflow_tool_id: 'tool-456' }) as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>) await result.current.handleUpdate(createMockRequest({ workflow_tool_id: 'tool-456' }) as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>)
}) })
@ -437,7 +403,7 @@ describe('useConfigureButton', () => {
expect(onRefreshData).toHaveBeenCalled() expect(onRefreshData).toHaveBeenCalled()
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled() expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
expect(result.current.showModal).toBe(false) expect(onConfigured).toHaveBeenCalled()
}) })
it('should show error toast when publish fails', async () => { it('should show error toast when publish fails', async () => {
@ -491,6 +457,16 @@ describe('useConfigureButton', () => {
expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled() expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled()
}) })
it('should not invalidate detail while disabled', () => {
renderHook(() => useConfigureButton(createDefaultOptions({
enabled: false,
published: true,
detailNeedUpdate: true,
})))
expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled()
})
}) })
// Edge cases // Edge cases

View File

@ -2,10 +2,9 @@ import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderPa
import type { InputVar, Variable } from '@/app/components/workflow/types' import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow' import type { PublishWorkflowParams } from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools' import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools'
import { useInvalidateAllWorkflowTools, useInvalidateWorkflowToolDetailByAppID, useWorkflowToolDetailByAppID } from '@/service/use-tools' import { useInvalidateAllWorkflowTools, useInvalidateWorkflowToolDetailByAppID, useWorkflowToolDetailByAppID } from '@/service/use-tools'
@ -89,6 +88,7 @@ function buildExistingOutputParameters(
// endregion // endregion
type UseConfigureButtonOptions = { type UseConfigureButtonOptions = {
enabled: boolean
published: boolean published: boolean
detailNeedUpdate: boolean detailNeedUpdate: boolean
workflowAppId: string workflowAppId: string
@ -99,10 +99,12 @@ type UseConfigureButtonOptions = {
outputs?: Variable[] outputs?: Variable[]
handlePublish: (params?: PublishWorkflowParams) => Promise<void> handlePublish: (params?: PublishWorkflowParams) => Promise<void>
onRefreshData?: () => void onRefreshData?: () => void
onConfigured?: () => void
} }
export function useConfigureButton(options: UseConfigureButtonOptions) { export function useConfigureButton(options: UseConfigureButtonOptions) {
const { const {
enabled,
published, published,
detailNeedUpdate, detailNeedUpdate,
workflowAppId, workflowAppId,
@ -113,16 +115,14 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
outputs, outputs,
handlePublish, handlePublish,
onRefreshData, onRefreshData,
onConfigured,
} = options } = options
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter()
const { isCurrentWorkspaceManager } = useAppContext() const { isCurrentWorkspaceManager } = useAppContext()
const [showModal, setShowModal] = useState(false)
// Data fetching via React Query // Data fetching via React Query
const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, published) const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, enabled && published)
// Invalidation functions (store in ref for stable effect dependency) // Invalidation functions (store in ref for stable effect dependency)
const invalidateDetail = useInvalidateWorkflowToolDetailByAppID() const invalidateDetail = useInvalidateWorkflowToolDetailByAppID()
@ -133,9 +133,9 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
// Refetch when detailNeedUpdate becomes true // Refetch when detailNeedUpdate becomes true
useEffect(() => { useEffect(() => {
if (detailNeedUpdate) if (enabled && detailNeedUpdate)
invalidateDetailRef.current(workflowAppId) invalidateDetailRef.current(workflowAppId)
}, [detailNeedUpdate, workflowAppId]) }, [detailNeedUpdate, enabled, workflowAppId])
// Computed values // Computed values
const outdated = useMemo( const outdated = useMemo(
@ -173,14 +173,6 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
} }
}, [detail, published, workflowAppId, icon, name, description, inputs, outputs]) }, [detail, published, workflowAppId, icon, name, description, inputs, outputs])
// Modal controls (stable callbacks)
const openModal = useCallback(() => setShowModal(true), [])
const closeModal = useCallback(() => setShowModal(false), [])
const navigateToTools = useCallback(
() => router.push('/tools?category=workflow'),
[router],
)
// Mutation handlers (not memoized — only used in conditionally-rendered modal) // Mutation handlers (not memoized — only used in conditionally-rendered modal)
const handleCreate = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => { const handleCreate = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => {
try { try {
@ -189,7 +181,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
onRefreshData?.() onRefreshData?.()
invalidateDetail(workflowAppId) invalidateDetail(workflowAppId)
toast.success(t('api.actionSuccess', { ns: 'common' })) toast.success(t('api.actionSuccess', { ns: 'common' }))
setShowModal(false) onConfigured?.()
} }
catch (e) { catch (e) {
toast.error((e as Error).message) toast.error((e as Error).message)
@ -206,7 +198,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
onRefreshData?.() onRefreshData?.()
invalidateAllWorkflowTools() invalidateAllWorkflowTools()
invalidateDetail(workflowAppId) invalidateDetail(workflowAppId)
setShowModal(false) onConfigured?.()
} }
catch (e) { catch (e) {
toast.error((e as Error).message) toast.error((e as Error).message)
@ -214,15 +206,11 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
} }
return { return {
showModal,
isLoading, isLoading,
outdated, outdated,
payload, payload,
isCurrentWorkspaceManager, isCurrentWorkspaceManager,
openModal,
closeModal,
handleCreate, handleCreate,
handleUpdate, handleUpdate,
navigateToTools,
} }
} }

View File

@ -1,9 +1,19 @@
'use client' 'use client'
import type { FC } from 'react' import type { DrawerRootProps } from '@langgenius/dify-ui/drawer'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types' import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { produce } from 'immer' import { produce } from 'immer'
@ -26,7 +36,7 @@ import {
isWorkflowToolNameValid, isWorkflowToolNameValid,
} from './helpers' } from './helpers'
export type WorkflowToolModalPayload = { export type WorkflowToolDrawerPayload = {
icon: Emoji icon: Emoji
label: string label: string
name: string name: string
@ -42,9 +52,9 @@ export type WorkflowToolModalPayload = {
workflow_app_id?: string workflow_app_id?: string
} }
type Props = { export type WorkflowToolDrawerProps = {
isAdd?: boolean isAdd?: boolean
payload: WorkflowToolModalPayload payload: WorkflowToolDrawerPayload
onHide: () => void onHide: () => void
onRemove?: () => void onRemove?: () => void
onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
@ -54,8 +64,9 @@ type Props = {
}>) => void }>) => void
} }
type WorkflowToolDrawerProps = { type WorkflowToolDrawerFrameProps = {
title: string title: string
closeLabel: string
onHide: () => void onHide: () => void
children: React.ReactNode children: React.ReactNode
} }
@ -77,39 +88,45 @@ const InfoTooltip = ({ children }: { children: React.ReactNode }) => {
) )
} }
const WorkflowToolDrawer = ({ title, onHide, children }: WorkflowToolDrawerProps) => { const WorkflowToolDrawerFrame = ({ title, closeLabel, onHide, children }: WorkflowToolDrawerFrameProps) => {
const handleOpenChange = React.useCallback<NonNullable<DrawerRootProps['onOpenChange']>>((open) => {
if (!open)
onHide()
}, [onHide])
return ( return (
<Dialog open disablePointerDismissal> <Drawer open modal disablePointerDismissal swipeDirection="right" onOpenChange={handleOpenChange}>
<DialogContent <DrawerPortal>
className={cn( <DrawerBackdrop />
'top-2 right-2 bottom-2 left-auto h-[calc(100dvh-16px)] max-h-[calc(100dvh-16px)] w-[640px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! overflow-hidden rounded-xl border-none bg-transparent p-0 shadow-none', <DrawerViewport>
'data-ending-style:translate-x-4 data-ending-style:scale-100 data-starting-style:translate-x-4 data-starting-style:scale-100', <DrawerPopup
)} data-testid="drawer"
backdropClassName="bg-background-overlay" className={cn(
> 'data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-160 data-[swipe-direction=right]:max-w-[calc(100vw-16px)]',
<div data-testid="drawer" className="flex h-full w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl"> 'data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle',
<div className="shrink-0 border-b border-divider-subtle py-4"> )}
<div className="flex h-6 items-center justify-between pr-5 pl-6"> >
<DialogTitle data-testid="drawer-title" className="system-xl-semibold text-text-primary"> <DrawerContent className="flex min-h-0 flex-1 flex-col overflow-hidden p-0 pb-0">
{title} <div className="shrink-0 border-b border-divider-subtle py-4">
</DialogTitle> <div className="flex h-6 items-center justify-between pr-5 pl-6">
<button <DrawerTitle data-testid="drawer-title" className="min-w-0 truncate system-xl-semibold text-text-primary">
type="button" {title}
data-testid="drawer-close" </DrawerTitle>
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover" <DrawerCloseButton
aria-label="Close" data-testid="drawer-close"
onClick={onHide} className="h-6 w-6 rounded-md"
> aria-label={closeLabel}
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" /> />
</button> </div>
</div> </div>
</div> <div className="grow overflow-hidden">
<div className="grow overflow-hidden"> {children}
{children} </div>
</div> </DrawerContent>
</div> </DrawerPopup>
</DialogContent> </DrawerViewport>
</Dialog> </DrawerPortal>
</Drawer>
) )
} }
@ -158,15 +175,14 @@ const WorkflowToolEmojiPicker = ({ onSelect, onClose }: WorkflowToolEmojiPickerP
) )
} }
// Add and Edit export function WorkflowToolDrawer({
const WorkflowToolAsModal: FC<Props> = ({
isAdd, isAdd,
payload, payload,
onHide, onHide,
onRemove, onRemove,
onSave, onSave,
onCreate, onCreate,
}) => { }: WorkflowToolDrawerProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false) const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false)
@ -200,7 +216,7 @@ const WorkflowToolAsModal: FC<Props> = ({
setLabels(value) setLabels(value)
} }
const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy) const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy)
const [showModal, setShowModal] = useState(false) const [confirmModalOpen, setConfirmModalOpen] = useState(false)
const onConfirm = () => { const onConfirm = () => {
let errorMessage = '' let errorMessage = ''
@ -243,9 +259,10 @@ const WorkflowToolAsModal: FC<Props> = ({
return ( return (
<> <>
<WorkflowToolDrawer <WorkflowToolDrawerFrame
onHide={onHide} onHide={onHide}
title={t('common.workflowAsTool', { ns: 'workflow' })!} title={t('common.workflowAsTool', { ns: 'workflow' })!}
closeLabel={t('operation.close', { ns: 'common' })!}
> >
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3"> <div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
@ -427,7 +444,7 @@ const WorkflowToolAsModal: FC<Props> = ({
if (isAdd) if (isAdd)
onConfirm() onConfirm()
else else
setShowModal(true) setConfirmModalOpen(true)
}} }}
> >
{t('operation.save', { ns: 'common' })} {t('operation.save', { ns: 'common' })}
@ -435,7 +452,7 @@ const WorkflowToolAsModal: FC<Props> = ({
</div> </div>
</div> </div>
</div> </div>
</WorkflowToolDrawer> </WorkflowToolDrawerFrame>
{showEmojiPicker && ( {showEmojiPicker && (
<WorkflowToolEmojiPicker <WorkflowToolEmojiPicker
onSelect={(icon, icon_background) => { onSelect={(icon, icon_background) => {
@ -447,10 +464,10 @@ const WorkflowToolAsModal: FC<Props> = ({
}} }}
/> />
)} )}
{showModal && ( {confirmModalOpen && (
<ConfirmModal <ConfirmModal
show={showModal} show={confirmModalOpen}
onClose={() => setShowModal(false)} onClose={() => setConfirmModalOpen(false)}
onConfirm={onConfirm} onConfirm={onConfirm}
/> />
)} )}
@ -458,4 +475,3 @@ const WorkflowToolAsModal: FC<Props> = ({
) )
} }
export default React.memo(WorkflowToolAsModal)

View File

@ -124,7 +124,7 @@ vi.mock('@/app/components/app/app-publisher', () => ({
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}> <button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}>
publisher-publish publisher-publish
</button> </button>
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}> <button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ url: '/apps/app-1/workflows/publish', title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
publisher-publish-with-params publisher-publish-with-params
</button> </button>
</div> </div>

View File

@ -271,6 +271,7 @@ const NodePanel: FC<Props> = ({
<div className={cn('mb-1')}> <div className={cn('mb-1')}>
<CodeEditor <CodeEditor
readOnly readOnly
showFileList
title={<div>{processDataTitle}</div>} title={<div>{processDataTitle}</div>}
language={CodeLanguage.json} language={CodeLanguage.json}
value={nodeInfo.process_data} value={nodeInfo.process_data}
@ -282,6 +283,7 @@ const NodePanel: FC<Props> = ({
<div> <div>
<CodeEditor <CodeEditor
readOnly readOnly
showFileList
title={<div>{outputTitle}</div>} title={<div>{outputTitle}</div>}
language={CodeLanguage.json} language={CodeLanguage.json}
value={nodeInfo.outputs} value={nodeInfo.outputs}

View File

@ -143,6 +143,7 @@ const ResultPanel: FC<ResultPanelProps> = ({
{process_data && ( {process_data && (
<CodeEditor <CodeEditor
readOnly readOnly
showFileList
title={<div>{t('common.processData', { ns: 'workflow' }).toLocaleUpperCase()}</div>} title={<div>{t('common.processData', { ns: 'workflow' }).toLocaleUpperCase()}</div>}
language={CodeLanguage.json} language={CodeLanguage.json}
value={process_data} value={process_data}
@ -153,6 +154,7 @@ const ResultPanel: FC<ResultPanelProps> = ({
{(outputs || status === 'running') && ( {(outputs || status === 'running') && (
<CodeEditor <CodeEditor
readOnly readOnly
showFileList
title={<div>{t('common.output', { ns: 'workflow' }).toLocaleUpperCase()}</div>} title={<div>{t('common.output', { ns: 'workflow' }).toLocaleUpperCase()}</div>}
language={CodeLanguage.json} language={CodeLanguage.json}
value={outputs} value={outputs}

View File

@ -10,12 +10,15 @@ This document tracks the Dify-web migration away from legacy overlay APIs.
- `@/app/components/base/tooltip` - `@/app/components/base/tooltip`
- `@/app/components/base/modal` - `@/app/components/base/modal`
- `@/app/components/base/dialog` - `@/app/components/base/dialog`
- `@/app/components/base/drawer`
- `@/app/components/base/drawer-plus`
- Replacement primitives: - Replacement primitives:
- `@langgenius/dify-ui/tooltip` - `@langgenius/dify-ui/tooltip`
- `@langgenius/dify-ui/dropdown-menu` - `@langgenius/dify-ui/dropdown-menu`
- `@langgenius/dify-ui/context-menu` - `@langgenius/dify-ui/context-menu`
- `@langgenius/dify-ui/popover` - `@langgenius/dify-ui/popover`
- `@langgenius/dify-ui/dialog` - `@langgenius/dify-ui/dialog`
- `@langgenius/dify-ui/drawer`
- `@langgenius/dify-ui/alert-dialog` - `@langgenius/dify-ui/alert-dialog`
- `@langgenius/dify-ui/autocomplete` - `@langgenius/dify-ui/autocomplete`
- `@langgenius/dify-ui/combobox` - `@langgenius/dify-ui/combobox`
@ -49,12 +52,12 @@ All new overlay primitives in `@langgenius/dify-ui/*` share a single z-index val
During the migration period, legacy and new overlays coexist. Legacy overlays During the migration period, legacy and new overlays coexist. Legacy overlays
portal to `document.body` with explicit z-index values: portal to `document.body` with explicit z-index values:
| Layer | z-index | Components | | Layer | z-index | Components |
| --------------------- | ------------ | -------------------------------------------------------------------------------- | | --------------------- | ------------ | ---------------------------------------------------------------------------------------- |
| Legacy Drawer | `z-30` | `base/drawer` | | Legacy Drawer | `z-30` | `base/drawer`, `base/drawer-plus` |
| Legacy Modal | `z-60` | `base/modal` (default) | | Legacy Modal | `z-60` | `base/modal` (default) |
| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Popover, Dialog, Autocomplete, Combobox, Tooltip, etc.) | | **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Drawer, Popover, Dialog, Autocomplete, Combobox, Tooltip, etc.) |
| Toast | `z-1003` | `@langgenius/dify-ui/toast` | | Toast | `z-1003` | `@langgenius/dify-ui/toast` |
`z-1002` sits above all common legacy overlays, so new primitives always `z-1002` sits above all common legacy overlays, so new primitives always
render on top without needing per-call-site z-index hacks. Among themselves, render on top without needing per-call-site z-index hacks. Among themselves,

View File

@ -66,6 +66,15 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
], ],
message: 'Deprecated: use @langgenius/dify-ui/dialog instead. See issue #32767.', message: 'Deprecated: use @langgenius/dify-ui/dialog instead. See issue #32767.',
}, },
{
group: [
'**/base/drawer',
'**/base/drawer/index',
'**/base/drawer-plus',
'**/base/drawer-plus/index',
],
message: 'Deprecated: use @langgenius/dify-ui/drawer instead. See issue #32767.',
},
] ]
export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = { export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = {

View File

@ -5,6 +5,7 @@ import type { MetadataItemWithValue } from '@/app/components/datasets/metadata/t
import type { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { MetadataFilteringVariableType } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import type { Tag } from '@/contract/console/tags' import type { Tag } from '@/contract/console/tags'
import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '@/types/app' import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '@/types/app'
import type { SegmentImportStatus } from '@/types/dataset'
import type { I18nKeysByPrefix } from '@/types/i18n' import type { I18nKeysByPrefix } from '@/types/i18n'
import { ExternalKnowledgeBase, General, ParentChild, Qa } from '@/app/components/base/icons/src/public/knowledge/dataset-card' import { ExternalKnowledgeBase, General, ParentChild, Qa } from '@/app/components/base/icons/src/public/knowledge/dataset-card'
@ -783,7 +784,7 @@ export type UpdateDocumentBatchParams = {
export type BatchImportResponse = { export type BatchImportResponse = {
job_id: string job_id: string
job_status: string job_status: SegmentImportStatus
} }
export const DOC_FORM_ICON_WITH_BG: Record<ChunkingMode | 'external', React.ComponentType<{ className: string }>> = { export const DOC_FORM_ICON_WITH_BG: Record<ChunkingMode | 'external', React.ComponentType<{ className: string }>> = {

8
web/types/dataset.ts Normal file
View File

@ -0,0 +1,8 @@
export const segmentImportStatus = {
waiting: 'waiting',
processing: 'processing',
completed: 'completed',
error: 'error',
} as const
export type SegmentImportStatus = typeof segmentImportStatus[keyof typeof segmentImportStatus]