Compare commits

..

2 Commits

Author SHA1 Message Date
8ad90a673b chore: replace x-shared-env anchor with env_file, drop template generator
The 1677-line generated docker-compose.yaml was ~440 lines of YAML-anchor
boilerplate (`x-shared-env: &shared-api-worker-env` populated by
`generate_docker_compose` from `.env.example`). Native compose has had
`env_file:` for years and supports interpolation in env files since 2.24,
so the anchor + generator pair was reimplementing what compose does itself.

This commit:

- Removes the `x-shared-env: &shared-api-worker-env` anchor and all
  `<<: *shared-api-worker-env` merges. Services that need the shared
  config (`api`, `worker`, `worker_beat`, `plugin_daemon`) now use
    env_file:
      - .env.example
      - .env
- Deletes `docker/docker-compose-template.yaml` (no longer needed; the
  generated compose file becomes the single source of truth).
- Deletes `docker/generate_docker_compose` (no template, no generation).

`docker/docker-compose.yaml` now contains the full, hand-readable compose
config in 958 lines (was 1677 — a 43% reduction). The default
`docker compose up -d` brings up exactly the same set of services
(api/web/worker/redis/postgres/weaviate/...) via the existing
profile-gating in COMPOSE_PROFILES.

Suggestion from #35925.

Behavior note: shared-config keys are no longer interpolated through
compose, so passing `KEY=value docker compose up -d` from the shell
no longer overrides container env for those keys — set them in
`docker/.env` instead. Service-specific overrides via the
`environment:` block continue to work as before.
2026-05-08 17:11:54 +08:00
d2404d0375 chore: remove dify-compose wrappers, use native docker compose
#35708 added 660+ lines of bash + PowerShell to wrap docker compose
and merge a new .env.default with the user's .env. Native Compose
already provides everything that script does:

- `${VAR:-default}` interpolation in compose.yaml gives per-key
  defaults without a separate .env.default file.
- Auto-loaded .env in the project dir interpolates ${VAR:-default}
  on the value side, so COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},
  ${DB_TYPE:-postgresql} expands correctly without a wrapper.
- `profiles:` on services already gates DB / vector-store selection.
- Maintaining two scripts (bash + PowerShell) duplicates env-file
  parsing logic that compose owns natively, with subtle differences
  (e.g. PowerShell's Get-Content uses system encoding, the bash
  script's awk parser doesn't handle inline `# comment` after a
  value).

Drop the wrappers and `.env.default`. Restore the README to the
two-step `cp .env.example .env && docker compose up -d` flow.
The .env.example placeholder cleanups landed in #35708 are kept.

Net: -712 lines, same UX modulo one extra `cp` step.
2026-05-08 16:48:00 +08:00
152 changed files with 2326 additions and 6319 deletions

View File

