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
run: |
cp docker/.env.example docker/.env
./docker/init-env.sh
cp docker/middleware.env.example docker/middleware.env
- name: Expose Service Ports

View File

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

View File

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

View File

@ -47,7 +47,7 @@ jobs:
- name: Set up dotenvs
run: |
cp docker/.env.example docker/.env
./docker/init-env.sh
cp docker/middleware.env.example docker/middleware.env
- 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:
```bash
cd dify/docker
cp .env.example .env
cd dify
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
```
@ -136,7 +144,7 @@ Star Dify on GitHub and be instantly notified of new releases.
### 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

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())
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()
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():
# 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:
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")
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:
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")
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}"
# 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 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.\
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>
> 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.
- **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`
1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system.
1. **Environment Setup**:
- Navigate to the `docker` directory.
- Copy the `.env.example` file to a new file named `.env` by running `cp .env.example .env`.
- Customize the `.env` file as needed. Refer to the `.env.example` file for detailed configuration options.
- **Optional (Recommended for upgrades)**:
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.
This is especially useful when upgrading Dify or managing a large, customized `.env` file.
- Create `.env` and generate a deployment-specific `SECRET_KEY`:
```bash
./init-env.sh
```
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.
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`.
1. **SSL Certificate Setup**:
- 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**:
- 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
@ -68,7 +82,7 @@ For users migrating from the `docker-legacy` setup:
#### 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**:
@ -118,23 +132,25 @@ The `.env.example` file provided in the Docker setup is extensive and covers a w
### Environment Variables Synchronization
When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.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.
#### `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**
- 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`
- Displays differences and variables removed from `.env.example` for review
- Displays differences and variables removed from `.env.all` for review
**Backup behavior**
@ -143,9 +159,9 @@ Before synchronization, the current `.env` file is saved to the `env-backup/` di
**When to use**
- After upgrading Dify to a newer version
- When `.env.example` has been updated with new environment variables
- When managing a large or heavily customized `.env` file
- After upgrading Dify to a newer version with a full `.env` file
- When `.env.all` has been updated with new environment variables
- When managing a large or heavily customized `.env` file copied from `.env.all`
**Usage**
@ -160,6 +176,6 @@ chmod +x dify-env-sync.sh
### 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.
- **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.

View File

@ -4,7 +4,7 @@
# Dify Environment Variables Synchronization Script
#
# Features:
# - Synchronize latest settings from .env.example to .env
# - Synchronize latest settings from .env.all to .env
# - Preserve custom settings in existing .env
# - Add new 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:
"""Verify required files exist; create .env from .env.example if absent.
"""Verify required files exist; create .env from .env.all if absent.
Args:
work_dir: Directory that must contain .env.example (and optionally .env).
work_dir: Directory that must contain .env.all (and optionally .env).
Raises:
SystemExit: If .env.example does not exist.
SystemExit: If .env.all does not exist.
"""
log_info("Checking required files...")
example_file = work_dir / ".env.example"
example_file = work_dir / ".env.all"
env_file = work_dir / ".env"
if not example_file.exists():
log_error(".env.example file not found")
log_error(".env.all file not found")
sys.exit(1)
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)
log_success(".env file created")
@ -147,7 +147,7 @@ def analyze_value_change(current: str, recommended: str) -> str | None:
Args:
current: Value currently set in .env.
recommended: Value present in .env.example.
recommended: Value present in .env.all.
Returns:
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]]:
"""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
variables are handled by separate functions.
Args:
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:
Mapping of key -> (env_value, example_value) for every key whose
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]] = {}
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:
print(f"{YELLOW}[{count}] {key}{NC}")
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:
print(f"[{count}] {key}")
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)
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]:
"""Identify variables present in .env but absent from .env.example.
"""Identify variables present in .env but absent from .env.all.
Args:
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:
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...")
removed = sorted(set(env_vars) - set(example_vars))
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:
log_warning(f" - {var}")
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:
"""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
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.
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.
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...")
example_file = work_dir / ".env.example"
example_file = work_dir / ".env.all"
new_env_file = work_dir / ".env.new"
# 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("Partial synchronization of .env file completed")
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:
"""Print a summary of variable counts from both env files.
Args:
work_dir: Directory containing .env and .env.example.
work_dir: Directory containing .env and .env.all.
"""
log_info("Synchronization statistics:")
example_file = work_dir / ".env.example"
example_file = work_dir / ".env.all"
env_file = work_dir / ".env"
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
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}")
@ -380,7 +380,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="dify-env-sync",
description=(
"Synchronize .env with .env.example: add new variables, "
"Synchronize .env with .env.all: add new variables, "
"preserve custom values, and report removed variables."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
@ -396,7 +396,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
"--dir",
metavar="DIRECTORY",
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(
"--no-backup",
@ -427,7 +427,7 @@ def main() -> None:
# 3. Parse both files
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)
diffs = detect_differences(env_vars, example_vars)

View File

@ -4,7 +4,7 @@
# Dify Environment Variables Synchronization Script
#
# Features:
# - Synchronize latest settings from .env.example to .env
# - Synchronize latest settings from .env.all to .env
# - Preserve custom settings in existing .env
# - Add new environment variables
# - Detect removed environment variables
@ -61,18 +61,18 @@ log_error() {
}
# 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() {
log_info "Checking required files..."
if [[ ! -f ".env.example" ]]; then
log_error ".env.example file not found"
if [[ ! -f ".env.all" ]]; then
log_error ".env.all file not found"
exit 1
fi
if [[ ! -f ".env" ]]; then
log_warning ".env file does not exist. Creating from .env.example."
cp ".env.example" ".env"
log_warning ".env file does not exist. Creating from .env.all."
cp ".env.all" ".env"
log_success ".env file created"
fi
@ -98,9 +98,9 @@ create_backup() {
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() {
log_info "Detecting differences between .env and .env.example..."
log_info "Detecting differences between .env and .env.all..."
# Create secure temporary directory
local temp_dir=$(mktemp -d)
@ -140,7 +140,7 @@ detect_differences() {
}
}
END { print diff_count }
' .env .env.example)
' .env .env.all)
if [[ $diff_count -gt 0 ]]; then
log_success "Detected differences in $diff_count environment variables"
@ -201,7 +201,7 @@ show_differences_detail() {
echo ""
echo -e "${YELLOW}[$count] $key${NC}"
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_change "$env_value" "$example_value"
@ -261,8 +261,8 @@ analyze_value_change() {
fi
}
# Synchronize .env file with .env.example while preserving custom values
# Creates a new .env file based on .env.example structure, preserving existing custom values
# Synchronize .env file with .env.all while preserving custom values
# Creates a new .env file based on .env.all structure, preserving existing custom values
# Global variables used: DIFF_FILE, TEMP_DIR
sync_env_file() {
log_info "Starting partial synchronization of .env file..."
@ -281,7 +281,7 @@ sync_env_file() {
fi
# 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 awk_preserved_count_file="${TEMP_DIR}/awk_preserved_count"
@ -332,7 +332,7 @@ sync_env_file() {
print preserved_count > preserved_count_file
print updated_count > updated_count_file
}
' .env.example > "$new_env_file"
' .env.all > "$new_env_file"
# Read counters and preserved keys
if [[ -f "$awk_preserved_count_file" ]]; then
@ -372,7 +372,7 @@ sync_env_file() {
log_success "Partial synchronization of .env file completed"
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
@ -394,8 +394,8 @@ detect_removed_variables() {
cleanup_temp_dir="$temp_dir"
fi
# Get keys from .env.example and .env, sorted for comm
awk -F= '!/^[[:space:]]*#/ && /=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1}' .env.example | sort > "$temp_example_keys"
# Get keys from .env.all and .env, sorted for comm
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"
# Get keys from existing .env and check for removals
@ -410,7 +410,7 @@ detect_removed_variables() {
fi
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
log_warning " - $var"
done
@ -424,10 +424,10 @@ detect_removed_variables() {
show_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")
log_info " .env.example environment variables: $total_example"
log_info " .env.all environment variables: $total_example"
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:
# Init container to fix permissions
init_permissions:
@ -22,12 +747,9 @@ services:
api:
image: langgenius/dify-api:1.14.0
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:
# Use the shared environment variables.
<<: *shared-api-worker-env
# Startup mode, 'api' starts the API server.
MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-}
@ -73,10 +795,9 @@ services:
worker:
image: langgenius/dify-api:1.14.0
restart: always
env_file:
- .env.example
- .env
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:-}
@ -120,10 +841,9 @@ services:
worker_beat:
image: langgenius/dify-api:1.14.0
restart: always
env_file:
- .env.example
- .env
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:
@ -298,10 +1018,9 @@ services:
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.6.0-local
restart: always
env_file:
- .env.example
- .env
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}

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

View File

@ -28,6 +28,7 @@ Always import from a **subpath export** — there is no barrel:
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import '@langgenius/dify-ui/styles.css' // once, in the app root
```
@ -36,12 +37,12 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
| Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
Utilities:
@ -65,7 +66,7 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s
## Overlay & portal contract
All overlay primitives (`dialog`, `alert-dialog`, `autocomplete`, `combobox`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually.
Overlay primitives render their floating surfaces inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Convenience content components such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portal internally; primitives with explicit portal anatomy such as `Drawer` expose the matching `DrawerPortal` part so consumers can compose the full Base UI structure.
### Root isolation requirement
@ -83,19 +84,19 @@ Equivalent: any root element with `isolation: isolate` in CSS. Without it, overl
Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites.
| Layer | z-index | Where |
| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
| Layer | z-index | Where |
| ------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` / `base/drawer` / `base/drawer-plus` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`.
### Rules
- Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated.
- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal.
- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal.
- When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites.
## Development

View File

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

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', () => ({
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">
<button data-testid="wf-modal-hide" onClick={onHide}>Hide</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,
}))
vi.mock('@/service/use-tools', () => ({
useWorkflowToolDetailByAppID: () => ({
data: undefined,
isLoading: false,
}),
useInvalidateAllWorkflowTools: () => vi.fn(),
useInvalidateWorkflowToolDetailByAppID: () => vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
@ -121,6 +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('../sections', () => ({
@ -143,6 +167,7 @@ vi.mock('../sections', () => ({
<div>
<button onClick={props.handleEmbed}>publisher-embed</button>
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
<button onClick={props.onConfigureWorkflowTool}>publisher-workflow-tool</button>
</div>
)
},
@ -231,6 +256,25 @@ describe('AppPublisher', () => {
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 () => {
render(
<AppPublisher

View File

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

View File

@ -5,13 +5,12 @@ import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { RiStoreLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useKeyPress } from 'ahooks'
import {
memo,
use,
useCallback,
useContext,
useEffect,
useMemo,
useState,
@ -20,9 +19,12 @@ import { useTranslation } from 'react-i18next'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { WorkflowContext } from '@/app/components/workflow/context'
import { appDefaultIconBackground } from '@/config'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
@ -57,8 +59,8 @@ export type AppPublisherProps = {
debugWithMultipleModel?: boolean
multipleModelConfigs?: ModelAndParameter[]
/** modelAndParameter is passed when debugWithMultipleModel is true */
onPublish?: (params?: any) => Promise<any> | any
onRestore?: () => Promise<any> | any
onPublish?: AppPublisherPublishHandler
onRestore?: AppPublisherRestoreHandler
onToggle?: (state: boolean) => void
crossAxisOffset?: number
toolPublished?: boolean
@ -74,6 +76,12 @@ export type AppPublisherProps = {
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 = ({
disabled = false,
publishDisabled = false,
@ -100,11 +108,12 @@ const AppPublisher = ({
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
const [publishingToMarketplace, setPublishingToMarketplace] = useState(false)
const workflowStore = useContext(WorkflowContext)
const workflowStore = use(WorkflowContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -273,6 +282,31 @@ const AppPublisher = ({
const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable
? t('common.workflowAsToolDisabledHint', { ns: 'workflow' })
: undefined
const workflowToolVisible = appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode
const workflowToolPublished = !!toolPublished
const closeWorkflowToolDrawer = useCallback(() => setWorkflowToolDrawerOpen(false), [])
const workflowToolIcon = useMemo(() => ({
content: (appDetail?.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
background: (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
}), [appDetail?.icon, appDetail?.icon_background, appDetail?.icon_type])
const workflowTool = useConfigureButton({
enabled: workflowToolVisible,
published: workflowToolPublished,
detailNeedUpdate: workflowToolPublished && published,
workflowAppId: appDetail?.id ?? '',
icon: workflowToolIcon,
name: appDetail?.name ?? '',
description: appDetail?.description ?? '',
inputs,
outputs,
handlePublish,
onRefreshData,
onConfigured: closeWorkflowToolDrawer,
})
const openWorkflowToolDrawer = useCallback(() => {
handleOpenChange(false)
setWorkflowToolDrawerOpen(true)
}, [handleOpenChange])
const upgradeHighlightStyle = useMemo(() => ({
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
WebkitBackgroundClip: 'text',
@ -343,23 +377,22 @@ const AppPublisher = ({
handleOpenChange(false)
handleOpenInExplore()
}}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable}
workflowToolIsLoading={workflowTool.isLoading}
workflowToolOutdated={workflowTool.outdated}
workflowToolIsCurrentWorkspaceManager={workflowTool.isCurrentWorkspaceManager}
workflowToolMessage={workflowToolMessage}
onConfigureWorkflowTool={openWorkflowToolDrawer}
/>
{systemFeatures.enable_creators_platform && (
<div className="border-t border-divider-subtle p-4">
<SuggestedAction
icon={<RiStoreLine className="h-4 w-4" />}
icon={<span className="i-ri-store-line h-4 w-4" />}
disabled={!publishedAt || publishingToMarketplace}
onClick={handlePublishToMarketplace}
>
@ -380,6 +413,15 @@ const AppPublisher = ({
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
</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'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
import Loading from '@/app/components/base/loading'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { appDefaultIconBackground } from '@/config'
import { AppModeEnum } from '@/types/app'
import ShortcutsName from '../../workflow/shortcuts-name'
import PublishWithMultipleModel from './publish-with-multiple-model'
@ -46,11 +44,8 @@ type AccessSectionProps = {
type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
| 'hasTriggerNode'
| 'inputs'
| 'missingStartNode'
| 'onRefreshData'
| 'toolPublished'
| 'outputs'
| 'publishedAt'
| 'workflowToolAvailable'> & {
appDetail: {
@ -67,9 +62,11 @@ type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
disabledFunctionTooltip?: string
handleEmbed: () => void
handleOpenInExplore: () => void
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
published: boolean
workflowToolIsLoading: boolean
workflowToolOutdated: boolean
workflowToolIsCurrentWorkspaceManager: boolean
workflowToolMessage?: string
onConfigureWorkflowTool: () => void
}
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
@ -256,18 +253,17 @@ export const PublisherActionsSection = ({
disabledFunctionTooltip,
handleEmbed,
handleOpenInExplore,
handlePublish,
hasHumanInputNode = false,
hasTriggerNode = false,
inputs,
missingStartNode = false,
onRefreshData,
outputs,
published,
publishedAt,
toolPublished,
workflowToolAvailable = true,
workflowToolIsLoading,
workflowToolOutdated,
workflowToolIsCurrentWorkspaceManager,
workflowToolMessage,
onConfigureWorkflowTool,
}: ActionsSectionProps) => {
const { t } = useTranslation()
@ -305,7 +301,7 @@ export const PublisherActionsSection = ({
<SuggestedAction
onClick={handleEmbed}
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' })}
</SuggestedAction>
@ -340,18 +336,10 @@ export const PublisherActionsSection = ({
<WorkflowToolConfigureButton
disabled={workflowToolDisabled}
published={!!toolPublished}
detailNeedUpdate={!!toolPublished && published}
workflowAppId={appDetail?.id ?? ''}
icon={{
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
}}
name={appDetail?.name ?? ''}
description={appDetail?.description ?? ''}
inputs={inputs}
outputs={outputs}
handlePublish={handlePublish}
onRefreshData={onRefreshData}
isLoading={workflowToolIsLoading}
outdated={workflowToolOutdated}
isCurrentWorkspaceManager={workflowToolIsCurrentWorkspaceManager}
onConfigure={onConfigureWorkflowTool}
disabledReason={workflowToolMessage}
/>
)}

View File

@ -54,22 +54,22 @@ const Operation: FC<Props> = ({
onOpenChange={setOpen}
>
<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()}
>
<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
placement="bottom-end"
sideOffset={4}

View File

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

View File

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

View File

@ -1,2 +1 @@
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', () => ({
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}>
<button data-testid="new-segment-btn" onClick={showNewSegmentModal}>New Segment</button>
<button data-testid="batch-btn" onClick={showBatchModal}>Batch Import</button>
</div>
),
ProcessStatus: {
WAITING: 'waiting',
PROCESSING: 'processing',
ERROR: 'error',
COMPLETED: 'completed',
},
}))
vi.mock('../../components/operations', () => ({

View File

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

View File

@ -137,9 +137,8 @@ vi.mock('../hooks/use-child-segment-data', () => ({
},
}))
// Mock child components to simplify testing
vi.mock('../components', () => ({
MenuBar: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
vi.mock('../components/menu-bar', () => ({
default: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
totalText: string
onInputChange: (value: string) => void
inputValue: string
@ -167,7 +166,13 @@ vi.mock('../components', () => ({
)}
</div>
),
}))
vi.mock('../components/drawer-group', () => ({
DrawerGroup: () => <div data-testid="drawer-group" />,
}))
vi.mock('../components/segment-list-content', () => ({
FullDocModeContent: () => <div data-testid="full-doc-mode-content" />,
GeneralModeContent: () => <div data-testid="general-mode-content" />,
}))
@ -563,7 +568,7 @@ describe('Edge Cases', () => {
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() })
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 { ChunkingMode } from '@/models/datasets'
import SegmentDetail from '../segment-detail'
import { SegmentDetail } from '../segment-detail'
// Mock dataset detail context
let mockIndexingTechnique = IndexingType.QUALIFIED
@ -167,7 +167,6 @@ describe('SegmentDetail', () => {
onCancel: vi.fn(),
isEditMode: false,
docForm: ChunkingMode.text,
onModalStateChange: vi.fn(),
}
describe('Rendering', () => {
@ -352,35 +351,12 @@ describe('SegmentDetail', () => {
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', () => {
const mockOnModalStateChange = vi.fn()
render(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onModalStateChange={mockOnModalStateChange}
/>,
)
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
fireEvent.click(screen.getByTestId('regenerate-btn'))
fireEvent.click(screen.getByTestId('cancel-regeneration'))
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
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', () => {
const mockOnCancel = vi.fn()
const mockOnModalStateChange = vi.fn()
render(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onCancel={mockOnCancel}
onModalStateChange={mockOnModalStateChange}
/>,
)
// Open regeneration modal
fireEvent.click(screen.getByTestId('regenerate-btn'))
fireEvent.click(screen.getByTestId('close-regeneration'))
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
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 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
// and invoke it directly to verify ESC key handling behavior.
vi.mock('ahooks', () => ({
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 },
}),
}))
const getOverlay = () =>
Array.from(document.querySelectorAll<HTMLElement>('[class]'))
.find(element => element.className.includes('bg-background-overlay'))
describe('Drawer', () => {
const defaultProps = {
@ -31,103 +20,109 @@ describe('Drawer', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedKeyPressCallback = undefined
})
describe('Rendering', () => {
it('should return null when open is false', () => {
const { container } = render(
<Drawer open={false} onClose={vi.fn()}>
<CompletedDrawer open={false} onClose={vi.fn()}>
<span>Content</span>
</Drawer>,
</CompletedDrawer>,
)
expect(container.innerHTML).toBe('')
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(
<Drawer {...defaultProps}>
<CompletedDrawer {...defaultProps}>
<span>Drawer content</span>
</Drawer>,
</CompletedDrawer>,
)
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()
})
})
// Overlay visibility
describe('Overlay', () => {
it('should show overlay when showOverlay is true', () => {
describe('Variant', () => {
it('should render a panel drawer without overlay by default', () => {
render(
<Drawer {...defaultProps} showOverlay={true}>
<CompletedDrawer {...defaultProps}>
<span>Content</span>
</Drawer>,
)
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>,
</CompletedDrawer>,
)
expect(getOverlay()).toBeUndefined()
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false')
})
})
// ESC key handling
describe('ESC Key', () => {
it('should call onClose when ESC is pressed and drawer is open', () => {
const onClose = vi.fn()
it('should render a modal drawer with overlay', () => {
render(
<Drawer open={true} onClose={onClose}>
<CompletedDrawer {...defaultProps} modal>
<span>Content</span>
</Drawer>,
</CompletedDrawer>,
)
expect(capturedKeyPressCallback).toBeDefined()
const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
capturedKeyPressCallback!(fakeEvent)
expect(getOverlay()).toBeInTheDocument()
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
})
})
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)
})

View File

@ -1,11 +1,11 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
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
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)
return null
return (
@ -13,8 +13,6 @@ vi.mock('../drawer', () => ({
data-testid="drawer-mock"
data-panel-class={panelClassName}
data-panel-content-class={panelContentClassName}
data-show-overlay={showOverlay}
data-need-check-chunks={needCheckChunks}
data-modal={modal}
>
{children}
@ -23,7 +21,7 @@ vi.mock('../drawer', () => ({
},
}))
describe('FullScreenDrawer', () => {
describe('DocumentDetailDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
@ -31,9 +29,9 @@ describe('FullScreenDrawer', () => {
describe('Rendering', () => {
it('should render without crashing when open', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
@ -41,9 +39,9 @@ describe('FullScreenDrawer', () => {
it('should not render when closed', () => {
render(
<FullScreenDrawer isOpen={false} fullScreen={false}>
<DocumentDetailDrawer open={false} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
@ -51,9 +49,9 @@ describe('FullScreenDrawer', () => {
it('should render children content', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Test Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.getByText('Test Content')).toBeInTheDocument()
@ -63,86 +61,46 @@ describe('FullScreenDrawer', () => {
describe('Props', () => {
it('should pass fullScreen=true to Drawer with full width class', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<DocumentDetailDrawer open={true} fullScreen={true}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
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', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
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(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
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>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-modal')).toBe('false')
})
it('should pass modal=true when specified', () => {
it('should pass modal when specified', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false} modal={true}>
<DocumentDetailDrawer open={true} fullScreen={false} modal>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
@ -154,9 +112,9 @@ describe('FullScreenDrawer', () => {
describe('Styling', () => {
it('should apply panel content classes for non-fullScreen mode', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
@ -167,9 +125,9 @@ describe('FullScreenDrawer', () => {
it('should apply panel content classes without border for fullScreen mode', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<DocumentDetailDrawer open={true} fullScreen={true}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
@ -184,24 +142,24 @@ describe('FullScreenDrawer', () => {
// Arrange & Act & Assert - should not throw
expect(() => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
}).not.toThrow()
})
it('should maintain structure when rerendered', () => {
const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
rerender(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<DocumentDetailDrawer open={true} fullScreen={true}>
<div>Updated Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.getByText('Updated Content')).toBeInTheDocument()
@ -209,16 +167,16 @@ describe('FullScreenDrawer', () => {
it('should handle toggle between open and closed states', () => {
const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
rerender(
<FullScreenDrawer isOpen={false} fullScreen={false}>
<DocumentDetailDrawer open={false} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
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 { useKeyPress } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useSegmentListContext } from '..'
import {
Drawer,
DrawerBackdrop,
DrawerContent,
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
onClose: () => void
side?: 'right' | 'left' | 'bottom' | 'top'
showOverlay?: boolean
modal?: boolean // click outside event can pass through if modal is false
closeOnOutsideClick?: boolean
side?: DrawerSide
panelClassName?: string
panelContentClassName?: string
needCheckChunks?: boolean
modal?: boolean
children: ReactNode
}
const SIDE_POSITION_CLASS = {
right: 'right-0',
left: 'left-0',
bottom: 'bottom-0',
top: 'top-0',
} as const
function containsTarget(selector: string, target: Node | null): boolean {
const elements = document.querySelectorAll(selector)
return Array.from(elements).some(el => el?.contains(target))
const SIDE_TO_SWIPE_DIRECTION: Record<DrawerSide, DrawerSwipeDirection> = {
right: 'right',
left: 'left',
bottom: 'down',
top: 'up',
}
function shouldReopenChunkDetail(
isClickOnChunk: boolean,
isClickOnChildChunk: boolean,
segmentModalOpen: boolean,
childChunkModalOpen: boolean,
): boolean {
if (segmentModalOpen && isClickOnChildChunk)
return true
if (childChunkModalOpen && isClickOnChunk && !isClickOnChildChunk)
return true
return !isClickOnChunk && !isClickOnChildChunk
}
const DRAWER_POPUP_CLASS_NAME = [
'pointer-events-auto overflow-visible border-0 bg-transparent shadow-none',
'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',
'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',
'data-[swipe-direction=down]:max-h-none data-[swipe-direction=down]:rounded-none data-[swipe-direction=down]:border-0',
'data-[swipe-direction=up]:max-h-none data-[swipe-direction=up]:rounded-none data-[swipe-direction=up]:border-0',
].join(' ')
const Drawer = ({
export function CompletedDrawer({
open,
onClose,
side = 'right',
showOverlay = true,
modal = false,
needCheckChunks = false,
children,
panelClassName,
panelContentClassName,
}: React.PropsWithChildren<DrawerProps>) => {
const panelContentRef = useRef<HTMLDivElement>(null)
const currSegment = useSegmentListContext(s => s.currSegment)
const currChildChunk = useSegmentListContext(s => s.currChildChunk)
useKeyPress('esc', (e) => {
if (!open)
modal = false,
}: CompletedDrawerProps) {
const handleOpenChange: DrawerOpenChange = (nextOpen, eventDetails) => {
if (nextOpen)
return
e.preventDefault()
if (eventDetails.reason === 'focus-out' || eventDetails.reason === 'outside-press')
return
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)
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 { noop } from 'es-toolkit/function'
import * as React from 'react'
import Drawer from './drawer'
import { CompletedDrawer } from './drawer'
type IFullScreenDrawerProps = {
isOpen: boolean
type DocumentDetailDrawerProps = {
open: boolean
onClose?: () => void
fullScreen: boolean
showOverlay?: boolean
needCheckChunks?: boolean
modal?: boolean
children: ReactNode
}
const FullScreenDrawer = ({
isOpen,
export function DocumentDetailDrawer({
open,
onClose = noop,
fullScreen,
children,
showOverlay = true,
needCheckChunks = false,
modal = false,
}: React.PropsWithChildren<IFullScreenDrawerProps>) => {
}: DocumentDetailDrawerProps) {
return (
<Drawer
open={isOpen}
<CompletedDrawer
open={open}
onClose={onClose}
panelClassName={cn(
fullScreen
? 'w-full'
: 'w-[568px] pt-16 pr-2 pb-2',
? 'w-full data-[swipe-direction=left]:w-full data-[swipe-direction=right]:w-full'
: 'w-[568px] pt-16 pr-2 pb-2 data-[swipe-direction=left]:w-[568px] data-[swipe-direction=right]:w-[568px]',
)}
panelContentClassName={cn(
'bg-components-panel-bg',
!fullScreen && 'rounded-xl border-[0.5px] border-components-panel-border',
)}
showOverlay={showOverlay}
needCheckChunks={needCheckChunks}
modal={modal}
>
{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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import DrawerGroup from '../drawer-group'
import { DrawerGroup } from '../drawer-group'
vi.mock('../../common/full-screen-drawer', () => ({
default: ({ isOpen, children }: { isOpen: boolean, children: React.ReactNode }) => (
isOpen ? <div data-testid="full-screen-drawer">{children}</div> : null
DocumentDetailDrawer: ({ open, children, modal = false }: { open: boolean, children: React.ReactNode, modal?: boolean }) => (
open ? <div data-testid="document-detail-drawer" data-modal={modal}>{children}</div> : null
),
}))
vi.mock('../../segment-detail', () => ({
default: () => <div data-testid="segment-detail" />,
SegmentDetail: () => <div data-testid="segment-detail" />,
}))
vi.mock('../../child-segment-detail', () => ({
@ -31,8 +31,6 @@ describe('DrawerGroup', () => {
currSegment: { segInfo: undefined, showModal: false, isEditMode: false },
onCloseSegmentDetail: vi.fn(),
onUpdateSegment: vi.fn(),
isRegenerationModalOpen: false,
setIsRegenerationModalOpen: vi.fn(),
showNewSegmentModal: false,
onCloseNewSegmentModal: vi.fn(),
onSaveNewSegment: vi.fn(),
@ -55,7 +53,7 @@ describe('DrawerGroup', () => {
it('should render nothing when all modals are closed', () => {
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', () => {
@ -66,6 +64,7 @@ describe('DrawerGroup', () => {
/>,
)
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', () => {
@ -73,6 +72,7 @@ describe('DrawerGroup', () => {
<DrawerGroup {...defaultProps} showNewSegmentModal={true} />,
)
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', () => {
@ -83,6 +83,7 @@ describe('DrawerGroup', () => {
/>,
)
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', () => {
@ -90,6 +91,7 @@ describe('DrawerGroup', () => {
<DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />,
)
expect(screen.getByTestId('new-child-segment')).toBeInTheDocument()
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true')
})
it('should render multiple drawers simultaneously', () => {

View File

@ -1,15 +1,13 @@
'use client'
import type { FC } from 'react'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, ChunkingMode, SegmentDetailModel } from '@/models/datasets'
import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
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 SegmentDetail from '../segment-detail'
import { SegmentDetail } from '../segment-detail'
type DrawerGroupProps = {
// Segment detail drawer
currSegment: {
segInfo?: SegmentDetailModel
showModal: boolean
@ -25,14 +23,10 @@ type DrawerGroupProps = {
summary?: string,
needRegenerate?: boolean,
) => Promise<void>
isRegenerationModalOpen: boolean
setIsRegenerationModalOpen: (open: boolean) => void
// New segment drawer
showNewSegmentModal: boolean
onCloseNewSegmentModal: () => void
onSaveNewSegment: () => void
viewNewlyAddedChunk: () => void
// Child segment detail drawer
currChildChunk: {
childChunkInfo?: ChildChunkDetail
showModal: boolean
@ -40,52 +34,39 @@ type DrawerGroupProps = {
currChunkId: string
onCloseChildSegmentDetail: () => void
onUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void>
// New child segment drawer
showNewChildSegmentModal: boolean
onCloseNewChildChunkModal: () => void
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
viewNewlyAddedChildChunk: () => void
// Common props
fullScreen: boolean
docForm: ChunkingMode
}
const DrawerGroup: FC<DrawerGroupProps> = ({
// Segment detail drawer
export function DrawerGroup({
currSegment,
onCloseSegmentDetail,
onUpdateSegment,
isRegenerationModalOpen,
setIsRegenerationModalOpen,
// New segment drawer
showNewSegmentModal,
onCloseNewSegmentModal,
onSaveNewSegment,
viewNewlyAddedChunk,
// Child segment detail drawer
currChildChunk,
currChunkId,
onCloseChildSegmentDetail,
onUpdateChildChunk,
// New child segment drawer
showNewChildSegmentModal,
onCloseNewChildChunkModal,
onSaveNewChildChunk,
viewNewlyAddedChildChunk,
// Common props
fullScreen,
docForm,
}) => {
}: DrawerGroupProps) {
return (
<>
{/* Edit or view segment detail */}
<FullScreenDrawer
isOpen={currSegment.showModal}
<DocumentDetailDrawer
open={currSegment.showModal}
fullScreen={fullScreen}
onClose={onCloseSegmentDetail}
showOverlay={false}
needCheckChunks
modal={isRegenerationModalOpen}
>
<SegmentDetail
key={currSegment.segInfo?.id}
@ -94,13 +75,11 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
isEditMode={currSegment.isEditMode}
onUpdate={onUpdateSegment}
onCancel={onCloseSegmentDetail}
onModalStateChange={setIsRegenerationModalOpen}
/>
</FullScreenDrawer>
</DocumentDetailDrawer>
{/* Create New Segment */}
<FullScreenDrawer
isOpen={showNewSegmentModal}
<DocumentDetailDrawer
open={showNewSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewSegmentModal}
modal
@ -111,15 +90,12 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onSave={onSaveNewSegment}
viewNewlyAddedChunk={viewNewlyAddedChunk}
/>
</FullScreenDrawer>
</DocumentDetailDrawer>
{/* Edit or view child segment detail */}
<FullScreenDrawer
isOpen={currChildChunk.showModal}
<DocumentDetailDrawer
open={currChildChunk.showModal}
fullScreen={fullScreen}
onClose={onCloseChildSegmentDetail}
showOverlay={false}
needCheckChunks
>
<ChildSegmentDetail
key={currChildChunk.childChunkInfo?.id}
@ -129,11 +105,10 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onUpdate={onUpdateChildChunk}
onCancel={onCloseChildSegmentDetail}
/>
</FullScreenDrawer>
</DocumentDetailDrawer>
{/* Create New Child Segment */}
<FullScreenDrawer
isOpen={showNewChildSegmentModal}
<DocumentDetailDrawer
open={showNewChildSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewChildChunkModal}
modal
@ -144,9 +119,7 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onSave={onSaveNewChildChunk}
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 { act, renderHook } from '@testing-library/react'
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', () => {
const onNewSegmentModalChange = vi.fn()
@ -10,22 +12,21 @@ describe('useModalState', () => {
vi.clearAllMocks()
})
const renderUseModalState = () =>
renderHook(() => useModalState({ onNewSegmentModalChange }))
const renderModalState = () =>
renderHook(() => renderDatasetModalState({ onNewSegmentModalChange }))
it('should initialize with all modals closed', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
expect(result.current.currSegment.showModal).toBe(false)
expect(result.current.currChildChunk.showModal).toBe(false)
expect(result.current.showNewChildSegmentModal).toBe(false)
expect(result.current.isRegenerationModalOpen).toBe(false)
expect(result.current.fullScreen).toBe(false)
expect(result.current.isCollapsed).toBe(true)
})
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
act(() => {
@ -37,8 +38,25 @@ describe('useModalState', () => {
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', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
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', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
act(() => {
@ -67,8 +85,25 @@ describe('useModalState', () => {
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', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
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', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
result.current.handleAddNewChildChunk('parent-chunk-1')
@ -98,7 +133,7 @@ describe('useModalState', () => {
})
it('should close new segment modal and notify parent', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
result.current.onCloseNewSegmentModal()
@ -108,7 +143,7 @@ describe('useModalState', () => {
})
it('should toggle full screen', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
result.current.toggleFullScreen()
@ -122,7 +157,7 @@ describe('useModalState', () => {
})
it('should toggle collapsed', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
result.current.toggleCollapsed()
@ -134,13 +169,4 @@ describe('useModalState', () => {
})
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 { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets'
import type { SegmentImportStatus } from '@/types/dataset'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets'
import { ProcessStatus } from '../../../segment-add'
import { segmentImportStatus } from '@/types/dataset'
import { useSegmentListData } from '../use-segment-list-data'
// Type for mutation callbacks
@ -176,7 +177,7 @@ const defaultOptions = {
searchValue: '',
selectedStatus: 'all' as boolean | 'all',
selectedSegmentIds: [] as string[],
importStatus: undefined as ProcessStatus | string | undefined,
importStatus: undefined as SegmentImportStatus | undefined,
currentPage: 1,
limit: 10,
onCloseSegmentDetail: vi.fn(),
@ -689,7 +690,7 @@ describe('useSegmentListData', () => {
renderHook(() => useSegmentListData({
...defaultOptions,
importStatus: ProcessStatus.COMPLETED,
importStatus: segmentImportStatus.completed,
clearSelection,
}), {
wrapper: createWrapper(),

View File

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

View File

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

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import type { ProcessStatus } from '../segment-add'
import type { SegmentListContextValue } from './segment-list-context'
import type { SegmentImportStatus } from '@/types/dataset'
import { useCallback, useMemo, useState } from 'react'
import Divider from '@/app/components/base/divider'
import Pagination from '@/app/components/base/pagination'
@ -13,7 +13,9 @@ import {
import { useInvalid } from '@/service/use-base'
import { useDocumentContext } from '../context'
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 {
useChildSegmentData,
useModalState,
@ -32,7 +34,7 @@ type ICompletedProps = {
embeddingAvailable: boolean
showNewSegmentModal: boolean
onNewSegmentModalChange: (state: boolean) => void
importStatus: ProcessStatus | string | undefined
importStatus: SegmentImportStatus | undefined
archived?: boolean
}
@ -225,8 +227,6 @@ const Completed: FC<ICompletedProps> = ({
currSegment={modalState.currSegment}
onCloseSegmentDetail={modalState.onCloseSegmentDetail}
onUpdateSegment={segmentListDataHook.handleUpdateSegment}
isRegenerationModalOpen={modalState.isRegenerationModalOpen}
setIsRegenerationModalOpen={modalState.setIsRegenerationModalOpen}
showNewSegmentModal={showNewSegmentModal}
onCloseNewSegmentModal={modalState.onCloseNewSegmentModal}
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 { SegmentDetailModel } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
@ -7,7 +6,6 @@ import {
RiCollapseDiagonalLine,
RiExpandDiagonalLine,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { v4 as uuid4 } from 'uuid'
@ -42,20 +40,15 @@ type ISegmentDetailProps = {
onCancel: () => void
isEditMode?: boolean
docForm: ChunkingMode
onModalStateChange?: (isOpen: boolean) => void
}
/**
* Show all the contents of the segment
*/
const SegmentDetail: FC<ISegmentDetailProps> = ({
export function SegmentDetail({
segInfo,
onUpdate,
onCancel,
isEditMode,
docForm,
onModalStateChange,
}) => {
}: ISegmentDetailProps) {
const { t } = useTranslation()
const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '')
const [answer, setAnswer] = useState(segInfo?.answer || '')
@ -99,19 +92,16 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
const handleRegeneration = useCallback(() => {
setShowRegenerationModal(true)
onModalStateChange?.(true)
}, [onModalStateChange])
}, [])
const onCancelRegeneration = useCallback(() => {
setShowRegenerationModal(false)
onModalStateChange?.(false)
}, [onModalStateChange])
}, [])
const onCloseAfterRegeneration = useCallback(() => {
setShowRegenerationModal(false)
onModalStateChange?.(false)
onCancel() // Close the edit drawer
}, [onCancel, onModalStateChange])
onCancel()
}, [onCancel])
const onConfirmRegeneration = useCallback(() => {
onUpdate(segInfo?.id || '', question, answer, keywords, attachments, summary, true)
@ -241,5 +231,3 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
</div>
)
}
export default React.memo(SegmentDetail)

View File

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

View File

@ -1,8 +1,10 @@
import type { SegmentImportStatus } from '@/types/dataset'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Plan } from '@/app/components/billing/type'
import { segmentImportStatus } from '@/types/dataset'
import SegmentAdd, { ProcessStatus } from '../index'
import { SegmentAdd } from '../index'
// Mock provider context
let mockPlan = { type: Plan.professional }
@ -22,8 +24,8 @@ describe('SegmentAdd', () => {
})
const defaultProps = {
importStatus: undefined as ProcessStatus | string | undefined,
clearProcessStatus: vi.fn(),
importStatus: undefined as SegmentImportStatus | undefined,
clearImportStatus: vi.fn(),
showNewSegmentModal: vi.fn(),
showBatchModal: vi.fn(),
embedding: false,
@ -52,33 +54,33 @@ describe('SegmentAdd', () => {
// Import Status displays
describe('Import Status Display', () => {
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()
})
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()
})
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\.ok/i)).toBeInTheDocument()
})
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\.ok/i)).toBeInTheDocument()
})
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()
})
@ -94,34 +96,34 @@ describe('SegmentAdd', () => {
expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1)
})
it('should call clearProcessStatus when ok is clicked on completed status', () => {
const mockClearProcessStatus = vi.fn()
it('should call clearImportStatus when ok is clicked on completed status', () => {
const mockClearImportStatus = vi.fn()
render(
<SegmentAdd
{...defaultProps}
importStatus={ProcessStatus.COMPLETED}
clearProcessStatus={mockClearProcessStatus}
importStatus={segmentImportStatus.completed}
clearImportStatus={mockClearImportStatus}
/>,
)
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', () => {
const mockClearProcessStatus = vi.fn()
it('should call clearImportStatus when ok is clicked on error status', () => {
const mockClearImportStatus = vi.fn()
render(
<SegmentAdd
{...defaultProps}
importStatus={ProcessStatus.ERROR}
clearProcessStatus={mockClearProcessStatus}
importStatus={segmentImportStatus.error}
clearImportStatus={mockClearImportStatus}
/>,
)
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 () => {
@ -215,14 +217,14 @@ describe('SegmentAdd', () => {
// Progress bar width tests
describe('Progress Bar', () => {
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')
expect(progressBar).toBeInTheDocument()
})
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')
expect(progressBar).toBeInTheDocument()
@ -230,15 +232,6 @@ describe('SegmentAdd', () => {
})
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', () => {
const { rerender } = render(<SegmentAdd {...defaultProps} />)

View File

@ -1,5 +1,5 @@
'use client'
import type { FC } from 'react'
import type { SegmentImportStatus } from '@/types/dataset'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@ -7,95 +7,92 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
import { useProviderContext } from '@/context/provider-context'
import { segmentImportStatus } from '@/types/dataset'
type ISegmentAddProps = {
importStatus: ProcessStatus | string | undefined
clearProcessStatus: () => void
type SegmentAddProps = {
importStatus: SegmentImportStatus | undefined
clearImportStatus: () => void
showNewSegmentModal: () => void
showBatchModal: () => void
embedding: boolean
}
export enum ProcessStatus {
WAITING = 'waiting',
PROCESSING = 'processing',
COMPLETED = 'completed',
ERROR = 'error',
}
const SegmentAdd: FC<ISegmentAddProps> = ({
export function SegmentAdd({
importStatus,
clearProcessStatus,
clearImportStatus,
showNewSegmentModal,
showBatchModal,
embedding,
}) => {
}: SegmentAddProps) {
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 [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false)
const batchMenuAnchorRef = useRef<HTMLDivElement>(null)
const { plan, enableBilling } = useProviderContext()
const canAddChunks = !enableBilling || plan.type !== Plan.sandbox
const withNeedUpgradeCheck = useCallback((fn: () => void) => {
return () => {
if (!canAdd) {
showPlanUpgradeModal()
return
}
fn()
const textColor = embedding
? 'text-components-button-secondary-accent-text-disabled'
: 'text-components-button-secondary-accent-text'
const handleAddClick = () => {
if (!canAddChunks) {
setIsPlanUpgradeModalOpen(true)
return
}
}, [canAdd, showPlanUpgradeModal])
const textColor = useMemo(() => {
return embedding
? 'text-components-button-secondary-accent-text-disabled'
: 'text-components-button-secondary-accent-text'
}, [embedding])
showNewSegmentModal()
}
const handleBatchAddClick = () => {
setIsBatchMenuOpen(false)
if (!canAddChunks) {
setIsPlanUpgradeModalOpen(true)
return
}
showBatchModal()
}
if (importStatus) {
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
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]"
>
<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 className="z-10 pr-0.5 system-sm-medium">{t('list.batchModal.processing', { ns: 'datasetDocuments' })}</span>
</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="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 className="pr-0.5 system-sm-medium">{t('list.batchModal.completed', { ns: 'datasetDocuments' })}</span>
</div>
<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 className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-success-bg opacity-40" />
</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="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 className="pr-0.5 system-sm-medium">{t('list.batchModal.error', { ns: 'datasetDocuments' })}</span>
</div>
<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 className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-error-bg opacity-40" />
</div>
@ -116,7 +113,7 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
type="button"
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`}
onClick={withNeedUpgradeCheck(showNewSegmentModal)}
onClick={handleAddClick}
disabled={embedding}
>
<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"
sideOffset={4}
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
className="h-auto w-full px-2 py-1.5 system-md-regular"
onClick={() => {
setIsBatchMenuOpen(false)
withNeedUpgradeCheck(showBatchModal)()
}}
>
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
</DropdownMenuItem>
</div>
<DropdownMenuItem
className="system-md-regular"
onClick={handleBatchAddClick}
>
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isShowPlanUpgradeModal && (
{isPlanUpgradeModalOpen && (
<PlanUpgradeModal
show
onClose={hidePlanUpgradeModal}
onClose={() => setIsPlanUpgradeModalOpen(false)}
title={t('upgrade.addChunks.title', { 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" />,
}))
vi.mock('../../../readme-panel/store', () => ({
ReadmeShowType: { modal: 'modal' },
}))
vi.mock('@/app/components/base/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" />,
}))
vi.mock('../../../readme-panel/store', () => ({
ReadmeShowType: { modal: 'modal' },
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', () => {
const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => {
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 Loading from '@/app/components/base/loading'
import { ReadmeEntrance } from '../../readme-panel/entrance'
import { ReadmeShowType } from '../../readme-panel/store'
import {
useAddPluginCredentialHook,
useGetPluginCredentialSchemaHook,
@ -159,7 +158,7 @@ const ApiKeyModal = ({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
)}
{
isLoading && (

View File

@ -19,7 +19,6 @@ import {
import { useTranslation } from 'react-i18next'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import { ReadmeEntrance } from '../../readme-panel/entrance'
import { ReadmeShowType } from '../../readme-panel/store'
import {
useDeletePluginOAuthCustomClientHook,
useInvalidPluginOAuthClientSchemaHook,
@ -157,7 +156,7 @@ const OAuthClientSettings = ({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0">
{pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
)}
<AuthForm
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 { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
@ -318,7 +317,7 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props)
</div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)}
<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 { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
@ -159,7 +158,7 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props)
</div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)}
<BaseForm
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 { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
@ -173,7 +172,7 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) =
</div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)}
<BaseForm
formSchemas={formSchemas}

View File

@ -1,31 +1,11 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
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'],
}))
import { beforeEach, describe, expect, it } from 'vitest'
import { ReadmeEntrance } from '../entrance'
import { useReadmePanelStore } from '../store'
describe('ReadmeEntrance', () => {
let ReadmeEntrance: (typeof import('../entrance'))['ReadmeEntrance']
beforeEach(async () => {
vi.clearAllMocks()
const mod = await import('../entrance')
ReadmeEntrance = mod.ReadmeEntrance
beforeEach(() => {
useReadmePanelStore.setState({ currentPanel: undefined })
})
it('should render readme button for non-builtin plugin with unique identifier', () => {
@ -35,18 +15,31 @@ describe('ReadmeEntrance', () => {
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
render(<ReadmeEntrance pluginDetail={pluginDetail} />)
const button = screen.getByRole('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', () => {
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} />)
expect(container.innerHTML).toBe('')

View File

@ -1,29 +1,29 @@
import type { ReactElement } from 'react'
import type { PluginDetail } from '../../types'
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 { PluginCategoryEnum, PluginSource } from '../../types'
import { ReadmeEntrance } from '../entrance'
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()
vi.mock('@/service/use-plugins', () => ({
usePluginReadme: (params: { plugin_unique_identifier: string, language?: string }) => mockUsePluginReadme(params),
}))
// Mock useLanguage hook
let mockLanguage = 'en-US'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => mockLanguage,
}))
// Mock DetailHeader component (complex component with many dependencies)
vi.mock('../../plugin-detail-panel/detail-header', () => ({
default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => (
<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 => ({
id: 'test-plugin-id',
created_at: '2024-01-01T00:00:00Z',
@ -93,10 +89,6 @@ const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDe
...overrides,
})
// ================================
// Test Utilities
// ================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
@ -105,7 +97,7 @@ const createQueryClient = () => new QueryClient({
},
})
const renderWithQueryClient = (ui: React.ReactElement) => {
const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
@ -114,15 +106,23 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
)
}
// Constants (BUILTIN_TOOLS_ARRAY) tests moved to constants.spec.ts
// Store (useReadmePanelStore) tests moved to store.spec.ts
// Entrance (ReadmeEntrance) tests moved to entrance.spec.tsx
const openReadmePanel = (
detail = createMockPluginDetail(),
presentation: 'drawer' | 'dialog' = 'drawer',
) => {
useReadmePanelStore.getState().openReadmePanel({
detail,
presentation,
triggerId: 'readme-trigger',
})
return detail
}
// ================================
// ReadmePanel Component Tests
// ================================
describe('ReadmePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en-US'
useReadmePanelStore.setState({ currentPanel: undefined })
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
@ -130,487 +130,114 @@ describe('ReadmePanel', () => {
})
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should return null when no plugin detail is set', () => {
const { container } = renderWithQueryClient(<ReadmePanel />)
it('should return null when no readme panel is open', () => {
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', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
error: new Error('Failed to fetch'),
})
rerender(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument()
it('should render DetailHeader component', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByTestId('detail-header')).toBeInTheDocument()
expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true')
mockUsePluginReadme.mockReturnValue({
data: { readme: '' },
isLoading: false,
error: null,
})
rerender(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
it('should render close button', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Test Readme Content' },
isLoading: false,
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
expect(screen.getByRole('button')).toBeInTheDocument()
renderWithQueryClient(<ReadmePanel />)
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'custom-plugin@2.0.0',
language: 'en-US',
})
})
// ================================
// Loading State Tests
// ================================
describe('Loading State', () => {
it('should show loading indicator when isLoading is true', () => {
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: true,
error: null,
})
it('should pass undefined language for zh-Hans locale', () => {
mockLanguage = 'zh-Hans'
openReadmePanel(createMockPluginDetail({
plugin_unique_identifier: 'zh-plugin@1.0.0',
}))
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
renderWithQueryClient(<ReadmePanel />)
// Loading component should be rendered with role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'zh-plugin@1.0.0',
language: undefined,
})
})
// ================================
// Error State Tests
// ================================
describe('Error State', () => {
it('should show error message when error occurs', () => {
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
error: new Error('Failed to fetch'),
})
it('should open correctly from ReadmeEntrance through the global host', () => {
const detail = createMockPluginDetail()
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(
<>
<ReadmeEntrance pluginDetail={detail} />
<ReadmePanel />
</>,
)
renderWithQueryClient(<ReadmePanel />)
fireEvent.click(screen.getByRole('button', { name: /plugin\.readmeInfo\.needHelpCheckReadme/ }))
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).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()
})
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})

View File

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

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 { ReadmePanelPresentation } from './store'
import { cn } from '@langgenius/dify-ui/cn'
import { RiBookReadLine } from '@remixicon/react'
import * as React from 'react'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import { BUILTIN_TOOLS_ARRAY } from './constants'
import { ReadmeShowType, useReadmePanelStore } from './store'
import { useReadmePanelStore } from './store'
export const ReadmeEntrance = ({
pluginDetail,
showType = ReadmeShowType.drawer,
presentation = 'drawer',
className,
showShortTip = false,
}: {
pluginDetail: PluginDetail
showType?: ReadmeShowType
presentation?: ReadmePanelPresentation
className?: string
showShortTip?: boolean
}) => {
const { t } = useTranslation()
const { setCurrentPluginDetail } = useReadmePanelStore()
const triggerId = useId()
const openReadmePanel = useReadmePanelStore(s => s.openReadmePanel)
const handleReadmeClick = () => {
if (pluginDetail)
setCurrentPluginDetail(pluginDetail, showType)
if (pluginDetail) {
openReadmePanel({
detail: pluginDetail,
presentation,
triggerId,
})
}
}
if (!pluginDetail || !pluginDetail?.plugin_unique_identifier || BUILTIN_TOOLS_ARRAY.includes(pluginDetail.id))
return null
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 && (
<div className="relative h-1 w-8 shrink-0">
<div className="h-px w-full bg-divider-regular"></div>
@ -36,11 +42,13 @@ export const ReadmeEntrance = ({
)}
<button
id={triggerId}
type="button"
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">
<RiBookReadLine className="h-3 w-3" />
<span aria-hidden="true" className="i-ri-book-read-line h-3 w-3" />
</div>
<span className="text-xs leading-4 font-normal">
{!showShortTip ? t('readmeInfo.needHelpCheckReadme', { ns: 'plugin' }) : t('readmeInfo.title', { ns: 'plugin' })}

View File

@ -1,124 +1,38 @@
'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 = () => {
const { currentPluginDetail, setCurrentPluginDetail } = useReadmePanelStore()
const { detail, showType } = currentPluginDetail || {}
const { t } = useTranslation()
const language = useLanguage()
import { ReadmeDialog } from './dialog'
import { ReadmeDrawer } from './drawer'
import { useReadmePanelStore } from './store'
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(
{ plugin_unique_identifier: pluginUniqueIdentifier, language: language === 'zh-Hans' ? undefined : language },
)
const onClose = () => {
setCurrentPluginDetail()
}
if (!detail)
if (!currentPanel)
return null
const children = (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="rounded-t-xl bg-background-body px-4 py-4">
<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>
const onOpenChange = (open: boolean) => {
if (!open)
closeReadmePanel()
}
<div className="flex-1 overflow-y-auto px-4 py-3">
{(() => {
if (isLoading) {
return (
<div className="flex h-40 items-center justify-center">
<Loading type="area" />
</div>
)
}
if (currentPanel.presentation === 'dialog') {
return (
<ReadmeDialog
detail={currentPanel.detail}
open
onOpenChange={onOpenChange}
triggerId={currentPanel.triggerId}
/>
)
}
if (error) {
return (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.failedToFetch', { ns: 'plugin' })}</p>
</div>
)
}
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,
return (
<ReadmeDrawer
detail={currentPanel.detail}
open
onOpenChange={onOpenChange}
triggerId={currentPanel.triggerId}
/>
)
}
export default ReadmePanel

View File

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

View File

@ -68,13 +68,13 @@ const MenuDropdown: FC<Props> = ({
onOpenChange={setOpen}
>
<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' })}
>
<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
placement={placement || 'bottom-end'}
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)
if (render) {
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
<button type="button" className={className} onClick={() => setOpen(!open)}>
{children}
</button>
)
}
@ -119,6 +135,12 @@ describe('LabelSelector', () => {
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', () => {
render(<LabelSelector value={['agent', 'rag']} onChange={mockOnChange} />)

View File

@ -6,7 +6,6 @@ import {
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useMemo, useState } from 'react'
@ -60,22 +59,19 @@ const LabelSelector: FC<LabelSelectorProps> = ({
<Popover open={open} onOpenChange={setOpen}>
<div className="relative">
<PopoverTrigger
render={(
<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 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>
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',
open && 'bg-components-input-bg-hover hover:bg-components-input-bg-hover',
)}
/>
>
<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
placement="bottom-start"
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', () => ({
default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
<div data-testid="workflow-tool-modal">
WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
<div data-testid="workflow-tool-drawer">
<button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}>Save</button>
<button data-testid="wf-remove" onClick={onRemove}>Remove</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(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
@ -593,7 +593,7 @@ describe('ProviderDetail', () => {
expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument()
expect(screen.getByTestId('workflow-tool-drawer'))!.toBeInTheDocument()
await act(async () => {
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 () => {
render(
<ProviderDetail
@ -665,7 +665,7 @@ describe('ProviderDetail', () => {
expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument()
})
it('closes WorkflowToolModal via onHide', async () => {
it('closes WorkflowToolDrawer via onHide', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
@ -677,9 +677,9 @@ describe('ProviderDetail', () => {
expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument()
})
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'))
expect(screen.queryByTestId('workflow-tool-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('workflow-tool-drawer')).not.toBeInTheDocument()
})
})

View File

@ -1,6 +1,6 @@
'use client'
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 {
AlertDialog,
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 EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
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 { useLocale } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
@ -140,7 +140,7 @@ const ProviderDetail = ({
setIsShowEditCustomCollectionModal(false)
}
// workflow provider
const [isShowEditWorkflowToolModal, setIsShowEditWorkflowToolModal] = useState(false)
const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
const getWorkflowToolProvider = useCallback(async () => {
setIsDetailLoading(true)
const res = await fetchWorkflowToolDetail(collection.id)
@ -164,7 +164,7 @@ const ProviderDetail = ({
await deleteWorkflowTool(collection.id)
onRefreshData()
toast.success(t('api.actionSuccess', { ns: 'common' }))
setIsShowEditWorkflowToolModal(false)
setWorkflowToolDrawerOpen(false)
}
const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
@ -175,7 +175,7 @@ const ProviderDetail = ({
onRefreshData()
getWorkflowToolProvider()
toast.success(t('api.actionSuccess', { ns: 'common' }))
setIsShowEditWorkflowToolModal(false)
setWorkflowToolDrawerOpen(false)
}
const onClickCustomToolDelete = () => {
setDeleteAction('customTool')
@ -287,7 +287,7 @@ const ProviderDetail = ({
</Button>
<Button
className={cn('my-3 w-[183px] shrink-0')}
onClick={() => setIsShowEditWorkflowToolModal(true)}
onClick={() => setWorkflowToolDrawerOpen(true)}
disabled={!isCurrentWorkspaceManager}
>
<div className="system-sm-medium text-text-secondary">{t('createTool.editAction', { ns: 'tools' })}</div>
@ -401,10 +401,10 @@ const ProviderDetail = ({
onRemove={onClickCustomToolDelete}
/>
)}
{isShowEditWorkflowToolModal && (
<WorkflowToolModal
payload={customCollection as unknown as WorkflowToolModalPayload}
onHide={() => setIsShowEditWorkflowToolModal(false)}
{workflowToolDrawerOpen && (
<WorkflowToolDrawer
payload={customCollection as unknown as WorkflowToolDrawerPayload}
onHide={() => setWorkflowToolDrawerOpen(false)}
onRemove={onClickWorkflowToolDelete}
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 userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WorkflowToolAsModal 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
),
}))
import { WorkflowToolDrawer } from '../index'
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
@ -46,8 +33,8 @@ vi.mock('@/app/components/base/tooltip', () => ({
children,
popupContent,
}: {
children?: React.ReactNode
popupContent?: React.ReactNode
children?: ReactNode
popupContent?: ReactNode
}) => (
<div>
{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' },
label: 'My Tool',
name: 'my_tool',
@ -105,7 +92,7 @@ const createPayload = (overrides: Partial<WorkflowToolModalPayload> = {}): Workf
...overrides,
})
describe('WorkflowToolAsModal', () => {
describe('WorkflowToolDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
@ -115,7 +102,7 @@ describe('WorkflowToolAsModal', () => {
const onCreate = vi.fn()
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
isAdd
payload={createPayload()}
onHide={vi.fn()}
@ -144,7 +131,7 @@ describe('WorkflowToolAsModal', () => {
const onCreate = vi.fn()
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
isAdd
payload={createPayload({ name: 'bad-name' })}
onHide={vi.fn()}
@ -165,7 +152,7 @@ describe('WorkflowToolAsModal', () => {
const onSave = vi.fn()
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
payload={createPayload()}
onHide={vi.fn()}
onSave={onSave}
@ -187,7 +174,7 @@ describe('WorkflowToolAsModal', () => {
it('should show duplicate reserved output warnings', () => {
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
isAdd
payload={createPayload()}
onHide={vi.fn()}

View File

@ -1,70 +1,33 @@
'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 { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
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 { useConfigureButton } from './hooks/use-configure-button'
type Props = {
disabled: boolean
published: boolean
detailNeedUpdate: boolean
workflowAppId: string
icon: Emoji
name: string
description: string
inputs?: InputVar[]
outputs?: Variable[]
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
onRefreshData?: () => void
isLoading: boolean
outdated: boolean
isCurrentWorkspaceManager: boolean
onConfigure: () => void
disabledReason?: string
}
const WorkflowToolConfigureButton = ({
disabled,
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
isLoading,
outdated,
isCurrentWorkspaceManager,
onConfigure,
disabledReason,
}: Props) => {
const { t } = useTranslation()
const {
showModal,
isLoading,
outdated,
payload,
isCurrentWorkspaceManager,
openModal,
closeModal,
handleCreate,
handleUpdate,
navigateToTools,
} = useConfigureButton({
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
})
const router = useRouter()
return (
<>
@ -80,9 +43,12 @@ const WorkflowToolConfigureButton = ({
? (
<div
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
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')}
@ -100,7 +66,7 @@ const WorkflowToolConfigureButton = ({
<div
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
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className="shrink grow basis-0 truncate system-sm-medium text-text-tertiary"
@ -120,7 +86,7 @@ const WorkflowToolConfigureButton = ({
<Button
size="small"
className="w-[140px]"
onClick={openModal}
onClick={onConfigure}
disabled={!isCurrentWorkspaceManager || disabled}
>
{t('common.configure', { ns: 'workflow' })}
@ -129,11 +95,11 @@ const WorkflowToolConfigureButton = ({
<Button
size="small"
className="w-[140px]"
onClick={navigateToTools}
onClick={() => router.push('/tools?category=workflow')}
disabled={disabled}
>
{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>
</div>
{outdated && (
@ -146,15 +112,6 @@ const WorkflowToolConfigureButton = ({
</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 { isParametersOutdated, useConfigureButton } from '../use-configure-button'
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
const mockIsCurrentWorkspaceManager = vi.fn(() => true)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
@ -98,6 +93,7 @@ const createMockDetail = (overrides: Partial<WorkflowToolProviderResponse> = {})
})
const createDefaultOptions = (overrides = {}) => ({
enabled: true,
published: false,
detailNeedUpdate: false,
workflowAppId: 'app-123',
@ -213,9 +209,9 @@ describe('useConfigureButton', () => {
})
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()))
expect(result.current.showModal).toBe(false)
expect(result.current.payload).toMatchObject({ workflow_app_id: 'app-123' })
})
it('should forward isCurrentWorkspaceManager from context', () => {
@ -239,6 +235,11 @@ describe('useConfigureButton', () => {
renderHook(() => useConfigureButton(createDefaultOptions({ published: 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
@ -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
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({})
const onRefreshData = vi.fn()
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData })))
act(() => {
result.current.openModal()
})
const onConfigured = vi.fn()
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData, onConfigured })))
await act(async () => {
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(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
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 () => {
@ -414,20 +382,18 @@ describe('useConfigureButton', () => {
})
describe('handleUpdate', () => {
it('should publish, save, invalidate caches, and close modal', async () => {
it('should publish, save, invalidate caches, and notify configured', async () => {
mockSaveWorkflowToolProvider.mockResolvedValue({})
const handlePublish = vi.fn().mockResolvedValue(undefined)
const onRefreshData = vi.fn()
const onConfigured = vi.fn()
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
published: true,
handlePublish,
onRefreshData,
onConfigured,
})))
act(() => {
result.current.openModal()
})
await act(async () => {
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(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
expect(result.current.showModal).toBe(false)
expect(onConfigured).toHaveBeenCalled()
})
it('should show error toast when publish fails', async () => {
@ -491,6 +457,16 @@ describe('useConfigureButton', () => {
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

View File

@ -2,10 +2,9 @@ import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderPa
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
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 { useAppContext } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools'
import { useInvalidateAllWorkflowTools, useInvalidateWorkflowToolDetailByAppID, useWorkflowToolDetailByAppID } from '@/service/use-tools'
@ -89,6 +88,7 @@ function buildExistingOutputParameters(
// endregion
type UseConfigureButtonOptions = {
enabled: boolean
published: boolean
detailNeedUpdate: boolean
workflowAppId: string
@ -99,10 +99,12 @@ type UseConfigureButtonOptions = {
outputs?: Variable[]
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
onRefreshData?: () => void
onConfigured?: () => void
}
export function useConfigureButton(options: UseConfigureButtonOptions) {
const {
enabled,
published,
detailNeedUpdate,
workflowAppId,
@ -113,16 +115,14 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
outputs,
handlePublish,
onRefreshData,
onConfigured,
} = options
const { t } = useTranslation()
const router = useRouter()
const { isCurrentWorkspaceManager } = useAppContext()
const [showModal, setShowModal] = useState(false)
// 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)
const invalidateDetail = useInvalidateWorkflowToolDetailByAppID()
@ -133,9 +133,9 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
// Refetch when detailNeedUpdate becomes true
useEffect(() => {
if (detailNeedUpdate)
if (enabled && detailNeedUpdate)
invalidateDetailRef.current(workflowAppId)
}, [detailNeedUpdate, workflowAppId])
}, [detailNeedUpdate, enabled, workflowAppId])
// Computed values
const outdated = useMemo(
@ -173,14 +173,6 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
}
}, [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)
const handleCreate = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => {
try {
@ -189,7 +181,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
onRefreshData?.()
invalidateDetail(workflowAppId)
toast.success(t('api.actionSuccess', { ns: 'common' }))
setShowModal(false)
onConfigured?.()
}
catch (e) {
toast.error((e as Error).message)
@ -206,7 +198,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
onRefreshData?.()
invalidateAllWorkflowTools()
invalidateDetail(workflowAppId)
setShowModal(false)
onConfigured?.()
}
catch (e) {
toast.error((e as Error).message)
@ -214,15 +206,11 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
}
return {
showModal,
isLoading,
outdated,
payload,
isCurrentWorkspaceManager,
openModal,
closeModal,
handleCreate,
handleUpdate,
navigateToTools,
}
}

View File

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

View File

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

View File

@ -143,6 +143,7 @@ const ResultPanel: FC<ResultPanelProps> = ({
{process_data && (
<CodeEditor
readOnly
showFileList
title={<div>{t('common.processData', { ns: 'workflow' }).toLocaleUpperCase()}</div>}
language={CodeLanguage.json}
value={process_data}
@ -153,6 +154,7 @@ const ResultPanel: FC<ResultPanelProps> = ({
{(outputs || status === 'running') && (
<CodeEditor
readOnly
showFileList
title={<div>{t('common.output', { ns: 'workflow' }).toLocaleUpperCase()}</div>}
language={CodeLanguage.json}
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/modal`
- `@/app/components/base/dialog`
- `@/app/components/base/drawer`
- `@/app/components/base/drawer-plus`
- Replacement primitives:
- `@langgenius/dify-ui/tooltip`
- `@langgenius/dify-ui/dropdown-menu`
- `@langgenius/dify-ui/context-menu`
- `@langgenius/dify-ui/popover`
- `@langgenius/dify-ui/dialog`
- `@langgenius/dify-ui/drawer`
- `@langgenius/dify-ui/alert-dialog`
- `@langgenius/dify-ui/autocomplete`
- `@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
portal to `document.body` with explicit z-index values:
| Layer | z-index | Components |
| --------------------- | ------------ | -------------------------------------------------------------------------------- |
| Legacy Drawer | `z-30` | `base/drawer` |
| Legacy Modal | `z-60` | `base/modal` (default) |
| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Popover, Dialog, Autocomplete, Combobox, Tooltip, etc.) |
| Toast | `z-1003` | `@langgenius/dify-ui/toast` |
| Layer | z-index | Components |
| --------------------- | ------------ | ---------------------------------------------------------------------------------------- |
| Legacy Drawer | `z-30` | `base/drawer`, `base/drawer-plus` |
| Legacy Modal | `z-60` | `base/modal` (default) |
| **New UI primitives** | **`z-1002`** | `@langgenius/dify-ui/*` (Drawer, Popover, Dialog, Autocomplete, Combobox, Tooltip, etc.) |
| Toast | `z-1003` | `@langgenius/dify-ui/toast` |
`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,

View File

@ -66,6 +66,15 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
],
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 = {

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 { Tag } from '@/contract/console/tags'
import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '@/types/app'
import type { SegmentImportStatus } from '@/types/dataset'
import type { I18nKeysByPrefix } from '@/types/i18n'
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 = {
job_id: string
job_status: string
job_status: SegmentImportStatus
}
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]