mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 04:16:14 +08:00
Compare commits
2 Commits
chore/remo
...
chore/simp
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ad90a673b | |||
| d2404d0375 |
2
.github/workflows/api-tests.yml
vendored
2
.github/workflows/api-tests.yml
vendored
@ -98,7 +98,7 @@ jobs:
|
||||
|
||||
- name: Set up dotenvs
|
||||
run: |
|
||||
./docker/init-env.sh
|
||||
cp docker/.env.example docker/.env
|
||||
cp docker/middleware.env.example docker/middleware.env
|
||||
|
||||
- name: Expose Service Ports
|
||||
|
||||
4
.github/workflows/main-ci.yml
vendored
4
.github/workflows/main-ci.yml
vendored
@ -56,9 +56,7 @@ 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'
|
||||
@ -95,9 +93,7 @@ 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'
|
||||
|
||||
2
.github/workflows/vdb-tests-full.yml
vendored
2
.github/workflows/vdb-tests-full.yml
vendored
@ -50,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Set up dotenvs
|
||||
run: |
|
||||
./docker/init-env.sh
|
||||
cp docker/.env.example docker/.env
|
||||
cp docker/middleware.env.example docker/middleware.env
|
||||
|
||||
- name: Expose Service Ports
|
||||
|
||||
2
.github/workflows/vdb-tests.yml
vendored
2
.github/workflows/vdb-tests.yml
vendored
@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Set up dotenvs
|
||||
run: |
|
||||
./docker/init-env.sh
|
||||
cp docker/.env.example docker/.env
|
||||
cp docker/middleware.env.example docker/middleware.env
|
||||
|
||||
- name: Expose Service Ports
|
||||
|
||||
14
README.md
14
README.md
@ -74,16 +74,8 @@ 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
|
||||
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
|
||||
cd dify/docker
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@ -144,7 +136,7 @@ Star Dify on GitHub and be instantly notified of new releases.
|
||||
|
||||
### Custom configurations
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
### Metrics Monitoring with Grafana
|
||||
|
||||
|
||||
@ -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.all")).keys())
|
||||
DOCKER_CONFIG_SET = set(dotenv_values(Path("docker") / Path(".env.example")).keys())
|
||||
DOCKER_COMPOSE_CONFIG_SET = set()
|
||||
|
||||
with open(Path("docker") / Path("docker-compose.yaml")) as f:
|
||||
@ -101,23 +101,15 @@ 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!")
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ export OPENDAL_FS_ROOT=${OPENDAL_FS_ROOT:-/tmp/dify-storage}
|
||||
mkdir -p "${OPENDAL_FS_ROOT}"
|
||||
|
||||
# Prepare env files like CI
|
||||
./docker/init-env.sh
|
||||
cp -n docker/.env.example docker/.env || true
|
||||
cp -n docker/middleware.env.example docker/middleware.env || true
|
||||
cp -n api/tests/integration_tests/.env.example api/tests/integration_tests/.env || true
|
||||
|
||||
|
||||
1631
docker/.env.all
1631
docker/.env.all
File diff suppressed because it is too large
Load Diff
1635
docker/.env.example
1635
docker/.env.example
File diff suppressed because it is too large
Load Diff
@ -7,38 +7,28 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
- **Certbot Container**: `docker-compose.yaml` now contains `certbot` for managing SSL certificates. This container automatically renews certificates and ensures secure HTTPS connections.\
|
||||
For more information, refer `docker/certbot/README.md`.
|
||||
|
||||
- **Persistent Environment Variables**: Default deployment values are provided in `.env.example`. Initialize `.env` from it and keep local changes there so your configuration persists across deployments.
|
||||
- **Persistent Environment Variables**: Environment variables are now managed through a `.env` file, ensuring that your configurations persist across deployments.
|
||||
|
||||
> What is `.env`? </br> </br>
|
||||
> 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.
|
||||
> 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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
- 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.
|
||||
See the [Environment Variables Synchronization](#environment-variables-synchronization) section below.
|
||||
1. **Running the Services**:
|
||||
- Execute `docker compose up -d` from the `docker` directory to start the services.
|
||||
- Execute `docker compose up` 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.
|
||||
@ -68,11 +58,7 @@ 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.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.
|
||||
### Overview of `.env`
|
||||
|
||||
#### Key Modules and Customization
|
||||
|
||||
@ -82,7 +68,7 @@ For users migrating from the `docker-legacy` setup:
|
||||
|
||||
#### Other notable 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:
|
||||
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:
|
||||
|
||||
1. **Common Variables**:
|
||||
|
||||
@ -132,25 +118,23 @@ The `.env.all` file provided in the Docker setup is extensive and covers a wide
|
||||
|
||||
### Environment Variables Synchronization
|
||||
|
||||
When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example` or `.env.all`.
|
||||
When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example`.
|
||||
|
||||
If you use the default workflow, review `.env.example` and add only the values you need to customize to `.env`.
|
||||
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 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`.
|
||||
> This tool performs a **one-way synchronization** from `.env.example` 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.all` template and helps safely apply new or updated environment variables.
|
||||
This script compares your current `.env` file with the latest `.env.example` 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.all`
|
||||
- Synchronizes newly added environment variables from `.env.example`
|
||||
- Preserves all existing custom values in `.env`
|
||||
- Displays differences and variables removed from `.env.all` for review
|
||||
- Displays differences and variables removed from `.env.example` for review
|
||||
|
||||
**Backup behavior**
|
||||
|
||||
@ -159,9 +143,9 @@ Before synchronization, the current `.env` file is saved to the `env-backup/` di
|
||||
|
||||
**When to use**
|
||||
|
||||
- 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`
|
||||
- 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
|
||||
|
||||
**Usage**
|
||||
|
||||
@ -176,6 +160,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.all` file and the Docker Compose configuration files in the `docker` directory.
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
# Dify Environment Variables Synchronization Script
|
||||
#
|
||||
# Features:
|
||||
# - Synchronize latest settings from .env.all to .env
|
||||
# - Synchronize latest settings from .env.example 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.all if absent.
|
||||
"""Verify required files exist; create .env from .env.example if absent.
|
||||
|
||||
Args:
|
||||
work_dir: Directory that must contain .env.all (and optionally .env).
|
||||
work_dir: Directory that must contain .env.example (and optionally .env).
|
||||
|
||||
Raises:
|
||||
SystemExit: If .env.all does not exist.
|
||||
SystemExit: If .env.example does not exist.
|
||||
"""
|
||||
log_info("Checking required files...")
|
||||
|
||||
example_file = work_dir / ".env.all"
|
||||
example_file = work_dir / ".env.example"
|
||||
env_file = work_dir / ".env"
|
||||
|
||||
if not example_file.exists():
|
||||
log_error(".env.all file not found")
|
||||
log_error(".env.example file not found")
|
||||
sys.exit(1)
|
||||
|
||||
if not env_file.exists():
|
||||
log_warning(".env file does not exist. Creating from .env.all.")
|
||||
log_warning(".env file does not exist. Creating from .env.example.")
|
||||
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.all.
|
||||
recommended: Value present in .env.example.
|
||||
|
||||
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.all.
|
||||
"""Find variables whose values differ between .env and .env.example.
|
||||
|
||||
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.all.
|
||||
example_vars: Parsed key/value pairs from .env.example.
|
||||
|
||||
Returns:
|
||||
Mapping of key -> (env_value, example_value) for every key whose
|
||||
values differ.
|
||||
"""
|
||||
log_info("Detecting differences between .env and .env.all...")
|
||||
log_info("Detecting differences between .env and .env.example...")
|
||||
|
||||
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.all (recommended){NC} : {example_value}")
|
||||
print(f" {BLUE}.env.example (recommended){NC} : {example_value}")
|
||||
else:
|
||||
print(f"[{count}] {key}")
|
||||
print(f" .env (current) : {env_value}")
|
||||
print(f" .env.all (recommended) : {example_value}")
|
||||
print(f" .env.example (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.all.
|
||||
"""Identify variables present in .env but absent from .env.example.
|
||||
|
||||
Args:
|
||||
env_vars: Parsed key/value pairs from .env.
|
||||
example_vars: Parsed key/value pairs from .env.all.
|
||||
example_vars: Parsed key/value pairs from .env.example.
|
||||
|
||||
Returns:
|
||||
Sorted list of variable names that no longer appear in .env.all.
|
||||
Sorted list of variable names that no longer appear in .env.example.
|
||||
"""
|
||||
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.all:")
|
||||
log_warning("The following environment variables have been removed from .env.example:")
|
||||
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.all while preserving custom values.
|
||||
"""Rewrite .env based on .env.example while preserving custom values.
|
||||
|
||||
The output file follows the exact line structure of .env.all
|
||||
The output file follows the exact line structure of .env.example
|
||||
(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.all
|
||||
current .env value is kept. Variables that are new in .env.example
|
||||
(not present in .env at all) are added with the example's default.
|
||||
|
||||
Args:
|
||||
work_dir: Directory containing .env and .env.all.
|
||||
work_dir: Directory containing .env and .env.example.
|
||||
env_vars: Parsed key/value pairs from the original .env.
|
||||
diffs: Keys whose .env values differ from .env.all (to preserve).
|
||||
diffs: Keys whose .env values differ from .env.example (to preserve).
|
||||
"""
|
||||
log_info("Starting partial synchronization of .env file...")
|
||||
|
||||
example_file = work_dir / ".env.all"
|
||||
example_file = work_dir / ".env.example"
|
||||
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.all values: {updated_count}")
|
||||
log_info(f" Updated to .env.example 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.all.
|
||||
work_dir: Directory containing .env and .env.example.
|
||||
"""
|
||||
log_info("Synchronization statistics:")
|
||||
|
||||
example_file = work_dir / ".env.all"
|
||||
example_file = work_dir / ".env.example"
|
||||
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.all environment variables: {example_count}")
|
||||
log_info(f" .env.example 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.all: add new variables, "
|
||||
"Synchronize .env with .env.example: 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.all (default: current directory)",
|
||||
help="Working directory containing .env and .env.example (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.all")
|
||||
example_vars = parse_env_file(work_dir / ".env.example")
|
||||
|
||||
# 4. Report differences (values that changed in the example)
|
||||
diffs = detect_differences(env_vars, example_vars)
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
# Dify Environment Variables Synchronization Script
|
||||
#
|
||||
# Features:
|
||||
# - Synchronize latest settings from .env.all to .env
|
||||
# - Synchronize latest settings from .env.example 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.all exists and creates .env from template if needed
|
||||
# Verifies that .env.example exists and creates .env from template if needed
|
||||
check_files() {
|
||||
log_info "Checking required files..."
|
||||
|
||||
if [[ ! -f ".env.all" ]]; then
|
||||
log_error ".env.all file not found"
|
||||
if [[ ! -f ".env.example" ]]; then
|
||||
log_error ".env.example file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f ".env" ]]; then
|
||||
log_warning ".env file does not exist. Creating from .env.all."
|
||||
cp ".env.all" ".env"
|
||||
log_warning ".env file does not exist. Creating from .env.example."
|
||||
cp ".env.example" ".env"
|
||||
log_success ".env file created"
|
||||
fi
|
||||
|
||||
@ -98,9 +98,9 @@ create_backup() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect differences between .env and .env.all (optimized for large files)
|
||||
# Detect differences between .env and .env.example (optimized for large files)
|
||||
detect_differences() {
|
||||
log_info "Detecting differences between .env and .env.all..."
|
||||
log_info "Detecting differences between .env and .env.example..."
|
||||
|
||||
# Create secure temporary directory
|
||||
local temp_dir=$(mktemp -d)
|
||||
@ -140,7 +140,7 @@ detect_differences() {
|
||||
}
|
||||
}
|
||||
END { print diff_count }
|
||||
' .env .env.all)
|
||||
' .env .env.example)
|
||||
|
||||
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.all (recommended)${NC}: ${example_value}"
|
||||
echo -e " ${BLUE}.env.example (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.all while preserving custom values
|
||||
# Creates a new .env file based on .env.all structure, preserving existing custom values
|
||||
# Synchronize .env file with .env.example while preserving custom values
|
||||
# Creates a new .env file based on .env.example 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.all) lines with AWK..."
|
||||
log_info "Processing $(wc -l < .env.example) 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.all > "$new_env_file"
|
||||
' .env.example > "$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.all values: $updated_count"
|
||||
log_info " Updated to .env.example values: $updated_count"
|
||||
}
|
||||
|
||||
# Detect removed environment variables
|
||||
@ -394,8 +394,8 @@ detect_removed_variables() {
|
||||
cleanup_temp_dir="$temp_dir"
|
||||
fi
|
||||
|
||||
# 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"
|
||||
# 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"
|
||||
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.all:"
|
||||
log_warning "The following environment variables have been removed from .env.example:"
|
||||
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.all 2>/dev/null || echo "0")
|
||||
local total_example=$(grep -c "^[^#]*=" .env.example 2>/dev/null || echo "0")
|
||||
local total_env=$(grep -c "^[^#]*=" .env 2>/dev/null || echo "0")
|
||||
|
||||
log_info " .env.all environment variables: $total_example"
|
||||
log_info " .env.example environment variables: $total_example"
|
||||
log_info " .env environment variables: $total_env"
|
||||
}
|
||||
|
||||
|
||||
@ -1,953 +0,0 @@
|
||||
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:
|
||||
@ -1,728 +1,3 @@
|
||||
# ==================================================================
|
||||
# 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:
|
||||
@ -747,9 +22,12 @@ 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:-}
|
||||
@ -795,9 +73,10 @@ 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:-}
|
||||
@ -841,9 +120,10 @@ 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:
|
||||
@ -1018,9 +298,10 @@ 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}
|
||||
|
||||
@ -1,132 +0,0 @@
|
||||
#!/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()
|
||||
@ -1,101 +0,0 @@
|
||||
$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."
|
||||
@ -1,117 +0,0 @@
|
||||
#!/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."
|
||||
@ -202,11 +202,6 @@
|
||||
"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
|
||||
@ -235,11 +230,6 @@
|
||||
"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
|
||||
@ -262,9 +252,6 @@
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 5
|
||||
},
|
||||
@ -282,6 +269,11 @@
|
||||
"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
|
||||
@ -352,9 +344,6 @@
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
@ -412,16 +401,6 @@
|
||||
"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
|
||||
@ -552,9 +531,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/app/log/list.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 6
|
||||
},
|
||||
@ -604,9 +580,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/app/workflow-log/list.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
@ -931,11 +904,6 @@
|
||||
"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
|
||||
@ -1061,11 +1029,6 @@
|
||||
"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
|
||||
@ -1270,7 +1233,7 @@
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/line/development/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/line/editor/index.ts": {
|
||||
@ -2181,6 +2144,14 @@
|
||||
"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
|
||||
@ -2191,6 +2162,11 @@
|
||||
"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
|
||||
@ -2255,6 +2231,14 @@
|
||||
"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
|
||||
@ -2296,9 +2280,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/hit-testing/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/unsupported-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
@ -2338,7 +2319,7 @@
|
||||
},
|
||||
"web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": {
|
||||
@ -2832,18 +2813,10 @@
|
||||
}
|
||||
},
|
||||
"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
|
||||
@ -2865,9 +2838,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
@ -2926,9 +2896,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
@ -2966,6 +2933,16 @@
|
||||
"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
|
||||
@ -3193,7 +3170,7 @@
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
|
||||
@ -3202,9 +3179,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 4
|
||||
},
|
||||
@ -3213,9 +3187,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/test-api.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -3225,11 +3196,6 @@
|
||||
"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
|
||||
@ -3258,20 +3224,12 @@
|
||||
"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
|
||||
}
|
||||
@ -4103,11 +4061,6 @@
|
||||
"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
|
||||
|
||||
@ -28,7 +28,6 @@ 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
|
||||
```
|
||||
@ -37,12 +36,12 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
|
||||
## Primitives
|
||||
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
Utilities:
|
||||
|
||||
@ -66,7 +65,7 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s
|
||||
|
||||
## Overlay & portal contract
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Root isolation requirement
|
||||
|
||||
@ -84,19 +83,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, 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. |
|
||||
| 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. |
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 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.
|
||||
- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. 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
|
||||
|
||||
@ -37,10 +37,6 @@
|
||||
"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"
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,116 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@ -205,7 +205,7 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
|
||||
default: ({ 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>
|
||||
|
||||
@ -91,21 +91,6 @@ 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),
|
||||
@ -136,15 +121,6 @@ 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', () => ({
|
||||
@ -167,7 +143,6 @@ 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>
|
||||
)
|
||||
},
|
||||
@ -256,25 +231,6 @@ 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
|
||||
|
||||
@ -190,17 +190,18 @@ 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()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -222,16 +223,17 @@ 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()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -246,16 +248,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()}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
@ -5,12 +5,13 @@ 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,
|
||||
@ -19,12 +20,9 @@ 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'
|
||||
@ -59,8 +57,8 @@ export type AppPublisherProps = {
|
||||
debugWithMultipleModel?: boolean
|
||||
multipleModelConfigs?: ModelAndParameter[]
|
||||
/** modelAndParameter is passed when debugWithMultipleModel is true */
|
||||
onPublish?: AppPublisherPublishHandler
|
||||
onRestore?: AppPublisherRestoreHandler
|
||||
onPublish?: (params?: any) => Promise<any> | any
|
||||
onRestore?: () => Promise<any> | any
|
||||
onToggle?: (state: boolean) => void
|
||||
crossAxisOffset?: number
|
||||
toolPublished?: boolean
|
||||
@ -76,12 +74,6 @@ 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,
|
||||
@ -108,12 +100,11 @@ 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 = use(WorkflowContext)
|
||||
const workflowStore = useContext(WorkflowContext)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(s => s.setAppDetail)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
@ -282,31 +273,6 @@ 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',
|
||||
@ -377,22 +343,23 @@ 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={<span className="i-ri-store-line h-4 w-4" />}
|
||||
icon={<RiStoreLine className="h-4 w-4" />}
|
||||
disabled={!publishedAt || publishingToMarketplace}
|
||||
onClick={handlePublishToMarketplace}
|
||||
>
|
||||
@ -413,15 +380,6 @@ 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -10,9 +10,11 @@ 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'
|
||||
@ -44,8 +46,11 @@ type AccessSectionProps = {
|
||||
|
||||
type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
|
||||
| 'hasTriggerNode'
|
||||
| 'inputs'
|
||||
| 'missingStartNode'
|
||||
| 'onRefreshData'
|
||||
| 'toolPublished'
|
||||
| 'outputs'
|
||||
| 'publishedAt'
|
||||
| 'workflowToolAvailable'> & {
|
||||
appDetail: {
|
||||
@ -62,11 +67,9 @@ type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
|
||||
disabledFunctionTooltip?: string
|
||||
handleEmbed: () => void
|
||||
handleOpenInExplore: () => void
|
||||
workflowToolIsLoading: boolean
|
||||
workflowToolOutdated: boolean
|
||||
workflowToolIsCurrentWorkspaceManager: boolean
|
||||
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
|
||||
published: boolean
|
||||
workflowToolMessage?: string
|
||||
onConfigureWorkflowTool: () => void
|
||||
}
|
||||
|
||||
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
|
||||
@ -253,17 +256,18 @@ 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()
|
||||
|
||||
@ -301,7 +305,7 @@ export const PublisherActionsSection = ({
|
||||
<SuggestedAction
|
||||
onClick={handleEmbed}
|
||||
disabled={!publishedAt}
|
||||
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
|
||||
icon={<CodeBrowser className="h-4 w-4" />}
|
||||
>
|
||||
{t('common.embedIntoSite', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
@ -336,10 +340,18 @@ export const PublisherActionsSection = ({
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
isLoading={workflowToolIsLoading}
|
||||
outdated={workflowToolOutdated}
|
||||
isCurrentWorkspaceManager={workflowToolIsCurrentWorkspaceManager}
|
||||
onConfigure={onConfigureWorkflowTool}
|
||||
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}
|
||||
disabledReason={workflowToolMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -54,22 +54,22 @@ const Operation: FC<Props> = ({
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
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>
|
||||
)}
|
||||
render={<div />}
|
||||
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}
|
||||
|
||||
@ -182,13 +182,11 @@ 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')
|
||||
|
||||
|
||||
@ -12,11 +12,6 @@ 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)
|
||||
@ -26,9 +21,6 @@ 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) {
|
||||
@ -42,39 +34,16 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
|
||||
}, [chatList.length])
|
||||
|
||||
const handleWindowResize = useCallback(() => {
|
||||
if (chatContainerRef.current) {
|
||||
const nextWidth = document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8
|
||||
setWidth(currentWidth => currentWidth === nextWidth ? currentWidth : nextWidth)
|
||||
}
|
||||
if (chatContainerRef.current)
|
||||
setWidth(document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8)
|
||||
|
||||
if (chatContainerRef.current && chatFooterRef.current)
|
||||
setStyleValue(chatFooterRef.current, 'width', `${chatContainerRef.current.clientWidth}px`)
|
||||
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
|
||||
|
||||
if (chatContainerInnerRef.current && chatFooterInnerRef.current)
|
||||
setStyleValue(chatFooterInnerRef.current, 'width', `${chatContainerInnerRef.current.clientWidth}px`)
|
||||
chatFooterInnerRef.current.style.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)
|
||||
@ -108,31 +77,26 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
|
||||
const resizeContainerObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { blockSize } = entry.borderBoxSize[0]!
|
||||
pendingFooterBlockSizeRef.current = blockSize
|
||||
chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
|
||||
handleScrollToBottom()
|
||||
}
|
||||
scheduleResizeObserverUpdate()
|
||||
})
|
||||
resizeContainerObserver.observe(chatFooterRef.current)
|
||||
|
||||
const resizeFooterObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { inlineSize } = entry.borderBoxSize[0]!
|
||||
pendingContainerInlineSizeRef.current = inlineSize
|
||||
chatFooterRef.current!.style.width = `${inlineSize}px`
|
||||
}
|
||||
scheduleResizeObserverUpdate()
|
||||
})
|
||||
resizeFooterObserver.observe(chatContainerRef.current)
|
||||
|
||||
return () => {
|
||||
if (resizeObserverFrameRef.current !== null) {
|
||||
cancelAnimationFrame(resizeObserverFrameRef.current)
|
||||
resizeObserverFrameRef.current = null
|
||||
}
|
||||
resizeContainerObserver.disconnect()
|
||||
resizeFooterObserver.disconnect()
|
||||
}
|
||||
}
|
||||
}, [scheduleResizeObserverUpdate])
|
||||
}, [handleScrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
const setUserScrolled = () => {
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export { default as BracketsX } from './BracketsX'
|
||||
export { default as CodeBrowser } from './CodeBrowser'
|
||||
|
||||
@ -120,12 +120,18 @@ vi.mock('../document-title', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../segment-add', () => ({
|
||||
SegmentAdd: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => (
|
||||
default: ({ 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', () => ({
|
||||
|
||||
@ -2,15 +2,12 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ChunkingMode, FileItem } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, 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'
|
||||
|
||||
@ -21,9 +18,8 @@ type IBatchModalProps = {
|
||||
onConfirm: (file: FileItem) => void
|
||||
}
|
||||
|
||||
type BatchModalContentProps = Omit<IBatchModalProps, 'isShow'>
|
||||
|
||||
const BatchModalContent: FC<BatchModalContentProps> = ({
|
||||
const BatchModal: FC<IBatchModalProps> = ({
|
||||
isShow,
|
||||
docForm,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
@ -39,13 +35,17 @@ const BatchModalContent: FC<BatchModalContentProps> = ({
|
||||
onConfirm(currentCSV)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShow)
|
||||
setCurrentCSV(undefined)
|
||||
}, [isShow])
|
||||
|
||||
return (
|
||||
<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' })}
|
||||
/>
|
||||
<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>
|
||||
<CSVUploader
|
||||
file={currentCSV}
|
||||
updateFile={handleFile}
|
||||
@ -61,33 +61,7 @@ const BatchModalContent: FC<BatchModalContentProps> = ({
|
||||
{t('list.batchModal.run', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@ -137,8 +137,9 @@ vi.mock('../hooks/use-child-segment-data', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/menu-bar', () => ({
|
||||
default: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
|
||||
// Mock child components to simplify testing
|
||||
vi.mock('../components', () => ({
|
||||
MenuBar: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
|
||||
totalText: string
|
||||
onInputChange: (value: string) => void
|
||||
inputValue: string
|
||||
@ -166,13 +167,7 @@ vi.mock('../components/menu-bar', () => ({
|
||||
)}
|
||||
</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" />,
|
||||
}))
|
||||
@ -568,7 +563,7 @@ describe('Edge Cases', () => {
|
||||
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle completed importStatus', () => {
|
||||
it('should handle ProcessStatus.COMPLETED importStatus', () => {
|
||||
render(<Completed {...defaultProps} importStatus="completed" />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()
|
||||
|
||||
@ -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,6 +167,7 @@ describe('SegmentDetail', () => {
|
||||
onCancel: vi.fn(),
|
||||
isEditMode: false,
|
||||
docForm: ChunkingMode.text,
|
||||
onModalStateChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -351,12 +352,35 @@ 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', () => {
|
||||
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
|
||||
const mockOnModalStateChange = vi.fn()
|
||||
render(
|
||||
<SegmentDetail
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
onModalStateChange={mockOnModalStateChange}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('regenerate-btn'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-regeneration'))
|
||||
|
||||
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
|
||||
expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -480,18 +504,22 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,16 +1,27 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CompletedDrawer } from '../drawer'
|
||||
import Drawer from '../drawer'
|
||||
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
BASE_UI_ANIMATIONS_DISABLED: boolean
|
||||
}
|
||||
).BASE_UI_ANIMATIONS_DISABLED = true
|
||||
let capturedKeyPressCallback: ((e: KeyboardEvent) => void) | undefined
|
||||
|
||||
const getOverlay = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>('[class]'))
|
||||
.find(element => element.className.includes('bg-background-overlay'))
|
||||
// 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 },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Drawer', () => {
|
||||
const defaultProps = {
|
||||
@ -20,109 +31,103 @@ describe('Drawer', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedKeyPressCallback = undefined
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should return null when open is false', () => {
|
||||
const { container } = render(
|
||||
<CompletedDrawer open={false} onClose={vi.fn()}>
|
||||
<Drawer open={false} onClose={vi.fn()}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
expect(screen.queryByText('Content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children in the drawer portal when open is true', () => {
|
||||
it('should render children in portal when open is true', () => {
|
||||
render(
|
||||
<CompletedDrawer {...defaultProps}>
|
||||
<Drawer {...defaultProps}>
|
||||
<span>Drawer content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variant', () => {
|
||||
it('should render a panel drawer without overlay by default', () => {
|
||||
// Overlay visibility
|
||||
describe('Overlay', () => {
|
||||
it('should show overlay when showOverlay is true', () => {
|
||||
render(
|
||||
<CompletedDrawer {...defaultProps}>
|
||||
<Drawer {...defaultProps} showOverlay={true}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(getOverlay()).toBeUndefined()
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false')
|
||||
const overlay = document.querySelector('[aria-hidden="true"]')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a modal drawer with overlay', () => {
|
||||
it('should hide overlay when showOverlay is false', () => {
|
||||
render(
|
||||
<CompletedDrawer {...defaultProps} modal>
|
||||
<Drawer {...defaultProps} showOverlay={false}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(getOverlay()).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
|
||||
const overlay = document.querySelector('[aria-hidden="true"]')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dismissal', () => {
|
||||
it('should call onClose when Escape is pressed', async () => {
|
||||
const onClose = vi.fn()
|
||||
// aria-modal attribute
|
||||
describe('aria-modal', () => {
|
||||
it('should set aria-modal="true" when modal is true', () => {
|
||||
render(
|
||||
<CompletedDrawer open={true} onClose={onClose}>
|
||||
<Drawer {...defaultProps} modal={true}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
|
||||
})
|
||||
|
||||
it('should keep a panel drawer open when the underlying page is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
it('should set aria-modal="false" when modal is false', () => {
|
||||
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>
|
||||
<Drawer {...defaultProps} modal={false}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
fireEvent.click(getOverlay()!)
|
||||
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()
|
||||
render(
|
||||
<Drawer open={true} onClose={onClose}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(capturedKeyPressCallback).toBeDefined()
|
||||
const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
|
||||
capturedKeyPressCallback!(fakeEvent)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@ -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 { DocumentDetailDrawer } from '../full-screen-drawer'
|
||||
import FullScreenDrawer from '../full-screen-drawer'
|
||||
|
||||
// Mock the Drawer component since it has high complexity
|
||||
vi.mock('../drawer', () => ({
|
||||
CompletedDrawer: ({ children, open, panelClassName, panelContentClassName, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, modal: boolean }) => {
|
||||
default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => {
|
||||
if (!open)
|
||||
return null
|
||||
return (
|
||||
@ -13,6 +13,8 @@ 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}
|
||||
@ -21,7 +23,7 @@ vi.mock('../drawer', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
describe('DocumentDetailDrawer', () => {
|
||||
describe('FullScreenDrawer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@ -29,9 +31,9 @@ describe('DocumentDetailDrawer', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when open', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
|
||||
@ -39,9 +41,9 @@ describe('DocumentDetailDrawer', () => {
|
||||
|
||||
it('should not render when closed', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={false} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={false} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
|
||||
@ -49,9 +51,9 @@ describe('DocumentDetailDrawer', () => {
|
||||
|
||||
it('should render children content', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Test Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument()
|
||||
@ -61,46 +63,86 @@ describe('DocumentDetailDrawer', () => {
|
||||
describe('Props', () => {
|
||||
it('should pass fullScreen=true to Drawer with full width class', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={true}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
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(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
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 render as non-modal by default', () => {
|
||||
it('should pass showOverlay prop with default true', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</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>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-modal')).toBe('false')
|
||||
})
|
||||
|
||||
it('should pass modal when specified', () => {
|
||||
it('should pass modal=true when specified', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false} modal>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false} modal={true}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
@ -112,9 +154,9 @@ describe('DocumentDetailDrawer', () => {
|
||||
describe('Styling', () => {
|
||||
it('should apply panel content classes for non-fullScreen mode', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
@ -125,9 +167,9 @@ describe('DocumentDetailDrawer', () => {
|
||||
|
||||
it('should apply panel content classes without border for fullScreen mode', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={true}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
@ -142,24 +184,24 @@ describe('DocumentDetailDrawer', () => {
|
||||
// Arrange & Act & Assert - should not throw
|
||||
expect(() => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
const { rerender } = render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<DocumentDetailDrawer open={true} fullScreen={true}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<div>Updated Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Updated Content')).toBeInTheDocument()
|
||||
@ -167,16 +209,16 @@ describe('DocumentDetailDrawer', () => {
|
||||
|
||||
it('should handle toggle between open and closed states', () => {
|
||||
const { rerender } = render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<DocumentDetailDrawer open={false} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={false} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
|
||||
|
||||
@ -1,92 +1,143 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerContent,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useSegmentListContext } from '..'
|
||||
|
||||
type DrawerSide = 'right' | 'left' | 'bottom' | 'top'
|
||||
type DrawerSwipeDirection = 'right' | 'left' | 'down' | 'up'
|
||||
type DrawerOpenChange = NonNullable<ComponentProps<typeof Drawer>['onOpenChange']>
|
||||
|
||||
type CompletedDrawerProps = {
|
||||
type DrawerProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
side?: DrawerSide
|
||||
side?: 'right' | 'left' | 'bottom' | 'top'
|
||||
showOverlay?: boolean
|
||||
modal?: boolean // click outside event can pass through if modal is false
|
||||
closeOnOutsideClick?: boolean
|
||||
panelClassName?: string
|
||||
panelContentClassName?: string
|
||||
modal?: boolean
|
||||
children: ReactNode
|
||||
needCheckChunks?: boolean
|
||||
}
|
||||
|
||||
const SIDE_TO_SWIPE_DIRECTION: Record<DrawerSide, DrawerSwipeDirection> = {
|
||||
right: 'right',
|
||||
left: 'left',
|
||||
bottom: 'down',
|
||||
top: 'up',
|
||||
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 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(' ')
|
||||
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
|
||||
}
|
||||
|
||||
export function CompletedDrawer({
|
||||
const Drawer = ({
|
||||
open,
|
||||
onClose,
|
||||
side = 'right',
|
||||
showOverlay = true,
|
||||
modal = false,
|
||||
needCheckChunks = false,
|
||||
children,
|
||||
panelClassName,
|
||||
panelContentClassName,
|
||||
modal = false,
|
||||
}: CompletedDrawerProps) {
|
||||
const handleOpenChange: DrawerOpenChange = (nextOpen, eventDetails) => {
|
||||
if (nextOpen)
|
||||
return
|
||||
}: React.PropsWithChildren<DrawerProps>) => {
|
||||
const panelContentRef = useRef<HTMLDivElement>(null)
|
||||
const currSegment = useSegmentListContext(s => s.currSegment)
|
||||
const currChildChunk = useSegmentListContext(s => s.currChildChunk)
|
||||
|
||||
if (eventDetails.reason === 'focus-out' || eventDetails.reason === 'outside-press')
|
||||
useKeyPress('esc', (e) => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
|
||||
export default Drawer
|
||||
|
||||
@ -1,39 +1,46 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { CompletedDrawer } from './drawer'
|
||||
import * as React from 'react'
|
||||
import Drawer from './drawer'
|
||||
|
||||
type DocumentDetailDrawerProps = {
|
||||
open: boolean
|
||||
type IFullScreenDrawerProps = {
|
||||
isOpen: boolean
|
||||
onClose?: () => void
|
||||
fullScreen: boolean
|
||||
showOverlay?: boolean
|
||||
needCheckChunks?: boolean
|
||||
modal?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function DocumentDetailDrawer({
|
||||
open,
|
||||
const FullScreenDrawer = ({
|
||||
isOpen,
|
||||
onClose = noop,
|
||||
fullScreen,
|
||||
children,
|
||||
showOverlay = true,
|
||||
needCheckChunks = false,
|
||||
modal = false,
|
||||
}: DocumentDetailDrawerProps) {
|
||||
}: React.PropsWithChildren<IFullScreenDrawerProps>) => {
|
||||
return (
|
||||
<CompletedDrawer
|
||||
open={open}
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
panelClassName={cn(
|
||||
fullScreen
|
||||
? '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]',
|
||||
? 'w-full'
|
||||
: 'w-[568px] pt-16 pr-2 pb-2',
|
||||
)}
|
||||
panelContentClassName={cn(
|
||||
'bg-components-panel-bg',
|
||||
!fullScreen && 'rounded-xl border-[0.5px] border-components-panel-border',
|
||||
)}
|
||||
showOverlay={showOverlay}
|
||||
needCheckChunks={needCheckChunks}
|
||||
modal={modal}
|
||||
>
|
||||
{children}
|
||||
</CompletedDrawer>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullScreenDrawer
|
||||
|
||||
@ -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', () => ({
|
||||
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
|
||||
default: ({ isOpen, children }: { isOpen: boolean, children: React.ReactNode }) => (
|
||||
isOpen ? <div data-testid="full-screen-drawer">{children}</div> : null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../segment-detail', () => ({
|
||||
SegmentDetail: () => <div data-testid="segment-detail" />,
|
||||
default: () => <div data-testid="segment-detail" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../child-segment-detail', () => ({
|
||||
@ -31,6 +31,8 @@ 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(),
|
||||
@ -53,7 +55,7 @@ describe('DrawerGroup', () => {
|
||||
|
||||
it('should render nothing when all modals are closed', () => {
|
||||
const { container } = render(<DrawerGroup {...defaultProps} />)
|
||||
expect(container.querySelector('[data-testid="document-detail-drawer"]')).toBeNull()
|
||||
expect(container.querySelector('[data-testid="full-screen-drawer"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('should render segment detail when segment modal is open', () => {
|
||||
@ -64,7 +66,6 @@ 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', () => {
|
||||
@ -72,7 +73,6 @@ 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,7 +83,6 @@ 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', () => {
|
||||
@ -91,7 +90,6 @@ 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', () => {
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
'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 { DocumentDetailDrawer } from '../common/full-screen-drawer'
|
||||
import FullScreenDrawer 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
|
||||
@ -23,10 +25,14 @@ 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
|
||||
@ -34,39 +40,52 @@ 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
|
||||
}
|
||||
|
||||
export function DrawerGroup({
|
||||
const DrawerGroup: FC<DrawerGroupProps> = ({
|
||||
// Segment detail drawer
|
||||
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 (
|
||||
<>
|
||||
<DocumentDetailDrawer
|
||||
open={currSegment.showModal}
|
||||
{/* Edit or view segment detail */}
|
||||
<FullScreenDrawer
|
||||
isOpen={currSegment.showModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseSegmentDetail}
|
||||
showOverlay={false}
|
||||
needCheckChunks
|
||||
modal={isRegenerationModalOpen}
|
||||
>
|
||||
<SegmentDetail
|
||||
key={currSegment.segInfo?.id}
|
||||
@ -75,11 +94,13 @@ export function DrawerGroup({
|
||||
isEditMode={currSegment.isEditMode}
|
||||
onUpdate={onUpdateSegment}
|
||||
onCancel={onCloseSegmentDetail}
|
||||
onModalStateChange={setIsRegenerationModalOpen}
|
||||
/>
|
||||
</DocumentDetailDrawer>
|
||||
</FullScreenDrawer>
|
||||
|
||||
<DocumentDetailDrawer
|
||||
open={showNewSegmentModal}
|
||||
{/* Create New Segment */}
|
||||
<FullScreenDrawer
|
||||
isOpen={showNewSegmentModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseNewSegmentModal}
|
||||
modal
|
||||
@ -90,12 +111,15 @@ export function DrawerGroup({
|
||||
onSave={onSaveNewSegment}
|
||||
viewNewlyAddedChunk={viewNewlyAddedChunk}
|
||||
/>
|
||||
</DocumentDetailDrawer>
|
||||
</FullScreenDrawer>
|
||||
|
||||
<DocumentDetailDrawer
|
||||
open={currChildChunk.showModal}
|
||||
{/* Edit or view child segment detail */}
|
||||
<FullScreenDrawer
|
||||
isOpen={currChildChunk.showModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseChildSegmentDetail}
|
||||
showOverlay={false}
|
||||
needCheckChunks
|
||||
>
|
||||
<ChildSegmentDetail
|
||||
key={currChildChunk.childChunkInfo?.id}
|
||||
@ -105,10 +129,11 @@ export function DrawerGroup({
|
||||
onUpdate={onUpdateChildChunk}
|
||||
onCancel={onCloseChildSegmentDetail}
|
||||
/>
|
||||
</DocumentDetailDrawer>
|
||||
</FullScreenDrawer>
|
||||
|
||||
<DocumentDetailDrawer
|
||||
open={showNewChildSegmentModal}
|
||||
{/* Create New Child Segment */}
|
||||
<FullScreenDrawer
|
||||
isOpen={showNewChildSegmentModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseNewChildChunkModal}
|
||||
modal
|
||||
@ -119,7 +144,9 @@ export function DrawerGroup({
|
||||
onSave={onSaveNewChildChunk}
|
||||
viewNewlyAddedChildChunk={viewNewlyAddedChildChunk}
|
||||
/>
|
||||
</DocumentDetailDrawer>
|
||||
</FullScreenDrawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DrawerGroup
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
export { default as DrawerGroup } from './drawer-group'
|
||||
export { default as MenuBar } from './menu-bar'
|
||||
export { FullDocModeContent, GeneralModeContent } from './segment-list-content'
|
||||
@ -1,9 +1,7 @@
|
||||
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as modalStateHooks from '../use-modal-state'
|
||||
|
||||
const renderDatasetModalState = modalStateHooks.useModalState
|
||||
import { useModalState } from '../use-modal-state'
|
||||
|
||||
describe('useModalState', () => {
|
||||
const onNewSegmentModalChange = vi.fn()
|
||||
@ -12,21 +10,22 @@ describe('useModalState', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const renderModalState = () =>
|
||||
renderHook(() => renderDatasetModalState({ onNewSegmentModalChange }))
|
||||
const renderUseModalState = () =>
|
||||
renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
it('should initialize with all modals closed', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
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 } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
const detail = { id: 'seg-1', content: 'test' } as unknown as SegmentDetailModel
|
||||
|
||||
act(() => {
|
||||
@ -38,25 +37,8 @@ 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 } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.onClickCard({ id: 'seg-1' } as unknown as SegmentDetailModel)
|
||||
@ -73,7 +55,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should open child segment detail on slice click', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
|
||||
|
||||
act(() => {
|
||||
@ -85,25 +67,8 @@ 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 } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.onClickSlice({ id: 'c1', segment_id: 's1' } as unknown as ChildChunkDetail)
|
||||
@ -116,7 +81,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should handle new child chunk modal', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.handleAddNewChildChunk('parent-chunk-1')
|
||||
@ -133,7 +98,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should close new segment modal and notify parent', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.onCloseNewSegmentModal()
|
||||
@ -143,7 +108,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should toggle full screen', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.toggleFullScreen()
|
||||
@ -157,7 +122,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should toggle collapsed', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.toggleCollapsed()
|
||||
@ -169,4 +134,13 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
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 { segmentImportStatus } from '@/types/dataset'
|
||||
import { ProcessStatus } from '../../../segment-add'
|
||||
import { useSegmentListData } from '../use-segment-list-data'
|
||||
|
||||
// Type for mutation callbacks
|
||||
@ -177,7 +176,7 @@ const defaultOptions = {
|
||||
searchValue: '',
|
||||
selectedStatus: 'all' as boolean | 'all',
|
||||
selectedSegmentIds: [] as string[],
|
||||
importStatus: undefined as SegmentImportStatus | undefined,
|
||||
importStatus: undefined as ProcessStatus | string | undefined,
|
||||
currentPage: 1,
|
||||
limit: 10,
|
||||
onCloseSegmentDetail: vi.fn(),
|
||||
@ -690,7 +689,7 @@ describe('useSegmentListData', () => {
|
||||
|
||||
renderHook(() => useSegmentListData({
|
||||
...defaultOptions,
|
||||
importStatus: segmentImportStatus.completed,
|
||||
importStatus: ProcessStatus.COMPLETED,
|
||||
clearSelection,
|
||||
}), {
|
||||
wrapper: createWrapper(),
|
||||
|
||||
@ -13,20 +13,29 @@ 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
|
||||
}
|
||||
@ -38,15 +47,25 @@ 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 })
|
||||
}, [])
|
||||
|
||||
@ -55,8 +74,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)
|
||||
}, [])
|
||||
@ -66,11 +85,13 @@ 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)
|
||||
@ -81,6 +102,7 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
|
||||
setFullScreen(false)
|
||||
}, [])
|
||||
|
||||
// Display handlers - handles both direct calls and click events
|
||||
const toggleFullScreen = useCallback(() => {
|
||||
setFullScreen(prev => !prev)
|
||||
}, [])
|
||||
@ -90,20 +112,29 @@ 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,
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
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'
|
||||
@ -10,16 +9,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: SegmentImportStatus | undefined
|
||||
importStatus: ProcessStatus | string | undefined
|
||||
currentPage: number
|
||||
limit: number
|
||||
onCloseSegmentDetail: () => void
|
||||
@ -93,7 +92,7 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme
|
||||
}, [pathname])
|
||||
// Reset list on import completion
|
||||
useEffect(() => {
|
||||
if (importStatus === segmentImportStatus.completed) {
|
||||
if (importStatus === ProcessStatus.COMPLETED) {
|
||||
clearSelection()
|
||||
invalidSegmentList()
|
||||
}
|
||||
|
||||
@ -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,9 +13,7 @@ import {
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { useDocumentContext } from '../context'
|
||||
import BatchAction from './common/batch-action'
|
||||
import { DrawerGroup } from './components/drawer-group'
|
||||
import MenuBar from './components/menu-bar'
|
||||
import { FullDocModeContent, GeneralModeContent } from './components/segment-list-content'
|
||||
import { DrawerGroup, FullDocModeContent, GeneralModeContent, MenuBar } from './components'
|
||||
import {
|
||||
useChildSegmentData,
|
||||
useModalState,
|
||||
@ -34,7 +32,7 @@ type ICompletedProps = {
|
||||
embeddingAvailable: boolean
|
||||
showNewSegmentModal: boolean
|
||||
onNewSegmentModalChange: (state: boolean) => void
|
||||
importStatus: SegmentImportStatus | undefined
|
||||
importStatus: ProcessStatus | string | undefined
|
||||
archived?: boolean
|
||||
}
|
||||
|
||||
@ -227,6 +225,8 @@ 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}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
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'
|
||||
@ -6,6 +7,7 @@ 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'
|
||||
@ -40,15 +42,20 @@ type ISegmentDetailProps = {
|
||||
onCancel: () => void
|
||||
isEditMode?: boolean
|
||||
docForm: ChunkingMode
|
||||
onModalStateChange?: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
export function SegmentDetail({
|
||||
/**
|
||||
* Show all the contents of the segment
|
||||
*/
|
||||
const SegmentDetail: FC<ISegmentDetailProps> = ({
|
||||
segInfo,
|
||||
onUpdate,
|
||||
onCancel,
|
||||
isEditMode,
|
||||
docForm,
|
||||
}: ISegmentDetailProps) {
|
||||
onModalStateChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '')
|
||||
const [answer, setAnswer] = useState(segInfo?.answer || '')
|
||||
@ -92,16 +99,19 @@ export function SegmentDetail({
|
||||
|
||||
const handleRegeneration = useCallback(() => {
|
||||
setShowRegenerationModal(true)
|
||||
}, [])
|
||||
onModalStateChange?.(true)
|
||||
}, [onModalStateChange])
|
||||
|
||||
const onCancelRegeneration = useCallback(() => {
|
||||
setShowRegenerationModal(false)
|
||||
}, [])
|
||||
onModalStateChange?.(false)
|
||||
}, [onModalStateChange])
|
||||
|
||||
const onCloseAfterRegeneration = useCallback(() => {
|
||||
setShowRegenerationModal(false)
|
||||
onCancel()
|
||||
}, [onCancel])
|
||||
onModalStateChange?.(false)
|
||||
onCancel() // Close the edit drawer
|
||||
}, [onCancel, onModalStateChange])
|
||||
|
||||
const onConfirmRegeneration = useCallback(() => {
|
||||
onUpdate(segInfo?.id || '', question, answer, keywords, attachments, summary, true)
|
||||
@ -231,3 +241,5 @@ export function SegmentDetail({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SegmentDetail)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'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'
|
||||
@ -18,7 +17,6 @@ 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'
|
||||
@ -26,7 +24,7 @@ import Completed from './completed'
|
||||
import { DocumentContext } from './context'
|
||||
import { DocumentTitle } from './document-title'
|
||||
import Embedding from './embedding'
|
||||
import { SegmentAdd } from './segment-add'
|
||||
import SegmentAdd, { ProcessStatus } from './segment-add'
|
||||
import style from './style.module.css'
|
||||
|
||||
type DocumentDetailProps = {
|
||||
@ -55,20 +53,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<SegmentImportStatus>()
|
||||
const [importStatus, setImportStatus] = useState<ProcessStatus | string>()
|
||||
const showNewSegmentModal = () => setNewSegmentModalVisible(true)
|
||||
const showBatchModal = () => setBatchModalVisible(true)
|
||||
const hideBatchModal = () => setBatchModalVisible(false)
|
||||
const resetImportStatus = () => setImportStatus(undefined)
|
||||
const resetProcessStatus = () => setImportStatus('')
|
||||
|
||||
const { mutateAsync: checkSegmentBatchImportProgress } = useCheckSegmentBatchImportProgress()
|
||||
const checkProcess = async (jobID: string) => {
|
||||
await checkSegmentBatchImportProgress({ jobID }, {
|
||||
onSuccess: (res) => {
|
||||
setImportStatus(res.job_status)
|
||||
if (res.job_status === segmentImportStatus.waiting || res.job_status === segmentImportStatus.processing)
|
||||
if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING)
|
||||
setTimeout(() => checkProcess(res.job_id), 2500)
|
||||
if (res.job_status === segmentImportStatus.error)
|
||||
if (res.job_status === ProcessStatus.ERROR)
|
||||
toast.error(`${t('list.batchModal.runError', { ns: 'datasetDocuments' })}`)
|
||||
},
|
||||
onError: (e) => {
|
||||
@ -224,7 +222,7 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
<>
|
||||
<SegmentAdd
|
||||
importStatus={importStatus}
|
||||
clearImportStatus={resetImportStatus}
|
||||
clearProcessStatus={resetProcessStatus}
|
||||
showNewSegmentModal={showNewSegmentModal}
|
||||
showBatchModal={showBatchModal}
|
||||
embedding={embedding}
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
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 } from '../index'
|
||||
import SegmentAdd, { ProcessStatus } from '../index'
|
||||
|
||||
// Mock provider context
|
||||
let mockPlan = { type: Plan.professional }
|
||||
@ -24,8 +22,8 @@ describe('SegmentAdd', () => {
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
importStatus: undefined as SegmentImportStatus | undefined,
|
||||
clearImportStatus: vi.fn(),
|
||||
importStatus: undefined as ProcessStatus | string | undefined,
|
||||
clearProcessStatus: vi.fn(),
|
||||
showNewSegmentModal: vi.fn(),
|
||||
showBatchModal: vi.fn(),
|
||||
embedding: false,
|
||||
@ -54,33 +52,33 @@ describe('SegmentAdd', () => {
|
||||
// Import Status displays
|
||||
describe('Import Status Display', () => {
|
||||
it('should show processing indicator when status is WAITING', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />)
|
||||
|
||||
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show processing indicator when status is PROCESSING', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
|
||||
|
||||
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show completed status with ok button', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.completed} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.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={segmentImportStatus.error} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.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={segmentImportStatus.processing} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
|
||||
|
||||
expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument()
|
||||
})
|
||||
@ -96,34 +94,34 @@ describe('SegmentAdd', () => {
|
||||
expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call clearImportStatus when ok is clicked on completed status', () => {
|
||||
const mockClearImportStatus = vi.fn()
|
||||
it('should call clearProcessStatus when ok is clicked on completed status', () => {
|
||||
const mockClearProcessStatus = vi.fn()
|
||||
render(
|
||||
<SegmentAdd
|
||||
{...defaultProps}
|
||||
importStatus={segmentImportStatus.completed}
|
||||
clearImportStatus={mockClearImportStatus}
|
||||
importStatus={ProcessStatus.COMPLETED}
|
||||
clearProcessStatus={mockClearProcessStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
|
||||
|
||||
expect(mockClearImportStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call clearImportStatus when ok is clicked on error status', () => {
|
||||
const mockClearImportStatus = vi.fn()
|
||||
it('should call clearProcessStatus when ok is clicked on error status', () => {
|
||||
const mockClearProcessStatus = vi.fn()
|
||||
render(
|
||||
<SegmentAdd
|
||||
{...defaultProps}
|
||||
importStatus={segmentImportStatus.error}
|
||||
clearImportStatus={mockClearImportStatus}
|
||||
importStatus={ProcessStatus.ERROR}
|
||||
clearProcessStatus={mockClearProcessStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
|
||||
|
||||
expect(mockClearImportStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render batch add option in dropdown', async () => {
|
||||
@ -217,14 +215,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={segmentImportStatus.waiting} />)
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.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={segmentImportStatus.processing} />)
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
|
||||
|
||||
const progressBar = container.querySelector('.w-2\\/3')
|
||||
expect(progressBar).toBeInTheDocument()
|
||||
@ -232,6 +230,15 @@ 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} />)
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -7,92 +7,95 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, 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 SegmentAddProps = {
|
||||
importStatus: SegmentImportStatus | undefined
|
||||
clearImportStatus: () => void
|
||||
type ISegmentAddProps = {
|
||||
importStatus: ProcessStatus | string | undefined
|
||||
clearProcessStatus: () => void
|
||||
showNewSegmentModal: () => void
|
||||
showBatchModal: () => void
|
||||
embedding: boolean
|
||||
}
|
||||
|
||||
export function SegmentAdd({
|
||||
export enum ProcessStatus {
|
||||
WAITING = 'waiting',
|
||||
PROCESSING = 'processing',
|
||||
COMPLETED = 'completed',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
const SegmentAdd: FC<ISegmentAddProps> = ({
|
||||
importStatus,
|
||||
clearImportStatus,
|
||||
clearProcessStatus,
|
||||
showNewSegmentModal,
|
||||
showBatchModal,
|
||||
embedding,
|
||||
}: SegmentAddProps) {
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false)
|
||||
const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false)
|
||||
const batchMenuAnchorRef = useRef<HTMLDivElement>(null)
|
||||
const [isShowPlanUpgradeModal, {
|
||||
setTrue: showPlanUpgradeModal,
|
||||
setFalse: hidePlanUpgradeModal,
|
||||
}] = useBoolean(false)
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const canAddChunks = !enableBilling || plan.type !== Plan.sandbox
|
||||
const { type } = plan
|
||||
const canAdd = enableBilling ? type !== Plan.sandbox : true
|
||||
const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false)
|
||||
const batchMenuAnchorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const textColor = embedding
|
||||
? 'text-components-button-secondary-accent-text-disabled'
|
||||
: 'text-components-button-secondary-accent-text'
|
||||
|
||||
const handleAddClick = () => {
|
||||
if (!canAddChunks) {
|
||||
setIsPlanUpgradeModalOpen(true)
|
||||
return
|
||||
const withNeedUpgradeCheck = useCallback((fn: () => void) => {
|
||||
return () => {
|
||||
if (!canAdd) {
|
||||
showPlanUpgradeModal()
|
||||
return
|
||||
}
|
||||
fn()
|
||||
}
|
||||
|
||||
showNewSegmentModal()
|
||||
}
|
||||
|
||||
const handleBatchAddClick = () => {
|
||||
setIsBatchMenuOpen(false)
|
||||
|
||||
if (!canAddChunks) {
|
||||
setIsPlanUpgradeModalOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
showBatchModal()
|
||||
}
|
||||
}, [canAdd, showPlanUpgradeModal])
|
||||
const textColor = useMemo(() => {
|
||||
return embedding
|
||||
? 'text-components-button-secondary-accent-text-disabled'
|
||||
: 'text-components-button-secondary-accent-text'
|
||||
}, [embedding])
|
||||
|
||||
if (importStatus) {
|
||||
return (
|
||||
<>
|
||||
{(importStatus === segmentImportStatus.waiting || importStatus === segmentImportStatus.processing) && (
|
||||
{(importStatus === ProcessStatus.WAITING || importStatus === ProcessStatus.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 === segmentImportStatus.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 === ProcessStatus.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 === segmentImportStatus.completed && (
|
||||
{importStatus === ProcessStatus.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={clearImportStatus}>{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={clearProcessStatus}>{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 === segmentImportStatus.error && (
|
||||
{importStatus === ProcessStatus.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={clearImportStatus}>{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={clearProcessStatus}>{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>
|
||||
@ -113,7 +116,7 @@ export function SegmentAdd({
|
||||
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={handleAddClick}
|
||||
onClick={withNeedUpgradeCheck(showNewSegmentModal)}
|
||||
disabled={embedding}
|
||||
>
|
||||
<span aria-hidden className={cn('i-ri-add-line h-4 w-4', textColor)} />
|
||||
@ -139,20 +142,25 @@ export function SegmentAdd({
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
positionerProps={{ anchor: batchMenuAnchorRef }}
|
||||
popupClassName="w-[var(--anchor-width)]"
|
||||
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]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="system-md-regular"
|
||||
onClick={handleBatchAddClick}
|
||||
>
|
||||
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
|
||||
</DropdownMenuItem>
|
||||
<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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isPlanUpgradeModalOpen && (
|
||||
{isShowPlanUpgradeModal && (
|
||||
<PlanUpgradeModal
|
||||
show
|
||||
onClose={() => setIsPlanUpgradeModalOpen(false)}
|
||||
onClose={hidePlanUpgradeModal}
|
||||
title={t('upgrade.addChunks.title', { ns: 'billing' })!}
|
||||
description={t('upgrade.addChunks.description', { ns: 'billing' })!}
|
||||
/>
|
||||
@ -161,3 +169,4 @@ export function SegmentAdd({
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(SegmentAdd)
|
||||
|
||||
@ -55,6 +55,10 @@ 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" />,
|
||||
}))
|
||||
|
||||
@ -41,6 +41,10 @@ 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
|
||||
|
||||
@ -19,6 +19,7 @@ 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,
|
||||
@ -158,7 +159,7 @@ const ApiKeyModal = ({
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
{
|
||||
isLoading && (
|
||||
|
||||
@ -19,6 +19,7 @@ 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,
|
||||
@ -156,7 +157,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} presentation="dialog" />
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
<AuthForm
|
||||
formFromProps={form}
|
||||
|
||||
@ -14,6 +14,7 @@ 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'
|
||||
|
||||
@ -317,7 +318,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} presentation="dialog" />
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
|
||||
<MultiSteps currentStep={currentStep} onStepClick={handleBack} />
|
||||
|
||||
@ -12,6 +12,7 @@ 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'
|
||||
|
||||
@ -158,7 +159,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} presentation="dialog" />
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
|
||||
@ -12,6 +12,7 @@ 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'
|
||||
|
||||
@ -172,7 +173,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} presentation="dialog" />
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
|
||||
@ -1,11 +1,31 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ReadmeEntrance } from '../entrance'
|
||||
import { useReadmePanelStore } from '../store'
|
||||
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'],
|
||||
}))
|
||||
|
||||
describe('ReadmeEntrance', () => {
|
||||
beforeEach(() => {
|
||||
useReadmePanelStore.setState({ currentPanel: undefined })
|
||||
let ReadmeEntrance: (typeof import('../entrance'))['ReadmeEntrance']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../entrance')
|
||||
ReadmeEntrance = mod.ReadmeEntrance
|
||||
})
|
||||
|
||||
it('should render readme button for non-builtin plugin with unique identifier', () => {
|
||||
@ -15,31 +35,18 @@ describe('ReadmeEntrance', () => {
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open drawer presentation by default', () => {
|
||||
it('should call setCurrentPluginDetail on button click', () => {
|
||||
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(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')
|
||||
expect(mockSetCurrentPluginDetail).toHaveBeenCalledWith(pluginDetail, 'drawer')
|
||||
})
|
||||
|
||||
it('should return null for builtin tools', () => {
|
||||
const pluginDetail = { id: 'code', name: 'Code', plugin_unique_identifier: 'org/code' } as never
|
||||
const pluginDetail = { id: 'google_search', name: 'Google Search', plugin_unique_identifier: 'org/google' } as never
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={pluginDetail} />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
|
||||
@ -1,29 +1,29 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { PluginDetail } from '../../types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } 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 { useReadmePanelStore } from '../store'
|
||||
import { ReadmeShowType, useReadmePanelStore } from '../store'
|
||||
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
BASE_UI_ANIMATIONS_DISABLED: boolean
|
||||
}
|
||||
).BASE_UI_ANIMATIONS_DISABLED = true
|
||||
// ================================
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// 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,6 +32,10 @@ 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',
|
||||
@ -89,6 +93,10 @@ const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDe
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Test Utilities
|
||||
// ================================
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@ -97,7 +105,7 @@ const createQueryClient = () => new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithQueryClient = (ui: ReactElement) => {
|
||||
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@ -106,23 +114,15 @@ const renderWithQueryClient = (ui: ReactElement) => {
|
||||
)
|
||||
}
|
||||
|
||||
const openReadmePanel = (
|
||||
detail = createMockPluginDetail(),
|
||||
presentation: 'drawer' | 'dialog' = 'drawer',
|
||||
) => {
|
||||
useReadmePanelStore.getState().openReadmePanel({
|
||||
detail,
|
||||
presentation,
|
||||
triggerId: 'readme-trigger',
|
||||
})
|
||||
return detail
|
||||
}
|
||||
// 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
|
||||
|
||||
// ================================
|
||||
// ReadmePanel Component Tests
|
||||
// ================================
|
||||
describe('ReadmePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLanguage = 'en-US'
|
||||
useReadmePanelStore.setState({ currentPanel: undefined })
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
@ -130,114 +130,487 @@ describe('ReadmePanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when no readme panel is open', () => {
|
||||
const { container } = renderWithQueryClient(<ReadmePanel />)
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should return null when no plugin detail is set', () => {
|
||||
const { container } = renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
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,
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
const { rerender } = renderWithQueryClient(<ReadmePanel />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to fetch'),
|
||||
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()
|
||||
})
|
||||
rerender(<ReadmePanel />)
|
||||
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument()
|
||||
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
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')
|
||||
})
|
||||
rerender(<ReadmePanel />)
|
||||
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
|
||||
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test Readme Content' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
rerender(<ReadmePanel />)
|
||||
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
|
||||
})
|
||||
it('should render close button', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
it('should call usePluginReadme with the plugin identifier and selected language', () => {
|
||||
openReadmePanel(createMockPluginDetail({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
}))
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
language: 'en-US',
|
||||
// ActionButton wraps the close icon
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass undefined language for zh-Hans locale', () => {
|
||||
mockLanguage = 'zh-Hans'
|
||||
openReadmePanel(createMockPluginDetail({
|
||||
plugin_unique_identifier: 'zh-plugin@1.0.0',
|
||||
}))
|
||||
// ================================
|
||||
// Loading State Tests
|
||||
// ================================
|
||||
describe('Loading State', () => {
|
||||
it('should show loading indicator when isLoading is true', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: 'zh-plugin@1.0.0',
|
||||
language: undefined,
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Loading component should be rendered with role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should open correctly from ReadmeEntrance through the global host', () => {
|
||||
const detail = createMockPluginDetail()
|
||||
// ================================
|
||||
// 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'),
|
||||
})
|
||||
|
||||
renderWithQueryClient(
|
||||
<>
|
||||
<ReadmeEntrance pluginDetail={detail} />
|
||||
<ReadmePanel />
|
||||
</>,
|
||||
)
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin\.readmeInfo\.needHelpCheckReadme/ }))
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,52 +1,54 @@
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useReadmePanelStore } from '../store'
|
||||
import { ReadmeShowType, useReadmePanelStore } from '../store'
|
||||
|
||||
describe('readme-panel/store', () => {
|
||||
beforeEach(() => {
|
||||
useReadmePanelStore.setState({ currentPanel: undefined })
|
||||
useReadmePanelStore.setState({ currentPluginDetail: undefined })
|
||||
})
|
||||
|
||||
it('initializes without an active panel', () => {
|
||||
it('initializes with undefined currentPluginDetail', () => {
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPanel).toBeUndefined()
|
||||
expect(state.currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('opens drawer presentation by default', () => {
|
||||
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail, triggerId: 'readme-trigger' })
|
||||
it('sets current plugin detail with drawer showType by default', () => {
|
||||
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail)
|
||||
|
||||
expect(useReadmePanelStore.getState().currentPanel).toEqual({
|
||||
detail,
|
||||
presentation: 'drawer',
|
||||
triggerId: 'readme-trigger',
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.drawer,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens dialog presentation when requested', () => {
|
||||
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail, presentation: 'dialog' })
|
||||
it('sets current plugin detail with modal showType', () => {
|
||||
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
|
||||
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
it('closes the active panel', () => {
|
||||
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail })
|
||||
expect(useReadmePanelStore.getState().currentPanel).toBeDefined()
|
||||
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()
|
||||
|
||||
useReadmePanelStore.getState().closeReadmePanel()
|
||||
expect(useReadmePanelStore.getState().currentPanel).toBeUndefined()
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(undefined)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replaces the active panel with the latest request', () => {
|
||||
it('replaces previous detail with new one', () => {
|
||||
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().openReadmePanel({ detail: detail1 })
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail: detail2, presentation: 'dialog' })
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(detail1)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-1')
|
||||
|
||||
expect(useReadmePanelStore.getState().currentPanel?.detail.id).toBe('plugin-2')
|
||||
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(detail2, ReadmeShowType.modal)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-2')
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@ -1,40 +1,34 @@
|
||||
import type { PluginDetail } from '../types'
|
||||
import type { ReadmePanelPresentation } from './store'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useId } from 'react'
|
||||
import { RiBookReadLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BUILTIN_TOOLS_ARRAY } from './constants'
|
||||
import { useReadmePanelStore } from './store'
|
||||
import { ReadmeShowType, useReadmePanelStore } from './store'
|
||||
|
||||
export const ReadmeEntrance = ({
|
||||
pluginDetail,
|
||||
presentation = 'drawer',
|
||||
showType = ReadmeShowType.drawer,
|
||||
className,
|
||||
showShortTip = false,
|
||||
}: {
|
||||
pluginDetail: PluginDetail
|
||||
presentation?: ReadmePanelPresentation
|
||||
showType?: ReadmeShowType
|
||||
className?: string
|
||||
showShortTip?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const triggerId = useId()
|
||||
const openReadmePanel = useReadmePanelStore(s => s.openReadmePanel)
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore()
|
||||
|
||||
const handleReadmeClick = () => {
|
||||
if (pluginDetail) {
|
||||
openReadmePanel({
|
||||
detail: pluginDetail,
|
||||
presentation,
|
||||
triggerId,
|
||||
})
|
||||
}
|
||||
if (pluginDetail)
|
||||
setCurrentPluginDetail(pluginDetail, showType)
|
||||
}
|
||||
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', presentation === 'drawer' && 'px-4', className)}>
|
||||
<div className={cn('flex flex-col items-start justify-center gap-2 pt-0 pb-4', showType === ReadmeShowType.drawer && 'px-4', className)}>
|
||||
{!showShortTip && (
|
||||
<div className="relative h-1 w-8 shrink-0">
|
||||
<div className="h-px w-full bg-divider-regular"></div>
|
||||
@ -42,13 +36,11 @@ export const ReadmeEntrance = ({
|
||||
)}
|
||||
|
||||
<button
|
||||
id={triggerId}
|
||||
type="button"
|
||||
onClick={handleReadmeClick}
|
||||
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"
|
||||
className="flex w-full items-center justify-start gap-1 text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only"
|
||||
>
|
||||
<div className="relative flex h-3 w-3 items-center justify-center overflow-hidden">
|
||||
<span aria-hidden="true" className="i-ri-book-read-line h-3 w-3" />
|
||||
<RiBookReadLine className="h-3 w-3" />
|
||||
</div>
|
||||
<span className="text-xs leading-4 font-normal">
|
||||
{!showShortTip ? t('readmeInfo.needHelpCheckReadme', { ns: 'plugin' }) : t('readmeInfo.title', { ns: 'plugin' })}
|
||||
|
||||
@ -1,38 +1,124 @@
|
||||
'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'
|
||||
|
||||
import { ReadmeDialog } from './dialog'
|
||||
import { ReadmeDrawer } from './drawer'
|
||||
import { useReadmePanelStore } from './store'
|
||||
const ReadmePanel: FC = () => {
|
||||
const { currentPluginDetail, setCurrentPluginDetail } = useReadmePanelStore()
|
||||
const { detail, showType } = currentPluginDetail || {}
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
|
||||
export default function ReadmePanel() {
|
||||
const currentPanel = useReadmePanelStore(s => s.currentPanel)
|
||||
const closeReadmePanel = useReadmePanelStore(s => s.closeReadmePanel)
|
||||
const pluginUniqueIdentifier = detail?.plugin_unique_identifier || ''
|
||||
|
||||
if (!currentPanel)
|
||||
const { data: readmeData, isLoading, error } = usePluginReadme(
|
||||
{ plugin_unique_identifier: pluginUniqueIdentifier, language: language === 'zh-Hans' ? undefined : language },
|
||||
)
|
||||
|
||||
const onClose = () => {
|
||||
setCurrentPluginDetail()
|
||||
}
|
||||
|
||||
if (!detail)
|
||||
return null
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (!open)
|
||||
closeReadmePanel()
|
||||
}
|
||||
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>
|
||||
|
||||
if (currentPanel.presentation === 'dialog') {
|
||||
return (
|
||||
<ReadmeDialog
|
||||
detail={currentPanel.detail}
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
triggerId={currentPanel.triggerId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ReadmeDrawer
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
export default ReadmePanel
|
||||
|
||||
@ -1,34 +1,27 @@
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type ReadmePanelPresentation = 'drawer' | 'dialog'
|
||||
|
||||
type ReadmePanelState = {
|
||||
detail: PluginDetail
|
||||
presentation: ReadmePanelPresentation
|
||||
triggerId?: string
|
||||
}
|
||||
|
||||
type OpenReadmePanelPayload = {
|
||||
detail: PluginDetail
|
||||
presentation?: ReadmePanelPresentation
|
||||
triggerId?: string
|
||||
export enum ReadmeShowType {
|
||||
drawer = 'drawer',
|
||||
modal = 'modal',
|
||||
}
|
||||
|
||||
type Shape = {
|
||||
currentPanel?: ReadmePanelState
|
||||
openReadmePanel: (payload: OpenReadmePanelPayload) => void
|
||||
closeReadmePanel: () => void
|
||||
currentPluginDetail?: {
|
||||
detail: PluginDetail
|
||||
showType: ReadmeShowType
|
||||
}
|
||||
setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => void
|
||||
}
|
||||
|
||||
export const useReadmePanelStore = create<Shape>(set => ({
|
||||
currentPanel: undefined,
|
||||
openReadmePanel: ({ detail, presentation = 'drawer', triggerId }) => set({
|
||||
currentPanel: {
|
||||
detail,
|
||||
presentation,
|
||||
triggerId,
|
||||
},
|
||||
currentPluginDetail: undefined,
|
||||
setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => set({
|
||||
currentPluginDetail: !detail
|
||||
? undefined
|
||||
: {
|
||||
detail,
|
||||
showType: showType ?? ReadmeShowType.drawer,
|
||||
},
|
||||
}),
|
||||
closeReadmePanel: () => set({ currentPanel: undefined }),
|
||||
}))
|
||||
|
||||
@ -68,13 +68,13 @@ const MenuDropdown: FC<Props> = ({
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
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>
|
||||
)}
|
||||
render={<div />}
|
||||
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}
|
||||
|
||||
@ -34,28 +34,12 @@ vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({
|
||||
children,
|
||||
className,
|
||||
render,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
render?: React.ReactNode
|
||||
}) => {
|
||||
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
if (render) {
|
||||
return (
|
||||
<div onClick={() => setOpen(!open)}>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className={className} onClick={() => setOpen(!open)}>
|
||||
{children}
|
||||
</button>
|
||||
<div onClick={() => setOpen(!open)}>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -135,12 +119,6 @@ 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} />)
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ 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'
|
||||
@ -59,19 +60,22 @@ const LabelSelector: FC<LabelSelectorProps> = ({
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<div className="relative">
|
||||
<PopoverTrigger
|
||||
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',
|
||||
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>
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
|
||||
@ -133,8 +133,8 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
|
||||
<div data-testid="workflow-tool-drawer">
|
||||
default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
|
||||
<div data-testid="workflow-tool-modal">
|
||||
<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 drawer', async () => {
|
||||
it('saves workflow tool via workflow modal', 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-drawer'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('wf-save'))
|
||||
})
|
||||
@ -627,7 +627,7 @@ describe('ProviderDetail', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Overlay Close Actions', () => {
|
||||
describe('Modal 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 WorkflowToolDrawer via onHide', async () => {
|
||||
it('closes WorkflowToolModal 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-drawer'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('wf-close'))
|
||||
expect(screen.queryByTestId('workflow-tool-drawer')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('workflow-tool-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types'
|
||||
import type { WorkflowToolDrawerPayload } from '@/app/components/tools/workflow-tool'
|
||||
import type { WorkflowToolModalPayload } 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 { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
|
||||
import WorkflowToolModal 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 [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
|
||||
const [isShowEditWorkflowToolModal, setIsShowEditWorkflowToolModal] = 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' }))
|
||||
setWorkflowToolDrawerOpen(false)
|
||||
setIsShowEditWorkflowToolModal(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' }))
|
||||
setWorkflowToolDrawerOpen(false)
|
||||
setIsShowEditWorkflowToolModal(false)
|
||||
}
|
||||
const onClickCustomToolDelete = () => {
|
||||
setDeleteAction('customTool')
|
||||
@ -287,7 +287,7 @@ const ProviderDetail = ({
|
||||
</Button>
|
||||
<Button
|
||||
className={cn('my-3 w-[183px] shrink-0')}
|
||||
onClick={() => setWorkflowToolDrawerOpen(true)}
|
||||
onClick={() => setIsShowEditWorkflowToolModal(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}
|
||||
/>
|
||||
)}
|
||||
{workflowToolDrawerOpen && (
|
||||
<WorkflowToolDrawer
|
||||
payload={customCollection as unknown as WorkflowToolDrawerPayload}
|
||||
onHide={() => setWorkflowToolDrawerOpen(false)}
|
||||
{isShowEditWorkflowToolModal && (
|
||||
<WorkflowToolModal
|
||||
payload={customCollection as unknown as WorkflowToolModalPayload}
|
||||
onHide={() => setIsShowEditWorkflowToolModal(false)}
|
||||
onRemove={onClickWorkflowToolDelete}
|
||||
onSave={updateWorkflowToolProvider}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,22 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { WorkflowToolDrawerPayload } from '../index'
|
||||
import type { WorkflowToolModalPayload } 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 { WorkflowToolDrawer } from '../index'
|
||||
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
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
|
||||
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
|
||||
@ -33,8 +46,8 @@ vi.mock('@/app/components/base/tooltip', () => ({
|
||||
children,
|
||||
popupContent,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
popupContent?: ReactNode
|
||||
children?: React.ReactNode
|
||||
popupContent?: React.ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
@ -73,7 +86,7 @@ vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const createPayload = (overrides: Partial<WorkflowToolDrawerPayload> = {}): WorkflowToolDrawerPayload => ({
|
||||
const createPayload = (overrides: Partial<WorkflowToolModalPayload> = {}): WorkflowToolModalPayload => ({
|
||||
icon: { content: '🔧', background: '#ffffff' },
|
||||
label: 'My Tool',
|
||||
name: 'my_tool',
|
||||
@ -92,7 +105,7 @@ const createPayload = (overrides: Partial<WorkflowToolDrawerPayload> = {}): Work
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('WorkflowToolDrawer', () => {
|
||||
describe('WorkflowToolAsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@ -102,7 +115,7 @@ describe('WorkflowToolDrawer', () => {
|
||||
const onCreate = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowToolDrawer
|
||||
<WorkflowToolAsModal
|
||||
isAdd
|
||||
payload={createPayload()}
|
||||
onHide={vi.fn()}
|
||||
@ -131,7 +144,7 @@ describe('WorkflowToolDrawer', () => {
|
||||
const onCreate = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowToolDrawer
|
||||
<WorkflowToolAsModal
|
||||
isAdd
|
||||
payload={createPayload({ name: 'bad-name' })}
|
||||
onHide={vi.fn()}
|
||||
@ -152,7 +165,7 @@ describe('WorkflowToolDrawer', () => {
|
||||
const onSave = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowToolDrawer
|
||||
<WorkflowToolAsModal
|
||||
payload={createPayload()}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
@ -174,7 +187,7 @@ describe('WorkflowToolDrawer', () => {
|
||||
|
||||
it('should show duplicate reserved output warnings', () => {
|
||||
render(
|
||||
<WorkflowToolDrawer
|
||||
<WorkflowToolAsModal
|
||||
isAdd
|
||||
payload={createPayload()}
|
||||
onHide={vi.fn()}
|
||||
|
||||
@ -1,33 +1,70 @@
|
||||
'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 { useRouter } from '@/next/navigation'
|
||||
import WorkflowToolModal from '@/app/components/tools/workflow-tool'
|
||||
import Divider from '../../base/divider'
|
||||
import { useConfigureButton } from './hooks/use-configure-button'
|
||||
|
||||
type Props = {
|
||||
disabled: boolean
|
||||
published: boolean
|
||||
isLoading: boolean
|
||||
outdated: boolean
|
||||
isCurrentWorkspaceManager: boolean
|
||||
onConfigure: () => void
|
||||
detailNeedUpdate: boolean
|
||||
workflowAppId: string
|
||||
icon: Emoji
|
||||
name: string
|
||||
description: string
|
||||
inputs?: InputVar[]
|
||||
outputs?: Variable[]
|
||||
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
|
||||
onRefreshData?: () => void
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
const WorkflowToolConfigureButton = ({
|
||||
disabled,
|
||||
published,
|
||||
isLoading,
|
||||
outdated,
|
||||
isCurrentWorkspaceManager,
|
||||
onConfigure,
|
||||
detailNeedUpdate,
|
||||
workflowAppId,
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
inputs,
|
||||
outputs,
|
||||
handlePublish,
|
||||
onRefreshData,
|
||||
disabledReason,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const {
|
||||
showModal,
|
||||
isLoading,
|
||||
outdated,
|
||||
payload,
|
||||
isCurrentWorkspaceManager,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
navigateToTools,
|
||||
} = useConfigureButton({
|
||||
published,
|
||||
detailNeedUpdate,
|
||||
workflowAppId,
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
inputs,
|
||||
outputs,
|
||||
handlePublish,
|
||||
onRefreshData,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -43,12 +80,9 @@ const WorkflowToolConfigureButton = ({
|
||||
? (
|
||||
<div
|
||||
className="flex items-center justify-start gap-2 p-2 pl-2.5"
|
||||
onClick={() => {
|
||||
if (!disabled && !published)
|
||||
onConfigure()
|
||||
}}
|
||||
onClick={() => !disabled && !published && openModal()}
|
||||
>
|
||||
<span className={cn('relative i-ri-hammer-line h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
|
||||
<RiHammerLine className={cn('relative 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')}
|
||||
@ -66,7 +100,7 @@ const WorkflowToolConfigureButton = ({
|
||||
<div
|
||||
className="flex items-center justify-start gap-2 p-2 pl-2.5"
|
||||
>
|
||||
<span className="i-ri-hammer-line h-4 w-4 text-text-tertiary" />
|
||||
<RiHammerLine className="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"
|
||||
@ -86,7 +120,7 @@ const WorkflowToolConfigureButton = ({
|
||||
<Button
|
||||
size="small"
|
||||
className="w-[140px]"
|
||||
onClick={onConfigure}
|
||||
onClick={openModal}
|
||||
disabled={!isCurrentWorkspaceManager || disabled}
|
||||
>
|
||||
{t('common.configure', { ns: 'workflow' })}
|
||||
@ -95,11 +129,11 @@ const WorkflowToolConfigureButton = ({
|
||||
<Button
|
||||
size="small"
|
||||
className="w-[140px]"
|
||||
onClick={() => router.push('/tools?category=workflow')}
|
||||
onClick={navigateToTools}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('common.manageInTools', { ns: 'workflow' })}
|
||||
<span className="ml-1 i-ri-arrow-right-up-line h-4 w-4" />
|
||||
<RiArrowRightUpLine className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{outdated && (
|
||||
@ -112,6 +146,15 @@ 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,6 +4,11 @@ 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: () => ({
|
||||
@ -93,7 +98,6 @@ const createMockDetail = (overrides: Partial<WorkflowToolProviderResponse> = {})
|
||||
})
|
||||
|
||||
const createDefaultOptions = (overrides = {}) => ({
|
||||
enabled: true,
|
||||
published: false,
|
||||
detailNeedUpdate: false,
|
||||
workflowAppId: 'app-123',
|
||||
@ -209,9 +213,9 @@ describe('useConfigureButton', () => {
|
||||
})
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should return workflow tool state without owning drawer visibility', () => {
|
||||
it('should return showModal as false by default', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
expect(result.current.payload).toMatchObject({ workflow_app_id: 'app-123' })
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should forward isCurrentWorkspaceManager from context', () => {
|
||||
@ -235,11 +239,6 @@ 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
|
||||
@ -349,13 +348,46 @@ 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 notify configured', async () => {
|
||||
it('should create provider, invalidate caches, refresh, and close modal', async () => {
|
||||
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
||||
const onRefreshData = vi.fn()
|
||||
const onConfigured = vi.fn()
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData, onConfigured })))
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData })))
|
||||
|
||||
act(() => {
|
||||
result.current.openModal()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string })
|
||||
@ -366,7 +398,7 @@ describe('useConfigureButton', () => {
|
||||
expect(onRefreshData).toHaveBeenCalled()
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) })
|
||||
expect(onConfigured).toHaveBeenCalled()
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
@ -382,18 +414,20 @@ describe('useConfigureButton', () => {
|
||||
})
|
||||
|
||||
describe('handleUpdate', () => {
|
||||
it('should publish, save, invalidate caches, and notify configured', async () => {
|
||||
it('should publish, save, invalidate caches, and close modal', 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 }>)
|
||||
})
|
||||
@ -403,7 +437,7 @@ describe('useConfigureButton', () => {
|
||||
expect(onRefreshData).toHaveBeenCalled()
|
||||
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
|
||||
expect(onConfigured).toHaveBeenCalled()
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should show error toast when publish fails', async () => {
|
||||
@ -457,16 +491,6 @@ 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
|
||||
|
||||
@ -2,9 +2,10 @@ 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 { useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } 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'
|
||||
|
||||
@ -88,7 +89,6 @@ function buildExistingOutputParameters(
|
||||
// endregion
|
||||
|
||||
type UseConfigureButtonOptions = {
|
||||
enabled: boolean
|
||||
published: boolean
|
||||
detailNeedUpdate: boolean
|
||||
workflowAppId: string
|
||||
@ -99,12 +99,10 @@ type UseConfigureButtonOptions = {
|
||||
outputs?: Variable[]
|
||||
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
|
||||
onRefreshData?: () => void
|
||||
onConfigured?: () => void
|
||||
}
|
||||
|
||||
export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
const {
|
||||
enabled,
|
||||
published,
|
||||
detailNeedUpdate,
|
||||
workflowAppId,
|
||||
@ -115,14 +113,16 @@ 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, enabled && published)
|
||||
const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, 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 (enabled && detailNeedUpdate)
|
||||
if (detailNeedUpdate)
|
||||
invalidateDetailRef.current(workflowAppId)
|
||||
}, [detailNeedUpdate, enabled, workflowAppId])
|
||||
}, [detailNeedUpdate, workflowAppId])
|
||||
|
||||
// Computed values
|
||||
const outdated = useMemo(
|
||||
@ -173,6 +173,14 @@ 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 {
|
||||
@ -181,7 +189,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
onRefreshData?.()
|
||||
invalidateDetail(workflowAppId)
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
onConfigured?.()
|
||||
setShowModal(false)
|
||||
}
|
||||
catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
@ -198,7 +206,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
onRefreshData?.()
|
||||
invalidateAllWorkflowTools()
|
||||
invalidateDetail(workflowAppId)
|
||||
onConfigured?.()
|
||||
setShowModal(false)
|
||||
}
|
||||
catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
@ -206,11 +214,15 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
}
|
||||
|
||||
return {
|
||||
showModal,
|
||||
isLoading,
|
||||
outdated,
|
||||
payload,
|
||||
isCurrentWorkspaceManager,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
navigateToTools,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +1,9 @@
|
||||
'use client'
|
||||
import type { DrawerRootProps } from '@langgenius/dify-ui/drawer'
|
||||
import type { FC } from 'react'
|
||||
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'
|
||||
@ -36,7 +26,7 @@ import {
|
||||
isWorkflowToolNameValid,
|
||||
} from './helpers'
|
||||
|
||||
export type WorkflowToolDrawerPayload = {
|
||||
export type WorkflowToolModalPayload = {
|
||||
icon: Emoji
|
||||
label: string
|
||||
name: string
|
||||
@ -52,9 +42,9 @@ export type WorkflowToolDrawerPayload = {
|
||||
workflow_app_id?: string
|
||||
}
|
||||
|
||||
export type WorkflowToolDrawerProps = {
|
||||
type Props = {
|
||||
isAdd?: boolean
|
||||
payload: WorkflowToolDrawerPayload
|
||||
payload: WorkflowToolModalPayload
|
||||
onHide: () => void
|
||||
onRemove?: () => void
|
||||
onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
|
||||
@ -64,9 +54,8 @@ export type WorkflowToolDrawerProps = {
|
||||
}>) => void
|
||||
}
|
||||
|
||||
type WorkflowToolDrawerFrameProps = {
|
||||
type WorkflowToolDrawerProps = {
|
||||
title: string
|
||||
closeLabel: string
|
||||
onHide: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
@ -88,45 +77,39 @@ const InfoTooltip = ({ children }: { children: React.ReactNode }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const WorkflowToolDrawerFrame = ({ title, closeLabel, onHide, children }: WorkflowToolDrawerFrameProps) => {
|
||||
const handleOpenChange = React.useCallback<NonNullable<DrawerRootProps['onOpenChange']>>((open) => {
|
||||
if (!open)
|
||||
onHide()
|
||||
}, [onHide])
|
||||
|
||||
const WorkflowToolDrawer = ({ title, onHide, children }: WorkflowToolDrawerProps) => {
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -175,14 +158,15 @@ const WorkflowToolEmojiPicker = ({ onSelect, onClose }: WorkflowToolEmojiPickerP
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkflowToolDrawer({
|
||||
// Add and Edit
|
||||
const WorkflowToolAsModal: FC<Props> = ({
|
||||
isAdd,
|
||||
payload,
|
||||
onHide,
|
||||
onRemove,
|
||||
onSave,
|
||||
onCreate,
|
||||
}: WorkflowToolDrawerProps) {
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false)
|
||||
@ -216,7 +200,7 @@ export function WorkflowToolDrawer({
|
||||
setLabels(value)
|
||||
}
|
||||
const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy)
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const onConfirm = () => {
|
||||
let errorMessage = ''
|
||||
@ -259,10 +243,9 @@ export function WorkflowToolDrawer({
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkflowToolDrawerFrame
|
||||
<WorkflowToolDrawer
|
||||
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">
|
||||
@ -444,7 +427,7 @@ export function WorkflowToolDrawer({
|
||||
if (isAdd)
|
||||
onConfirm()
|
||||
else
|
||||
setConfirmModalOpen(true)
|
||||
setShowModal(true)
|
||||
}}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
@ -452,7 +435,7 @@ export function WorkflowToolDrawer({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WorkflowToolDrawerFrame>
|
||||
</WorkflowToolDrawer>
|
||||
{showEmojiPicker && (
|
||||
<WorkflowToolEmojiPicker
|
||||
onSelect={(icon, icon_background) => {
|
||||
@ -464,10 +447,10 @@ export function WorkflowToolDrawer({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{confirmModalOpen && (
|
||||
{showModal && (
|
||||
<ConfirmModal
|
||||
show={confirmModalOpen}
|
||||
onClose={() => setConfirmModalOpen(false)}
|
||||
show={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
)}
|
||||
@ -475,3 +458,4 @@ export function WorkflowToolDrawer({
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(WorkflowToolAsModal)
|
||||
|
||||
@ -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?.({ url: '/apps/app-1/workflows/publish', title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
|
||||
publisher-publish-with-params
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -271,7 +271,6 @@ const NodePanel: FC<Props> = ({
|
||||
<div className={cn('mb-1')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
showFileList
|
||||
title={<div>{processDataTitle}</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={nodeInfo.process_data}
|
||||
@ -283,7 +282,6 @@ const NodePanel: FC<Props> = ({
|
||||
<div>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
showFileList
|
||||
title={<div>{outputTitle}</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={nodeInfo.outputs}
|
||||
|
||||
@ -143,7 +143,6 @@ const ResultPanel: FC<ResultPanelProps> = ({
|
||||
{process_data && (
|
||||
<CodeEditor
|
||||
readOnly
|
||||
showFileList
|
||||
title={<div>{t('common.processData', { ns: 'workflow' }).toLocaleUpperCase()}</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={process_data}
|
||||
@ -154,7 +153,6 @@ const ResultPanel: FC<ResultPanelProps> = ({
|
||||
{(outputs || status === 'running') && (
|
||||
<CodeEditor
|
||||
readOnly
|
||||
showFileList
|
||||
title={<div>{t('common.output', { ns: 'workflow' }).toLocaleUpperCase()}</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={outputs}
|
||||
|
||||
@ -10,15 +10,12 @@ 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`
|
||||
@ -52,12 +49,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`, `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` |
|
||||
| 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` |
|
||||
|
||||
`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,
|
||||
|
||||
@ -66,15 +66,6 @@ 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 = {
|
||||
|
||||
@ -5,7 +5,6 @@ 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'
|
||||
|
||||
@ -784,7 +783,7 @@ export type UpdateDocumentBatchParams = {
|
||||
|
||||
export type BatchImportResponse = {
|
||||
job_id: string
|
||||
job_status: SegmentImportStatus
|
||||
job_status: string
|
||||
}
|
||||
|
||||
export const DOC_FORM_ICON_WITH_BG: Record<ChunkingMode | 'external', React.ComponentType<{ className: string }>> = {
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
export const segmentImportStatus = {
|
||||
waiting: 'waiting',
|
||||
processing: 'processing',
|
||||
completed: 'completed',
|
||||
error: 'error',
|
||||
} as const
|
||||
|
||||
export type SegmentImportStatus = typeof segmentImportStatus[keyof typeof segmentImportStatus]
|
||||
Reference in New Issue
Block a user