@ -74,8 +74,7 @@ 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
cd dify/docker
cp .env.example .env
docker compose up -d
```

View File

@ -98,8 +98,6 @@ DB_DATABASE=dify
SQLALCHEMY_POOL_PRE_PING=true
SQLALCHEMY_POOL_TIMEOUT=30
# Connection pool reset behavior on return
SQLALCHEMY_POOL_RESET_ON_RETURN=rollback
# Storage configuration
# use for store upload files, private keys...
@ -383,7 +381,7 @@ VIKINGDB_ACCESS_KEY=your-ak
VIKINGDB_SECRET_KEY=your-sk
VIKINGDB_REGION=cn-shanghai
VIKINGDB_HOST=api-vikingdb.xxx.volces.com
VIKINGDB_SCHEME=http
VIKINGDB_SCHEMA=http
VIKINGDB_CONNECTION_TIMEOUT=30
VIKINGDB_SOCKET_TIMEOUT=30
@ -434,6 +432,8 @@ UPLOAD_FILE_EXTENSION_BLACKLIST=
# Model configuration
MULTIMODAL_SEND_FORMAT=base64
PROMPT_GENERATION_MAX_TOKENS=512
CODE_GENERATION_MAX_TOKENS=1024
PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false
# Mail configuration, support: resend, smtp, sendgrid

View File

@ -114,7 +114,7 @@ class SQLAlchemyEngineOptionsDict(TypedDict):
pool_pre_ping: bool
connect_args: dict[str, str]
pool_use_lifo: bool
pool_reset_on_return: Literal["commit", "rollback", None]
pool_reset_on_return: None
pool_timeout: int
@ -223,11 +223,6 @@ class DatabaseConfig(BaseSettings):
default=30,
)
SQLALCHEMY_POOL_RESET_ON_RETURN: Literal["commit", "rollback", None] = Field(
description="Connection pool reset behavior on return. Options: 'commit', 'rollback', or None",
default="rollback",
)
RETRIEVAL_SERVICE_EXECUTORS: NonNegativeInt = Field(
description="Number of processes for the retrieval service, default to CPU cores.",
default=os.cpu_count() or 1,
@ -257,7 +252,7 @@ class DatabaseConfig(BaseSettings):
"pool_pre_ping": self.SQLALCHEMY_POOL_PRE_PING,
"connect_args": connect_args,
"pool_use_lifo": self.SQLALCHEMY_POOL_USE_LIFO,
"pool_reset_on_return": self.SQLALCHEMY_POOL_RESET_ON_RETURN,
"pool_reset_on_return": None,
"pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT,
}
return result

View File

@ -842,24 +842,24 @@ class WorkflowResponseConverter:
return []
files: list[Mapping[str, Any]] = []
match value:
case FileSegment():
files.append(value.value.to_dict())
case ArrayFileSegment():
files.extend([i.to_dict() for i in value.value])
case File():
files.append(value.to_dict())
case list():
for item in value:
file = cls._get_file_var_from_value(item)
if file:
files.append(file)
case dict():
file = cls._get_file_var_from_value(value)
if isinstance(value, FileSegment):
files.append(value.value.to_dict())
elif isinstance(value, ArrayFileSegment):
files.extend([i.to_dict() for i in value.value])
elif isinstance(value, File):
files.append(value.to_dict())
elif isinstance(value, list):
for item in value:
file = cls._get_file_var_from_value(item)
if file:
files.append(file)
case _:
pass
elif isinstance(
value,
dict,
):
file = cls._get_file_var_from_value(value)
if file:
files.append(file)
return files

View File

@ -53,27 +53,24 @@ class PromptMessageUtil:
files = []
if isinstance(prompt_message.content, list):
for content in prompt_message.content:
match content:
case TextPromptMessageContent():
text += content.data
case ImagePromptMessageContent():
files.append(
{
"type": "image",
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
"detail": content.detail.value,
}
)
case AudioPromptMessageContent():
files.append(
{
"type": "audio",
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
"format": content.format,
}
)
case _:
continue
if isinstance(content, TextPromptMessageContent):
text += content.data
elif isinstance(content, ImagePromptMessageContent):
files.append(
{
"type": "image",
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
"detail": content.detail.value,
}
)
elif isinstance(content, AudioPromptMessageContent):
files.append(
{
"type": "audio",
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
"format": content.format,
}
)
else:
text = cast(str, prompt_message.content)

View File

@ -23,37 +23,36 @@ _TOOL_FILE_URL_PATTERN = re.compile(r"(?:^|/+)files/tools/(?P<tool_file_id>[^/?#
def safe_json_value(v):
match v:
case datetime():
tz_name = "UTC"
if isinstance(current_user, Account) and current_user.timezone is not None:
tz_name = current_user.timezone
return v.astimezone(pytz.timezone(tz_name)).isoformat()
case date():
return v.isoformat()
case UUID():
return str(v)
case Decimal():
return float(v)
case bytes():
try:
return v.decode("utf-8")
except UnicodeDecodeError:
return v.hex()
case memoryview():
return v.tobytes().hex()
case np.integer():
return int(v)
case np.floating():
return float(v)
case np.ndarray():
return v.tolist()
case dict():
return safe_json_dict(v)
case list() | tuple() | set():
return [safe_json_value(i) for i in v]
case _:
return v
if isinstance(v, datetime):
tz_name = "UTC"
if isinstance(current_user, Account) and current_user.timezone is not None:
tz_name = current_user.timezone
return v.astimezone(pytz.timezone(tz_name)).isoformat()
elif isinstance(v, date):
return v.isoformat()
elif isinstance(v, UUID):
return str(v)
elif isinstance(v, Decimal):
return float(v)
elif isinstance(v, bytes):
try:
return v.decode("utf-8")
except UnicodeDecodeError:
return v.hex()
elif isinstance(v, memoryview):
return v.tobytes().hex()
elif isinstance(v, np.integer):
return int(v)
elif isinstance(v, np.floating):
return float(v)
elif isinstance(v, np.ndarray):
return v.tolist()
elif isinstance(v, dict):
return safe_json_dict(v)
elif isinstance(v, list | tuple | set):
return [safe_json_value(i) for i in v]
else:
return v
def safe_json_dict(d: dict[str, Any]):

View File

@ -194,15 +194,14 @@ class VariableTruncator(BaseTruncator):
result: _PartResult[Any]
# Apply type-specific truncation with target size
match segment:
case ArraySegment():
result = self._truncate_array(segment.value, target_size)
case StringSegment():
result = self._truncate_string(segment.value, target_size)
case ObjectSegment():
result = self._truncate_object(segment.value, target_size)
case _:
raise AssertionError("this should be unreachable.")
if isinstance(segment, ArraySegment):
result = self._truncate_array(segment.value, target_size)
elif isinstance(segment, StringSegment):
result = self._truncate_string(segment.value, target_size)
elif isinstance(segment, ObjectSegment):
result = self._truncate_object(segment.value, target_size)
else:
raise AssertionError("this should be unreachable.")
return _PartResult(
value=segment.model_copy(update={"value": result.value}),
@ -220,41 +219,40 @@ class VariableTruncator(BaseTruncator):
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
if depth > _MAX_DEPTH:
raise MaxDepthExceededError()
match value:
case str():
# Ideally, the size of strings should be calculated based on their utf-8 encoded length.
# However, this adds complexity as we would need to compute encoded sizes consistently
# throughout the code. Therefore, we approximate the size using the string's length.
# Rough estimate: number of characters, plus 2 for quotes
return len(value) + 2
case bool():
return 4 if value else 5 # "true" or "false"
case int() | float():
return len(str(value))
case None:
return 4 # "null"
case list():
# Size = sum of elements + separators + brackets
total = 2 # "[]"
for i, item in enumerate(value):
if i > 0:
total += 1 # ","
total += VariableTruncator.calculate_json_size(item, depth=depth + 1)
return total
case dict():
# Size = sum of keys + values + separators + brackets
total = 2 # "{}"
for index, key in enumerate(value.keys()):
if index > 0:
total += 1 # ","
total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string
total += 1 # ":"
total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1)
return total
case File():
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
case _:
raise UnknownTypeError(f"got unknown type {type(value)}")
if isinstance(value, str):
# Ideally, the size of strings should be calculated based on their utf-8 encoded length.
# However, this adds complexity as we would need to compute encoded sizes consistently
# throughout the code. Therefore, we approximate the size using the string's length.
# Rough estimate: number of characters, plus 2 for quotes
return len(value) + 2
elif isinstance(value, (int, float)):
return len(str(value))
elif isinstance(value, bool):
return 4 if value else 5 # "true" or "false"
elif value is None:
return 4 # "null"
elif isinstance(value, list):
# Size = sum of elements + separators + brackets
total = 2 # "[]"
for i, item in enumerate(value):
if i > 0:
total += 1 # ","
total += VariableTruncator.calculate_json_size(item, depth=depth + 1)
return total
elif isinstance(value, dict):
# Size = sum of keys + values + separators + brackets
total = 2 # "{}"
for index, key in enumerate(value.keys()):
if index > 0:
total += 1 # ","
total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string
total += 1 # ":"
total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1)
return total
elif isinstance(value, File):
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
else:
raise UnknownTypeError(f"got unknown type {type(value)}")
def _truncate_string(self, value: str, target_size: int) -> _PartResult[str]:
if (size := self.calculate_json_size(value)) < target_size:
@ -421,23 +419,22 @@ class VariableTruncator(BaseTruncator):
target_size: int,
) -> _PartResult[Any]:
"""Truncate a value within an object to fit within budget."""
match val:
case UpdatedVariable():
# TODO(Workflow): push UpdatedVariable normalization closer to its producer.
return self._truncate_object(val.model_dump(), target_size)
case str():
return self._truncate_string(val, target_size)
case list():
return self._truncate_array(val, target_size)
case dict():
return self._truncate_object(val, target_size)
case File():
# File objects should not be truncated, return as-is
return _PartResult(val, self.calculate_json_size(val), False)
case None | bool() | int() | float():
return _PartResult(val, self.calculate_json_size(val), False)
case _:
raise AssertionError("this statement should be unreachable.")
if isinstance(val, UpdatedVariable):
# TODO(Workflow): push UpdatedVariable normalization closer to its producer.
return self._truncate_object(val.model_dump(), target_size)
elif isinstance(val, str):
return self._truncate_string(val, target_size)
elif isinstance(val, list):
return self._truncate_array(val, target_size)
elif isinstance(val, dict):
return self._truncate_object(val, target_size)
elif isinstance(val, File):
# File objects should not be truncated, return as-is
return _PartResult(val, self.calculate_json_size(val), False)
elif val is None or isinstance(val, (bool, int, float)):
return _PartResult(val, self.calculate_json_size(val), False)
else:
raise AssertionError("this statement should be unreachable.")
class DummyVariableTruncator(BaseTruncator):

View File

@ -114,8 +114,8 @@ def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
"pool_recycle": 3600,
"pool_size": 30,
"pool_use_lifo": False,
"pool_reset_on_return": None,
"pool_timeout": 30,
"pool_reset_on_return": "rollback",
}
assert config["CONSOLE_WEB_URL"] == "https://example.com"

View File

@ -268,8 +268,6 @@ SQLALCHEMY_POOL_USE_LIFO=false
# Number of seconds to wait for a connection from the pool before raising a timeout error.
# Default is 30
SQLALCHEMY_POOL_TIMEOUT=30
# Connection pool reset behavior on return
SQLALCHEMY_POOL_RESET_ON_RETURN=rollback
# Maximum number of connections to the database
# Default is 100
@ -810,7 +808,7 @@ VIKINGDB_ACCESS_KEY=your-ak
VIKINGDB_SECRET_KEY=your-sk
VIKINGDB_REGION=cn-shanghai
VIKINGDB_HOST=api-vikingdb.xxx.volces.com
VIKINGDB_SCHEME=http
VIKINGDB_SCHEMA=http
VIKINGDB_CONNECTION_TIMEOUT=30
VIKINGDB_SOCKET_TIMEOUT=30
@ -922,6 +920,18 @@ SCARF_NO_ANALYTICS=true
# Model Configuration
# ------------------------------
# The maximum number of tokens allowed for prompt generation.
# This setting controls the upper limit of tokens that can be used by the LLM
# when generating a prompt in the prompt generation tool.
# Default: 512 tokens.
PROMPT_GENERATION_MAX_TOKENS=512
# The maximum number of tokens allowed for code generation.
# This setting controls the upper limit of tokens that can be used by the LLM
# when generating code in the code generation tool.
# Default: 1024 tokens.
CODE_GENERATION_MAX_TOKENS=1024
# Enable or disable plugin based token counting. If disabled, token counting will return 0.
# This can improve performance by skipping token counting operations.
# Default: false (disabled).
@ -993,7 +1003,7 @@ NOTION_INTERNAL_SECRET=
# ------------------------------
# Mail type, support: resend, smtp, sendgrid
MAIL_TYPE=resend
MAIL_TYPE=
# Default send from email address, if not specified
# If using SendGrid, use the 'from' field for authentication if necessary.
@ -1001,7 +1011,7 @@ MAIL_DEFAULT_SEND_FROM=
# API-Key for the Resend email provider, used when MAIL_TYPE is `resend`.
RESEND_API_URL=https://api.resend.com
RESEND_API_KEY=your-resend-api-key
RESEND_API_KEY=
# SMTP server configuration, used when MAIL_TYPE is `smtp`
@ -1349,10 +1359,10 @@ NGINX_ENABLE_CERTBOT_CHALLENGE=false
# ------------------------------
# Email address (required to get certificates from Let's Encrypt)
CERTBOT_EMAIL=your_email@example.com
CERTBOT_EMAIL=
# Domain name
CERTBOT_DOMAIN=your_domain.com
CERTBOT_DOMAIN=
# certbot command options
# i.e: --force-renewal --dry-run --test-cert --debug
@ -1463,6 +1473,7 @@ CREATORS_PLATFORM_API_URL=https://creators.dify.ai
CREATORS_PLATFORM_OAUTH_CLIENT_ID=
FORCE_VERIFYING_SIGNATURE=true
ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES=true
PLUGIN_STDIO_BUFFER_SIZE=1024
PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880

View File

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

View File

@ -51,7 +51,7 @@ services:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
MYSQL_DATABASE: ${DB_DATABASE:-dify}
command: >
--max_connections=${MYSQL_MAX_CONNECTIONS:-1000}
--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}

View File

@ -1,726 +1,3 @@
# ==================================================================
# WARNING: This file is auto-generated by generate_docker_compose
# Do not modify this file directly. Instead, update the .env.example
# or docker-compose-template.yaml and regenerate this file.
# ==================================================================
x-shared-env: &shared-api-worker-env
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
CONSOLE_WEB_URL: ${CONSOLE_WEB_URL:-}
SERVICE_API_URL: ${SERVICE_API_URL:-}
TRIGGER_URL: ${TRIGGER_URL:-http://localhost}
APP_API_URL: ${APP_API_URL:-}
APP_WEB_URL: ${APP_WEB_URL:-}
FILES_URL: ${FILES_URL:-}
INTERNAL_FILES_URL: ${INTERNAL_FILES_URL:-}
LANG: ${LANG:-C.UTF-8}
LC_ALL: ${LC_ALL:-C.UTF-8}
PYTHONIOENCODING: ${PYTHONIOENCODING:-utf-8}
UV_CACHE_DIR: ${UV_CACHE_DIR:-/tmp/.uv-cache}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text}
LOG_FILE: ${LOG_FILE:-/app/logs/server.log}
LOG_FILE_MAX_SIZE: ${LOG_FILE_MAX_SIZE:-20}
LOG_FILE_BACKUP_COUNT: ${LOG_FILE_BACKUP_COUNT:-5}
LOG_DATEFORMAT: ${LOG_DATEFORMAT:-%Y-%m-%d %H:%M:%S}
LOG_TZ: ${LOG_TZ:-UTC}
DEBUG: ${DEBUG:-false}
FLASK_DEBUG: ${FLASK_DEBUG:-false}
ENABLE_REQUEST_LOGGING: ${ENABLE_REQUEST_LOGGING:-False}
SECRET_KEY: ${SECRET_KEY:-sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U}
INIT_PASSWORD: ${INIT_PASSWORD:-}
DEPLOY_ENV: ${DEPLOY_ENV:-PRODUCTION}
CHECK_UPDATE_URL: ${CHECK_UPDATE_URL:-https://updates.dify.ai}
OPENAI_API_BASE: ${OPENAI_API_BASE:-https://api.openai.com/v1}
MIGRATION_ENABLED: ${MIGRATION_ENABLED:-true}
FILES_ACCESS_TIMEOUT: ${FILES_ACCESS_TIMEOUT:-300}
ENABLE_COLLABORATION_MODE: ${ENABLE_COLLABORATION_MODE:-false}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
APP_DEFAULT_ACTIVE_REQUESTS: ${APP_DEFAULT_ACTIVE_REQUESTS:-0}
APP_MAX_ACTIVE_REQUESTS: ${APP_MAX_ACTIVE_REQUESTS:-0}
APP_MAX_EXECUTION_TIME: ${APP_MAX_EXECUTION_TIME:-1200}
DIFY_BIND_ADDRESS: ${DIFY_BIND_ADDRESS:-0.0.0.0}
DIFY_PORT: ${DIFY_PORT:-5001}
SERVER_WORKER_AMOUNT: ${SERVER_WORKER_AMOUNT:-1}
SERVER_WORKER_CLASS: ${SERVER_WORKER_CLASS:-gevent}
SERVER_WORKER_CONNECTIONS: ${SERVER_WORKER_CONNECTIONS:-10}
CELERY_WORKER_CLASS: ${CELERY_WORKER_CLASS:-}
GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-360}
CELERY_WORKER_AMOUNT: ${CELERY_WORKER_AMOUNT:-4}
CELERY_AUTO_SCALE: ${CELERY_AUTO_SCALE:-false}
CELERY_MAX_WORKERS: ${CELERY_MAX_WORKERS:-}
CELERY_MIN_WORKERS: ${CELERY_MIN_WORKERS:-}
API_TOOL_DEFAULT_CONNECT_TIMEOUT: ${API_TOOL_DEFAULT_CONNECT_TIMEOUT:-10}
API_TOOL_DEFAULT_READ_TIMEOUT: ${API_TOOL_DEFAULT_READ_TIMEOUT:-60}
ENABLE_WEBSITE_JINAREADER: ${ENABLE_WEBSITE_JINAREADER:-true}
ENABLE_WEBSITE_FIRECRAWL: ${ENABLE_WEBSITE_FIRECRAWL:-true}
ENABLE_WEBSITE_WATERCRAWL: ${ENABLE_WEBSITE_WATERCRAWL:-true}
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: ${NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX:-false}
DB_TYPE: ${DB_TYPE:-postgresql}
DB_USERNAME: ${DB_USERNAME:-postgres}
DB_PASSWORD: ${DB_PASSWORD:-difyai123456}
DB_HOST: ${DB_HOST:-db_postgres}
DB_PORT: ${DB_PORT:-5432}
DB_DATABASE: ${DB_DATABASE:-dify}
SQLALCHEMY_POOL_SIZE: ${SQLALCHEMY_POOL_SIZE:-30}
SQLALCHEMY_MAX_OVERFLOW: ${SQLALCHEMY_MAX_OVERFLOW:-10}
SQLALCHEMY_POOL_RECYCLE: ${SQLALCHEMY_POOL_RECYCLE:-3600}
SQLALCHEMY_ECHO: ${SQLALCHEMY_ECHO:-false}
SQLALCHEMY_POOL_PRE_PING: ${SQLALCHEMY_POOL_PRE_PING:-false}
SQLALCHEMY_POOL_USE_LIFO: ${SQLALCHEMY_POOL_USE_LIFO:-false}
SQLALCHEMY_POOL_TIMEOUT: ${SQLALCHEMY_POOL_TIMEOUT:-30}
SQLALCHEMY_POOL_RESET_ON_RETURN: ${SQLALCHEMY_POOL_RESET_ON_RETURN:-rollback}
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_SCHEME: ${VIKINGDB_SCHEME:-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}
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:-resend}
MAIL_DEFAULT_SEND_FROM: ${MAIL_DEFAULT_SEND_FROM:-}
RESEND_API_URL: ${RESEND_API_URL:-https://api.resend.com}
RESEND_API_KEY: ${RESEND_API_KEY:-your-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:-your_email@example.com}
CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-your_domain.com}
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}
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:
@ -745,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:-}
@ -793,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:-}
@ -839,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:
@ -892,8 +174,8 @@ services:
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:-}
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-}
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}
@ -950,7 +232,7 @@ services:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
MYSQL_DATABASE: ${DB_DATABASE:-dify}
command: >
--max_connections=${MYSQL_MAX_CONNECTIONS:-1000}
--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}
@ -1016,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}
@ -1124,8 +407,8 @@ services:
- ./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_EMAIL=${CERTBOT_EMAIL:-}
- CERTBOT_DOMAIN=${CERTBOT_DOMAIN:-}
- CERTBOT_OPTIONS=${CERTBOT_OPTIONS:-}
entrypoint: ["/docker-entrypoint.sh"]
command: ["tail", "-f", "/dev/null"]

View File

@ -1,127 +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_example(file_path):
"""
Parses the .env.example 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 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_example_path = ".env.example"
template_path = "docker-compose-template.yaml"
output_path = "docker-compose.yaml"
anchor_name = "shared-api-worker-env" # Can be modified as needed
# 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.example\n"
"# or docker-compose-template.yaml and regenerate this file.\n"
"# ==================================================================\n"
)
# Check if required files exist
for path in [env_example_path, template_path]:
if not os.path.isfile(path):
print(f"Error: File {path} does not exist.")
sys.exit(1)
# Parse .env.example file
env_vars = parse_env_example(env_example_path)
if not env_vars:
print("Warning: No environment variables found in .env.example.")
# Generate shared environment variables block
shared_env_block = generate_shared_env_block(env_vars, anchor_name)
# Insert shared environment variables block and header comments into the template
insert_shared_env(template_path, output_path, shared_env_block, header_comments)
if __name__ == "__main__":
main()

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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()
})
})
})
})

View File

@ -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>
)
}

View File

@ -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>

View File

@ -39,9 +39,7 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
const getSigninUrl = useCallback(() => {
const params = new URLSearchParams(searchParams)
params.delete('message')
const query = params.toString()
const fullPath = query ? `${pathname}?${query}` : pathname
params.set('redirect_url', fullPath)
params.set('redirect_url', pathname)
return `/webapp-signin?${params.toString()}`
}, [searchParams, pathname])

View File

@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
<ContentDialog
show={show}
onClose={onClose}
className="absolute top-2 bottom-2 left-2 flex w-[452px] max-w-[calc(100vw-1rem)] flex-col rounded-2xl p-0!"
className="absolute top-2 bottom-2 left-2 flex w-[420px] flex-col rounded-2xl p-0!"
>
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
<div className="flex items-center gap-3 self-stretch">

View File

@ -20,7 +20,6 @@ const mockOpenAsyncWindow = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockToastError = vi.fn()
const mockWindowOpen = vi.fn()
const mockInvalidateAppWorkflow = vi.fn()
const sectionProps = vi.hoisted(() => ({
@ -38,7 +37,6 @@ vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null,
}))
vi.mock('ahooks', async () => {
@ -93,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),
@ -138,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', () => ({
@ -169,13 +143,6 @@ vi.mock('../sections', () => ({
<div>
<button onClick={props.handleEmbed}>publisher-embed</button>
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
{props.handleOpenRunConfig && (
<>
<button onClick={() => props.handleOpenRunConfig(props.appURL)}>publisher-run-config</button>
<button onClick={() => props.handleOpenRunConfig(`${props.appURL}?mode=batch`)}>publisher-batch-run-config</button>
</>
)}
<button onClick={props.onConfigureWorkflowTool}>publisher-workflow-tool</button>
</div>
)
},
@ -208,10 +175,6 @@ describe('AppPublisher', () => {
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
await resolver()
})
Object.defineProperty(window, 'open', {
writable: true,
value: mockWindowOpen,
})
})
it('should open the publish popover and refetch access permission data', async () => {
@ -268,94 +231,6 @@ describe('AppPublisher', () => {
expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument()
})
it('should collect hidden inputs before opening published run links from config actions', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
inputs={[{
variable: 'secret',
label: 'Secret',
type: 'text-input',
required: true,
hide: true,
default: '',
} as any]}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-run-config'))
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Secret'), {
target: { value: 'top-secret' },
})
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
`https://example.com${basePath}/chat/token-1?secret=${encodeURIComponent('top-secret')}`,
'_blank',
)
})
})
it('should open batch run config links with the configured hidden inputs', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
render(
<AppPublisher
publishedAt={Date.now()}
inputs={[{
variable: 'batch_secret',
label: 'Batch Secret',
type: 'text-input',
required: true,
hide: true,
default: '',
} as any]}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-batch-run-config'))
fireEvent.change(screen.getByLabelText('Batch Secret'), {
target: { value: 'batch-value' },
})
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
`https://example.com${basePath}/workflow/token-1?mode=batch&batch_secret=${encodeURIComponent('batch-value')}`,
'_blank',
)
})
})
it('should keep workflow tool drawer mounted after closing the publish popover', () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-workflow-tool'))
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
expect(screen.getByTestId('workflow-tool-drawer')).toBeInTheDocument()
})
it('should close embedded and access control panels through child callbacks', async () => {
render(
<AppPublisher

View File

@ -18,32 +18,8 @@ vi.mock('../publish-with-multiple-model', () => ({
}))
vi.mock('../suggested-action', () => ({
default: ({
children,
onClick,
link,
disabled,
actionButton,
}: {
children: ReactNode
onClick?: () => void
link?: string
disabled?: boolean
actionButton?: { ariaLabel: string, onClick: () => void }
}) => (
<div>
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>{children}</button>
{actionButton && (
<button
type="button"
aria-label={actionButton.ariaLabel}
disabled={disabled}
onClick={actionButton.onClick}
>
{actionButton.ariaLabel}
</button>
)}
</div>
default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => (
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>{children}</button>
),
}))
@ -194,25 +170,9 @@ describe('app-publisher sections', () => {
expect(render(<AccessModeDisplay />).container).toBeEmptyDOMElement()
})
it('should hide access control content when enabled is false', () => {
render(
<PublisherAccessSection
enabled={false}
isAppAccessSet
isLoading={false}
accessMode={AccessMode.PUBLIC}
onClick={vi.fn()}
/>,
)
expect(screen.queryByText('publishApp.title')).not.toBeInTheDocument()
expect(screen.queryByText('accessControlDialog.accessItems.anyone')).not.toBeInTheDocument()
})
it('should render workflow actions, batch run links, and workflow tool configuration', () => {
const handleOpenInExplore = vi.fn()
const handleEmbed = vi.fn()
const handleOpenRunConfig = vi.fn()
const { rerender } = render(
<PublisherActionsSection
@ -230,30 +190,22 @@ describe('app-publisher sections', () => {
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode={false}
inputs={[]}
missingStartNode={false}
published={false}
onRefreshData={vi.fn()}
outputs={[]}
published={true}
publishedAt={Date.now()}
showBatchRunConfig
showRunConfig
toolPublished
workflowToolAvailable={false}
workflowToolIsLoading={false}
workflowToolOutdated={false}
workflowToolIsCurrentWorkspaceManager
workflowToolMessage="workflow-disabled"
onConfigureWorkflowTool={vi.fn()}
/>,
)
expect(screen.getByText('common.batchRunApp')).toHaveAttribute('data-link', 'https://example.com/app?mode=batch')
fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[0]!)
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app')
fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[1]!)
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app?mode=batch')
fireEvent.click(screen.getByText('common.openInExplore'))
expect(handleOpenInExplore).toHaveBeenCalled()
expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument()
@ -271,19 +223,17 @@ describe('app-publisher sections', () => {
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode={false}
inputs={[]}
missingStartNode
onRefreshData={vi.fn()}
outputs={[]}
published={false}
publishedAt={Date.now()}
toolPublished={false}
workflowToolAvailable
workflowToolIsLoading={false}
workflowToolOutdated={false}
workflowToolIsCurrentWorkspaceManager
onConfigureWorkflowTool={vi.fn()}
/>,
)
@ -298,19 +248,16 @@ describe('app-publisher sections', () => {
disabledFunctionButton={false}
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode
inputs={[]}
missingStartNode={false}
outputs={[]}
published={false}
publishedAt={undefined}
toolPublished={false}
workflowToolAvailable
workflowToolIsLoading={false}
workflowToolOutdated={false}
workflowToolIsCurrentWorkspaceManager
onConfigureWorkflowTool={vi.fn()}
/>,
)

View File

@ -46,47 +46,4 @@ describe('SuggestedAction', () => {
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should render and trigger the trailing action button when configured', () => {
const handleActionClick = vi.fn()
render(
<SuggestedAction
link="https://example.com/docs"
actionButton={{
ariaLabel: 'Configure action',
icon: <span>config</span>,
onClick: handleActionClick,
}}
>
Configurable action
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute('href', 'https://example.com/docs')
expect(handleActionClick).toHaveBeenCalledTimes(1)
})
it('should block action button clicks when disabled', () => {
const handleActionClick = vi.fn()
render(
<SuggestedAction
link="https://example.com/docs"
disabled
actionButton={{
ariaLabel: 'Configure action',
icon: <span>config</span>,
onClick: handleActionClick,
}}
>
Disabled with action
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
expect(handleActionClick).not.toHaveBeenCalled()
})
})

View File

@ -1,40 +1,28 @@
import type { FormEvent } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types'
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '@/app/components/app/overview/app-card-utils'
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { RiStoreLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useKeyPress } from 'ahooks'
import {
memo,
use,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections'
import {
buildWorkflowLaunchUrl,
createWorkflowLaunchInitialValues,
isWorkflowLaunchInputSupported,
} from '@/app/components/app/overview/app-card-utils'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { WorkflowContext } from '@/app/components/workflow/context'
import { appDefaultIconBackground } from '@/config'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
@ -69,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
@ -86,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,
@ -118,15 +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 [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false)
const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('')
const [workflowLaunchValues, setWorkflowLaunchValues] = useState<Record<string, WorkflowLaunchInputValue>>({})
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())
@ -135,22 +113,6 @@ const AppPublisher = ({
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
const hiddenLaunchVariables = useMemo<WorkflowHiddenStartVariable[]>(
() => (inputs ?? []).filter(input => input.hide === true),
[inputs],
)
const supportedWorkflowLaunchVariables = useMemo(
() => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported),
[hiddenLaunchVariables],
)
const unsupportedWorkflowLaunchVariables = useMemo(
() => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)),
[hiddenLaunchVariables],
)
const initialWorkflowLaunchValues = useMemo(
() => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables),
[supportedWorkflowLaunchVariables],
)
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
@ -260,31 +222,6 @@ const AppPublisher = ({
}
}, [appDetail, setAppDetail])
const handleOpenWorkflowLaunchDialog = useCallback((targetUrl: string) => {
setWorkflowLaunchValues(initialWorkflowLaunchValues)
setWorkflowLaunchTargetUrl(targetUrl)
setWorkflowLaunchDialogOpen(true)
}, [initialWorkflowLaunchValues])
const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => {
setWorkflowLaunchValues(prev => ({
...prev,
[variable]: value,
}))
}, [])
const handleWorkflowLaunchConfirm = useCallback(async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const targetUrl = await buildWorkflowLaunchUrl({
accessibleUrl: workflowLaunchTargetUrl,
variables: supportedWorkflowLaunchVariables,
values: workflowLaunchValues,
})
window.open(targetUrl, '_blank')
setWorkflowLaunchDialogOpen(false)
}, [supportedWorkflowLaunchVariables, workflowLaunchTargetUrl, workflowLaunchValues])
const handlePublishToMarketplace = useCallback(async () => {
if (!appDetail?.id || publishingToMarketplace)
return
@ -336,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',
@ -431,27 +343,23 @@ const AppPublisher = ({
handleOpenChange(false)
handleOpenInExplore()
}}
handleOpenRunConfig={handleOpenWorkflowLaunchDialog}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)}
showRunConfig={hiddenLaunchVariables.length > 0}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable}
workflowToolIsLoading={workflowTool.isLoading}
workflowToolOutdated={workflowTool.outdated}
workflowToolIsCurrentWorkspaceManager={workflowTool.isCurrentWorkspaceManager}
workflowToolMessage={workflowToolMessage}
onConfigureWorkflowTool={openWorkflowToolDrawer}
/>
{systemFeatures.enable_creators_platform && (
<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}
>
@ -469,29 +377,9 @@ const AppPublisher = ({
onClose={() => setEmbeddingModalOpen(false)}
appBaseUrl={appBaseURL}
accessToken={accessToken}
hiddenInputs={hiddenLaunchVariables}
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
<WorkflowLaunchDialog
t={t}
open={workflowLaunchDialogOpen}
hiddenVariables={supportedWorkflowLaunchVariables}
unsupportedVariables={unsupportedWorkflowLaunchVariables}
values={workflowLaunchValues}
onOpenChange={setWorkflowLaunchDialogOpen}
onValueChange={handleWorkflowLaunchValueChange}
onSubmit={handleWorkflowLaunchConfirm}
/>
</Popover>
{workflowToolDrawerOpen && (
<WorkflowToolDrawer
isAdd={!workflowToolPublished}
payload={workflowTool.payload}
onHide={closeWorkflowToolDrawer}
onCreate={workflowTool.handleCreate}
onSave={workflowTool.handleUpdate}
/>
)}
</>
)
}

View File

@ -8,12 +8,13 @@ import {
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import { RiSettings2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
import Loading from '@/app/components/base/loading'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { appDefaultIconBackground } from '@/config'
import { AppModeEnum } from '@/types/app'
import ShortcutsName from '../../workflow/shortcuts-name'
import PublishWithMultipleModel from './publish-with-multiple-model'
@ -45,8 +46,11 @@ type AccessSectionProps = {
type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
| 'hasTriggerNode'
| 'inputs'
| 'missingStartNode'
| 'onRefreshData'
| 'toolPublished'
| 'outputs'
| 'publishedAt'
| 'workflowToolAvailable'> & {
appDetail: {
@ -63,16 +67,9 @@ type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
disabledFunctionTooltip?: string
handleEmbed: () => void
handleOpenInExplore: () => void
handleOpenRunConfig?: (url: string) => void
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
published: boolean
showBatchRunConfig?: boolean
showRunConfig?: boolean
workflowToolIsLoading: boolean
workflowToolOutdated: boolean
workflowToolIsCurrentWorkspaceManager: boolean
workflowToolMessage?: string
onConfigureWorkflowTool: () => void
}
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
@ -259,20 +256,18 @@ export const PublisherActionsSection = ({
disabledFunctionTooltip,
handleEmbed,
handleOpenInExplore,
handleOpenRunConfig,
handlePublish,
hasHumanInputNode = false,
hasTriggerNode = false,
inputs,
missingStartNode = false,
onRefreshData,
outputs,
published,
publishedAt,
showBatchRunConfig = false,
showRunConfig = false,
toolPublished,
workflowToolAvailable = true,
workflowToolIsLoading,
workflowToolOutdated,
workflowToolIsCurrentWorkspaceManager,
workflowToolMessage,
onConfigureWorkflowTool,
}: ActionsSectionProps) => {
const { t } = useTranslation()
@ -289,13 +284,6 @@ export const PublisherActionsSection = ({
disabled={disabledFunctionButton}
link={appURL}
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
actionButton={showRunConfig
? {
ariaLabel: t('operation.config', { ns: 'common' }),
icon: <RiSettings2Line className="h-4 w-4" />,
onClick: () => handleOpenRunConfig?.(appURL),
}
: undefined}
>
{t('common.runApp', { ns: 'workflow' })}
</SuggestedAction>
@ -308,13 +296,6 @@ export const PublisherActionsSection = ({
disabled={disabledFunctionButton}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
actionButton={showBatchRunConfig
? {
ariaLabel: t('operation.config', { ns: 'common' }),
icon: <RiSettings2Line className="h-4 w-4" />,
onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`),
}
: undefined}
>
{t('common.batchRunApp', { ns: 'workflow' })}
</SuggestedAction>
@ -324,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>
@ -359,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}
/>
)}

View File

@ -1,93 +1,33 @@
import type { HTMLProps, PropsWithChildren, MouseEvent as ReactMouseEvent } from 'react'
import type { HTMLProps, PropsWithChildren } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine } from '@remixicon/react'
type SuggestedActionButton = {
ariaLabel: string
icon: React.ReactNode
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void
}
type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement> & {
icon?: React.ReactNode
link?: string
disabled?: boolean
actionButton?: SuggestedActionButton
}>
const SuggestedAction = ({
icon,
link,
disabled,
children,
className,
onClick,
actionButton,
...props
}: SuggestedActionProps) => {
const handleClick = (event: ReactMouseEvent<HTMLAnchorElement>) => {
if (disabled) {
event.preventDefault()
const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (disabled)
return
}
onClick?.(event)
onClick?.(e)
}
const handleActionClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
if (disabled) {
event.preventDefault()
return
}
actionButton?.onClick(event)
}
const mainAction = (
return (
<a
href={disabled ? undefined : link}
target="_blank"
rel="noreferrer"
className={cn(
'flex min-w-0 items-center justify-start gap-2 px-2.5 py-2 text-text-secondary transition-colors',
actionButton ? 'flex-1 rounded-l-lg' : 'rounded-lg bg-background-section-burn not-first:mt-1',
disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
)}
className={cn('flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors not-first:mt-1', disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer text-text-secondary hover:bg-state-accent-hover hover:text-text-accent', className)}
onClick={handleClick}
{...props}
>
<div className="relative h-4 w-4 shrink-0">{icon}</div>
<div className="relative h-4 w-4">{icon}</div>
<div className="shrink grow basis-0 system-sm-medium">{children}</div>
<RiArrowRightUpLine className="h-3.5 w-3.5 shrink-0" />
<RiArrowRightUpLine className="h-3.5 w-3.5" />
</a>
)
if (!actionButton)
return mainAction
return (
<div
className={cn(
'flex items-stretch rounded-lg bg-background-section-burn not-first:mt-1',
disabled ? 'opacity-30 shadow-xs' : '',
className,
)}
>
{mainAction}
<button
type="button"
aria-label={actionButton.ariaLabel}
disabled={disabled}
className={cn(
'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary transition-colors',
disabled ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
)}
onClick={handleActionClick}
>
{actionButton.icon}
</button>
</div>
)
}
export default SuggestedAction

View File

@ -4,29 +4,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import ConfigModalFormFields from '../form-fields'
vi.mock('react-i18next', async () => {
const React = await import('react')
return {
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const ns = options?.ns as string | undefined
return ns ? `${ns}.${key}` : key
},
i18n: { language: 'en', changeLanguage: vi.fn() },
}),
Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, ReactNode> }) => (
<span data-i18n-key={i18nKey}>
{i18nKey}
{components?.docLink}
</span>
),
}
})
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.example.com${path || ''}`,
}))
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({
onChange,
@ -97,12 +74,6 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
}
})
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../field', () => ({
default: ({ children, title }: { children: ReactNode, title: string }) => (
<div>
@ -205,18 +176,7 @@ describe('ConfigModalFormFields', () => {
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta')
})
it('should wire file, json schema, and visibility controls', async () => {
const textInputProps = createBaseProps()
const textInputView = render(<ConfigModalFormFields {...textInputProps} />)
expect(screen.getByText('variableConfig.hidden')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'variableConfig.hiddenDescription' }))
expect(await screen.findByText('variableConfig.hiddenDescription')).toBeInTheDocument()
const docLink = await screen.findByRole('link')
expect(docLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/nodes/user-input#hide-and-pre-fill-input-fields')
expect(docLink).toHaveAttribute('target', '_blank')
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
textInputView.unmount()
it('should wire file, json schema, and visibility controls', () => {
const singleFileProps = createBaseProps()
singleFileProps.tempPayload = {
...singleFileProps.tempPayload,
@ -225,20 +185,18 @@ describe('ConfigModalFormFields', () => {
allowed_file_extensions: [],
allowed_file_upload_methods: ['remote_url'],
}
const singleFileView = render(<ConfigModalFormFields {...singleFileProps} />)
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument()
render(<ConfigModalFormFields {...singleFileProps} />)
fireEvent.click(screen.getByText('single-file-setting'))
fireEvent.click(screen.getByText('upload-file'))
fireEvent.click(screen.getAllByText('unchecked')[0]!)
fireEvent.click(screen.getAllByText('unchecked')[1]!)
expect(singleFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 1 })
expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith(expect.objectContaining({
fileId: 'file-1',
}))
expect(singleFileProps.payloadChangeHandlers.required).toHaveBeenCalledWith(true)
expect(singleFileProps.payloadChangeHandlers.hide).not.toHaveBeenCalled()
singleFileView.unmount()
expect(singleFileProps.payloadChangeHandlers.hide).toHaveBeenCalledWith(true)
const multiFileProps = createBaseProps()
multiFileProps.tempPayload = {
@ -249,9 +207,8 @@ describe('ConfigModalFormFields', () => {
allowed_file_upload_methods: ['remote_url'],
}
render(<ConfigModalFormFields {...multiFileProps} />)
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('multi-file-setting'))
fireEvent.click(screen.getAllByText('upload-file')[0]!)
fireEvent.click(screen.getAllByText('upload-file')[1]!)
expect(multiFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 3 })
expect(multiFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith([
expect.objectContaining({ fileId: 'file-1' }),
@ -410,23 +367,4 @@ describe('ConfigModalFormFields', () => {
expect(screen.getByRole('spinbutton')).toHaveValue(null)
})
it('should disable hide checkbox when required is true and disable required when hide is true', () => {
const requiredProps = createBaseProps()
requiredProps.tempPayload = { ...requiredProps.tempPayload, type: InputVarType.textInput, required: true, hide: false }
const { unmount } = render(<ConfigModalFormFields {...requiredProps} />)
const buttons = screen.getAllByRole('button')
const hideButton = buttons.find(btn => btn.textContent === 'unchecked' && btn !== buttons[0])
expect(hideButton).toBeDefined()
unmount()
const hideProps = createBaseProps()
hideProps.tempPayload = { ...hideProps.tempPayload, type: InputVarType.textInput, required: false, hide: true }
render(<ConfigModalFormFields {...hideProps} />)
const allButtons = screen.getAllByRole('button')
const checkedHideButton = allButtons.find(btn => btn.textContent === 'checked')
expect(checkedHideButton).toBeDefined()
})
})

View File

@ -25,7 +25,6 @@ vi.mock('../form-fields', () => ({
return (
<div data-testid="config-form-fields">
<div data-testid="payload-type">{String(props.tempPayload.type)}</div>
<div data-testid="payload-hide">{String(props.tempPayload.hide)}</div>
<div data-testid="payload-label">{String(props.tempPayload.label ?? '')}</div>
<div data-testid="payload-schema">{String(props.tempPayload.json_schema ?? '')}</div>
<div data-testid="payload-default">{String(props.tempPayload.default ?? '')}</div>
@ -116,7 +115,7 @@ describe('ConfigModal logic', () => {
})
it('should derive payload fields from mocked form-field callbacks', async () => {
renderConfigModal(createPayload({ hide: true }))
renderConfigModal()
fireEvent.click(screen.getByTestId('valid-key-blur'))
await waitFor(() => {
@ -139,7 +138,6 @@ describe('ConfigModal logic', () => {
fireEvent.click(screen.getByTestId('type-change'))
await waitFor(() => {
expect(screen.getByTestId('payload-type')).toHaveTextContent(InputVarType.singleFile)
expect(screen.getByTestId('payload-hide')).toHaveTextContent('false')
})
fireEvent.click(screen.getByTestId('file-payload-change'))

View File

@ -49,13 +49,11 @@ describe('config-modal utils', () => {
const payload = createInputVar({
type: InputVarType.textInput,
default: 'hello',
hide: true,
})
const nextPayload = createPayloadForType(payload, InputVarType.multiFiles)
expect(nextPayload.type).toBe(InputVarType.multiFiles)
expect(nextPayload.hide).toBe(false)
expect(nextPayload.max_length).toBe(DEFAULT_FILE_UPLOAD_SETTING.max_length)
expect(nextPayload.allowed_file_types).toEqual(DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types)
expect(nextPayload.default).toBe('hello')
@ -251,24 +249,6 @@ describe('config-modal utils', () => {
})
})
it('should force file inputs to stay visible when saving', () => {
const result = validateConfigModalPayload({
tempPayload: createInputVar({
type: InputVarType.singleFile,
hide: true,
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_extensions: [],
}),
payload: createInputVar(),
checkVariableName: () => true,
t,
})
expect(result.payloadToSave).toEqual(expect.objectContaining({
hide: false,
}))
})
it('should stop validation when the variable name checker rejects the payload', () => {
const result = validateConfigModalPayload({
tempPayload: createInputVar({

View File

@ -13,17 +13,14 @@ import {
SelectValue,
} from '@langgenius/dify-ui/select'
import * as React from 'react'
import { Trans } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { useDocLink } from '@/context/i18n'
import { TransferMethod } from '@/types/app'
import ConfigSelect from '../config-select'
import ConfigString from '../config-string'
@ -71,9 +68,6 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
t,
}) => {
const { type, label, variable } = tempPayload
const isFileInput = [InputVarType.singleFile, InputVarType.multiFiles].includes(type)
const docLink = useDocLink()
const hiddenDescriptionAriaLabel = t('variableConfig.hiddenDescription', { ns: 'appDebug' }).replace(/<[^>]+>/g, '')
return (
<div className="space-y-2">
@ -111,7 +105,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
{type === InputVarType.textInput && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
value={typeof tempPayload.default === 'string' ? tempPayload.default : ''}
value={tempPayload.default || ''}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
@ -132,7 +126,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
type="number"
value={typeof tempPayload.default === 'number' || typeof tempPayload.default === 'string' ? tempPayload.default : ''}
value={tempPayload.default || ''}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
@ -192,7 +186,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
</>
)}
{isFileInput && (
{[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && (
<>
<FileUploadSetting
payload={tempPayload as UploadFileSetting}
@ -233,37 +227,14 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
)}
<div className="mt-5! flex h-6 items-center space-x-2">
<Checkbox checked={tempPayload.required} disabled={!isFileInput && tempPayload.hide} onCheck={() => onPayloadChange('required')(!tempPayload.required)} />
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => onPayloadChange('required')(!tempPayload.required)} />
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
</div>
{!isFileInput && (
<div className="mt-5! flex h-6 items-center space-x-2">
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => onPayloadChange('hide')(!tempPayload.hide)} />
<div className="flex items-center gap-1">
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.hidden', { ns: 'appDebug' })}</span>
<Infotip
aria-label={hiddenDescriptionAriaLabel}
popupClassName="max-w-[300px]"
>
<Trans
i18nKey="variableConfig.hiddenDescription"
ns="appDebug"
components={{
docLink: (
<a
href={docLink('/use-dify/nodes/user-input#hide-and-pre-fill-input-fields')}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent hover:underline"
/>
),
}}
/>
</Infotip>
</div>
</div>
)}
<div className="mt-5! flex h-6 items-center space-x-2">
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => onPayloadChange('hide')(!tempPayload.hide)} />
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.hide', { ns: 'appDebug' })}</span>
</div>
</div>
)
}

View File

@ -88,9 +88,7 @@ export const createPayloadForType = (payload: InputVar, type: InputVarType) => {
draft.default = undefined
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
draft.hide = false
const fileUploadSettingKeys = Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array<keyof typeof DEFAULT_FILE_UPLOAD_SETTING>
fileUploadSettingKeys.forEach((key) => {
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array<keyof typeof DEFAULT_FILE_UPLOAD_SETTING>).forEach((key) => {
if (key !== 'max_length')
draft[key] = DEFAULT_FILE_UPLOAD_SETTING[key] as never
})
@ -160,41 +158,38 @@ export const validateConfigModalPayload = ({
checkVariableName,
t,
}: ValidateConfigModalPayloadOptions): ValidateConfigModalPayloadResult => {
const normalizedTempPayload = [InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)
? { ...tempPayload, hide: false }
: tempPayload
const jsonSchemaValue = tempPayload.json_schema
const schemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
const normalizedJsonSchema = schemaEmpty ? undefined : jsonSchemaValue
const payloadToSave = normalizedTempPayload.type === InputVarType.jsonObject && schemaEmpty
? { ...normalizedTempPayload, json_schema: undefined }
: normalizedTempPayload
const payloadToSave = tempPayload.type === InputVarType.jsonObject && schemaEmpty
? { ...tempPayload, json_schema: undefined }
: tempPayload
const moreInfo = normalizedTempPayload.variable === payload?.variable
const moreInfo = tempPayload.variable === payload?.variable
? undefined
: {
type: ChangeType.changeVarName,
payload: { beforeKey: payload?.variable || '', afterKey: normalizedTempPayload.variable },
payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable },
}
if (!checkVariableName(normalizedTempPayload.variable))
if (!checkVariableName(tempPayload.variable))
return {}
if (!normalizedTempPayload.label) {
if (!tempPayload.label) {
return {
errorMessage: t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }),
}
}
if (normalizedTempPayload.type === InputVarType.select) {
if (!normalizedTempPayload.options?.length) {
if (tempPayload.type === InputVarType.select) {
if (!tempPayload.options?.length) {
return {
errorMessage: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }),
}
}
const duplicated = new Set<string>()
const hasRepeatedItem = normalizedTempPayload.options.some((option) => {
const hasRepeatedItem = tempPayload.options.some((option) => {
if (duplicated.has(option))
return true
@ -209,8 +204,8 @@ export const validateConfigModalPayload = ({
}
}
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(normalizedTempPayload.type)) {
if (!normalizedTempPayload.allowed_file_types?.length) {
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)) {
if (!tempPayload.allowed_file_types?.length) {
return {
errorMessage: t('errorMsg.fieldRequired', {
ns: 'workflow',
@ -219,7 +214,7 @@ export const validateConfigModalPayload = ({
}
}
if (normalizedTempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !normalizedTempPayload.allowed_file_extensions?.length) {
if (tempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
return {
errorMessage: t('errorMsg.fieldRequired', {
ns: 'workflow',
@ -229,7 +224,7 @@ export const validateConfigModalPayload = ({
}
}
if (normalizedTempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') {
if (tempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') {
try {
const schema = JSON.parse(normalizedJsonSchema)
if (schema?.type !== 'object') {

View File

@ -1,38 +1,8 @@
import type { FormEvent } from 'react'
import type { AppDetailResponse } from '@/models/app'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { AppCardAccessControlSection, AppCardDialogs, AppCardOperations, AppCardUrlSection, createAppCardOperations, WorkflowLaunchDialog } from '../app-card-sections'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
}))
vi.mock('../settings', () => ({
default: () => <div data-testid="settings-modal" />,
}))
vi.mock('../embedded', () => ({
default: () => <div data-testid="embedded-modal" />,
}))
vi.mock('../customize', () => ({
default: () => <div data-testid="customize-modal" />,
}))
vi.mock('../../app-access-control', () => ({
default: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => (
<div data-testid="access-control">
<button type="button" onClick={onClose}>close-access</button>
<button type="button" onClick={onConfirm}>confirm-access</button>
</div>
),
}))
import { AppCardAccessControlSection, AppCardOperations, AppCardUrlSection, createAppCardOperations } from '../app-card-sections'
describe('app-card-sections', () => {
const t = (key: string) => key
@ -82,7 +52,6 @@ describe('app-card-sections', () => {
it('should render operation buttons and execute enabled actions', () => {
const onLaunch = vi.fn()
const onLaunchConfig = vi.fn()
const operations = createAppCardOperations({
operationKeys: ['launch', 'embedded'],
t: t as never,
@ -99,19 +68,12 @@ describe('app-card-sections', () => {
<AppCardOperations
t={t as never}
operations={operations}
launchConfigAction={{
label: 'operation.config',
disabled: false,
onClick: onLaunchConfig,
}}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }))
fireEvent.click(screen.getByRole('button', { name: /operation\.config/i }))
expect(onLaunch).toHaveBeenCalledTimes(1)
expect(onLaunchConfig).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument()
})
@ -165,127 +127,4 @@ describe('app-card-sections', () => {
fireEvent.click(within(dialog).getByRole('button', { name: /operation\.confirm/i }))
expect(onRegenerate).toHaveBeenCalledTimes(1)
})
it('should disable all operations when triggerModeDisabled is true', () => {
const operations = createAppCardOperations({
operationKeys: ['launch', 'settings'],
t: t as never,
runningStatus: true,
triggerModeDisabled: true,
onLaunch: vi.fn(),
onEmbedded: vi.fn(),
onCustomize: vi.fn(),
onSettings: vi.fn(),
onDevelop: vi.fn(),
})
expect(operations[0]!.disabled).toBe(true)
expect(operations[1]!.disabled).toBe(true)
})
it('should render WorkflowLaunchDialog and submit values', () => {
const onOpenChange = vi.fn()
const onValueChange = vi.fn()
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
})
render(
<WorkflowLaunchDialog
t={t as never}
open
hiddenVariables={[{
variable: 'secret',
label: 'Secret',
type: InputVarType.textInput,
hide: true,
required: true,
}]}
unsupportedVariables={[]}
values={{ secret: 'hello' }}
onOpenChange={onOpenChange}
onValueChange={onValueChange}
onSubmit={onSubmit}
/>,
)
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
fireEvent.submit(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }).closest('form')!)
expect(onSubmit).toHaveBeenCalled()
})
it('should return null for WorkflowLaunchDialog when no variables are provided', () => {
const { container } = render(
<WorkflowLaunchDialog
t={t as never}
open
hiddenVariables={[]}
unsupportedVariables={[]}
values={{}}
onOpenChange={vi.fn()}
onValueChange={vi.fn()}
onSubmit={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should render AppCardDialogs with all modals for web apps', () => {
const appInfo = {
id: 'app-1',
mode: AppModeEnum.CHAT,
enable_site: true,
enable_api: false,
site: { app_base_url: 'https://example.com', access_token: 'token-1' },
api_base_url: 'https://api.example.com',
} as never
render(
<AppCardDialogs
isApp
appInfo={appInfo}
appMode={AppModeEnum.CHAT}
showSettingsModal
showEmbedded
showCustomizeModal
showAccessControl
appDetail={{ id: 'app-1', access_mode: AccessMode.PUBLIC } as AppDetailResponse}
onCloseSettings={vi.fn()}
onCloseEmbedded={vi.fn()}
onCloseCustomize={vi.fn()}
onCloseAccessControl={vi.fn()}
onSaveSiteConfig={vi.fn()}
onConfirmAccessControl={vi.fn()}
/>,
)
expect(screen.getByTestId('settings-modal')).toBeInTheDocument()
expect(screen.getByTestId('embedded-modal')).toBeInTheDocument()
expect(screen.getByTestId('customize-modal')).toBeInTheDocument()
expect(screen.getByTestId('access-control')).toBeInTheDocument()
})
it('should return null for AppCardDialogs when not an app', () => {
const { container } = render(
<AppCardDialogs
isApp={false}
appInfo={{} as never}
appMode={AppModeEnum.CHAT}
showSettingsModal={false}
showEmbedded={false}
showCustomizeModal={false}
showAccessControl={false}
appDetail={null}
onCloseSettings={vi.fn()}
onCloseEmbedded={vi.fn()}
onCloseCustomize={vi.fn()}
onCloseAccessControl={vi.fn()}
onSaveSiteConfig={vi.fn()}
onConfirmAccessControl={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -1,22 +1,9 @@
import type { AppDetailResponse } from '@/models/app'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import {
buildWorkflowLaunchUrl,
compressAndEncodeBase64,
createWorkflowLaunchInitialValues,
getAppCardDisplayState,
getAppCardOperationKeys,
getAppHiddenLaunchVariables,
getEmbeddedIframeSnippet,
getEmbeddedScriptSnippet,
getWorkflowHiddenStartVariables,
hasWorkflowStartNode,
isAppAccessConfigured,
isWorkflowLaunchInputSupported,
} from '../app-card-utils'
import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils'
describe('app-card-utils', () => {
const baseAppInfo = {
@ -46,108 +33,6 @@ describe('app-card-utils', () => {
})).toBe(false)
})
it('should return hidden workflow start variables and their initial launch values', () => {
const hiddenVariables = getWorkflowHiddenStartVariables({
graph: {
nodes: [{
data: {
type: BlockEnum.Start,
variables: [
{
variable: 'visible',
label: 'Visible',
type: InputVarType.textInput,
hide: false,
required: false,
},
{
variable: 'secret',
label: 'Secret',
type: InputVarType.textInput,
hide: true,
default: 'prefilled',
required: false,
},
{
variable: 'enabled',
label: 'Enabled',
type: InputVarType.checkbox,
hide: true,
default: true,
required: false,
},
],
},
}],
},
})
expect(hiddenVariables.map(variable => variable.variable)).toEqual(['secret', 'enabled'])
expect(createWorkflowLaunchInitialValues(hiddenVariables)).toEqual({
secret: 'prefilled',
enabled: true,
})
})
it('should return hidden advanced-chat launch variables from the workflow start node first', () => {
const hiddenVariables = getAppHiddenLaunchVariables({
appInfo: {
...baseAppInfo,
mode: AppModeEnum.ADVANCED_CHAT,
model_config: {
user_input_form: [
{
'text-input': {
label: 'Visible',
variable: 'visible',
required: true,
max_length: 48,
default: '',
hide: false,
},
},
{
checkbox: {
label: 'Hidden Toggle',
variable: 'hidden_toggle',
required: false,
default: true,
hide: true,
},
},
],
},
} as AppDetailResponse,
currentWorkflow: {
graph: {
nodes: [{
data: {
type: BlockEnum.Start,
variables: [
{
variable: 'start_secret',
label: 'Start Secret',
type: InputVarType.textInput,
hide: true,
default: 'from-start',
required: false,
},
],
},
}],
},
},
})
expect(hiddenVariables).toEqual([
expect.objectContaining({
variable: 'start_secret',
type: InputVarType.textInput,
default: 'from-start',
}),
])
})
it('should build the display state for a published web app', () => {
const state = getAppCardDisplayState({
appInfo: baseAppInfo,
@ -219,108 +104,4 @@ describe('app-card-utils', () => {
isCurrentWorkspaceEditor: false,
})).toEqual(['launch', 'embedded', 'customize'])
})
it('should build a workflow launch URL with serialized parameters', async () => {
const url = await buildWorkflowLaunchUrl({
accessibleUrl: 'https://example.com/app/workflow/token-1',
variables: [
{ variable: 'name', label: 'Name', type: InputVarType.textInput, hide: true, required: false },
{ variable: 'enabled', label: 'Enabled', type: InputVarType.checkbox, hide: true, required: false },
],
values: { name: 'Alice', enabled: true },
})
const parsed = new URL(url)
expect(parsed.searchParams.get('name')).toBe('Alice')
expect(parsed.searchParams.get('enabled')).toBe('true')
})
it('should serialize checkbox false and empty string values in launch URL', async () => {
const url = await buildWorkflowLaunchUrl({
accessibleUrl: 'https://example.com/app/workflow/token-1',
variables: [
{ variable: 'flag', label: 'Flag', type: InputVarType.checkbox, hide: true, required: false },
{ variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false },
],
values: { flag: false, empty: '' },
})
const parsed = new URL(url)
expect(parsed.searchParams.get('flag')).toBe('false')
expect(parsed.searchParams.get('empty')).toBe('')
})
it('should generate an iframe snippet with the provided URL', () => {
const snippet = getEmbeddedIframeSnippet('https://example.com/chatbot/token-1')
expect(snippet).toContain('src="https://example.com/chatbot/token-1"')
expect(snippet).toContain('frameborder="0"')
expect(snippet).toContain('allow="microphone"')
})
it('should generate an embedded script snippet with inputs', () => {
const snippet = getEmbeddedScriptSnippet({
url: 'https://example.com',
token: 'abc123',
primaryColor: '#FF0000',
isTestEnv: true,
inputValues: { name: 'Alice', count: '5' },
})
expect(snippet).toContain('token: \'abc123\'')
expect(snippet).toContain('isDev: true')
expect(snippet).toContain('name: "Alice"')
expect(snippet).toContain('count: "5"')
expect(snippet).toContain('background-color: #FF0000')
})
it('should generate an embedded script snippet with empty inputs comment', () => {
const snippet = getEmbeddedScriptSnippet({
url: 'https://example.com',
token: 'abc123',
primaryColor: '#1C64F2',
inputValues: {},
})
expect(snippet).toContain('// You can define the inputs from the Start node here')
expect(snippet).not.toContain('isDev: true')
})
it('should compress and encode base64 using CompressionStream when available', async () => {
const result = await compressAndEncodeBase64('hello')
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
it('should fallback to plain base64 when CompressionStream is unavailable', async () => {
const original = globalThis.CompressionStream
// @ts-expect-error remove for test
delete globalThis.CompressionStream
const result = await compressAndEncodeBase64('hello')
expect(result).toBe(btoa('hello'))
globalThis.CompressionStream = original
})
it('should identify supported workflow launch input types', () => {
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.textInput, hide: true, required: false })).toBe(true)
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.paragraph, hide: true, required: false })).toBe(true)
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.select, hide: true, required: false })).toBe(true)
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.number, hide: true, required: false })).toBe(true)
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.checkbox, hide: true, required: false })).toBe(true)
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.json, hide: true, required: false })).toBe(true)
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.jsonObject, hide: true, required: false })).toBe(true)
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.url, hide: true, required: false })).toBe(true)
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.files, hide: true, required: false })).toBe(false)
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.singleFile, hide: true, required: false })).toBe(false)
})
it('should coerce numeric defaults to string in createWorkflowLaunchInitialValues', () => {
const result = createWorkflowLaunchInitialValues([
{ variable: 'count', label: 'Count', type: InputVarType.number, hide: true, required: false, default: 42 },
{ variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false },
])
expect(result).toEqual({ count: '42', empty: '' })
})
})

View File

@ -2,7 +2,6 @@ import type { ReactElement, ReactNode } from 'react'
import type { AppDetailResponse } from '@/models/app'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { InputVarType } from '@/app/components/workflow/types'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
@ -18,7 +17,7 @@ const mockSetAppDetail = vi.fn()
const mockOnChangeStatus = vi.fn()
const mockOnGenerateCode = vi.fn()
let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array<Record<string, unknown>> } }> } } | null = null
let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string } }> } } | null = null
let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] }
let mockAppDetail: AppDetailResponse | undefined
@ -26,7 +25,6 @@ vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null,
}))
vi.mock('@/context/app-context', () => ({
@ -166,182 +164,6 @@ describe('AppCard', () => {
expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com${basePath}/chat/access-token`, '_blank')
})
it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => {
mockWorkflow = {
graph: {
nodes: [{
data: {
type: 'start',
variables: [
{
variable: 'secret',
label: 'Secret',
type: InputVarType.textInput,
hide: true,
required: true,
default: '',
},
],
},
}],
},
}
render(
<AppCard
appInfo={{
...appInfo,
mode: AppModeEnum.WORKFLOW,
}}
onChangeStatus={mockOnChangeStatus}
/>,
)
fireEvent.click(screen.getByText('overview.appInfo.launch'))
expect(mockWindowOpen).toHaveBeenCalledWith(
`https://example.com${basePath}/workflow/access-token`,
'_blank',
)
expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument()
})
it('should collect hidden workflow inputs from the config action before launching the workflow web app', async () => {
mockWorkflow = {
graph: {
nodes: [{
data: {
type: 'start',
variables: [
{
variable: 'secret',
label: 'Secret',
type: InputVarType.textInput,
hide: true,
required: true,
default: '',
},
],
},
}],
},
}
render(
<AppCard
appInfo={{
...appInfo,
mode: AppModeEnum.WORKFLOW,
}}
onChangeStatus={mockOnChangeStatus}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.config' }))
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Secret'), {
target: { value: 'top-secret' },
})
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
`https://example.com${basePath}/workflow/access-token?secret=${encodeURIComponent('top-secret')}`,
'_blank',
)
})
})
it('should open the chat web app directly when launch is clicked even with hidden inputs', () => {
mockWorkflow = {
graph: {
nodes: [{
data: {
type: 'start',
variables: [
{
variable: 'chat_secret',
label: 'Chat Secret',
type: InputVarType.textInput,
hide: true,
required: true,
default: '',
},
],
},
}],
},
}
render(
<AppCard
appInfo={{
...appInfo,
mode: AppModeEnum.ADVANCED_CHAT,
} as AppDetailResponse}
onChangeStatus={mockOnChangeStatus}
/>,
)
fireEvent.click(screen.getByText('overview.appInfo.launch'))
expect(mockWindowOpen).toHaveBeenCalledWith(
`https://example.com${basePath}/chat/access-token`,
'_blank',
)
expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument()
})
it('should collect hidden chatflow inputs from the config action before launching the chat web app', async () => {
mockWorkflow = {
graph: {
nodes: [{
data: {
type: 'start',
variables: [
{
variable: 'chat_secret',
label: 'Chat Secret',
type: InputVarType.textInput,
hide: true,
required: true,
default: '',
},
],
},
}],
},
}
render(
<AppCard
appInfo={{
...appInfo,
mode: AppModeEnum.ADVANCED_CHAT,
} as AppDetailResponse}
onChangeStatus={mockOnChangeStatus}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.config' }))
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Chat Secret'), {
target: { value: 'chat-secret' },
})
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
`https://example.com${basePath}/chat/access-token?chat_secret=${encodeURIComponent('chat-secret')}`,
'_blank',
)
})
})
it('should show the access-control not-set badge when specific access has no subjects', () => {
render(
<AppCard
@ -480,7 +302,7 @@ describe('AppCard', () => {
})
it('should report refresh failures from access control updates', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchAppDetailDirect.mockRejectedValueOnce(new Error('refresh failed'))
render(

View File

@ -1,214 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import WorkflowHiddenInputFields from '../workflow-hidden-input-fields'
describe('WorkflowHiddenInputFields', () => {
const onValueChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render a text input with label and placeholder', () => {
render(
<WorkflowHiddenInputFields
hiddenVariables={[{
variable: 'name',
label: 'Full Name',
type: InputVarType.textInput,
hide: true,
required: true,
}]}
values={{ name: 'Alice' }}
onValueChange={onValueChange}
/>,
)
const input = screen.getByLabelText('Full Name')
expect(input).toHaveValue('Alice')
fireEvent.change(input, { target: { value: 'Bob' } })
expect(onValueChange).toHaveBeenCalledWith('name', 'Bob')
})
it('should render a number input for number-typed variables', () => {
render(
<WorkflowHiddenInputFields
hiddenVariables={[{
variable: 'count',
label: 'Count',
type: InputVarType.number,
hide: true,
required: false,
}]}
values={{ count: '5' }}
onValueChange={onValueChange}
/>,
)
const input = screen.getByLabelText('Count')
expect(input).toHaveAttribute('type', 'number')
fireEvent.change(input, { target: { value: '10' } })
expect(onValueChange).toHaveBeenCalledWith('count', '10')
})
it('should render a checkbox input without a separate label element above', () => {
render(
<WorkflowHiddenInputFields
hiddenVariables={[{
variable: 'enabled',
label: 'Enable Feature',
type: InputVarType.checkbox,
hide: true,
required: false,
}]}
values={{ enabled: true }}
onValueChange={onValueChange}
/>,
)
const checkbox = screen.getByRole('checkbox')
expect(checkbox).toBeChecked()
expect(screen.getByText('Enable Feature')).toBeInTheDocument()
fireEvent.click(checkbox)
expect(onValueChange).toHaveBeenCalledWith('enabled', false)
})
it('should render a select dropdown for select-typed variables', () => {
render(
<WorkflowHiddenInputFields
hiddenVariables={[{
variable: 'color',
label: 'Color',
type: InputVarType.select,
hide: true,
required: false,
options: ['red', 'green', 'blue'],
}]}
values={{ color: 'red' }}
onValueChange={onValueChange}
/>,
)
expect(screen.getByRole('combobox', { name: 'Color' })).toBeInTheDocument()
})
it('should render a textarea for paragraph-typed variables', () => {
render(
<WorkflowHiddenInputFields
hiddenVariables={[{
variable: 'description',
label: 'Description',
type: InputVarType.paragraph,
hide: true,
required: false,
max_length: 500,
}]}
values={{ description: 'Hello world' }}
onValueChange={onValueChange}
/>,
)
const textarea = screen.getByPlaceholderText('Description')
expect(textarea).toHaveValue('Hello world')
fireEvent.change(textarea, { target: { value: 'Updated' } })
expect(onValueChange).toHaveBeenCalledWith('description', 'Updated')
})
it('should render a textarea for json-typed variables', () => {
render(
<WorkflowHiddenInputFields
hiddenVariables={[{
variable: 'config',
label: 'Config JSON',
type: InputVarType.json,
hide: true,
required: false,
}]}
values={{ config: '{"key": "value"}' }}
onValueChange={onValueChange}
/>,
)
const textarea = screen.getByPlaceholderText('Config JSON')
expect(textarea).toHaveValue('{"key": "value"}')
})
it('should render a textarea for jsonObject-typed variables', () => {
render(
<WorkflowHiddenInputFields
hiddenVariables={[{
variable: 'schema',
label: 'Schema',
type: InputVarType.jsonObject,
hide: true,
required: false,
}]}
values={{ schema: '{}' }}
onValueChange={onValueChange}
/>,
)
const textarea = screen.getByPlaceholderText('Schema')
expect(textarea).toHaveValue('{}')
})
it('should use the variable key as label when label is not a string', () => {
render(
<WorkflowHiddenInputFields
hiddenVariables={[{
variable: 'my_var',
label: { nodeType: 'start' as never, nodeName: 'Start', variable: 'my_var' },
type: InputVarType.textInput,
hide: true,
required: false,
}]}
values={{ my_var: '' }}
onValueChange={onValueChange}
/>,
)
expect(screen.getByText('my_var')).toBeInTheDocument()
})
it('should use the custom fieldIdPrefix for element ids', () => {
const { container } = render(
<WorkflowHiddenInputFields
hiddenVariables={[{
variable: 'token',
label: 'Token',
type: InputVarType.textInput,
hide: true,
required: false,
}]}
values={{ token: 'abc' }}
onValueChange={onValueChange}
fieldIdPrefix="custom-prefix"
/>,
)
expect(container.querySelector('#custom-prefix-token')).toBeInTheDocument()
})
it('should render empty string for non-string fieldValue in text inputs', () => {
render(
<WorkflowHiddenInputFields
hiddenVariables={[{
variable: 'flag',
label: 'Flag',
type: InputVarType.textInput,
hide: true,
required: false,
}]}
values={{ flag: true as never }}
onValueChange={onValueChange}
/>,
)
const input = screen.getByLabelText('Flag')
expect(input).toHaveValue('')
})
})

View File

@ -1,11 +1,7 @@
/* eslint-disable react-refresh/only-export-components */
import type { TFunction } from 'i18next'
import type { ComponentType, FormEvent, ReactNode } from 'react'
import type {
OverviewOperationKey,
WorkflowHiddenStartVariable,
WorkflowLaunchInputValue,
} from './app-card-utils'
import type { ComponentType, ReactNode } from 'react'
import type { OverviewOperationKey } from './app-card-utils'
import type { ConfigParams } from './settings'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
@ -19,19 +15,12 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiSettings2Line, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react'
import { Trans } from 'react-i18next'
import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import ShareQRCode from '@/app/components/base/qrcode'
@ -42,7 +31,6 @@ import CustomizeModal from './customize'
import EmbeddedModal from './embedded'
import SettingsModal from './settings'
import style from './style.module.css'
import WorkflowHiddenInputFields from './workflow-hidden-input-fields'
type AppInfo = AppDetailResponse & Partial<AppSSO>
@ -62,12 +50,6 @@ type AppCardOperation = {
onClick: () => void
}
type LaunchConfigAction = {
label: string
disabled: boolean
onClick: () => void
}
const OPERATION_ICON_MAP: Record<OverviewOperationKey, OperationIcon> = {
launch: RiExternalLinkLine,
embedded: RiWindowLine,
@ -114,65 +96,6 @@ const MaybeTooltip = ({
)
}
export const WorkflowLaunchDialog = ({
t,
open,
hiddenVariables,
unsupportedVariables,
values,
onOpenChange,
onValueChange,
onSubmit,
}: {
t: TFunction
open: boolean
hiddenVariables: WorkflowHiddenStartVariable[]
unsupportedVariables: WorkflowHiddenStartVariable[]
values: Record<string, WorkflowLaunchInputValue>
onOpenChange: (open: boolean) => void
onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void
onSubmit: (event: FormEvent<HTMLFormElement>) => void
}) => {
if (!hiddenVariables.length && !unsupportedVariables.length)
return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[560px]! max-w-[calc(100vw-2rem)]! p-0!">
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('overview.appInfo.workflowLaunchHiddenInputs.title', { ns: 'appOverview' })}
</DialogTitle>
<DialogDescription className="system-md-regular text-text-tertiary">
<Trans
i18nKey="overview.appInfo.workflowLaunchHiddenInputs.description"
ns="appOverview"
components={{ bold: <span className="system-md-medium" /> }}
/>
</DialogDescription>
</div>
<form onSubmit={onSubmit}>
<div className="space-y-4 px-6 pb-4">
<WorkflowHiddenInputFields
hiddenVariables={hiddenVariables}
values={values}
onValueChange={onValueChange}
/>
</div>
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4">
<Button onClick={() => onOpenChange(false)}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button type="submit" variant="primary">
{t('overview.appInfo.launch', { ns: 'appOverview' })}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
export const createAppCardOperations = ({
operationKeys,
t,
@ -328,15 +251,20 @@ export const AppCardAccessControlSection = ({
export const AppCardOperations = ({
t,
operations,
launchConfigAction,
}: {
t: TFunction
operations: AppCardOperation[]
launchConfigAction?: LaunchConfigAction
}) => (
<>
{operations.map(({ key, label, Icon, disabled, onClick }) => {
const buttonContent = (
{operations.map(({ key, label, Icon, disabled, onClick }) => (
<Button
className="mr-1 min-w-[88px]"
size="small"
variant="ghost"
key={key}
onClick={onClick}
disabled={disabled}
>
<MaybeTooltip
content={t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''}
tooltipClassName="mt-[-8px]"
@ -347,72 +275,8 @@ export const AppCardOperations = ({
<div className={`${disabled ? 'text-components-button-ghost-text-disabled' : 'text-text-tertiary'} px-[3px] system-xs-medium`}>{label}</div>
</div>
</MaybeTooltip>
)
if (key === 'launch' && launchConfigAction) {
return (
<MaybeTooltip
key={key}
content={t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''}
tooltipClassName="mt-[-8px]"
show={disabled}
>
<Button
className="mr-1 border-0 px-0 py-0 shadow-none backdrop-blur-none hover:bg-components-button-secondary-bg"
size="small"
variant="secondary"
onClick={onClick}
disabled={disabled}
>
<div className="flex h-full min-w-[88px] items-center justify-center rounded-l-md px-2 hover:bg-components-button-secondary-bg-hover">
<div className="flex items-center justify-center gap-px">
<Icon className="h-3.5 w-3.5" />
<div className="px-[3px] system-xs-medium">{label}</div>
</div>
</div>
<div
aria-hidden="true"
className="h-4 w-px shrink-0 bg-divider-regular opacity-100"
/>
<div
className="flex h-full w-8 shrink-0 items-center justify-center rounded-r-md hover:bg-components-button-secondary-bg-hover"
onClick={(event) => {
event.stopPropagation()
launchConfigAction.onClick()
}}
aria-label={launchConfigAction.label}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(event) => {
if (disabled)
return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
launchConfigAction.onClick()
}
}}
>
<RiSettings2Line className="h-3.5 w-3.5" />
</div>
</Button>
</MaybeTooltip>
)
}
return (
<Button
className="mr-1 min-w-[88px]"
size="small"
variant="ghost"
key={key}
onClick={onClick}
disabled={disabled}
>
{buttonContent}
</Button>
)
})}
</Button>
))}
</>
)
@ -431,7 +295,6 @@ export const AppCardDialogs = ({
onCloseAccessControl,
onSaveSiteConfig,
onConfirmAccessControl,
hiddenInputs,
}: {
isApp: boolean
appInfo: AppInfo
@ -447,7 +310,6 @@ export const AppCardDialogs = ({
onCloseAccessControl: () => void
onSaveSiteConfig?: (params: ConfigParams) => Promise<void>
onConfirmAccessControl: () => Promise<void>
hiddenInputs?: WorkflowHiddenStartVariable[]
}) => {
if (!isApp)
return null
@ -467,7 +329,6 @@ export const AppCardDialogs = ({
onClose={onCloseEmbedded}
appBaseUrl={appInfo.site?.app_base_url}
accessToken={appInfo.site?.access_token}
hiddenInputs={hiddenInputs}
/>
<CustomizeModal
isShow={showCustomizeModal}

View File

@ -1,8 +1,6 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { IS_CE_EDITION } from '@/config'
import { BlockEnum } from '@/app/components/workflow/types'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
@ -10,11 +8,6 @@ import { basePath } from '@/utils/var'
type OverviewCardType = 'api' | 'webapp'
export type OverviewOperationKey = 'launch' | 'embedded' | 'customize' | 'settings' | 'develop'
export type WorkflowLaunchInputValue = string | boolean
export type WorkflowHiddenStartVariable = Pick<
InputVar,
'default' | 'hide' | 'label' | 'max_length' | 'options' | 'required' | 'type' | 'variable'
>
type AppInfo = AppDetailResponse & Partial<AppSSO>
@ -23,7 +16,6 @@ type WorkflowLike = {
nodes?: Array<{
data?: {
type?: string
variables?: InputVar[]
}
}>
}
@ -50,173 +42,10 @@ const getCardAppMode = (mode: AppModeEnum) => {
return (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : mode
}
const SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES = new Set<InputVarType>([
InputVarType.textInput,
InputVarType.paragraph,
InputVarType.select,
InputVarType.number,
InputVarType.checkbox,
InputVarType.json,
InputVarType.jsonObject,
InputVarType.url,
])
const coerceWorkflowLaunchDefaultValue = (variable: WorkflowHiddenStartVariable): WorkflowLaunchInputValue => {
if (variable.type === InputVarType.checkbox) {
if (typeof variable.default === 'boolean')
return variable.default
return String(variable.default).toLowerCase() === 'true'
}
if (typeof variable.default === 'number')
return String(variable.default)
return String(variable.default ?? '')
}
export const hasWorkflowStartNode = (currentWorkflow: WorkflowLike) => {
return currentWorkflow?.graph?.nodes?.some(node => node.data?.type === BlockEnum.Start) ?? false
}
export const getWorkflowHiddenStartVariables = (currentWorkflow: WorkflowLike): WorkflowHiddenStartVariable[] => {
const startNode = currentWorkflow?.graph?.nodes?.find(node => node.data?.type === BlockEnum.Start)
return (startNode?.data?.variables ?? []).filter(variable => variable.hide === true)
}
export const getAppHiddenLaunchVariables = ({
appInfo,
currentWorkflow,
}: {
appInfo: AppInfo
currentWorkflow: WorkflowLike
}) => {
if ([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT].includes(appInfo.mode))
return getWorkflowHiddenStartVariables(currentWorkflow)
}
export const isWorkflowLaunchInputSupported = (variable: WorkflowHiddenStartVariable) => {
return SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES.has(variable.type)
}
export const createWorkflowLaunchInitialValues = (variables: WorkflowHiddenStartVariable[]) => {
return variables.reduce<Record<string, WorkflowLaunchInputValue>>((acc, variable) => {
acc[variable.variable] = coerceWorkflowLaunchDefaultValue(variable)
return acc
}, {})
}
export const buildWorkflowLaunchUrl = async ({
accessibleUrl,
variables,
values,
}: {
accessibleUrl: string
variables: WorkflowHiddenStartVariable[]
values: Record<string, WorkflowLaunchInputValue>
}) => {
const targetUrl = new URL(accessibleUrl, window.location.origin)
variables.forEach((variable) => {
const rawValue = values[variable.variable]
const serializedValue = variable.type === InputVarType.checkbox
? String(Boolean(rawValue))
: String(rawValue ?? '')
targetUrl.searchParams.set(variable.variable, serializedValue)
})
return targetUrl.toString()
}
export const getEmbeddedIframeSnippet = (iframeUrl: string) =>
`<iframe
src="${iframeUrl}"
style="width: 100%; height: 100%; min-height: 700px"
frameborder="0"
allow="microphone">
</iframe>`
const getScriptInputsContent = (values: Record<string, WorkflowLaunchInputValue>) => {
const entries = Object.entries(values)
if (!entries.length) {
return `{
// You can define the inputs from the Start node here
// key is the variable name
// e.g.
// name: "NAME"
}`
}
return `{
${entries.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join('\n')}
}`
}
export const getEmbeddedScriptSnippet = ({
url,
token,
primaryColor,
isTestEnv,
inputValues,
}: {
url: string
token: string
primaryColor: string
isTestEnv?: boolean
inputValues: Record<string, WorkflowLaunchInputValue>
}) =>
`<script>
window.difyChatbotConfig = {
token: '${token}'${isTestEnv
? `,
isDev: true`
: ''}${IS_CE_EDITION
? `,
baseUrl: '${url}${basePath}'`
: ''},
inputs: ${getScriptInputsContent(inputValues)},
systemVariables: {
// user_id: 'YOU CAN DEFINE USER ID HERE',
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
},
userVariables: {
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
// name: 'YOU CAN DEFINE USER NAME HERE',
},
}
</script>
<script
src="${url}${basePath}/embed.min.js"
id="${token}"
defer>
</script>
<style>
#dify-chatbot-bubble-button {
background-color: ${primaryColor} !important;
}
#dify-chatbot-bubble-window {
width: 24rem !important;
height: 40rem !important;
}
</style>`
export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${iframeUrl}`
export const compressAndEncodeBase64 = async (input: string) => {
const uint8Array = new TextEncoder().encode(input)
if (typeof CompressionStream === 'undefined')
return btoa(String.fromCharCode(...uint8Array))
const compressedStream = new Response(
new Blob([uint8Array])
.stream()
.pipeThrough(new CompressionStream('gzip')),
).arrayBuffer()
const compressedUint8Array = new Uint8Array(await compressedStream)
return btoa(String.fromCharCode(...compressedUint8Array))
}
export const getAppCardDisplayState = ({
appInfo,
cardType,

View File

@ -1,5 +1,4 @@
'use client'
import type { WorkflowLaunchInputValue } from './app-card-utils'
import type { ConfigParams } from './settings'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
@ -29,16 +28,11 @@ import {
AppCardOperations,
AppCardUrlSection,
createAppCardOperations,
WorkflowLaunchDialog,
} from './app-card-sections'
import {
buildWorkflowLaunchUrl,
createWorkflowLaunchInitialValues,
getAppCardDisplayState,
getAppCardOperationKeys,
getAppHiddenLaunchVariables,
isAppAccessConfigured,
isWorkflowLaunchInputSupported,
} from './app-card-utils'
export type IAppCardProps = {
@ -69,8 +63,7 @@ function AppCard({
const router = useRouter()
const pathname = usePathname()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const shouldFetchWorkflow = appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT
const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '')
const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '')
const docLink = useDocLink()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
@ -80,8 +73,6 @@ function AppCard({
const [genLoading, setGenLoading] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showAccessControl, setShowAccessControl] = useState(false)
const [showWorkflowLaunchDialog, setShowWorkflowLaunchDialog] = useState(false)
const [workflowLaunchValues, setWorkflowLaunchValues] = useState<Record<string, WorkflowLaunchInputValue>>({})
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: appAccessSubjects } = useAppWhiteListSubjects(
@ -107,25 +98,6 @@ function AppCard({
() => isAppAccessConfigured(appDetail, appAccessSubjects),
[appAccessSubjects, appDetail],
)
const hiddenLaunchVariables = useMemo(
() => getAppHiddenLaunchVariables({
appInfo,
currentWorkflow,
}) || [],
[appInfo, currentWorkflow],
)
const supportedWorkflowLaunchVariables = useMemo(
() => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported),
[hiddenLaunchVariables],
)
const unsupportedWorkflowLaunchVariables = useMemo(
() => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)),
[hiddenLaunchVariables],
)
const initialWorkflowLaunchValues = useMemo(
() => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables),
[supportedWorkflowLaunchVariables],
)
const onGenCode = async () => {
if (!onGenerateCode)
@ -167,31 +139,6 @@ function AppCard({
window.open(cardState.accessibleUrl, '_blank')
}, [cardState.accessibleUrl])
const handleOpenWorkflowLaunchDialog = useCallback(() => {
setWorkflowLaunchValues(initialWorkflowLaunchValues)
setShowWorkflowLaunchDialog(true)
}, [initialWorkflowLaunchValues])
const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => {
setWorkflowLaunchValues(prev => ({
...prev,
[variable]: value,
}))
}, [])
const handleWorkflowLaunchConfirm = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const targetUrl = await buildWorkflowLaunchUrl({
accessibleUrl: cardState.accessibleUrl,
variables: supportedWorkflowLaunchVariables,
values: workflowLaunchValues,
})
window.open(targetUrl, '_blank')
setShowWorkflowLaunchDialog(false)
}, [cardState.accessibleUrl, supportedWorkflowLaunchVariables, workflowLaunchValues])
const handleOpenCustomize = useCallback(() => {
setShowCustomizeModal(true)
}, [])
@ -357,17 +304,7 @@ function AppCard({
{!cardState.isMinimalState && (
<div className="flex items-center gap-1 self-stretch p-3">
{!isApp && <SecretKeyButton appId={appInfo.id} />}
<AppCardOperations
t={t}
operations={operations}
launchConfigAction={hiddenLaunchVariables.length > 0
? {
label: t('operation.config', { ns: 'common' }),
disabled: triggerModeDisabled || !cardState.runningStatus,
onClick: handleOpenWorkflowLaunchDialog,
}
: undefined}
/>
<AppCardOperations t={t} operations={operations} />
</div>
)}
</div>
@ -386,17 +323,6 @@ function AppCard({
onCloseAccessControl={() => setShowAccessControl(false)}
onSaveSiteConfig={onSaveSiteConfig}
onConfirmAccessControl={handleAccessControlUpdate}
hiddenInputs={hiddenLaunchVariables}
/>
<WorkflowLaunchDialog
t={t}
open={showWorkflowLaunchDialog}
hiddenVariables={supportedWorkflowLaunchVariables}
unsupportedVariables={unsupportedWorkflowLaunchVariables}
values={workflowLaunchValues}
onOpenChange={setShowWorkflowLaunchDialog}
onValueChange={handleWorkflowLaunchValueChange}
onSubmit={handleWorkflowLaunchConfirm}
/>
</div>
)

View File

@ -1,11 +1,10 @@
import type { SiteInfo } from '@/models/share'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { act } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import { act } from 'react'
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
import Embedded from '../index'
vi.mock('../style.module.css', () => ({
@ -47,7 +46,6 @@ vi.mock('@/context/app-context', () => ({
}))
const mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
const mockedCopy = vi.mocked(copy)
const originalCompressionStream = globalThis.CompressionStream
const siteInfo: SiteInfo = {
title: 'test site',
@ -72,22 +70,6 @@ const getCopyButton = () => {
}
describe('Embedded', () => {
beforeAll(() => {
class MockCompressionStream {
readable: ReadableStream<Uint8Array>
writable: WritableStream<Uint8Array>
constructor() {
const transformStream = new TransformStream<Uint8Array, Uint8Array>()
this.readable = transformStream.readable
this.writable = transformStream.writable
}
}
// @ts-expect-error test polyfill
globalThis.CompressionStream = MockCompressionStream
})
afterEach(() => {
vi.clearAllMocks()
mockWindowOpen.mockClear()
@ -95,7 +77,6 @@ describe('Embedded', () => {
afterAll(() => {
mockWindowOpen.mockRestore()
globalThis.CompressionStream = originalCompressionStream
})
it('builds theme and copies iframe snippet', async () => {
@ -103,20 +84,14 @@ describe('Embedded', () => {
render(<Embedded {...baseProps} />)
})
await waitFor(() => {
expect(screen.getByText((content, node) => node?.tagName.toLowerCase() === 'pre' && content.includes('/chatbot/token'))).toBeInTheDocument()
})
const actionButton = getCopyButton()
const innerDiv = actionButton.querySelector('div')
await act(async () => {
act(() => {
fireEvent.click(innerDiv ?? actionButton)
})
expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(siteInfo.chat_color_theme, siteInfo.chat_color_theme_inverted)
await waitFor(() => {
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
})
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
})
it('opens chrome plugin store link when chrome option selected', async () => {
@ -141,106 +116,4 @@ describe('Embedded', () => {
'noopener,noreferrer',
)
})
it('keeps hidden inputs collapsed by default and updates iframe and script content when values change', async () => {
render(
<Embedded
{...baseProps}
hiddenInputs={[{
variable: 'secret',
label: 'Secret',
type: InputVarType.textInput,
hide: true,
required: true,
default: '',
}]}
/>,
)
expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('appOverview.overview.appInfo.embedded.hiddenInputs.title').closest('button')!)
})
await waitFor(() => {
expect(screen.getByLabelText('Secret')).toBeInTheDocument()
})
await act(async () => {
fireEvent.change(screen.getByLabelText('Secret'), {
target: { value: 'top-secret' },
})
})
expect(document.querySelector('pre')?.textContent ?? '').toContain('/chatbot/token')
await waitFor(() => {
const codeBlock = document.querySelector('pre')
expect(codeBlock?.textContent ?? '').toContain('/chatbot/token?secret=dG9wLXNlY3JldA%3D%3D')
})
const optionButtons = document.body.querySelectorAll('[class*="option"]')
act(() => {
fireEvent.click(optionButtons[1]!)
})
await waitFor(() => {
const codeBlock = document.querySelector('pre')
expect(codeBlock?.textContent ?? '').toContain('secret: "top-secret"')
})
})
it('copies script content when scripts option is selected', async () => {
await act(async () => {
render(<Embedded {...baseProps} />)
})
const optionButtons = document.body.querySelectorAll('[class*="option"]')
act(() => {
fireEvent.click(optionButtons[1]!)
})
await waitFor(() => {
const codeBlock = document.querySelector('pre')
expect(codeBlock?.textContent ?? '').toContain('token: \'token\'')
})
const actionButton = getCopyButton()
const innerDiv = actionButton.querySelector('div')
await act(async () => {
fireEvent.click(innerDiv ?? actionButton)
})
await waitFor(() => {
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('token: \'token\''))
})
})
it('copies chrome plugin URL (without prefix) when chromePlugin option is selected', async () => {
await act(async () => {
render(<Embedded {...baseProps} />)
})
const optionButtons = document.body.querySelectorAll('[class*="option"]')
act(() => {
fireEvent.click(optionButtons[2]!)
})
await waitFor(() => {
const codeBlock = document.querySelector('pre')
expect(codeBlock?.textContent ?? '').toContain('ChatBot URL:')
})
const actionButton = getCopyButton()
const innerDiv = actionButton.querySelector('div')
await act(async () => {
fireEvent.click(innerDiv ?? actionButton)
})
await waitFor(() => {
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
expect(mockedCopy).not.toHaveBeenCalledWith(expect.stringContaining('ChatBot URL:'))
})
})
})

View File

@ -1,46 +1,88 @@
import type { MutableRefObject } from 'react'
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils'
import type { SiteInfo } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiArrowDownSLine,
RiArrowRightSLine,
} from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
import { InputVarType } from '@/app/components/workflow/types'
import { IS_CE_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { basePath } from '@/utils/var'
import {
compressAndEncodeBase64,
createWorkflowLaunchInitialValues,
getChromePluginContent,
getEmbeddedIframeSnippet,
getEmbeddedScriptSnippet,
isWorkflowLaunchInputSupported,
} from '../app-card-utils'
import WorkflowHiddenInputFields from '../workflow-hidden-input-fields'
import style from './style.module.css'
type Props = {
siteInfo?: SiteInfo
isShow: boolean
onClose: () => void
accessToken?: string
appBaseUrl?: string
hiddenInputs?: WorkflowHiddenStartVariable[]
accessToken: string
appBaseUrl: string
className?: string
}
const OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const
const OPTION_MAP = {
iframe: {
getContent: (url: string, token: string) =>
`<iframe
src="${url}${basePath}/chatbot/${token}"
style="width: 100%; height: 100%; min-height: 700px"
frameborder="0"
allow="microphone">
</iframe>`,
},
scripts: {
getContent: (url: string, token: string, primaryColor: string, isTestEnv?: boolean) =>
`<script>
window.difyChatbotConfig = {
token: '${token}'${isTestEnv
? `,
isDev: true`
: ''}${IS_CE_EDITION
? `,
baseUrl: '${url}${basePath}'`
: ''},
inputs: {
// You can define the inputs from the Start node here
// key is the variable name
// e.g.
// name: "NAME"
},
systemVariables: {
// user_id: 'YOU CAN DEFINE USER ID HERE',
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
},
userVariables: {
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
// name: 'YOU CAN DEFINE USER NAME HERE',
},
}
</script>
<script
src="${url}${basePath}/embed.min.js"
id="${token}"
defer>
</script>
<style>
#dify-chatbot-bubble-button {
background-color: ${primaryColor} !important;
}
#dify-chatbot-bubble-window {
width: 24rem !important;
height: 40rem !important;
}
</style>`,
},
chromePlugin: {
getContent: (url: string, token: string) => `ChatBot URL: ${url}${basePath}/chatbot/${token}`,
},
}
const prefixEmbedded = 'overview.appInfo.embedded'
type Option = typeof OPTION_KEYS[number]
type Option = keyof typeof OPTION_MAP
const OPTIONS: Option[] = ['iframe', 'scripts', 'chromePlugin']
const optionIconClassName: Record<Option, string> = {
iframe: style.iframeIcon!,
@ -48,274 +90,38 @@ const optionIconClassName: Record<Option, string> = {
chromePlugin: style.chromePluginIcon!,
}
const getSerializedHiddenInputValue = (
variable: WorkflowHiddenStartVariable,
values: Record<string, WorkflowLaunchInputValue>,
) => {
const rawValue = values[variable.variable]
if (variable.type === InputVarType.checkbox)
return String(Boolean(rawValue))
return String(rawValue ?? '')
}
const buildEmbeddedIframeUrl = async ({
appBaseUrl,
accessToken,
variables,
values,
}: {
appBaseUrl: string
accessToken: string
variables: WorkflowHiddenStartVariable[]
values: Record<string, WorkflowLaunchInputValue>
}) => {
const iframeUrl = new URL(`${appBaseUrl}${basePath}/chatbot/${accessToken}`, window.location.origin)
await Promise.all(variables.map(async (variable) => {
iframeUrl.searchParams.set(variable.variable, await compressAndEncodeBase64(getSerializedHiddenInputValue(variable, values)))
}))
return iframeUrl.toString()
}
const AsyncEmbeddedOptionContent = ({
option,
iframeUrlPromise,
latestResolvedIframeUrlRef,
}: {
option: Option
iframeUrlPromise: Promise<string>
latestResolvedIframeUrlRef: MutableRefObject<string>
}) => {
const iframeUrl = use(iframeUrlPromise)
latestResolvedIframeUrlRef.current = iframeUrl
if (option === 'chromePlugin')
return getChromePluginContent(iframeUrl)
return getEmbeddedIframeSnippet(iframeUrl)
}
const EmbeddedContent = ({
siteInfo,
appBaseUrl,
accessToken,
hiddenInputs,
}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> & Pick<Props, 'siteInfo' | 'hiddenInputs'>) => {
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, className }: Props) => {
const { t } = useTranslation()
const supportedHiddenInputs = useMemo<WorkflowHiddenStartVariable[]>(
() => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported),
[hiddenInputs],
)
const initialHiddenInputValues = useMemo(
() => createWorkflowLaunchInitialValues(supportedHiddenInputs),
[supportedHiddenInputs],
)
const [option, setOption] = useState<Option>('iframe')
const [copiedOption, setCopiedOption] = useState<Option | null>(null)
const [hiddenInputsCollapsed, setHiddenInputsCollapsed] = useState(true)
const [hiddenInputValues, setHiddenInputValues] = useState<Record<string, WorkflowLaunchInputValue>>(
() => initialHiddenInputValues,
)
const [previewIframeUrlPromise, setPreviewIframeUrlPromise] = useState<Promise<string>>(
() => buildEmbeddedIframeUrl({
appBaseUrl,
accessToken,
variables: supportedHiddenInputs,
values: initialHiddenInputValues,
}),
)
const latestResolvedIframeUrlRef = useRef('')
const { langGeniusVersionInfo } = useAppContext()
const themeBuilder = useThemeContext()
themeBuilder.buildTheme(siteInfo?.chat_color_theme ?? null, siteInfo?.chat_color_theme_inverted ?? false)
const isTestEnv = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT'
const handleHiddenInputValueChange = (variable: string, value: WorkflowLaunchInputValue) => {
const nextHiddenInputValues = {
...hiddenInputValues,
[variable]: value,
}
setCopiedOption(null)
setHiddenInputValues(nextHiddenInputValues)
setPreviewIframeUrlPromise(buildEmbeddedIframeUrl({
appBaseUrl,
accessToken,
variables: supportedHiddenInputs,
values: nextHiddenInputValues,
}))
}
const scriptsContent = useMemo(() => getEmbeddedScriptSnippet({
url: appBaseUrl,
token: accessToken,
primaryColor: themeBuilder.theme?.primaryColor ?? '#1C64F2',
isTestEnv,
inputValues: hiddenInputValues,
}), [accessToken, appBaseUrl, hiddenInputValues, isTestEnv, themeBuilder.theme?.primaryColor])
const onClickCopy = async () => {
const latestIframeUrl = await buildEmbeddedIframeUrl({
appBaseUrl,
accessToken,
variables: supportedHiddenInputs,
values: hiddenInputValues,
})
const onClickCopy = () => {
if (option === 'chromePlugin') {
const splitUrl = getChromePluginContent(latestIframeUrl).split(': ')
const splitUrl = OPTION_MAP[option].getContent(appBaseUrl, accessToken).split(': ')
if (splitUrl.length > 1)
copy(splitUrl[1]!)
}
else if (option === 'iframe') {
copy(getEmbeddedIframeSnippet(latestIframeUrl))
}
else {
copy(scriptsContent)
copy(OPTION_MAP[option].getContent(appBaseUrl, accessToken, themeBuilder.theme?.primaryColor ?? '#1C64F2', isTestEnv))
}
setCopiedOption(option)
}
const previewFallback = latestResolvedIframeUrlRef.current
? (option === 'chromePlugin'
? getChromePluginContent(latestResolvedIframeUrlRef.current)
: getEmbeddedIframeSnippet(latestResolvedIframeUrlRef.current))
: ''
const navigateToChromeUrl = () => {
window.open('https://chrome.google.com/webstore/detail/dify-chatbot/ceehdapohffmjmkdcifjofadiaoeggaf', '_blank', 'noopener,noreferrer')
}
useEffect(() => {
themeBuilder.buildTheme(siteInfo?.chat_color_theme ?? null, siteInfo?.chat_color_theme_inverted ?? false)
}, [siteInfo?.chat_color_theme, siteInfo?.chat_color_theme_inverted, themeBuilder])
return (
<>
<div className="mt-8 mb-4 system-sm-medium text-text-primary">
{t(`${prefixEmbedded}.explanation`, { ns: 'appOverview' })}
</div>
{supportedHiddenInputs.length > 0 && (
<div className="mb-6 rounded-xl border-[0.5px] border-components-panel-border bg-background-section">
<button
type="button"
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
onClick={() => setHiddenInputsCollapsed(prev => !prev)}
>
<div>
<div className="system-sm-medium text-text-primary">
{t(`${prefixEmbedded}.hiddenInputs.title`, { ns: 'appOverview' })}
</div>
<div className="mt-1 system-xs-regular text-text-tertiary">
{t(`${prefixEmbedded}.hiddenInputs.description`, { ns: 'appOverview' })}
</div>
</div>
{hiddenInputsCollapsed
? <RiArrowRightSLine className="h-4 w-4 shrink-0 text-text-tertiary" />
: <RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-tertiary" />}
</button>
{!hiddenInputsCollapsed && (
<div className="max-h-72 space-y-4 overflow-y-auto border-t-[0.5px] border-divider-subtle px-4 py-4">
<WorkflowHiddenInputFields
hiddenVariables={supportedHiddenInputs}
values={hiddenInputValues}
onValueChange={handleHiddenInputValueChange}
fieldIdPrefix="embedded-hidden-input"
/>
</div>
)}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-y-2">
{OPTION_KEYS.map((v) => {
return (
<button
type="button"
key={v}
aria-label={t(`${prefixEmbedded}.${v}`, { ns: 'appOverview' }) || v}
className={cn(
style.option,
optionIconClassName[v],
option === v && style.active,
)}
onClick={() => {
setOption(v)
setCopiedOption(null)
}}
>
</button>
)
})}
</div>
{option === 'chromePlugin' && (
<div className="mt-6 w-full">
<button
type="button"
className={cn('inline-flex w-full items-center justify-center gap-2 rounded-lg py-3', 'shrink-0 bg-primary-600 text-white hover:bg-primary-600/75 hover:shadow-sm')}
onClick={navigateToChromeUrl}
>
<div className={`relative h-4 w-4 ${style.pluginInstallIcon}`}></div>
<div className="font-['Inter'] text-sm leading-tight font-medium text-white">{t(`${prefixEmbedded}.chromePlugin`, { ns: 'appOverview' })}</div>
</button>
</div>
)}
<div className={cn('inline-flex w-full flex-col items-start justify-start rounded-lg border-[0.5px] border-components-panel-border bg-background-section', 'mt-6')}>
<div className="inline-flex items-center justify-start gap-2 self-stretch rounded-t-lg bg-background-section-burn py-1 pr-1 pl-3">
<div className="shrink-0 grow system-sm-medium text-text-secondary">
{t(`${prefixEmbedded}.${option}`, { ns: 'appOverview' })}
</div>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
aria-label={(copiedOption === option
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
onClick={() => void onClickCopy()}
>
{copiedOption === option && <span aria-hidden="true" className="i-ri-clipboard-fill h-4 w-4" />}
{copiedOption !== option && <span aria-hidden="true" className="i-ri-clipboard-line h-4 w-4" />}
</ActionButton>
)}
/>
<TooltipContent>
{(copiedOption === option
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
</TooltipContent>
</Tooltip>
</div>
<div className="flex max-h-[clamp(180px,calc(100dvh-320px),360px)] w-full items-start justify-start gap-2 overflow-auto p-3">
<div className="shrink grow basis-0 font-mono text-[13px] leading-tight text-text-secondary">
<pre className="select-text">
{option === 'scripts'
? scriptsContent
: (
<Suspense fallback={previewFallback}>
<AsyncEmbeddedOptionContent
option={option}
iframeUrlPromise={previewIframeUrlPromise}
latestResolvedIframeUrlRef={latestResolvedIframeUrlRef}
/>
</Suspense>
)}
</pre>
</div>
</div>
</div>
</>
)
}
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenInputs, className }: Props) => {
const { t } = useTranslation()
return (
<Dialog
open={isShow}
onOpenChange={(open) => {
if (open)
return
setCopiedOption(null)
onClose()
}}
>
@ -324,16 +130,73 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenIn
{t(`${prefixEmbedded}.title`, { ns: 'appOverview' })}
</DialogTitle>
<DialogCloseButton />
<div className="max-h-[calc(90vh-88px)] overflow-y-auto">
{isShow && (
<EmbeddedContent
key={`${appBaseUrl ?? ''}:${accessToken ?? ''}:${JSON.stringify(hiddenInputs ?? [])}`}
siteInfo={siteInfo}
appBaseUrl={appBaseUrl ?? ''}
accessToken={accessToken ?? ''}
hiddenInputs={hiddenInputs}
/>
)}
<div className="mt-8 mb-4 system-sm-medium text-text-primary">
{t(`${prefixEmbedded}.explanation`, { ns: 'appOverview' })}
</div>
<div className="flex flex-wrap items-center justify-between gap-y-2">
{OPTIONS.map((v) => {
return (
<button
type="button"
key={v}
aria-label={t(`${prefixEmbedded}.${v}`, { ns: 'appOverview' }) || v}
className={cn(
style.option,
optionIconClassName[v],
option === v && style.active,
)}
onClick={() => {
setOption(v)
setCopiedOption(null)
}}
>
</button>
)
})}
</div>
{option === 'chromePlugin' && (
<div className="mt-6 w-full">
<button
type="button"
className={cn('inline-flex w-full items-center justify-center gap-2 rounded-lg py-3', 'shrink-0 bg-primary-600 text-white hover:bg-primary-600/75 hover:shadow-sm')}
onClick={navigateToChromeUrl}
>
<div className={`relative h-4 w-4 ${style.pluginInstallIcon}`}></div>
<div className="font-['Inter'] text-sm leading-tight font-medium text-white">{t(`${prefixEmbedded}.chromePlugin`, { ns: 'appOverview' })}</div>
</button>
</div>
)}
<div className={cn('inline-flex w-full flex-col items-start justify-start rounded-lg border-[0.5px] border-components-panel-border bg-background-section', 'mt-6')}>
<div className="inline-flex items-center justify-start gap-2 self-stretch rounded-t-lg bg-background-section-burn py-1 pr-1 pl-3">
<div className="shrink-0 grow system-sm-medium text-text-secondary">
{t(`${prefixEmbedded}.${option}`, { ns: 'appOverview' })}
</div>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
aria-label={(copiedOption === option
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
onClick={onClickCopy}
>
{copiedOption === option && <span aria-hidden="true" className="i-ri-clipboard-fill h-4 w-4" />}
{copiedOption !== option && <span aria-hidden="true" className="i-ri-clipboard-line h-4 w-4" />}
</ActionButton>
)}
/>
<TooltipContent>
{(copiedOption === option
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
</TooltipContent>
</Tooltip>
</div>
<div className="flex max-h-[clamp(180px,calc(100dvh-320px),360px)] w-full items-start justify-start gap-2 overflow-auto p-3">
<div className="shrink grow basis-0 font-mono text-[13px] leading-tight text-text-secondary">
<pre className="select-text">{OPTION_MAP[option].getContent(appBaseUrl, accessToken, themeBuilder.theme?.primaryColor ?? '#1C64F2', isTestEnv)}</pre>
</div>
</div>
</div>
</DialogContent>
</Dialog>

View File

@ -1,116 +0,0 @@
import type { ChangeEvent } from 'react'
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from './app-card-utils'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { InputVarType } from '@/app/components/workflow/types'
type WorkflowHiddenInputFieldsProps = {
hiddenVariables: WorkflowHiddenStartVariable[]
values: Record<string, WorkflowLaunchInputValue>
onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void
fieldIdPrefix?: string
}
const WorkflowHiddenInputFields = ({
hiddenVariables,
values,
onValueChange,
fieldIdPrefix = 'workflow-launch-hidden-input',
}: WorkflowHiddenInputFieldsProps) => {
const renderField = (variable: WorkflowHiddenStartVariable) => {
const fieldId = `${fieldIdPrefix}-${variable.variable}`
const fieldValue = values[variable.variable]
const label = typeof variable.label === 'string' ? variable.label : variable.variable
if (variable.type === InputVarType.select) {
return (
<Select
value={typeof fieldValue === 'string' ? fieldValue : ''}
onValueChange={value => onValueChange(variable.variable, value ?? '')}
>
<SelectTrigger className="w-full" aria-label={label}>
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
{(variable.options ?? []).map(option => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (variable.type === InputVarType.checkbox) {
return (
<label className="flex min-h-10 w-full cursor-pointer items-center gap-3 rounded-lg bg-components-input-bg-normal px-3 py-2">
<input
id={fieldId}
type="checkbox"
checked={Boolean(fieldValue)}
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.checked)}
className="h-4 w-4 rounded border-divider-subtle"
/>
<span className="system-sm-regular text-text-secondary">{label}</span>
</label>
)
}
if (
variable.type === InputVarType.paragraph
|| variable.type === InputVarType.json
|| variable.type === InputVarType.jsonObject
) {
return (
<Textarea
id={fieldId}
value={typeof fieldValue === 'string' ? fieldValue : ''}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onValueChange(variable.variable, event.target.value)}
placeholder={label}
maxLength={variable.max_length}
className="min-h-24"
/>
)
}
return (
<Input
id={fieldId}
type={variable.type === InputVarType.number ? 'number' : 'text'}
value={typeof fieldValue === 'string' ? fieldValue : ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.value)}
placeholder={label}
maxLength={variable.max_length}
/>
)
}
return (
<>
{hiddenVariables.map(variable => (
<div key={variable.variable} className="space-y-1.5">
{variable.type !== InputVarType.checkbox && (
<label
htmlFor={`${fieldIdPrefix}-${variable.variable}`}
className="block system-sm-medium text-text-secondary"
>
{typeof variable.label === 'string' ? variable.label : variable.variable}
</label>
)}
{renderField(variable)}
</div>
))}
</>
)
}
export default WorkflowHiddenInputFields

View File

@ -321,86 +321,47 @@ describe('chat utils - url params and answer helpers', () => {
expect(res).toEqual({ custom: '123', encoded: 'a b' })
})
it('getRawInputsFromUrlParams keeps encoded launch params as decoded plain values', async () => {
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}`)
const res = await getRawInputsFromUrlParams()
expect(res).toEqual({ custom: 'YWJjZA==' })
})
it('getRawUserVariablesFromUrlParams extracts only user. prefixed params', async () => {
setSearch('?custom=123&sys.param=456&user.param=789&user.encoded=a%20b')
const res = await getRawUserVariablesFromUrlParams()
expect(res).toEqual({ param: '789', encoded: 'a b' })
})
it('getRawUserVariablesFromUrlParams keeps encoded user values as decoded plain values', async () => {
setSearch(`?user.param=${encodeURIComponent('YWJjZA==')}`)
const res = await getRawUserVariablesFromUrlParams()
expect(res).toEqual({ param: 'YWJjZA==' })
})
it('getProcessedInputsFromUrlParams decompresses base64 inputs', async () => {
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}&sys.param=456&user.param=789`)
setSearch('?custom=123&sys.param=456&user.param=789')
const res = await getProcessedInputsFromUrlParams()
expect(res).toEqual({ custom: 'decompressed_text' })
})
it('getProcessedInputsFromUrlParams returns undefined for plain decoded values', async () => {
vi.stubGlobal('atob', () => {
throw new Error('invalid')
})
setSearch('?custom=a%20b')
const res = await getProcessedInputsFromUrlParams()
expect(res).toEqual({ custom: undefined })
})
it('getProcessedSystemVariablesFromUrlParams decompresses sys. prefixed params', async () => {
setSearch(`?custom=123&sys.param=${encodeURIComponent('YWJjZA==')}&user.param=789`)
setSearch('?custom=123&sys.param=456&user.param=789')
const res = await getProcessedSystemVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text' })
})
it('getProcessedSystemVariablesFromUrlParams returns undefined for plain decoded values', async () => {
vi.stubGlobal('atob', () => {
throw new Error('invalid')
})
setSearch('?sys.param=a%20b')
const res = await getProcessedSystemVariablesFromUrlParams()
expect(res).toEqual({ param: undefined })
})
it('getProcessedSystemVariablesFromUrlParams parses redirect_url without query string', async () => {
setSearch(`?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=${encodeURIComponent('YWJjZA==')}`)
setSearch(`?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=456`)
const res = await getProcessedSystemVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text' })
})
it('getProcessedSystemVariablesFromUrlParams parses redirect_url', async () => {
setSearch(`?redirect_url=${encodeURIComponent(`http://example.com?sys.redirected=${encodeURIComponent('YWJjZA==')}`)}&sys.param=${encodeURIComponent('YWJjZA==')}`)
setSearch(`?redirect_url=${encodeURIComponent('http://example.com?sys.redirected=abc')}&sys.param=456`)
const res = await getProcessedSystemVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text', redirected: 'decompressed_text' })
})
it('getProcessedUserVariablesFromUrlParams decompresses user. prefixed params', async () => {
setSearch(`?custom=123&sys.param=456&user.param=${encodeURIComponent('YWJjZA==')}`)
setSearch('?custom=123&sys.param=456&user.param=789')
const res = await getProcessedUserVariablesFromUrlParams()
expect(res).toEqual({ param: 'decompressed_text' })
})
it('getProcessedUserVariablesFromUrlParams returns undefined for plain decoded values', async () => {
vi.stubGlobal('atob', () => {
throw new Error('invalid')
})
setSearch('?user.param=a%20b')
const res = await getProcessedUserVariablesFromUrlParams()
expect(res).toEqual({ param: undefined })
})
it('decodeBase64AndDecompress failure returns undefined softly', async () => {
vi.stubGlobal('atob', () => {
throw new Error('invalid')
})
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}`)
setSearch('?custom=invalid_base64')
const res = await getProcessedInputsFromUrlParams()
expect(res).toEqual({ custom: undefined })
})

View File

@ -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}

View File

@ -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')

View File

@ -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 = () => {

View File

@ -19,13 +19,11 @@ async function getRawInputsFromUrlParams(): Promise<Record<string, any>> {
const urlParams = new URLSearchParams(window.location.search)
const inputs: Record<string, any> = {}
const entriesArray = Array.from(urlParams.entries())
await Promise.all(entriesArray.map(async ([key, value]) => {
entriesArray.forEach(([key, value]) => {
const prefixArray = ['sys.', 'user.']
if (prefixArray.some(prefix => key.startsWith(prefix)))
return
inputs[key] = decodeURIComponent(value)
}))
if (!prefixArray.some(prefix => key.startsWith(prefix)))
inputs[key] = decodeURIComponent(value)
})
return inputs
}
@ -83,12 +81,10 @@ async function getRawUserVariablesFromUrlParams(): Promise<Record<string, any>>
const urlParams = new URLSearchParams(window.location.search)
const userVariables: Record<string, any> = {}
const entriesArray = Array.from(urlParams.entries())
await Promise.all(entriesArray.map(async ([key, value]) => {
if (!key.startsWith('user.'))
return
userVariables[key.slice(5)] = decodeURIComponent(value)
}))
entriesArray.forEach(([key, value]) => {
if (key.startsWith('user.'))
userVariables[key.slice(5)] = decodeURIComponent(value)
})
return userVariables
}

View File

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

View File

@ -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', () => ({

View File

@ -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)

View File

@ -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()

View File

@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode } from '@/models/datasets'
import { SegmentDetail } from '../segment-detail'
import SegmentDetail from '../segment-detail'
// Mock dataset detail context
let mockIndexingTechnique = IndexingType.QUALIFIED
@ -167,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()
})
})

View File

@ -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)
})

View File

@ -1,11 +1,11 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { 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()

View File

@ -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

View File

@ -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

View File

@ -2,16 +2,16 @@ import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { DrawerGroup } from '../drawer-group'
import DrawerGroup from '../drawer-group'
vi.mock('../../common/full-screen-drawer', () => ({
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', () => {

View File

@ -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

View File

@ -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'

View File

@ -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)
})
})

View File

@ -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(),

View File

@ -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,
}

View File

@ -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()
}

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import type { ProcessStatus } from '../segment-add'
import type { SegmentListContextValue } from './segment-list-context'
import type { SegmentImportStatus } from '@/types/dataset'
import { useCallback, useMemo, useState } from 'react'
import Divider from '@/app/components/base/divider'
import Pagination from '@/app/components/base/pagination'
@ -13,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}

View File

@ -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)

View File

@ -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}

View File

@ -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} />)

View File

@ -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)

View File

@ -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" />,
}))

View File

@ -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

View File

@ -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 && (

View File

@ -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}

View File

@ -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} />

View File

@ -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}

View File

@ -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}

View File

@ -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('')

View File

@ -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()
})
})
})

View File

@ -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)
})
})

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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' })}

View File

@ -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

View File

@ -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 }),
}))

View File

@ -11,7 +11,6 @@ const renderHook = <Result, Props = void>(callback: (props: Props) => Result) =>
const {
changeLanguageMock,
fetchSavedMessageMock,
getRawInputsFromUrlParamsMock,
notifyMock,
removeMessageMock,
saveMessageMock,
@ -20,7 +19,6 @@ const {
} = vi.hoisted(() => ({
changeLanguageMock: vi.fn(() => Promise.resolve()),
fetchSavedMessageMock: vi.fn(),
getRawInputsFromUrlParamsMock: vi.fn(),
notifyMock: vi.fn(),
removeMessageMock: vi.fn(),
saveMessageMock: vi.fn(),
@ -52,10 +50,6 @@ vi.mock('@/i18n-config/client', () => ({
changeLanguage: changeLanguageMock,
}))
vi.mock('@/app/components/base/chat/utils', () => ({
getRawInputsFromUrlParams: getRawInputsFromUrlParamsMock,
}))
vi.mock('@/service/share', async () => {
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
return {
@ -108,7 +102,7 @@ const defaultAppParams = {
hide: false,
},
},
] as Record<string, Record<string, unknown>>[],
],
more_like_this: {
enabled: true,
},
@ -181,7 +175,6 @@ describe('useTextGenerationAppState', () => {
})
removeMessageMock.mockResolvedValue(undefined)
saveMessageMock.mockResolvedValue(undefined)
getRawInputsFromUrlParamsMock.mockResolvedValue({})
})
it('should initialize app state and fetch saved messages for non-workflow web apps', async () => {
@ -302,239 +295,4 @@ describe('useTextGenerationAppState', () => {
enable: false,
}))
})
it('should apply workflow launch inputs from the url to hidden prompt variables', async () => {
mockWebAppState.appParams = {
...defaultAppParams,
user_input_form: [
{
'text-input': {
label: 'Visible',
variable: 'visible',
required: true,
max_length: 48,
default: 'Shown',
hide: false,
},
},
{
'text-input': {
label: 'Hidden Secret',
variable: 'secret',
required: true,
max_length: 48,
default: '',
hide: true,
},
},
],
}
getRawInputsFromUrlParamsMock.mockResolvedValue({
secret: 'prefilled-secret',
})
const { result } = renderHook(() => useTextGenerationAppState({
isInstalledApp: false,
isWorkflow: true,
}))
await waitFor(() => {
expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([
expect.objectContaining({
key: 'visible',
default: 'Shown',
}),
expect.objectContaining({
key: 'secret',
hide: true,
default: 'prefilled-secret',
}),
]))
})
expect(getRawInputsFromUrlParamsMock).toHaveBeenCalled()
expect(fetchSavedMessageMock).not.toHaveBeenCalled()
})
it('should coerce checkbox url defaults from string and boolean values', async () => {
mockWebAppState.appParams = {
...defaultAppParams,
user_input_form: [
{
checkbox: {
label: 'Bool True',
variable: 'bool_true',
required: false,
default: false,
hide: true,
},
},
{
checkbox: {
label: 'String True',
variable: 'str_true',
required: false,
default: false,
hide: true,
},
},
{
checkbox: {
label: 'String False',
variable: 'str_false',
required: false,
default: true,
hide: true,
},
},
{
checkbox: {
label: 'Invalid',
variable: 'invalid_cb',
required: false,
default: false,
hide: true,
},
},
],
}
getRawInputsFromUrlParamsMock.mockResolvedValue({
bool_true: true,
str_true: 'true',
str_false: 'false',
invalid_cb: 'invalid',
})
const { result } = renderHook(() => useTextGenerationAppState({
isInstalledApp: false,
isWorkflow: true,
}))
await waitFor(() => {
expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([
expect.objectContaining({ key: 'bool_true', default: true }),
expect.objectContaining({ key: 'str_true', default: true }),
expect.objectContaining({ key: 'str_false', default: false }),
expect.objectContaining({ key: 'invalid_cb', default: false }),
]))
})
})
it('should coerce number url defaults and ignore NaN values', async () => {
mockWebAppState.appParams = {
...defaultAppParams,
user_input_form: [
{
number: {
label: 'Valid Number',
variable: 'num_valid',
required: false,
default: 0,
hide: true,
},
},
{
number: {
label: 'NaN Number',
variable: 'num_nan',
required: false,
default: 0,
hide: true,
},
},
],
}
getRawInputsFromUrlParamsMock.mockResolvedValue({
num_valid: '42',
num_nan: 'not-a-number',
})
const { result } = renderHook(() => useTextGenerationAppState({
isInstalledApp: false,
isWorkflow: true,
}))
await waitFor(() => {
expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([
expect.objectContaining({ key: 'num_valid', default: 42 }),
expect.objectContaining({ key: 'num_nan', default: 0 }),
]))
})
})
it('should coerce select url defaults and ignore invalid options', async () => {
mockWebAppState.appParams = {
...defaultAppParams,
user_input_form: [
{
select: {
label: 'Valid Option',
variable: 'sel_valid',
required: false,
default: '',
options: ['alpha', 'beta'],
hide: true,
},
},
{
select: {
label: 'Invalid Option',
variable: 'sel_invalid',
required: false,
default: 'alpha',
options: ['alpha', 'beta'],
hide: true,
},
},
],
}
getRawInputsFromUrlParamsMock.mockResolvedValue({
sel_valid: 'beta',
sel_invalid: 'gamma',
})
const { result } = renderHook(() => useTextGenerationAppState({
isInstalledApp: false,
isWorkflow: true,
}))
await waitFor(() => {
expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([
expect.objectContaining({ key: 'sel_valid', default: 'beta' }),
expect.objectContaining({ key: 'sel_invalid', default: 'alpha' }),
]))
})
})
it('should ignore non-string url values for text inputs', async () => {
mockWebAppState.appParams = {
...defaultAppParams,
user_input_form: [
{
'text-input': {
label: 'Text Field',
variable: 'text_field',
required: false,
max_length: 48,
default: 'original',
hide: true,
},
},
],
}
getRawInputsFromUrlParamsMock.mockResolvedValue({
text_field: 12345,
})
const { result } = renderHook(() => useTextGenerationAppState({
isInstalledApp: false,
isWorkflow: true,
}))
await waitFor(() => {
expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([
expect.objectContaining({ key: 'text_field', default: 'original' }),
]))
})
})
})

View File

@ -6,7 +6,6 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRawInputsFromUrlParams } from '@/app/components/base/chat/utils'
import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import useDocumentTitle from '@/hooks/use-document-title'
@ -32,44 +31,6 @@ type ShareAppParams = {
image_file_size_limit?: number
}
}
const coerceWorkflowUrlDefault = (
promptVariable: NonNullable<PromptConfig['prompt_variables']>[number],
rawValue: unknown,
) => {
if (rawValue === undefined || rawValue === null)
return undefined
if (promptVariable.type === 'checkbox') {
if (typeof rawValue === 'boolean')
return rawValue
const normalized = String(rawValue).toLowerCase()
if (normalized === 'true')
return true
if (normalized === 'false')
return false
return undefined
}
if (promptVariable.type === 'number') {
const numericValue = Number(rawValue)
return Number.isNaN(numericValue) ? undefined : numericValue
}
if (typeof rawValue !== 'string')
return undefined
if (promptVariable.type === 'select')
return promptVariable.options?.includes(rawValue) ? rawValue : undefined
if (promptVariable.max_length)
return rawValue.slice(0, promptVariable.max_length)
return rawValue
}
export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTextGenerationAppStateOptions) => {
const { t } = useTranslation()
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
@ -123,15 +84,6 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex
setCustomConfig((custom_config || null) as TextGenerationCustomConfig | null)
await changeLanguage(site.default_language)
const { user_input_form, more_like_this, file_upload, text_to_speech } = appParams as unknown as ShareAppParams
const promptVariables = userInputsFormToPromptVariables(user_input_form)
if (isWorkflow && !isInstalledApp) {
const workflowUrlInputs = await getRawInputsFromUrlParams()
promptVariables.forEach((promptVariable) => {
const workflowDefault = coerceWorkflowUrlDefault(promptVariable, workflowUrlInputs[promptVariable.key])
if (workflowDefault !== undefined)
promptVariable.default = workflowDefault
})
}
if (cancelled)
return
setVisionConfig({
@ -142,7 +94,7 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex
} as VisionSettings)
setPromptConfig({
prompt_template: '',
prompt_variables: promptVariables,
prompt_variables: userInputsFormToPromptVariables(user_input_form),
} as PromptConfig)
setMoreLikeThisConfig(more_like_this)
setTextToSpeechConfig(text_to_speech)
@ -153,7 +105,7 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex
return () => {
cancelled = true
}
}, [appData, appParams, fetchSavedMessages, isInstalledApp, isWorkflow])
}, [appData, appParams, fetchSavedMessages, isWorkflow])
useDocumentTitle(siteInfo?.title || t('generation.title', { ns: 'share' }))
useAppFavicon({
enable: !isInstalledApp,

View File

@ -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}

View File

@ -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} />)

View File

@ -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}

View File

@ -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()
})
})

View File

@ -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}
/>

View File

@ -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()}

View File

@ -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}
/>
)}
</>
)
}

View File

@ -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

View File

@ -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,
}
}

View File

@ -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)

View File

@ -124,7 +124,7 @@ vi.mock('@/app/components/app/app-publisher', () => ({
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}>
publisher-publish
</button>
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ 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>

View File

@ -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}

View File

@ -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}

Some files were not shown because too many files have changed in this diff Show More