mirror of
https://github.com/langgenius/dify.git
synced 2026-05-16 06:57:11 +08:00
Compare commits
2 Commits
codex/reve
...
chore/simp
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ad90a673b | |||
| d2404d0375 |
@ -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
|
||||
```
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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]):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
@ -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}
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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()
|
||||
@ -202,11 +202,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/add-annotation-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/batch-add-annotation-modal/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -235,11 +230,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/edit-annotation-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/header-opts/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -262,9 +252,6 @@
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 5
|
||||
},
|
||||
@ -282,6 +269,11 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/app/app-publisher/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/app/app-publisher/version-info-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -352,9 +344,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/exhaustive-deps": {
|
||||
"count": 1
|
||||
},
|
||||
@ -412,16 +401,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/configuration-view.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/dataset-config/card-item/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/dataset-config/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -552,9 +531,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/app/log/list.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 6
|
||||
},
|
||||
@ -604,9 +580,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/app/workflow-log/list.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
@ -931,11 +904,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/drawer-plus/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/emoji-picker/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -1061,11 +1029,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/base/float-right-container/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/form/components/base/base-form.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 6
|
||||
@ -1270,7 +1233,7 @@
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/line/development/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/line/editor/index.ts": {
|
||||
@ -2181,6 +2144,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/batch-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -2191,6 +2162,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/completed/components/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": {
|
||||
"ts/no-non-null-asserted-optional-chain": {
|
||||
"count": 1
|
||||
@ -2255,6 +2231,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/segment-add/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 6
|
||||
@ -2296,9 +2280,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/hit-testing/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/unsupported-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
@ -2338,7 +2319,7 @@
|
||||
},
|
||||
"web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": {
|
||||
@ -2832,18 +2813,10 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/model-list.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -2865,9 +2838,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
@ -2926,9 +2896,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
@ -2966,6 +2933,16 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/readme-panel/index.tsx": {
|
||||
"react/unsupported-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/readme-panel/store.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
@ -3193,7 +3170,7 @@
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
|
||||
@ -3202,9 +3179,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 4
|
||||
},
|
||||
@ -3213,9 +3187,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/test-api.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -3225,11 +3196,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/detail/provider-detail.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/mcp-server-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -3258,20 +3224,12 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/provider/detail.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/provider/empty.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/setting/build-in/config-credentials.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
@ -4103,11 +4061,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
@ -28,7 +28,6 @@ Always import from a **subpath export** — there is no barrel:
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import '@langgenius/dify-ui/styles.css' // once, in the app root
|
||||
```
|
||||
@ -37,12 +36,12 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
|
||||
| Category | Subpath | Notes |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
|
||||
|
||||
Utilities:
|
||||
|
||||
@ -66,7 +65,7 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s
|
||||
|
||||
## Overlay & portal contract
|
||||
|
||||
Overlay primitives render their floating surfaces inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Convenience content components such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portal internally; primitives with explicit portal anatomy such as `Drawer` expose the matching `DrawerPortal` part so consumers can compose the full Base UI structure.
|
||||
All overlay primitives (`dialog`, `alert-dialog`, `autocomplete`, `combobox`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually.
|
||||
|
||||
### Root isolation requirement
|
||||
|
||||
@ -84,19 +83,19 @@ Equivalent: any root element with `isolation: isolate` in CSS. Without it, overl
|
||||
|
||||
Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites.
|
||||
|
||||
| Layer | z-index | Where |
|
||||
| ------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
|
||||
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
|
||||
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
|
||||
| Layer | z-index | Where |
|
||||
| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
|
||||
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
|
||||
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
|
||||
|
||||
Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` / `base/drawer` / `base/drawer-plus` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
|
||||
Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
|
||||
|
||||
See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`.
|
||||
|
||||
### Rules
|
||||
|
||||
- Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated.
|
||||
- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal.
|
||||
- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal.
|
||||
- When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites.
|
||||
|
||||
## Development
|
||||
|
||||
@ -37,10 +37,6 @@
|
||||
"types": "./src/dialog/index.tsx",
|
||||
"import": "./src/dialog/index.tsx"
|
||||
},
|
||||
"./drawer": {
|
||||
"types": "./src/drawer/index.tsx",
|
||||
"import": "./src/drawer/index.tsx"
|
||||
},
|
||||
"./dropdown-menu": {
|
||||
"types": "./src/dropdown-menu/index.tsx",
|
||||
"import": "./src/dropdown-menu/index.tsx"
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
DrawerViewport,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
describe('Drawer wrapper', () => {
|
||||
describe('User Interactions', () => {
|
||||
it('should open a portalled drawer and close it with the default close button', async () => {
|
||||
const screen = await render(
|
||||
<Drawer>
|
||||
<DrawerTrigger>Open settings</DrawerTrigger>
|
||||
<DrawerPortal>
|
||||
<DrawerBackdrop data-testid="drawer-backdrop" />
|
||||
<DrawerViewport>
|
||||
<DrawerPopup>
|
||||
<DrawerTitle>Settings</DrawerTitle>
|
||||
<DrawerDescription>Configure the current workspace.</DrawerDescription>
|
||||
<DrawerContent>
|
||||
<p>Workspace controls</p>
|
||||
<DrawerCloseButton />
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument()
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Open settings' }).element()).click()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const dialog = asHTMLElement(document.body.querySelector('[role="dialog"]')!)
|
||||
expect(document.body).toContainElement(dialog)
|
||||
expect(screen.container).not.toContainElement(dialog)
|
||||
await expect.element(dialog).toHaveTextContent('Workspace controls')
|
||||
await expect.element(screen.getByText('Configure the current workspace.')).toBeInTheDocument()
|
||||
await expect.element(screen.getByTestId('drawer-backdrop')).toHaveClass('z-1002')
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Close drawer' }).element()).click()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,116 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Drawer as BaseDrawer } from '@base-ui/react/drawer'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export const Drawer = BaseDrawer.Root
|
||||
export const DrawerProvider = BaseDrawer.Provider
|
||||
export const DrawerIndent = BaseDrawer.Indent
|
||||
export const DrawerIndentBackground = BaseDrawer.IndentBackground
|
||||
export const DrawerTrigger = BaseDrawer.Trigger
|
||||
export const DrawerSwipeArea = BaseDrawer.SwipeArea
|
||||
export const DrawerPortal = BaseDrawer.Portal
|
||||
export const DrawerTitle = BaseDrawer.Title
|
||||
export const DrawerDescription = BaseDrawer.Description
|
||||
export const DrawerClose = BaseDrawer.Close
|
||||
export const createDrawerHandle = BaseDrawer.createHandle
|
||||
|
||||
export type DrawerRootProps<Payload = unknown> = BaseDrawer.Root.Props<Payload>
|
||||
export type DrawerRootActions = BaseDrawer.Root.Actions
|
||||
export type DrawerRootChangeEventDetails = BaseDrawer.Root.ChangeEventDetails
|
||||
export type DrawerRootChangeEventReason = BaseDrawer.Root.ChangeEventReason
|
||||
export type DrawerRootSnapPoint = BaseDrawer.Root.SnapPoint
|
||||
export type DrawerRootSnapPointChangeEventDetails = BaseDrawer.Root.SnapPointChangeEventDetails
|
||||
export type DrawerRootSnapPointChangeEventReason = BaseDrawer.Root.SnapPointChangeEventReason
|
||||
export type DrawerTriggerProps<Payload = unknown> = BaseDrawer.Trigger.Props<Payload>
|
||||
|
||||
export function DrawerBackdrop({
|
||||
className,
|
||||
...props
|
||||
}: BaseDrawer.Backdrop.Props) {
|
||||
return (
|
||||
<BaseDrawer.Backdrop
|
||||
className={cn(
|
||||
'fixed inset-0 z-1002 bg-background-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))]',
|
||||
'transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 motion-reduce:transition-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DrawerViewport({
|
||||
className,
|
||||
...props
|
||||
}: BaseDrawer.Viewport.Props) {
|
||||
return (
|
||||
<BaseDrawer.Viewport
|
||||
className={cn('fixed inset-0 z-1002 touch-none overflow-hidden overscroll-contain outline-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DrawerPopup({
|
||||
className,
|
||||
...props
|
||||
}: BaseDrawer.Popup.Props) {
|
||||
return (
|
||||
<BaseDrawer.Popup
|
||||
className={cn(
|
||||
'fixed z-1002 flex min-h-0 flex-col overflow-hidden border-[0.5px] border-components-panel-border bg-components-panel-bg text-text-primary shadow-xl outline-hidden touch-none',
|
||||
'transition-[transform,opacity,box-shadow] duration-200 data-swiping:select-none data-swiping:duration-0 motion-reduce:transition-none',
|
||||
'data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:h-dvh data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-2rem)] data-[swipe-direction=right]:rounded-l-2xl data-[swipe-direction=right]:border-r-0 data-[swipe-direction=right]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]',
|
||||
'data-starting-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))] data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))]',
|
||||
'data-[swipe-direction=left]:inset-y-0 data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:h-dvh data-[swipe-direction=left]:w-120 data-[swipe-direction=left]:max-w-[calc(100vw-2rem)] data-[swipe-direction=left]:rounded-r-2xl data-[swipe-direction=left]:border-l-0 data-[swipe-direction=left]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]',
|
||||
'data-starting-style:data-[swipe-direction=left]:transform-[translateX(calc(-100%-2px))] data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(-100%-2px))]',
|
||||
'data-[swipe-direction=down]:inset-x-0 data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:max-h-[calc(100dvh-2rem)] data-[swipe-direction=down]:w-full data-[swipe-direction=down]:rounded-t-2xl data-[swipe-direction=down]:border-b-0 data-[swipe-direction=down]:transform-[translateY(calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)))]',
|
||||
'data-starting-style:data-[swipe-direction=down]:transform-[translateY(calc(100%+2px))] data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(100%+2px))]',
|
||||
'data-[swipe-direction=up]:inset-x-0 data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:max-h-[calc(100dvh-2rem)] data-[swipe-direction=up]:w-full data-[swipe-direction=up]:rounded-b-2xl data-[swipe-direction=up]:border-t-0 data-[swipe-direction=up]:transform-[translateY(var(--drawer-swipe-movement-y,0px))]',
|
||||
'data-starting-style:data-[swipe-direction=up]:transform-[translateY(calc(-100%-2px))] data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(-100%-2px))]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DrawerContent({
|
||||
className,
|
||||
...props
|
||||
}: BaseDrawer.Content.Props) {
|
||||
return (
|
||||
<BaseDrawer.Content
|
||||
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain p-6 pb-[calc(1.5rem+env(safe-area-inset-bottom,0))]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DrawerCloseButtonProps = Omit<BaseDrawer.Close.Props, 'children'> & {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function DrawerCloseButton({
|
||||
className,
|
||||
children,
|
||||
type = 'button',
|
||||
'aria-label': ariaLabel = 'Close drawer',
|
||||
...props
|
||||
}: DrawerCloseButtonProps) {
|
||||
return (
|
||||
<BaseDrawer.Close
|
||||
type={type}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <span aria-hidden="true" className="i-ri-close-line h-4 w-4" />}
|
||||
</BaseDrawer.Close>
|
||||
)
|
||||
}
|
||||
@ -205,7 +205,7 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
|
||||
default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
|
||||
<div data-testid="workflow-tool-modal">
|
||||
<button data-testid="wf-modal-hide" onClick={onHide}>Hide</button>
|
||||
<button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>Save</button>
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: '' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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:'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
@ -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 })
|
||||
})
|
||||
|
||||
@ -54,22 +54,22 @@ const Operation: FC<Props> = ({
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
|
||||
state={
|
||||
isActive
|
||||
? ActionButtonState.Active
|
||||
: open
|
||||
? ActionButtonState.Hover
|
||||
: ActionButtonState.Default
|
||||
}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
render={<div />}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
>
|
||||
<ActionButton
|
||||
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
|
||||
state={
|
||||
isActive
|
||||
? ActionButtonState.Active
|
||||
: open
|
||||
? ActionButtonState.Hover
|
||||
: ActionButtonState.Default
|
||||
}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
|
||||
@ -182,13 +182,11 @@ describe('useChatLayout', () => {
|
||||
|
||||
act(() => {
|
||||
capturedResizeCallbacks[0]?.([makeResizeEntry(80, 400)], {} as ResizeObserver)
|
||||
flushAnimationFrames()
|
||||
})
|
||||
expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px')
|
||||
|
||||
act(() => {
|
||||
capturedResizeCallbacks[1]?.([makeResizeEntry(50, 560)], {} as ResizeObserver)
|
||||
flushAnimationFrames()
|
||||
})
|
||||
expect(screen.getByTestId('chat-footer').style.width).toBe('560px')
|
||||
|
||||
|
||||
@ -12,11 +12,6 @@ type UseChatLayoutOptions = {
|
||||
sidebarCollapseState?: boolean
|
||||
}
|
||||
|
||||
const setStyleValue = (element: HTMLElement, property: 'paddingBottom' | 'width', value: string) => {
|
||||
if (element.style[property] !== value)
|
||||
element.style[property] = value
|
||||
}
|
||||
|
||||
export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => {
|
||||
const [width, setWidth] = useState(0)
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null)
|
||||
@ -26,9 +21,6 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
|
||||
const userScrolledRef = useRef(false)
|
||||
const isAutoScrollingRef = useRef(false)
|
||||
const prevFirstMessageIdRef = useRef<string | undefined>(undefined)
|
||||
const resizeObserverFrameRef = useRef<number | null>(null)
|
||||
const pendingFooterBlockSizeRef = useRef<number | null>(null)
|
||||
const pendingContainerInlineSizeRef = useRef<number | null>(null)
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) {
|
||||
@ -42,39 +34,16 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
|
||||
}, [chatList.length])
|
||||
|
||||
const handleWindowResize = useCallback(() => {
|
||||
if (chatContainerRef.current) {
|
||||
const nextWidth = document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8
|
||||
setWidth(currentWidth => currentWidth === nextWidth ? currentWidth : nextWidth)
|
||||
}
|
||||
if (chatContainerRef.current)
|
||||
setWidth(document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8)
|
||||
|
||||
if (chatContainerRef.current && chatFooterRef.current)
|
||||
setStyleValue(chatFooterRef.current, 'width', `${chatContainerRef.current.clientWidth}px`)
|
||||
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
|
||||
|
||||
if (chatContainerInnerRef.current && chatFooterInnerRef.current)
|
||||
setStyleValue(chatFooterInnerRef.current, 'width', `${chatContainerInnerRef.current.clientWidth}px`)
|
||||
chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px`
|
||||
}, [])
|
||||
|
||||
const scheduleResizeObserverUpdate = useCallback(() => {
|
||||
if (resizeObserverFrameRef.current !== null)
|
||||
return
|
||||
|
||||
resizeObserverFrameRef.current = requestAnimationFrame(() => {
|
||||
resizeObserverFrameRef.current = null
|
||||
|
||||
const footerBlockSize = pendingFooterBlockSizeRef.current
|
||||
pendingFooterBlockSizeRef.current = null
|
||||
if (footerBlockSize !== null && chatContainerRef.current) {
|
||||
setStyleValue(chatContainerRef.current, 'paddingBottom', `${footerBlockSize}px`)
|
||||
handleScrollToBottom()
|
||||
}
|
||||
|
||||
const containerInlineSize = pendingContainerInlineSizeRef.current
|
||||
pendingContainerInlineSizeRef.current = null
|
||||
if (containerInlineSize !== null && chatFooterRef.current)
|
||||
setStyleValue(chatFooterRef.current, 'width', `${containerInlineSize}px`)
|
||||
})
|
||||
}, [handleScrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
handleScrollToBottom()
|
||||
const animationFrame = requestAnimationFrame(handleWindowResize)
|
||||
@ -108,31 +77,26 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
|
||||
const resizeContainerObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { blockSize } = entry.borderBoxSize[0]!
|
||||
pendingFooterBlockSizeRef.current = blockSize
|
||||
chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
|
||||
handleScrollToBottom()
|
||||
}
|
||||
scheduleResizeObserverUpdate()
|
||||
})
|
||||
resizeContainerObserver.observe(chatFooterRef.current)
|
||||
|
||||
const resizeFooterObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { inlineSize } = entry.borderBoxSize[0]!
|
||||
pendingContainerInlineSizeRef.current = inlineSize
|
||||
chatFooterRef.current!.style.width = `${inlineSize}px`
|
||||
}
|
||||
scheduleResizeObserverUpdate()
|
||||
})
|
||||
resizeFooterObserver.observe(chatContainerRef.current)
|
||||
|
||||
return () => {
|
||||
if (resizeObserverFrameRef.current !== null) {
|
||||
cancelAnimationFrame(resizeObserverFrameRef.current)
|
||||
resizeObserverFrameRef.current = null
|
||||
}
|
||||
resizeContainerObserver.disconnect()
|
||||
resizeFooterObserver.disconnect()
|
||||
}
|
||||
}
|
||||
}, [scheduleResizeObserverUpdate])
|
||||
}, [handleScrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
const setUserScrolled = () => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export { default as BracketsX } from './BracketsX'
|
||||
export { default as CodeBrowser } from './CodeBrowser'
|
||||
|
||||
@ -120,12 +120,18 @@ vi.mock('../document-title', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../segment-add', () => ({
|
||||
SegmentAdd: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => (
|
||||
default: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => (
|
||||
<div data-testid="segment-add" data-embedding={embedding}>
|
||||
<button data-testid="new-segment-btn" onClick={showNewSegmentModal}>New Segment</button>
|
||||
<button data-testid="batch-btn" onClick={showBatchModal}>Batch Import</button>
|
||||
</div>
|
||||
),
|
||||
ProcessStatus: {
|
||||
WAITING: 'waiting',
|
||||
PROCESSING: 'processing',
|
||||
ERROR: 'error',
|
||||
COMPLETED: 'completed',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../components/operations', () => ({
|
||||
|
||||
@ -2,15 +2,12 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ChunkingMode, FileItem } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import CSVDownloader from './csv-downloader'
|
||||
import CSVUploader from './csv-uploader'
|
||||
|
||||
@ -21,9 +18,8 @@ type IBatchModalProps = {
|
||||
onConfirm: (file: FileItem) => void
|
||||
}
|
||||
|
||||
type BatchModalContentProps = Omit<IBatchModalProps, 'isShow'>
|
||||
|
||||
const BatchModalContent: FC<BatchModalContentProps> = ({
|
||||
const BatchModal: FC<IBatchModalProps> = ({
|
||||
isShow,
|
||||
docForm,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
@ -39,13 +35,17 @@ const BatchModalContent: FC<BatchModalContentProps> = ({
|
||||
onConfirm(currentCSV)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShow)
|
||||
setCurrentCSV(undefined)
|
||||
}, [isShow])
|
||||
|
||||
return (
|
||||
<DialogContent className="w-[520px]! overflow-hidden! rounded-xl! border-0! px-8 py-6">
|
||||
<DialogTitle className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary">{t('list.batchModal.title', { ns: 'datasetDocuments' })}</DialogTitle>
|
||||
<DialogCloseButton
|
||||
className="top-4 right-4"
|
||||
aria-label={t('list.batchModal.cancel', { ns: 'datasetDocuments' })}
|
||||
/>
|
||||
<Modal isShow={isShow} onClose={noop} className="max-w-[520px]! rounded-xl! px-8 py-6">
|
||||
<div className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary">{t('list.batchModal.title', { ns: 'datasetDocuments' })}</div>
|
||||
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onCancel}>
|
||||
<RiCloseLine className="h-4 w-4 text-text-secondary" />
|
||||
</div>
|
||||
<CSVUploader
|
||||
file={currentCSV}
|
||||
updateFile={handleFile}
|
||||
@ -61,33 +61,7 @@ const BatchModalContent: FC<BatchModalContentProps> = ({
|
||||
{t('list.batchModal.run', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const BatchModal: FC<IBatchModalProps> = ({
|
||||
isShow,
|
||||
docForm,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={isShow}
|
||||
onOpenChange={open => !open && onCancel()}
|
||||
disablePointerDismissal
|
||||
>
|
||||
{isShow
|
||||
? (
|
||||
<BatchModalContent
|
||||
docForm={docForm}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BatchModal)
|
||||
|
||||
@ -137,8 +137,9 @@ vi.mock('../hooks/use-child-segment-data', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/menu-bar', () => ({
|
||||
default: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
|
||||
// Mock child components to simplify testing
|
||||
vi.mock('../components', () => ({
|
||||
MenuBar: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
|
||||
totalText: string
|
||||
onInputChange: (value: string) => void
|
||||
inputValue: string
|
||||
@ -166,13 +167,7 @@ vi.mock('../components/menu-bar', () => ({
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/drawer-group', () => ({
|
||||
DrawerGroup: () => <div data-testid="drawer-group" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/segment-list-content', () => ({
|
||||
FullDocModeContent: () => <div data-testid="full-doc-mode-content" />,
|
||||
GeneralModeContent: () => <div data-testid="general-mode-content" />,
|
||||
}))
|
||||
@ -568,7 +563,7 @@ describe('Edge Cases', () => {
|
||||
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle completed importStatus', () => {
|
||||
it('should handle ProcessStatus.COMPLETED importStatus', () => {
|
||||
render(<Completed {...defaultProps} importStatus="completed" />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()
|
||||
|
||||
@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
|
||||
import { SegmentDetail } from '../segment-detail'
|
||||
import SegmentDetail from '../segment-detail'
|
||||
|
||||
// Mock dataset detail context
|
||||
let mockIndexingTechnique = IndexingType.QUALIFIED
|
||||
@ -167,6 +167,7 @@ describe('SegmentDetail', () => {
|
||||
onCancel: vi.fn(),
|
||||
isEditMode: false,
|
||||
docForm: ChunkingMode.text,
|
||||
onModalStateChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -351,12 +352,35 @@ describe('SegmentDetail', () => {
|
||||
expect(screen.getByTestId('regeneration-modal'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onModalStateChange when regeneration modal opens', () => {
|
||||
const mockOnModalStateChange = vi.fn()
|
||||
render(
|
||||
<SegmentDetail
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
onModalStateChange={mockOnModalStateChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('regenerate-btn'))
|
||||
|
||||
expect(mockOnModalStateChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should close modal when cancel is clicked', () => {
|
||||
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
|
||||
const mockOnModalStateChange = vi.fn()
|
||||
render(
|
||||
<SegmentDetail
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
onModalStateChange={mockOnModalStateChange}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('regenerate-btn'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-regeneration'))
|
||||
|
||||
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
|
||||
expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -480,18 +504,22 @@ describe('SegmentDetail', () => {
|
||||
|
||||
it('should close modal and edit drawer when close after regeneration is clicked', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnModalStateChange = vi.fn()
|
||||
render(
|
||||
<SegmentDetail
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
onCancel={mockOnCancel}
|
||||
onModalStateChange={mockOnModalStateChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open regeneration modal
|
||||
fireEvent.click(screen.getByTestId('regenerate-btn'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-regeneration'))
|
||||
|
||||
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,16 +1,27 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CompletedDrawer } from '../drawer'
|
||||
import Drawer from '../drawer'
|
||||
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
BASE_UI_ANIMATIONS_DISABLED: boolean
|
||||
}
|
||||
).BASE_UI_ANIMATIONS_DISABLED = true
|
||||
let capturedKeyPressCallback: ((e: KeyboardEvent) => void) | undefined
|
||||
|
||||
const getOverlay = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>('[class]'))
|
||||
.find(element => element.className.includes('bg-background-overlay'))
|
||||
// Mock useKeyPress: required because tests capture the registered callback
|
||||
// and invoke it directly to verify ESC key handling behavior.
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: vi.fn((_key: string, cb: (e: KeyboardEvent) => void) => {
|
||||
capturedKeyPressCallback = cb
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../..', () => ({
|
||||
useSegmentListContext: (selector: (state: {
|
||||
currSegment: { showModal: boolean }
|
||||
currChildChunk: { showModal: boolean }
|
||||
}) => unknown) =>
|
||||
selector({
|
||||
currSegment: { showModal: false },
|
||||
currChildChunk: { showModal: false },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Drawer', () => {
|
||||
const defaultProps = {
|
||||
@ -20,109 +31,103 @@ describe('Drawer', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedKeyPressCallback = undefined
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should return null when open is false', () => {
|
||||
const { container } = render(
|
||||
<CompletedDrawer open={false} onClose={vi.fn()}>
|
||||
<Drawer open={false} onClose={vi.fn()}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
expect(screen.queryByText('Content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children in the drawer portal when open is true', () => {
|
||||
it('should render children in portal when open is true', () => {
|
||||
render(
|
||||
<CompletedDrawer {...defaultProps}>
|
||||
<Drawer {...defaultProps}>
|
||||
<span>Drawer content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Drawer content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dialog with role="dialog"', () => {
|
||||
render(
|
||||
<Drawer {...defaultProps}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variant', () => {
|
||||
it('should render a panel drawer without overlay by default', () => {
|
||||
// Overlay visibility
|
||||
describe('Overlay', () => {
|
||||
it('should show overlay when showOverlay is true', () => {
|
||||
render(
|
||||
<CompletedDrawer {...defaultProps}>
|
||||
<Drawer {...defaultProps} showOverlay={true}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(getOverlay()).toBeUndefined()
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false')
|
||||
const overlay = document.querySelector('[aria-hidden="true"]')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a modal drawer with overlay', () => {
|
||||
it('should hide overlay when showOverlay is false', () => {
|
||||
render(
|
||||
<CompletedDrawer {...defaultProps} modal>
|
||||
<Drawer {...defaultProps} showOverlay={false}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(getOverlay()).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
|
||||
const overlay = document.querySelector('[aria-hidden="true"]')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dismissal', () => {
|
||||
it('should call onClose when Escape is pressed', async () => {
|
||||
const onClose = vi.fn()
|
||||
// aria-modal attribute
|
||||
describe('aria-modal', () => {
|
||||
it('should set aria-modal="true" when modal is true', () => {
|
||||
render(
|
||||
<CompletedDrawer open={true} onClose={onClose}>
|
||||
<Drawer {...defaultProps} modal={true}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
|
||||
})
|
||||
|
||||
it('should keep a panel drawer open when the underlying page is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
it('should set aria-modal="false" when modal is false', () => {
|
||||
render(
|
||||
<>
|
||||
<button type="button">Outside</button>
|
||||
<CompletedDrawer open={true} onClose={onClose}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>
|
||||
</>,
|
||||
)
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' }))
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep a panel drawer open when the pointer down starts inside content', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<CompletedDrawer open={true} onClose={onClose}>
|
||||
<button type="button">Inside</button>
|
||||
</CompletedDrawer>,
|
||||
)
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'Inside' }))
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
it('should close a modal drawer when the overlay is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<CompletedDrawer open={true} onClose={onClose} modal>
|
||||
<Drawer {...defaultProps} modal={false}>
|
||||
<span>Content</span>
|
||||
</CompletedDrawer>,
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
fireEvent.click(getOverlay()!)
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
// ESC key handling
|
||||
describe('ESC Key', () => {
|
||||
it('should call onClose when ESC is pressed and drawer is open', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Drawer open={true} onClose={onClose}>
|
||||
<span>Content</span>
|
||||
</Drawer>,
|
||||
)
|
||||
|
||||
expect(capturedKeyPressCallback).toBeDefined()
|
||||
const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
|
||||
capturedKeyPressCallback!(fakeEvent)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DocumentDetailDrawer } from '../full-screen-drawer'
|
||||
import FullScreenDrawer from '../full-screen-drawer'
|
||||
|
||||
// Mock the Drawer component since it has high complexity
|
||||
vi.mock('../drawer', () => ({
|
||||
CompletedDrawer: ({ children, open, panelClassName, panelContentClassName, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, modal: boolean }) => {
|
||||
default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => {
|
||||
if (!open)
|
||||
return null
|
||||
return (
|
||||
@ -13,6 +13,8 @@ vi.mock('../drawer', () => ({
|
||||
data-testid="drawer-mock"
|
||||
data-panel-class={panelClassName}
|
||||
data-panel-content-class={panelContentClassName}
|
||||
data-show-overlay={showOverlay}
|
||||
data-need-check-chunks={needCheckChunks}
|
||||
data-modal={modal}
|
||||
>
|
||||
{children}
|
||||
@ -21,7 +23,7 @@ vi.mock('../drawer', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
describe('DocumentDetailDrawer', () => {
|
||||
describe('FullScreenDrawer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@ -29,9 +31,9 @@ describe('DocumentDetailDrawer', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when open', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
|
||||
@ -39,9 +41,9 @@ describe('DocumentDetailDrawer', () => {
|
||||
|
||||
it('should not render when closed', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={false} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={false} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
|
||||
@ -49,9 +51,9 @@ describe('DocumentDetailDrawer', () => {
|
||||
|
||||
it('should render children content', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Test Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument()
|
||||
@ -61,46 +63,86 @@ describe('DocumentDetailDrawer', () => {
|
||||
describe('Props', () => {
|
||||
it('should pass fullScreen=true to Drawer with full width class', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={true}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('w-full')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-full')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-full')
|
||||
})
|
||||
|
||||
it('should pass fullScreen=false to Drawer with fixed width class', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-[568px]')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-[568px]')
|
||||
})
|
||||
|
||||
it('should render as non-modal by default', () => {
|
||||
it('should pass showOverlay prop with default true', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-show-overlay')).toBe('true')
|
||||
})
|
||||
|
||||
it('should pass showOverlay=false when specified', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false} showOverlay={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-show-overlay')).toBe('false')
|
||||
})
|
||||
|
||||
it('should pass needCheckChunks prop with default false', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-need-check-chunks')).toBe('false')
|
||||
})
|
||||
|
||||
it('should pass needCheckChunks=true when specified', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false} needCheckChunks={true}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-need-check-chunks')).toBe('true')
|
||||
})
|
||||
|
||||
it('should pass modal prop with default false', () => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-modal')).toBe('false')
|
||||
})
|
||||
|
||||
it('should pass modal when specified', () => {
|
||||
it('should pass modal=true when specified', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false} modal>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false} modal={true}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
@ -112,9 +154,9 @@ describe('DocumentDetailDrawer', () => {
|
||||
describe('Styling', () => {
|
||||
it('should apply panel content classes for non-fullScreen mode', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
@ -125,9 +167,9 @@ describe('DocumentDetailDrawer', () => {
|
||||
|
||||
it('should apply panel content classes without border for fullScreen mode', () => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={true}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
@ -142,24 +184,24 @@ describe('DocumentDetailDrawer', () => {
|
||||
// Arrange & Act & Assert - should not throw
|
||||
expect(() => {
|
||||
render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
const { rerender } = render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<DocumentDetailDrawer open={true} fullScreen={true}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<div>Updated Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Updated Content')).toBeInTheDocument()
|
||||
@ -167,16 +209,16 @@ describe('DocumentDetailDrawer', () => {
|
||||
|
||||
it('should handle toggle between open and closed states', () => {
|
||||
const { rerender } = render(
|
||||
<DocumentDetailDrawer open={true} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<DocumentDetailDrawer open={false} fullScreen={false}>
|
||||
<FullScreenDrawer isOpen={false} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</DocumentDetailDrawer>,
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
|
||||
|
||||
@ -1,92 +1,143 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerContent,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useSegmentListContext } from '..'
|
||||
|
||||
type DrawerSide = 'right' | 'left' | 'bottom' | 'top'
|
||||
type DrawerSwipeDirection = 'right' | 'left' | 'down' | 'up'
|
||||
type DrawerOpenChange = NonNullable<ComponentProps<typeof Drawer>['onOpenChange']>
|
||||
|
||||
type CompletedDrawerProps = {
|
||||
type DrawerProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
side?: DrawerSide
|
||||
side?: 'right' | 'left' | 'bottom' | 'top'
|
||||
showOverlay?: boolean
|
||||
modal?: boolean // click outside event can pass through if modal is false
|
||||
closeOnOutsideClick?: boolean
|
||||
panelClassName?: string
|
||||
panelContentClassName?: string
|
||||
modal?: boolean
|
||||
children: ReactNode
|
||||
needCheckChunks?: boolean
|
||||
}
|
||||
|
||||
const SIDE_TO_SWIPE_DIRECTION: Record<DrawerSide, DrawerSwipeDirection> = {
|
||||
right: 'right',
|
||||
left: 'left',
|
||||
bottom: 'down',
|
||||
top: 'up',
|
||||
const SIDE_POSITION_CLASS = {
|
||||
right: 'right-0',
|
||||
left: 'left-0',
|
||||
bottom: 'bottom-0',
|
||||
top: 'top-0',
|
||||
} as const
|
||||
|
||||
function containsTarget(selector: string, target: Node | null): boolean {
|
||||
const elements = document.querySelectorAll(selector)
|
||||
return Array.from(elements).some(el => el?.contains(target))
|
||||
}
|
||||
|
||||
const DRAWER_POPUP_CLASS_NAME = [
|
||||
'pointer-events-auto overflow-visible border-0 bg-transparent shadow-none',
|
||||
'data-[swipe-direction=right]:h-screen data-[swipe-direction=right]:max-w-none data-[swipe-direction=right]:rounded-none data-[swipe-direction=right]:border-0',
|
||||
'data-[swipe-direction=left]:h-screen data-[swipe-direction=left]:max-w-none data-[swipe-direction=left]:rounded-none data-[swipe-direction=left]:border-0',
|
||||
'data-[swipe-direction=down]:max-h-none data-[swipe-direction=down]:rounded-none data-[swipe-direction=down]:border-0',
|
||||
'data-[swipe-direction=up]:max-h-none data-[swipe-direction=up]:rounded-none data-[swipe-direction=up]:border-0',
|
||||
].join(' ')
|
||||
function shouldReopenChunkDetail(
|
||||
isClickOnChunk: boolean,
|
||||
isClickOnChildChunk: boolean,
|
||||
segmentModalOpen: boolean,
|
||||
childChunkModalOpen: boolean,
|
||||
): boolean {
|
||||
if (segmentModalOpen && isClickOnChildChunk)
|
||||
return true
|
||||
if (childChunkModalOpen && isClickOnChunk && !isClickOnChildChunk)
|
||||
return true
|
||||
return !isClickOnChunk && !isClickOnChildChunk
|
||||
}
|
||||
|
||||
export function CompletedDrawer({
|
||||
const Drawer = ({
|
||||
open,
|
||||
onClose,
|
||||
side = 'right',
|
||||
showOverlay = true,
|
||||
modal = false,
|
||||
needCheckChunks = false,
|
||||
children,
|
||||
panelClassName,
|
||||
panelContentClassName,
|
||||
modal = false,
|
||||
}: CompletedDrawerProps) {
|
||||
const handleOpenChange: DrawerOpenChange = (nextOpen, eventDetails) => {
|
||||
if (nextOpen)
|
||||
return
|
||||
}: React.PropsWithChildren<DrawerProps>) => {
|
||||
const panelContentRef = useRef<HTMLDivElement>(null)
|
||||
const currSegment = useSegmentListContext(s => s.currSegment)
|
||||
const currChildChunk = useSegmentListContext(s => s.currChildChunk)
|
||||
|
||||
if (eventDetails.reason === 'focus-out' || eventDetails.reason === 'outside-press')
|
||||
useKeyPress('esc', (e) => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
const shouldCloseDrawer = useCallback((target: Node | null) => {
|
||||
const panelContent = panelContentRef.current
|
||||
if (!panelContent || !target)
|
||||
return false
|
||||
|
||||
if (panelContent.contains(target))
|
||||
return false
|
||||
|
||||
if (containsTarget('.image-previewer', target))
|
||||
return false
|
||||
|
||||
if (!needCheckChunks)
|
||||
return true
|
||||
|
||||
const isClickOnChunk = containsTarget('.chunk-card', target)
|
||||
const isClickOnChildChunk = containsTarget('.child-chunk', target)
|
||||
return shouldReopenChunkDetail(isClickOnChunk, isClickOnChildChunk, currSegment.showModal, currChildChunk.showModal)
|
||||
}, [currSegment.showModal, currChildChunk.showModal, needCheckChunks])
|
||||
|
||||
const onDownCapture = useCallback((e: PointerEvent) => {
|
||||
if (!open || modal)
|
||||
return
|
||||
const panelContent = panelContentRef.current
|
||||
if (!panelContent)
|
||||
return
|
||||
const target = e.target as Node | null
|
||||
if (shouldCloseDrawer(target))
|
||||
queueMicrotask(onClose)
|
||||
}, [shouldCloseDrawer, onClose, open, modal])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('pointerdown', onDownCapture, { capture: true })
|
||||
return () =>
|
||||
window.removeEventListener('pointerdown', onDownCapture, { capture: true })
|
||||
}, [onDownCapture])
|
||||
|
||||
const isHorizontal = side === 'left' || side === 'right'
|
||||
|
||||
const overlayPointerEvents = modal && open ? 'pointer-events-auto' : 'pointer-events-none'
|
||||
|
||||
const content = (
|
||||
<div className="pointer-events-none fixed inset-0 z-9999">
|
||||
{showOverlay && (
|
||||
<div
|
||||
onClick={modal ? onClose : undefined}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/30 opacity-0 transition-opacity duration-200 ease-in',
|
||||
open && 'opacity-100',
|
||||
overlayPointerEvents,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal={modal ? 'true' : 'false'}
|
||||
className={cn(
|
||||
'pointer-events-auto fixed flex flex-col',
|
||||
SIDE_POSITION_CLASS[side],
|
||||
isHorizontal ? 'h-screen' : 'w-screen',
|
||||
panelClassName,
|
||||
)}
|
||||
>
|
||||
<div ref={panelContentRef} className={cn('flex grow flex-col', panelContentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
modal={modal}
|
||||
swipeDirection={SIDE_TO_SWIPE_DIRECTION[side]}
|
||||
disablePointerDismissal
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DrawerPortal>
|
||||
{modal && (
|
||||
<DrawerBackdrop
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
<DrawerViewport className="pointer-events-none">
|
||||
<DrawerPopup
|
||||
aria-modal={modal ? 'true' : 'false'}
|
||||
className={cn(DRAWER_POPUP_CLASS_NAME, panelClassName)}
|
||||
>
|
||||
<DrawerContent
|
||||
className={cn('flex grow flex-col overflow-visible p-0 pb-0', panelContentClassName)}
|
||||
>
|
||||
{children}
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>
|
||||
)
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
|
||||
export default Drawer
|
||||
|
||||
@ -1,39 +1,46 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { CompletedDrawer } from './drawer'
|
||||
import * as React from 'react'
|
||||
import Drawer from './drawer'
|
||||
|
||||
type DocumentDetailDrawerProps = {
|
||||
open: boolean
|
||||
type IFullScreenDrawerProps = {
|
||||
isOpen: boolean
|
||||
onClose?: () => void
|
||||
fullScreen: boolean
|
||||
showOverlay?: boolean
|
||||
needCheckChunks?: boolean
|
||||
modal?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function DocumentDetailDrawer({
|
||||
open,
|
||||
const FullScreenDrawer = ({
|
||||
isOpen,
|
||||
onClose = noop,
|
||||
fullScreen,
|
||||
children,
|
||||
showOverlay = true,
|
||||
needCheckChunks = false,
|
||||
modal = false,
|
||||
}: DocumentDetailDrawerProps) {
|
||||
}: React.PropsWithChildren<IFullScreenDrawerProps>) => {
|
||||
return (
|
||||
<CompletedDrawer
|
||||
open={open}
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
panelClassName={cn(
|
||||
fullScreen
|
||||
? 'w-full data-[swipe-direction=left]:w-full data-[swipe-direction=right]:w-full'
|
||||
: 'w-[568px] pt-16 pr-2 pb-2 data-[swipe-direction=left]:w-[568px] data-[swipe-direction=right]:w-[568px]',
|
||||
? 'w-full'
|
||||
: 'w-[568px] pt-16 pr-2 pb-2',
|
||||
)}
|
||||
panelContentClassName={cn(
|
||||
'bg-components-panel-bg',
|
||||
!fullScreen && 'rounded-xl border-[0.5px] border-components-panel-border',
|
||||
)}
|
||||
showOverlay={showOverlay}
|
||||
needCheckChunks={needCheckChunks}
|
||||
modal={modal}
|
||||
>
|
||||
{children}
|
||||
</CompletedDrawer>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullScreenDrawer
|
||||
|
||||
@ -2,16 +2,16 @@ import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import { DrawerGroup } from '../drawer-group'
|
||||
import DrawerGroup from '../drawer-group'
|
||||
|
||||
vi.mock('../../common/full-screen-drawer', () => ({
|
||||
DocumentDetailDrawer: ({ open, children, modal = false }: { open: boolean, children: React.ReactNode, modal?: boolean }) => (
|
||||
open ? <div data-testid="document-detail-drawer" data-modal={modal}>{children}</div> : null
|
||||
default: ({ isOpen, children }: { isOpen: boolean, children: React.ReactNode }) => (
|
||||
isOpen ? <div data-testid="full-screen-drawer">{children}</div> : null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../segment-detail', () => ({
|
||||
SegmentDetail: () => <div data-testid="segment-detail" />,
|
||||
default: () => <div data-testid="segment-detail" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../child-segment-detail', () => ({
|
||||
@ -31,6 +31,8 @@ describe('DrawerGroup', () => {
|
||||
currSegment: { segInfo: undefined, showModal: false, isEditMode: false },
|
||||
onCloseSegmentDetail: vi.fn(),
|
||||
onUpdateSegment: vi.fn(),
|
||||
isRegenerationModalOpen: false,
|
||||
setIsRegenerationModalOpen: vi.fn(),
|
||||
showNewSegmentModal: false,
|
||||
onCloseNewSegmentModal: vi.fn(),
|
||||
onSaveNewSegment: vi.fn(),
|
||||
@ -53,7 +55,7 @@ describe('DrawerGroup', () => {
|
||||
|
||||
it('should render nothing when all modals are closed', () => {
|
||||
const { container } = render(<DrawerGroup {...defaultProps} />)
|
||||
expect(container.querySelector('[data-testid="document-detail-drawer"]')).toBeNull()
|
||||
expect(container.querySelector('[data-testid="full-screen-drawer"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('should render segment detail when segment modal is open', () => {
|
||||
@ -64,7 +66,6 @@ describe('DrawerGroup', () => {
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('segment-detail')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false')
|
||||
})
|
||||
|
||||
it('should render new segment modal when showNewSegmentModal is true', () => {
|
||||
@ -72,7 +73,6 @@ describe('DrawerGroup', () => {
|
||||
<DrawerGroup {...defaultProps} showNewSegmentModal={true} />,
|
||||
)
|
||||
expect(screen.getByTestId('new-segment')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true')
|
||||
})
|
||||
|
||||
it('should render child segment detail when child chunk modal is open', () => {
|
||||
@ -83,7 +83,6 @@ describe('DrawerGroup', () => {
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('child-segment-detail')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false')
|
||||
})
|
||||
|
||||
it('should render new child segment modal when showNewChildSegmentModal is true', () => {
|
||||
@ -91,7 +90,6 @@ describe('DrawerGroup', () => {
|
||||
<DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />,
|
||||
)
|
||||
expect(screen.getByTestId('new-child-segment')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true')
|
||||
})
|
||||
|
||||
it('should render multiple drawers simultaneously', () => {
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
|
||||
import type { ChildChunkDetail, ChunkingMode, SegmentDetailModel } from '@/models/datasets'
|
||||
import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
|
||||
import ChildSegmentDetail from '../child-segment-detail'
|
||||
import { DocumentDetailDrawer } from '../common/full-screen-drawer'
|
||||
import FullScreenDrawer from '../common/full-screen-drawer'
|
||||
import NewChildSegment from '../new-child-segment'
|
||||
import { SegmentDetail } from '../segment-detail'
|
||||
import SegmentDetail from '../segment-detail'
|
||||
|
||||
type DrawerGroupProps = {
|
||||
// Segment detail drawer
|
||||
currSegment: {
|
||||
segInfo?: SegmentDetailModel
|
||||
showModal: boolean
|
||||
@ -23,10 +25,14 @@ type DrawerGroupProps = {
|
||||
summary?: string,
|
||||
needRegenerate?: boolean,
|
||||
) => Promise<void>
|
||||
isRegenerationModalOpen: boolean
|
||||
setIsRegenerationModalOpen: (open: boolean) => void
|
||||
// New segment drawer
|
||||
showNewSegmentModal: boolean
|
||||
onCloseNewSegmentModal: () => void
|
||||
onSaveNewSegment: () => void
|
||||
viewNewlyAddedChunk: () => void
|
||||
// Child segment detail drawer
|
||||
currChildChunk: {
|
||||
childChunkInfo?: ChildChunkDetail
|
||||
showModal: boolean
|
||||
@ -34,39 +40,52 @@ type DrawerGroupProps = {
|
||||
currChunkId: string
|
||||
onCloseChildSegmentDetail: () => void
|
||||
onUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void>
|
||||
// New child segment drawer
|
||||
showNewChildSegmentModal: boolean
|
||||
onCloseNewChildChunkModal: () => void
|
||||
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
|
||||
viewNewlyAddedChildChunk: () => void
|
||||
// Common props
|
||||
fullScreen: boolean
|
||||
docForm: ChunkingMode
|
||||
}
|
||||
|
||||
export function DrawerGroup({
|
||||
const DrawerGroup: FC<DrawerGroupProps> = ({
|
||||
// Segment detail drawer
|
||||
currSegment,
|
||||
onCloseSegmentDetail,
|
||||
onUpdateSegment,
|
||||
isRegenerationModalOpen,
|
||||
setIsRegenerationModalOpen,
|
||||
// New segment drawer
|
||||
showNewSegmentModal,
|
||||
onCloseNewSegmentModal,
|
||||
onSaveNewSegment,
|
||||
viewNewlyAddedChunk,
|
||||
// Child segment detail drawer
|
||||
currChildChunk,
|
||||
currChunkId,
|
||||
onCloseChildSegmentDetail,
|
||||
onUpdateChildChunk,
|
||||
// New child segment drawer
|
||||
showNewChildSegmentModal,
|
||||
onCloseNewChildChunkModal,
|
||||
onSaveNewChildChunk,
|
||||
viewNewlyAddedChildChunk,
|
||||
// Common props
|
||||
fullScreen,
|
||||
docForm,
|
||||
}: DrawerGroupProps) {
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<DocumentDetailDrawer
|
||||
open={currSegment.showModal}
|
||||
{/* Edit or view segment detail */}
|
||||
<FullScreenDrawer
|
||||
isOpen={currSegment.showModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseSegmentDetail}
|
||||
showOverlay={false}
|
||||
needCheckChunks
|
||||
modal={isRegenerationModalOpen}
|
||||
>
|
||||
<SegmentDetail
|
||||
key={currSegment.segInfo?.id}
|
||||
@ -75,11 +94,13 @@ export function DrawerGroup({
|
||||
isEditMode={currSegment.isEditMode}
|
||||
onUpdate={onUpdateSegment}
|
||||
onCancel={onCloseSegmentDetail}
|
||||
onModalStateChange={setIsRegenerationModalOpen}
|
||||
/>
|
||||
</DocumentDetailDrawer>
|
||||
</FullScreenDrawer>
|
||||
|
||||
<DocumentDetailDrawer
|
||||
open={showNewSegmentModal}
|
||||
{/* Create New Segment */}
|
||||
<FullScreenDrawer
|
||||
isOpen={showNewSegmentModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseNewSegmentModal}
|
||||
modal
|
||||
@ -90,12 +111,15 @@ export function DrawerGroup({
|
||||
onSave={onSaveNewSegment}
|
||||
viewNewlyAddedChunk={viewNewlyAddedChunk}
|
||||
/>
|
||||
</DocumentDetailDrawer>
|
||||
</FullScreenDrawer>
|
||||
|
||||
<DocumentDetailDrawer
|
||||
open={currChildChunk.showModal}
|
||||
{/* Edit or view child segment detail */}
|
||||
<FullScreenDrawer
|
||||
isOpen={currChildChunk.showModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseChildSegmentDetail}
|
||||
showOverlay={false}
|
||||
needCheckChunks
|
||||
>
|
||||
<ChildSegmentDetail
|
||||
key={currChildChunk.childChunkInfo?.id}
|
||||
@ -105,10 +129,11 @@ export function DrawerGroup({
|
||||
onUpdate={onUpdateChildChunk}
|
||||
onCancel={onCloseChildSegmentDetail}
|
||||
/>
|
||||
</DocumentDetailDrawer>
|
||||
</FullScreenDrawer>
|
||||
|
||||
<DocumentDetailDrawer
|
||||
open={showNewChildSegmentModal}
|
||||
{/* Create New Child Segment */}
|
||||
<FullScreenDrawer
|
||||
isOpen={showNewChildSegmentModal}
|
||||
fullScreen={fullScreen}
|
||||
onClose={onCloseNewChildChunkModal}
|
||||
modal
|
||||
@ -119,7 +144,9 @@ export function DrawerGroup({
|
||||
onSave={onSaveNewChildChunk}
|
||||
viewNewlyAddedChildChunk={viewNewlyAddedChildChunk}
|
||||
/>
|
||||
</DocumentDetailDrawer>
|
||||
</FullScreenDrawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DrawerGroup
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
export { default as DrawerGroup } from './drawer-group'
|
||||
export { default as MenuBar } from './menu-bar'
|
||||
export { FullDocModeContent, GeneralModeContent } from './segment-list-content'
|
||||
@ -1,9 +1,7 @@
|
||||
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as modalStateHooks from '../use-modal-state'
|
||||
|
||||
const renderDatasetModalState = modalStateHooks.useModalState
|
||||
import { useModalState } from '../use-modal-state'
|
||||
|
||||
describe('useModalState', () => {
|
||||
const onNewSegmentModalChange = vi.fn()
|
||||
@ -12,21 +10,22 @@ describe('useModalState', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const renderModalState = () =>
|
||||
renderHook(() => renderDatasetModalState({ onNewSegmentModalChange }))
|
||||
const renderUseModalState = () =>
|
||||
renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
it('should initialize with all modals closed', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
expect(result.current.currSegment.showModal).toBe(false)
|
||||
expect(result.current.currChildChunk.showModal).toBe(false)
|
||||
expect(result.current.showNewChildSegmentModal).toBe(false)
|
||||
expect(result.current.isRegenerationModalOpen).toBe(false)
|
||||
expect(result.current.fullScreen).toBe(false)
|
||||
expect(result.current.isCollapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('should open segment detail on card click', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
const detail = { id: 'seg-1', content: 'test' } as unknown as SegmentDetailModel
|
||||
|
||||
act(() => {
|
||||
@ -38,25 +37,8 @@ describe('useModalState', () => {
|
||||
expect(result.current.currSegment.isEditMode).toBe(true)
|
||||
})
|
||||
|
||||
it('should close child detail when opening segment detail', () => {
|
||||
const { result } = renderModalState()
|
||||
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
|
||||
const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel
|
||||
|
||||
act(() => {
|
||||
result.current.onClickSlice(childDetail)
|
||||
})
|
||||
act(() => {
|
||||
result.current.onClickCard(segmentDetail)
|
||||
})
|
||||
|
||||
expect(result.current.currSegment.showModal).toBe(true)
|
||||
expect(result.current.currSegment.segInfo).toBe(segmentDetail)
|
||||
expect(result.current.currChildChunk.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should close segment detail and reset fullscreen', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.onClickCard({ id: 'seg-1' } as unknown as SegmentDetailModel)
|
||||
@ -73,7 +55,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should open child segment detail on slice click', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
|
||||
|
||||
act(() => {
|
||||
@ -85,25 +67,8 @@ describe('useModalState', () => {
|
||||
expect(result.current.currChunkId).toBe('seg-1')
|
||||
})
|
||||
|
||||
it('should close segment detail when opening child detail', () => {
|
||||
const { result } = renderModalState()
|
||||
const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel
|
||||
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
|
||||
|
||||
act(() => {
|
||||
result.current.onClickCard(segmentDetail)
|
||||
})
|
||||
act(() => {
|
||||
result.current.onClickSlice(childDetail)
|
||||
})
|
||||
|
||||
expect(result.current.currSegment.showModal).toBe(false)
|
||||
expect(result.current.currChildChunk.showModal).toBe(true)
|
||||
expect(result.current.currChildChunk.childChunkInfo).toBe(childDetail)
|
||||
})
|
||||
|
||||
it('should close child segment detail', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.onClickSlice({ id: 'c1', segment_id: 's1' } as unknown as ChildChunkDetail)
|
||||
@ -116,7 +81,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should handle new child chunk modal', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.handleAddNewChildChunk('parent-chunk-1')
|
||||
@ -133,7 +98,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should close new segment modal and notify parent', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.onCloseNewSegmentModal()
|
||||
@ -143,7 +108,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should toggle full screen', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.toggleFullScreen()
|
||||
@ -157,7 +122,7 @@ describe('useModalState', () => {
|
||||
})
|
||||
|
||||
it('should toggle collapsed', () => {
|
||||
const { result } = renderModalState()
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.toggleCollapsed()
|
||||
@ -169,4 +134,13 @@ describe('useModalState', () => {
|
||||
})
|
||||
expect(result.current.isCollapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('should set regeneration modal state', () => {
|
||||
const { result } = renderUseModalState()
|
||||
|
||||
act(() => {
|
||||
result.current.setIsRegenerationModalOpen(true)
|
||||
})
|
||||
expect(result.current.isRegenerationModalOpen).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
|
||||
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
|
||||
import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
import { ProcessStatus } from '../../../segment-add'
|
||||
import { useSegmentListData } from '../use-segment-list-data'
|
||||
|
||||
// Type for mutation callbacks
|
||||
@ -177,7 +176,7 @@ const defaultOptions = {
|
||||
searchValue: '',
|
||||
selectedStatus: 'all' as boolean | 'all',
|
||||
selectedSegmentIds: [] as string[],
|
||||
importStatus: undefined as SegmentImportStatus | undefined,
|
||||
importStatus: undefined as ProcessStatus | string | undefined,
|
||||
currentPage: 1,
|
||||
limit: 10,
|
||||
onCloseSegmentDetail: vi.fn(),
|
||||
@ -690,7 +689,7 @@ describe('useSegmentListData', () => {
|
||||
|
||||
renderHook(() => useSegmentListData({
|
||||
...defaultOptions,
|
||||
importStatus: segmentImportStatus.completed,
|
||||
importStatus: ProcessStatus.COMPLETED,
|
||||
clearSelection,
|
||||
}), {
|
||||
wrapper: createWrapper(),
|
||||
|
||||
@ -13,20 +13,29 @@ type CurrChildChunkType = {
|
||||
}
|
||||
|
||||
type UseModalStateReturn = {
|
||||
// Segment detail modal
|
||||
currSegment: CurrSegmentType
|
||||
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
|
||||
onCloseSegmentDetail: () => void
|
||||
// Child segment detail modal
|
||||
currChildChunk: CurrChildChunkType
|
||||
currChunkId: string
|
||||
onClickSlice: (detail: ChildChunkDetail) => void
|
||||
onCloseChildSegmentDetail: () => void
|
||||
// New segment modal
|
||||
onCloseNewSegmentModal: () => void
|
||||
// New child segment modal
|
||||
showNewChildSegmentModal: boolean
|
||||
handleAddNewChildChunk: (parentChunkId: string) => void
|
||||
onCloseNewChildChunkModal: () => void
|
||||
// Regeneration modal
|
||||
isRegenerationModalOpen: boolean
|
||||
setIsRegenerationModalOpen: (open: boolean) => void
|
||||
// Full screen
|
||||
fullScreen: boolean
|
||||
toggleFullScreen: () => void
|
||||
setFullScreen: (fullScreen: boolean) => void
|
||||
// Collapsed state
|
||||
isCollapsed: boolean
|
||||
toggleCollapsed: () => void
|
||||
}
|
||||
@ -38,15 +47,25 @@ type UseModalStateOptions = {
|
||||
export const useModalState = (options: UseModalStateOptions): UseModalStateReturn => {
|
||||
const { onNewSegmentModalChange } = options
|
||||
|
||||
// Segment detail modal state
|
||||
const [currSegment, setCurrSegment] = useState<CurrSegmentType>({ showModal: false })
|
||||
|
||||
// Child segment detail modal state
|
||||
const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ showModal: false })
|
||||
const [currChunkId, setCurrChunkId] = useState('')
|
||||
|
||||
// New child segment modal state
|
||||
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
|
||||
|
||||
// Regeneration modal state
|
||||
const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
|
||||
|
||||
// Display state
|
||||
const [fullScreen, setFullScreen] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(true)
|
||||
|
||||
// Segment detail handlers
|
||||
const onClickCard = useCallback((detail: SegmentDetailModel, isEditMode = false) => {
|
||||
setCurrChildChunk({ showModal: false })
|
||||
setCurrSegment({ segInfo: detail, showModal: true, isEditMode })
|
||||
}, [])
|
||||
|
||||
@ -55,8 +74,8 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
|
||||
setFullScreen(false)
|
||||
}, [])
|
||||
|
||||
// Child segment detail handlers
|
||||
const onClickSlice = useCallback((detail: ChildChunkDetail) => {
|
||||
setCurrSegment({ showModal: false })
|
||||
setCurrChildChunk({ childChunkInfo: detail, showModal: true })
|
||||
setCurrChunkId(detail.segment_id)
|
||||
}, [])
|
||||
@ -66,11 +85,13 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
|
||||
setFullScreen(false)
|
||||
}, [])
|
||||
|
||||
// New segment modal handlers
|
||||
const onCloseNewSegmentModal = useCallback(() => {
|
||||
onNewSegmentModalChange(false)
|
||||
setFullScreen(false)
|
||||
}, [onNewSegmentModalChange])
|
||||
|
||||
// New child segment modal handlers
|
||||
const handleAddNewChildChunk = useCallback((parentChunkId: string) => {
|
||||
setShowNewChildSegmentModal(true)
|
||||
setCurrChunkId(parentChunkId)
|
||||
@ -81,6 +102,7 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
|
||||
setFullScreen(false)
|
||||
}, [])
|
||||
|
||||
// Display handlers - handles both direct calls and click events
|
||||
const toggleFullScreen = useCallback(() => {
|
||||
setFullScreen(prev => !prev)
|
||||
}, [])
|
||||
@ -90,20 +112,29 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Segment detail modal
|
||||
currSegment,
|
||||
onClickCard,
|
||||
onCloseSegmentDetail,
|
||||
// Child segment detail modal
|
||||
currChildChunk,
|
||||
currChunkId,
|
||||
onClickSlice,
|
||||
onCloseChildSegmentDetail,
|
||||
// New segment modal
|
||||
onCloseNewSegmentModal,
|
||||
// New child segment modal
|
||||
showNewChildSegmentModal,
|
||||
handleAddNewChildChunk,
|
||||
onCloseNewChildChunkModal,
|
||||
// Regeneration modal
|
||||
isRegenerationModalOpen,
|
||||
setIsRegenerationModalOpen,
|
||||
// Full screen
|
||||
fullScreen,
|
||||
toggleFullScreen,
|
||||
setFullScreen,
|
||||
// Collapsed state
|
||||
isCollapsed,
|
||||
toggleCollapsed,
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
|
||||
import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
@ -10,16 +9,16 @@ import { ChunkingMode } from '@/models/datasets'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { useChunkListAllKey, useChunkListDisabledKey, useChunkListEnabledKey, useDeleteSegment, useDisableSegment, useEnableSegment, useSegmentList, useSegmentListKey, useUpdateSegment } from '@/service/knowledge/use-segment'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { useDocumentContext } from '../../context'
|
||||
import { ProcessStatus } from '../../segment-add'
|
||||
|
||||
const DEFAULT_LIMIT = 10
|
||||
type UseSegmentListDataOptions = {
|
||||
searchValue: string
|
||||
selectedStatus: boolean | 'all'
|
||||
selectedSegmentIds: string[]
|
||||
importStatus: SegmentImportStatus | undefined
|
||||
importStatus: ProcessStatus | string | undefined
|
||||
currentPage: number
|
||||
limit: number
|
||||
onCloseSegmentDetail: () => void
|
||||
@ -93,7 +92,7 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme
|
||||
}, [pathname])
|
||||
// Reset list on import completion
|
||||
useEffect(() => {
|
||||
if (importStatus === segmentImportStatus.completed) {
|
||||
if (importStatus === ProcessStatus.COMPLETED) {
|
||||
clearSelection()
|
||||
invalidSegmentList()
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ProcessStatus } from '../segment-add'
|
||||
import type { SegmentListContextValue } from './segment-list-context'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
@ -13,9 +13,7 @@ import {
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { useDocumentContext } from '../context'
|
||||
import BatchAction from './common/batch-action'
|
||||
import { DrawerGroup } from './components/drawer-group'
|
||||
import MenuBar from './components/menu-bar'
|
||||
import { FullDocModeContent, GeneralModeContent } from './components/segment-list-content'
|
||||
import { DrawerGroup, FullDocModeContent, GeneralModeContent, MenuBar } from './components'
|
||||
import {
|
||||
useChildSegmentData,
|
||||
useModalState,
|
||||
@ -34,7 +32,7 @@ type ICompletedProps = {
|
||||
embeddingAvailable: boolean
|
||||
showNewSegmentModal: boolean
|
||||
onNewSegmentModalChange: (state: boolean) => void
|
||||
importStatus: SegmentImportStatus | undefined
|
||||
importStatus: ProcessStatus | string | undefined
|
||||
archived?: boolean
|
||||
}
|
||||
|
||||
@ -227,6 +225,8 @@ const Completed: FC<ICompletedProps> = ({
|
||||
currSegment={modalState.currSegment}
|
||||
onCloseSegmentDetail={modalState.onCloseSegmentDetail}
|
||||
onUpdateSegment={segmentListDataHook.handleUpdateSegment}
|
||||
isRegenerationModalOpen={modalState.isRegenerationModalOpen}
|
||||
setIsRegenerationModalOpen={modalState.setIsRegenerationModalOpen}
|
||||
showNewSegmentModal={showNewSegmentModal}
|
||||
onCloseNewSegmentModal={modalState.onCloseNewSegmentModal}
|
||||
onSaveNewSegment={segmentListDataHook.resetList}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
|
||||
import type { SegmentDetailModel } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@ -6,6 +7,7 @@ import {
|
||||
RiCollapseDiagonalLine,
|
||||
RiExpandDiagonalLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
@ -40,15 +42,20 @@ type ISegmentDetailProps = {
|
||||
onCancel: () => void
|
||||
isEditMode?: boolean
|
||||
docForm: ChunkingMode
|
||||
onModalStateChange?: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
export function SegmentDetail({
|
||||
/**
|
||||
* Show all the contents of the segment
|
||||
*/
|
||||
const SegmentDetail: FC<ISegmentDetailProps> = ({
|
||||
segInfo,
|
||||
onUpdate,
|
||||
onCancel,
|
||||
isEditMode,
|
||||
docForm,
|
||||
}: ISegmentDetailProps) {
|
||||
onModalStateChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '')
|
||||
const [answer, setAnswer] = useState(segInfo?.answer || '')
|
||||
@ -92,16 +99,19 @@ export function SegmentDetail({
|
||||
|
||||
const handleRegeneration = useCallback(() => {
|
||||
setShowRegenerationModal(true)
|
||||
}, [])
|
||||
onModalStateChange?.(true)
|
||||
}, [onModalStateChange])
|
||||
|
||||
const onCancelRegeneration = useCallback(() => {
|
||||
setShowRegenerationModal(false)
|
||||
}, [])
|
||||
onModalStateChange?.(false)
|
||||
}, [onModalStateChange])
|
||||
|
||||
const onCloseAfterRegeneration = useCallback(() => {
|
||||
setShowRegenerationModal(false)
|
||||
onCancel()
|
||||
}, [onCancel])
|
||||
onModalStateChange?.(false)
|
||||
onCancel() // Close the edit drawer
|
||||
}, [onCancel, onModalStateChange])
|
||||
|
||||
const onConfirmRegeneration = useCallback(() => {
|
||||
onUpdate(segInfo?.id || '', question, answer, keywords, attachments, summary, true)
|
||||
@ -231,3 +241,5 @@ export function SegmentDetail({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SegmentDetail)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DataSourceInfo, DocumentDisplayStatus, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
@ -18,7 +17,6 @@ import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document'
|
||||
import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
import Operations from '../components/operations'
|
||||
import StatusItem from '../status-item'
|
||||
import BatchModal from './batch-modal'
|
||||
@ -26,7 +24,7 @@ import Completed from './completed'
|
||||
import { DocumentContext } from './context'
|
||||
import { DocumentTitle } from './document-title'
|
||||
import Embedding from './embedding'
|
||||
import { SegmentAdd } from './segment-add'
|
||||
import SegmentAdd, { ProcessStatus } from './segment-add'
|
||||
import style from './style.module.css'
|
||||
|
||||
type DocumentDetailProps = {
|
||||
@ -55,20 +53,20 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
const [showMetadata, setShowMetadata] = useState(!isMobile)
|
||||
const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false)
|
||||
const [batchModalVisible, setBatchModalVisible] = useState(false)
|
||||
const [importStatus, setImportStatus] = useState<SegmentImportStatus>()
|
||||
const [importStatus, setImportStatus] = useState<ProcessStatus | string>()
|
||||
const showNewSegmentModal = () => setNewSegmentModalVisible(true)
|
||||
const showBatchModal = () => setBatchModalVisible(true)
|
||||
const hideBatchModal = () => setBatchModalVisible(false)
|
||||
const resetImportStatus = () => setImportStatus(undefined)
|
||||
const resetProcessStatus = () => setImportStatus('')
|
||||
|
||||
const { mutateAsync: checkSegmentBatchImportProgress } = useCheckSegmentBatchImportProgress()
|
||||
const checkProcess = async (jobID: string) => {
|
||||
await checkSegmentBatchImportProgress({ jobID }, {
|
||||
onSuccess: (res) => {
|
||||
setImportStatus(res.job_status)
|
||||
if (res.job_status === segmentImportStatus.waiting || res.job_status === segmentImportStatus.processing)
|
||||
if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING)
|
||||
setTimeout(() => checkProcess(res.job_id), 2500)
|
||||
if (res.job_status === segmentImportStatus.error)
|
||||
if (res.job_status === ProcessStatus.ERROR)
|
||||
toast.error(`${t('list.batchModal.runError', { ns: 'datasetDocuments' })}`)
|
||||
},
|
||||
onError: (e) => {
|
||||
@ -224,7 +222,7 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
<>
|
||||
<SegmentAdd
|
||||
importStatus={importStatus}
|
||||
clearImportStatus={resetImportStatus}
|
||||
clearProcessStatus={resetProcessStatus}
|
||||
showNewSegmentModal={showNewSegmentModal}
|
||||
showBatchModal={showBatchModal}
|
||||
embedding={embedding}
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
|
||||
import { SegmentAdd } from '../index'
|
||||
import SegmentAdd, { ProcessStatus } from '../index'
|
||||
|
||||
// Mock provider context
|
||||
let mockPlan = { type: Plan.professional }
|
||||
@ -24,8 +22,8 @@ describe('SegmentAdd', () => {
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
importStatus: undefined as SegmentImportStatus | undefined,
|
||||
clearImportStatus: vi.fn(),
|
||||
importStatus: undefined as ProcessStatus | string | undefined,
|
||||
clearProcessStatus: vi.fn(),
|
||||
showNewSegmentModal: vi.fn(),
|
||||
showBatchModal: vi.fn(),
|
||||
embedding: false,
|
||||
@ -54,33 +52,33 @@ describe('SegmentAdd', () => {
|
||||
// Import Status displays
|
||||
describe('Import Status Display', () => {
|
||||
it('should show processing indicator when status is WAITING', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />)
|
||||
|
||||
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show processing indicator when status is PROCESSING', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
|
||||
|
||||
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show completed status with ok button', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.completed} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.COMPLETED} />)
|
||||
|
||||
expect(screen.getByText(/list\.batchModal\.completed/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error status with ok button', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.error} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.ERROR} />)
|
||||
|
||||
expect(screen.getByText(/list\.batchModal\.error/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show add button when importStatus is set', () => {
|
||||
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
|
||||
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
|
||||
|
||||
expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument()
|
||||
})
|
||||
@ -96,34 +94,34 @@ describe('SegmentAdd', () => {
|
||||
expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call clearImportStatus when ok is clicked on completed status', () => {
|
||||
const mockClearImportStatus = vi.fn()
|
||||
it('should call clearProcessStatus when ok is clicked on completed status', () => {
|
||||
const mockClearProcessStatus = vi.fn()
|
||||
render(
|
||||
<SegmentAdd
|
||||
{...defaultProps}
|
||||
importStatus={segmentImportStatus.completed}
|
||||
clearImportStatus={mockClearImportStatus}
|
||||
importStatus={ProcessStatus.COMPLETED}
|
||||
clearProcessStatus={mockClearProcessStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
|
||||
|
||||
expect(mockClearImportStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call clearImportStatus when ok is clicked on error status', () => {
|
||||
const mockClearImportStatus = vi.fn()
|
||||
it('should call clearProcessStatus when ok is clicked on error status', () => {
|
||||
const mockClearProcessStatus = vi.fn()
|
||||
render(
|
||||
<SegmentAdd
|
||||
{...defaultProps}
|
||||
importStatus={segmentImportStatus.error}
|
||||
clearImportStatus={mockClearImportStatus}
|
||||
importStatus={ProcessStatus.ERROR}
|
||||
clearProcessStatus={mockClearProcessStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
|
||||
|
||||
expect(mockClearImportStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render batch add option in dropdown', async () => {
|
||||
@ -217,14 +215,14 @@ describe('SegmentAdd', () => {
|
||||
// Progress bar width tests
|
||||
describe('Progress Bar', () => {
|
||||
it('should show 3/12 width progress bar for WAITING status', () => {
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />)
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />)
|
||||
|
||||
const progressBar = container.querySelector('.w-3\\/12')
|
||||
expect(progressBar).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show 2/3 width progress bar for PROCESSING status', () => {
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
|
||||
|
||||
const progressBar = container.querySelector('.w-2\\/3')
|
||||
expect(progressBar).toBeInTheDocument()
|
||||
@ -232,6 +230,15 @@ describe('SegmentAdd', () => {
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle unknown importStatus string', () => {
|
||||
// Arrange & Act - pass unknown status
|
||||
const { container } = render(<SegmentAdd {...defaultProps} importStatus="unknown" />)
|
||||
|
||||
// Assert - empty fragment is rendered for unknown status (container exists but has no visible content)
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
const { rerender } = render(<SegmentAdd {...defaultProps} />)
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -7,92 +7,95 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
|
||||
type SegmentAddProps = {
|
||||
importStatus: SegmentImportStatus | undefined
|
||||
clearImportStatus: () => void
|
||||
type ISegmentAddProps = {
|
||||
importStatus: ProcessStatus | string | undefined
|
||||
clearProcessStatus: () => void
|
||||
showNewSegmentModal: () => void
|
||||
showBatchModal: () => void
|
||||
embedding: boolean
|
||||
}
|
||||
|
||||
export function SegmentAdd({
|
||||
export enum ProcessStatus {
|
||||
WAITING = 'waiting',
|
||||
PROCESSING = 'processing',
|
||||
COMPLETED = 'completed',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
const SegmentAdd: FC<ISegmentAddProps> = ({
|
||||
importStatus,
|
||||
clearImportStatus,
|
||||
clearProcessStatus,
|
||||
showNewSegmentModal,
|
||||
showBatchModal,
|
||||
embedding,
|
||||
}: SegmentAddProps) {
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false)
|
||||
const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false)
|
||||
const batchMenuAnchorRef = useRef<HTMLDivElement>(null)
|
||||
const [isShowPlanUpgradeModal, {
|
||||
setTrue: showPlanUpgradeModal,
|
||||
setFalse: hidePlanUpgradeModal,
|
||||
}] = useBoolean(false)
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const canAddChunks = !enableBilling || plan.type !== Plan.sandbox
|
||||
const { type } = plan
|
||||
const canAdd = enableBilling ? type !== Plan.sandbox : true
|
||||
const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false)
|
||||
const batchMenuAnchorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const textColor = embedding
|
||||
? 'text-components-button-secondary-accent-text-disabled'
|
||||
: 'text-components-button-secondary-accent-text'
|
||||
|
||||
const handleAddClick = () => {
|
||||
if (!canAddChunks) {
|
||||
setIsPlanUpgradeModalOpen(true)
|
||||
return
|
||||
const withNeedUpgradeCheck = useCallback((fn: () => void) => {
|
||||
return () => {
|
||||
if (!canAdd) {
|
||||
showPlanUpgradeModal()
|
||||
return
|
||||
}
|
||||
fn()
|
||||
}
|
||||
|
||||
showNewSegmentModal()
|
||||
}
|
||||
|
||||
const handleBatchAddClick = () => {
|
||||
setIsBatchMenuOpen(false)
|
||||
|
||||
if (!canAddChunks) {
|
||||
setIsPlanUpgradeModalOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
showBatchModal()
|
||||
}
|
||||
}, [canAdd, showPlanUpgradeModal])
|
||||
const textColor = useMemo(() => {
|
||||
return embedding
|
||||
? 'text-components-button-secondary-accent-text-disabled'
|
||||
: 'text-components-button-secondary-accent-text'
|
||||
}, [embedding])
|
||||
|
||||
if (importStatus) {
|
||||
return (
|
||||
<>
|
||||
{(importStatus === segmentImportStatus.waiting || importStatus === segmentImportStatus.processing) && (
|
||||
{(importStatus === ProcessStatus.WAITING || importStatus === ProcessStatus.PROCESSING) && (
|
||||
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-progress-bar-border
|
||||
bg-components-progress-bar-border px-2.5 py-2 text-components-button-secondary-accent-text
|
||||
shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"
|
||||
>
|
||||
<div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === segmentImportStatus.waiting ? 'w-3/12' : 'w-2/3')} />
|
||||
<div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === ProcessStatus.WAITING ? 'w-3/12' : 'w-2/3')} />
|
||||
<span aria-hidden className="mr-1 i-ri-loader-2-line h-4 w-4 animate-spin" />
|
||||
<span className="z-10 pr-0.5 system-sm-medium">{t('list.batchModal.processing', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{importStatus === segmentImportStatus.completed && (
|
||||
{importStatus === ProcessStatus.COMPLETED && (
|
||||
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
|
||||
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-success">
|
||||
<span aria-hidden className="mr-1 i-custom-vender-solid-general-check-circle h-4 w-4" />
|
||||
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.completed', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
<div className="m-1 inline-flex items-center">
|
||||
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
|
||||
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearProcessStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-success-bg opacity-40" />
|
||||
</div>
|
||||
)}
|
||||
{importStatus === segmentImportStatus.error && (
|
||||
{importStatus === ProcessStatus.ERROR && (
|
||||
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
|
||||
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-destructive">
|
||||
<span aria-hidden className="mr-1 i-ri-error-warning-fill h-4 w-4" />
|
||||
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.error', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
<div className="m-1 inline-flex items-center">
|
||||
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
|
||||
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearProcessStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-error-bg opacity-40" />
|
||||
</div>
|
||||
@ -113,7 +116,7 @@ export function SegmentAdd({
|
||||
type="button"
|
||||
className={`inline-flex items-center rounded-l-lg border-r border-r-divider-subtle px-2.5 py-2
|
||||
hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`}
|
||||
onClick={handleAddClick}
|
||||
onClick={withNeedUpgradeCheck(showNewSegmentModal)}
|
||||
disabled={embedding}
|
||||
>
|
||||
<span aria-hidden className={cn('i-ri-add-line h-4 w-4', textColor)} />
|
||||
@ -139,20 +142,25 @@ export function SegmentAdd({
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
positionerProps={{ anchor: batchMenuAnchorRef }}
|
||||
popupClassName="w-[var(--anchor-width)]"
|
||||
popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-0 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="system-md-regular"
|
||||
onClick={handleBatchAddClick}
|
||||
>
|
||||
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
|
||||
</DropdownMenuItem>
|
||||
<div className="w-full p-1">
|
||||
<DropdownMenuItem
|
||||
className="h-auto w-full px-2 py-1.5 system-md-regular"
|
||||
onClick={() => {
|
||||
setIsBatchMenuOpen(false)
|
||||
withNeedUpgradeCheck(showBatchModal)()
|
||||
}}
|
||||
>
|
||||
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isPlanUpgradeModalOpen && (
|
||||
{isShowPlanUpgradeModal && (
|
||||
<PlanUpgradeModal
|
||||
show
|
||||
onClose={() => setIsPlanUpgradeModalOpen(false)}
|
||||
onClose={hidePlanUpgradeModal}
|
||||
title={t('upgrade.addChunks.title', { ns: 'billing' })!}
|
||||
description={t('upgrade.addChunks.description', { ns: 'billing' })!}
|
||||
/>
|
||||
@ -161,3 +169,4 @@ export function SegmentAdd({
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(SegmentAdd)
|
||||
|
||||
@ -55,6 +55,10 @@ vi.mock('../../../readme-panel/entrance', () => ({
|
||||
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../../readme-panel/store', () => ({
|
||||
ReadmeShowType: { modal: 'modal' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/encrypted-bottom', () => ({
|
||||
EncryptedBottom: () => <div data-testid="encrypted-bottom" />,
|
||||
}))
|
||||
|
||||
@ -41,6 +41,10 @@ vi.mock('../../../readme-panel/entrance', () => ({
|
||||
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../../readme-panel/store', () => ({
|
||||
ReadmeShowType: { modal: 'modal' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => {
|
||||
const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => {
|
||||
mockAuthFormProps = props
|
||||
|
||||
@ -19,6 +19,7 @@ import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
import {
|
||||
useAddPluginCredentialHook,
|
||||
useGetPluginCredentialSchemaHook,
|
||||
@ -158,7 +159,7 @@ const ApiKeyModal = ({
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
{
|
||||
isLoading && (
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import { ReadmeShowType } from '../../readme-panel/store'
|
||||
import {
|
||||
useDeletePluginOAuthCustomClientHook,
|
||||
useInvalidPluginOAuthClientSchemaHook,
|
||||
@ -156,7 +157,7 @@ const OAuthClientSettings = ({
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0">
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
<AuthForm
|
||||
formFromProps={form}
|
||||
|
||||
@ -14,6 +14,7 @@ import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
|
||||
import { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers'
|
||||
import { parsePluginErrorMessage } from '@/utils/error-parser'
|
||||
import { ReadmeShowType } from '../../../readme-panel/store'
|
||||
import { usePluginStore } from '../../store'
|
||||
import { useSubscriptionList } from '../use-subscription-list'
|
||||
|
||||
@ -317,7 +318,7 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props)
|
||||
</div>
|
||||
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{pluginDetail && (
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
|
||||
<MultiSteps currentStep={currentStep} onStepClick={handleBack} />
|
||||
|
||||
@ -12,6 +12,7 @@ import { BaseForm } from '@/app/components/base/form/components/base'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
|
||||
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
|
||||
import { ReadmeShowType } from '../../../readme-panel/store'
|
||||
import { usePluginStore } from '../../store'
|
||||
import { useSubscriptionList } from '../use-subscription-list'
|
||||
|
||||
@ -158,7 +159,7 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props)
|
||||
</div>
|
||||
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{pluginDetail && (
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
|
||||
@ -12,6 +12,7 @@ import { BaseForm } from '@/app/components/base/form/components/base'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
|
||||
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
|
||||
import { ReadmeShowType } from '../../../readme-panel/store'
|
||||
import { usePluginStore } from '../../store'
|
||||
import { useSubscriptionList } from '../use-subscription-list'
|
||||
|
||||
@ -172,7 +173,7 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) =
|
||||
</div>
|
||||
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
{pluginDetail && (
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
|
||||
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
|
||||
)}
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
|
||||
@ -1,11 +1,31 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ReadmeEntrance } from '../entrance'
|
||||
import { useReadmePanelStore } from '../store'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/cn', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
const mockSetCurrentPluginDetail = vi.fn()
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
ReadmeShowType: { drawer: 'drawer', side: 'side', modal: 'modal' },
|
||||
useReadmePanelStore: () => ({
|
||||
setCurrentPluginDetail: mockSetCurrentPluginDetail,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../constants', () => ({
|
||||
BUILTIN_TOOLS_ARRAY: ['google_search', 'bing_search'],
|
||||
}))
|
||||
|
||||
describe('ReadmeEntrance', () => {
|
||||
beforeEach(() => {
|
||||
useReadmePanelStore.setState({ currentPanel: undefined })
|
||||
let ReadmeEntrance: (typeof import('../entrance'))['ReadmeEntrance']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../entrance')
|
||||
ReadmeEntrance = mod.ReadmeEntrance
|
||||
})
|
||||
|
||||
it('should render readme button for non-builtin plugin with unique identifier', () => {
|
||||
@ -15,31 +35,18 @@ describe('ReadmeEntrance', () => {
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open drawer presentation by default', () => {
|
||||
it('should call setCurrentPluginDetail on button click', () => {
|
||||
const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never
|
||||
render(<ReadmeEntrance pluginDetail={pluginDetail} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(useReadmePanelStore.getState().currentPanel).toEqual({
|
||||
detail: pluginDetail,
|
||||
presentation: 'drawer',
|
||||
triggerId: button.id,
|
||||
})
|
||||
})
|
||||
|
||||
it('should open dialog presentation when requested', () => {
|
||||
const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never
|
||||
render(<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
|
||||
expect(mockSetCurrentPluginDetail).toHaveBeenCalledWith(pluginDetail, 'drawer')
|
||||
})
|
||||
|
||||
it('should return null for builtin tools', () => {
|
||||
const pluginDetail = { id: 'code', name: 'Code', plugin_unique_identifier: 'org/code' } as never
|
||||
const pluginDetail = { id: 'google_search', name: 'Google Search', plugin_unique_identifier: 'org/google' } as never
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={pluginDetail} />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
|
||||
@ -1,29 +1,29 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { PluginDetail } from '../../types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, PluginSource } from '../../types'
|
||||
import { ReadmeEntrance } from '../entrance'
|
||||
import ReadmePanel from '../index'
|
||||
import { useReadmePanelStore } from '../store'
|
||||
import { ReadmeShowType, useReadmePanelStore } from '../store'
|
||||
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
BASE_UI_ANIMATIONS_DISABLED: boolean
|
||||
}
|
||||
).BASE_UI_ANIMATIONS_DISABLED = true
|
||||
// ================================
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock usePluginReadme hook
|
||||
const mockUsePluginReadme = vi.fn()
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
usePluginReadme: (params: { plugin_unique_identifier: string, language?: string }) => mockUsePluginReadme(params),
|
||||
}))
|
||||
|
||||
// Mock useLanguage hook
|
||||
let mockLanguage = 'en-US'
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => mockLanguage,
|
||||
}))
|
||||
|
||||
// Mock DetailHeader component (complex component with many dependencies)
|
||||
vi.mock('../../plugin-detail-panel/detail-header', () => ({
|
||||
default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => (
|
||||
<div data-testid="detail-header" data-is-readme-view={isReadmeView}>
|
||||
@ -32,6 +32,10 @@ vi.mock('../../plugin-detail-panel/detail-header', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Test Data Factories
|
||||
// ================================
|
||||
|
||||
const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'test-plugin-id',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
@ -89,6 +93,10 @@ const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDe
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Test Utilities
|
||||
// ================================
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@ -97,7 +105,7 @@ const createQueryClient = () => new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithQueryClient = (ui: ReactElement) => {
|
||||
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@ -106,23 +114,15 @@ const renderWithQueryClient = (ui: ReactElement) => {
|
||||
)
|
||||
}
|
||||
|
||||
const openReadmePanel = (
|
||||
detail = createMockPluginDetail(),
|
||||
presentation: 'drawer' | 'dialog' = 'drawer',
|
||||
) => {
|
||||
useReadmePanelStore.getState().openReadmePanel({
|
||||
detail,
|
||||
presentation,
|
||||
triggerId: 'readme-trigger',
|
||||
})
|
||||
return detail
|
||||
}
|
||||
// Constants (BUILTIN_TOOLS_ARRAY) tests moved to constants.spec.ts
|
||||
// Store (useReadmePanelStore) tests moved to store.spec.ts
|
||||
// Entrance (ReadmeEntrance) tests moved to entrance.spec.tsx
|
||||
|
||||
// ================================
|
||||
// ReadmePanel Component Tests
|
||||
// ================================
|
||||
describe('ReadmePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLanguage = 'en-US'
|
||||
useReadmePanelStore.setState({ currentPanel: undefined })
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
@ -130,114 +130,487 @@ describe('ReadmePanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when no readme panel is open', () => {
|
||||
const { container } = renderWithQueryClient(<ReadmePanel />)
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should return null when no plugin detail is set', () => {
|
||||
const { container } = renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render drawer presentation with plugin header content', () => {
|
||||
openReadmePanel()
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true')
|
||||
expect(screen.getByRole('dialog')).toHaveClass('data-[swipe-direction=left]:w-150')
|
||||
})
|
||||
|
||||
it('should render dialog presentation when requested', () => {
|
||||
openReadmePanel(createMockPluginDetail(), 'dialog')
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveClass('max-w-200')
|
||||
})
|
||||
|
||||
it('should close the active panel when close button is clicked', () => {
|
||||
openReadmePanel()
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(useReadmePanelStore.getState().currentPanel).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should render loading, error, empty, and readme states from the readme query', () => {
|
||||
openReadmePanel()
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
const { rerender } = renderWithQueryClient(<ReadmePanel />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to fetch'),
|
||||
it('should render portal content when plugin detail is set', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
|
||||
})
|
||||
rerender(<ReadmePanel />)
|
||||
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument()
|
||||
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
it('should render DetailHeader component', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByTestId('detail-header')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true')
|
||||
})
|
||||
rerender(<ReadmePanel />)
|
||||
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
|
||||
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test Readme Content' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
rerender(<ReadmePanel />)
|
||||
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
|
||||
})
|
||||
it('should render close button', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
it('should call usePluginReadme with the plugin identifier and selected language', () => {
|
||||
openReadmePanel(createMockPluginDetail({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
}))
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
language: 'en-US',
|
||||
// ActionButton wraps the close icon
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass undefined language for zh-Hans locale', () => {
|
||||
mockLanguage = 'zh-Hans'
|
||||
openReadmePanel(createMockPluginDetail({
|
||||
plugin_unique_identifier: 'zh-plugin@1.0.0',
|
||||
}))
|
||||
// ================================
|
||||
// Loading State Tests
|
||||
// ================================
|
||||
describe('Loading State', () => {
|
||||
it('should show loading indicator when isLoading is true', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: 'zh-plugin@1.0.0',
|
||||
language: undefined,
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Loading component should be rendered with role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should open correctly from ReadmeEntrance through the global host', () => {
|
||||
const detail = createMockPluginDetail()
|
||||
// ================================
|
||||
// Error State Tests
|
||||
// ================================
|
||||
describe('Error State', () => {
|
||||
it('should show error message when error occurs', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to fetch'),
|
||||
})
|
||||
|
||||
renderWithQueryClient(
|
||||
<>
|
||||
<ReadmeEntrance pluginDetail={detail} />
|
||||
<ReadmePanel />
|
||||
</>,
|
||||
)
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /plugin\.readmeInfo\.needHelpCheckReadme/ }))
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// No Readme Available State Tests
|
||||
// ================================
|
||||
describe('No Readme Available', () => {
|
||||
it('should show no readme message when readme is empty', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show no readme message when data is null', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Markdown Content Tests
|
||||
// ================================
|
||||
describe('Markdown Content', () => {
|
||||
it('should render markdown container when readme is available', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test Readme Content' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Markdown component container should be rendered
|
||||
// Note: The Markdown component uses dynamic import, so content may load asynchronously
|
||||
const markdownContainer = document.querySelector('.markdown-body')
|
||||
expect(markdownContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show error or no-readme message when readme is available', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test Readme Content' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Should not show error or no-readme message
|
||||
expect(screen.queryByText('plugin.readmeInfo.failedToFetch')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('plugin.readmeInfo.noReadmeAvailable')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Portal Rendering Tests (Drawer Mode)
|
||||
// ================================
|
||||
describe('Portal Rendering - Drawer Mode', () => {
|
||||
it('should render drawer styled container in drawer mode', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Drawer mode has specific max-width
|
||||
const drawerContainer = document.querySelector('.max-w-\\[600px\\]')
|
||||
expect(drawerContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct drawer positioning classes', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Check for drawer-specific classes
|
||||
const backdrop = document.querySelector('.justify-start')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Portal Rendering Tests (Modal Mode)
|
||||
// ================================
|
||||
describe('Portal Rendering - Modal Mode', () => {
|
||||
it('should render modal styled container in modal mode', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Test' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Modal mode has different max-width
|
||||
const modalContainer = document.querySelector('.max-w-\\[800px\\]')
|
||||
expect(modalContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct modal positioning classes', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Check for modal-specific classes
|
||||
const backdrop = document.querySelector('.items-center.justify-center')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions / Event Handlers
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should close panel when close button is clicked', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should close panel when backdrop is clicked', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Click on the backdrop (outer div)
|
||||
const backdrop = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(backdrop!)
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not close panel when content area is clicked', async () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Click on the content container (should stop propagation)
|
||||
const contentContainer = document.querySelector('.pointer-events-auto')
|
||||
fireEvent.click(contentContainer!)
|
||||
|
||||
await waitFor(() => {
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not close panel when content area is clicked in modal mode', async () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// Click on the content container in modal mode (should stop propagation)
|
||||
const contentContainer = document.querySelector('.pointer-events-auto')
|
||||
fireEvent.click(contentContainer!)
|
||||
|
||||
await waitFor(() => {
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// API Call Tests
|
||||
// ================================
|
||||
describe('API Calls', () => {
|
||||
it('should call usePluginReadme with correct parameters', () => {
|
||||
const mockDetail = createMockPluginDetail({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
})
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: 'custom-plugin@2.0.0',
|
||||
language: 'en-US',
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass undefined language for zh-Hans locale', () => {
|
||||
// Set language to zh-Hans
|
||||
mockLanguage = 'zh-Hans'
|
||||
|
||||
const mockDetail = createMockPluginDetail({
|
||||
plugin_unique_identifier: 'zh-plugin@1.0.0',
|
||||
})
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
// The component should pass undefined for language when zh-Hans
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: 'zh-plugin@1.0.0',
|
||||
language: undefined,
|
||||
})
|
||||
|
||||
// Reset language
|
||||
mockLanguage = 'en-US'
|
||||
})
|
||||
|
||||
it('should handle empty plugin_unique_identifier', () => {
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mockDetail = createMockPluginDetail({
|
||||
plugin_unique_identifier: '',
|
||||
})
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(mockUsePluginReadme).toHaveBeenCalledWith({
|
||||
plugin_unique_identifier: '',
|
||||
language: 'en-US',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle detail with missing declaration', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
// Simulate missing fields
|
||||
delete (mockDetail as Partial<PluginDetail>).declaration
|
||||
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// This should not throw
|
||||
expect(() => setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle rapid open/close operations', async () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// Rapidly toggle the panel
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
setCurrentPluginDetail()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
it('should handle switching between drawer and modal modes', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// Start with drawer
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
})
|
||||
|
||||
let state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.drawer)
|
||||
|
||||
// Switch to modal
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
it('should handle undefined detail gracefully', () => {
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// Set to undefined explicitly
|
||||
act(() => {
|
||||
setCurrentPluginDetail(undefined, ReadmeShowType.drawer)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Integration Tests
|
||||
// ================================
|
||||
describe('Integration', () => {
|
||||
it('should work correctly when opened from ReadmeEntrance', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
mockUsePluginReadme.mockReturnValue({
|
||||
data: { readme: '# Integration Test' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
// Render both components
|
||||
const { rerender } = renderWithQueryClient(
|
||||
<>
|
||||
<ReadmeEntrance pluginDetail={mockDetail} />
|
||||
<ReadmePanel />
|
||||
</>,
|
||||
)
|
||||
|
||||
// Initially panel should not show content
|
||||
expect(screen.queryByTestId('detail-header')).not.toBeInTheDocument()
|
||||
|
||||
// Click the entrance button
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Re-render to pick up store changes
|
||||
rerender(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<ReadmeEntrance pluginDetail={mockDetail} />
|
||||
<ReadmePanel />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
// Panel should now show content
|
||||
expect(screen.getByTestId('detail-header')).toBeInTheDocument()
|
||||
// Markdown content renders in a container (dynamic import may not render content synchronously)
|
||||
expect(document.querySelector('.markdown-body')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct plugin information in header', () => {
|
||||
const mockDetail = createMockPluginDetail({
|
||||
name: 'my-awesome-plugin',
|
||||
})
|
||||
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
|
||||
|
||||
renderWithQueryClient(<ReadmePanel />)
|
||||
|
||||
expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,52 +1,54 @@
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useReadmePanelStore } from '../store'
|
||||
import { ReadmeShowType, useReadmePanelStore } from '../store'
|
||||
|
||||
describe('readme-panel/store', () => {
|
||||
beforeEach(() => {
|
||||
useReadmePanelStore.setState({ currentPanel: undefined })
|
||||
useReadmePanelStore.setState({ currentPluginDetail: undefined })
|
||||
})
|
||||
|
||||
it('initializes without an active panel', () => {
|
||||
it('initializes with undefined currentPluginDetail', () => {
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPanel).toBeUndefined()
|
||||
expect(state.currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('opens drawer presentation by default', () => {
|
||||
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail, triggerId: 'readme-trigger' })
|
||||
it('sets current plugin detail with drawer showType by default', () => {
|
||||
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail)
|
||||
|
||||
expect(useReadmePanelStore.getState().currentPanel).toEqual({
|
||||
detail,
|
||||
presentation: 'drawer',
|
||||
triggerId: 'readme-trigger',
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.drawer,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens dialog presentation when requested', () => {
|
||||
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail, presentation: 'dialog' })
|
||||
it('sets current plugin detail with modal showType', () => {
|
||||
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
|
||||
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
it('closes the active panel', () => {
|
||||
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail })
|
||||
expect(useReadmePanelStore.getState().currentPanel).toBeDefined()
|
||||
it('clears current plugin detail when called with undefined', () => {
|
||||
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail).toBeDefined()
|
||||
|
||||
useReadmePanelStore.getState().closeReadmePanel()
|
||||
expect(useReadmePanelStore.getState().currentPanel).toBeUndefined()
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(undefined)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replaces the active panel with the latest request', () => {
|
||||
it('replaces previous detail with new one', () => {
|
||||
const detail1 = { id: 'plugin-1', plugin_unique_identifier: 'uid-1' } as PluginDetail
|
||||
const detail2 = { id: 'plugin-2', plugin_unique_identifier: 'uid-2' } as PluginDetail
|
||||
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail: detail1 })
|
||||
useReadmePanelStore.getState().openReadmePanel({ detail: detail2, presentation: 'dialog' })
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(detail1)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-1')
|
||||
|
||||
expect(useReadmePanelStore.getState().currentPanel?.detail.id).toBe('plugin-2')
|
||||
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(detail2, ReadmeShowType.modal)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-2')
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PluginDetail } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { usePluginReadme } from '@/service/use-plugins'
|
||||
import DetailHeader from '../plugin-detail-panel/detail-header'
|
||||
|
||||
type ReadmePanelContentProps = {
|
||||
detail: PluginDetail
|
||||
title: ReactNode
|
||||
closeButton: ReactNode
|
||||
}
|
||||
|
||||
export function ReadmePanelContent({
|
||||
detail,
|
||||
title,
|
||||
closeButton,
|
||||
}: ReadmePanelContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const pluginUniqueIdentifier = detail.plugin_unique_identifier || ''
|
||||
|
||||
const { data: readmeData, isLoading, error } = usePluginReadme({
|
||||
plugin_unique_identifier: pluginUniqueIdentifier,
|
||||
language: language === 'zh-Hans' ? undefined : language,
|
||||
})
|
||||
|
||||
let readmeContent: ReactNode
|
||||
if (isLoading) {
|
||||
readmeContent = (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else if (error) {
|
||||
readmeContent = (
|
||||
<div className="py-8 text-center text-text-tertiary">
|
||||
<p>{t('readmeInfo.failedToFetch', { ns: 'plugin' })}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else if (readmeData?.readme) {
|
||||
readmeContent = (
|
||||
<Markdown
|
||||
content={readmeData.readme}
|
||||
pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else {
|
||||
readmeContent = (
|
||||
<div className="py-8 text-center text-text-tertiary">
|
||||
<p>{t('readmeInfo.noReadmeAvailable', { ns: 'plugin' })}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 rounded-t-xl bg-background-body px-4 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span aria-hidden="true" className="i-ri-book-read-line h-3 w-3 shrink-0 text-text-tertiary" />
|
||||
{title}
|
||||
</div>
|
||||
{closeButton}
|
||||
</div>
|
||||
<DetailHeader detail={detail} isReadmeView={true} />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
|
||||
{readmeContent}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDetail } from '../types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReadmePanelContent } from './content'
|
||||
|
||||
type ReadmeDialogProps = {
|
||||
detail: PluginDetail
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
triggerId?: string
|
||||
}
|
||||
|
||||
export function ReadmeDialog({
|
||||
detail,
|
||||
open,
|
||||
onOpenChange,
|
||||
triggerId,
|
||||
}: ReadmeDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
triggerId={triggerId}
|
||||
>
|
||||
<DialogContent className="h-[calc(100dvh-16px)] w-full max-w-200 overflow-hidden p-0">
|
||||
<ReadmePanelContent
|
||||
detail={detail}
|
||||
title={(
|
||||
<DialogTitle className="truncate text-xs font-medium text-text-tertiary uppercase">
|
||||
{t('readmeInfo.title', { ns: 'plugin' })}
|
||||
</DialogTitle>
|
||||
)}
|
||||
closeButton={(
|
||||
<DialogCloseButton
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="static h-8 w-8 rounded-lg"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDetail } from '../types'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReadmePanelContent } from './content'
|
||||
|
||||
type ReadmeDrawerProps = {
|
||||
detail: PluginDetail
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
triggerId?: string
|
||||
}
|
||||
|
||||
export function ReadmeDrawer({
|
||||
detail,
|
||||
open,
|
||||
onOpenChange,
|
||||
triggerId,
|
||||
}: ReadmeDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
triggerId={triggerId}
|
||||
modal
|
||||
swipeDirection="left"
|
||||
>
|
||||
<DrawerPortal>
|
||||
<DrawerBackdrop className="bg-transparent" />
|
||||
<DrawerViewport>
|
||||
<DrawerPopup className="data-[swipe-direction=left]:top-16 data-[swipe-direction=left]:bottom-2 data-[swipe-direction=left]:left-2 data-[swipe-direction=left]:h-auto data-[swipe-direction=left]:w-150 data-[swipe-direction=left]:max-w-[calc(100vw-1rem)] data-[swipe-direction=left]:rounded-2xl data-[swipe-direction=left]:border-l-[0.5px]">
|
||||
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0">
|
||||
<ReadmePanelContent
|
||||
detail={detail}
|
||||
title={(
|
||||
<DrawerTitle className="truncate text-xs font-medium text-text-tertiary uppercase">
|
||||
{t('readmeInfo.title', { ns: 'plugin' })}
|
||||
</DrawerTitle>
|
||||
)}
|
||||
closeButton={(
|
||||
<DrawerCloseButton aria-label={t('operation.close', { ns: 'common' })} />
|
||||
)}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@ -1,40 +1,34 @@
|
||||
import type { PluginDetail } from '../types'
|
||||
import type { ReadmePanelPresentation } from './store'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useId } from 'react'
|
||||
import { RiBookReadLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BUILTIN_TOOLS_ARRAY } from './constants'
|
||||
import { useReadmePanelStore } from './store'
|
||||
import { ReadmeShowType, useReadmePanelStore } from './store'
|
||||
|
||||
export const ReadmeEntrance = ({
|
||||
pluginDetail,
|
||||
presentation = 'drawer',
|
||||
showType = ReadmeShowType.drawer,
|
||||
className,
|
||||
showShortTip = false,
|
||||
}: {
|
||||
pluginDetail: PluginDetail
|
||||
presentation?: ReadmePanelPresentation
|
||||
showType?: ReadmeShowType
|
||||
className?: string
|
||||
showShortTip?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const triggerId = useId()
|
||||
const openReadmePanel = useReadmePanelStore(s => s.openReadmePanel)
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore()
|
||||
|
||||
const handleReadmeClick = () => {
|
||||
if (pluginDetail) {
|
||||
openReadmePanel({
|
||||
detail: pluginDetail,
|
||||
presentation,
|
||||
triggerId,
|
||||
})
|
||||
}
|
||||
if (pluginDetail)
|
||||
setCurrentPluginDetail(pluginDetail, showType)
|
||||
}
|
||||
if (!pluginDetail || !pluginDetail?.plugin_unique_identifier || BUILTIN_TOOLS_ARRAY.includes(pluginDetail.id))
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-start justify-center gap-2 pt-0 pb-4', presentation === 'drawer' && 'px-4', className)}>
|
||||
<div className={cn('flex flex-col items-start justify-center gap-2 pt-0 pb-4', showType === ReadmeShowType.drawer && 'px-4', className)}>
|
||||
{!showShortTip && (
|
||||
<div className="relative h-1 w-8 shrink-0">
|
||||
<div className="h-px w-full bg-divider-regular"></div>
|
||||
@ -42,13 +36,11 @@ export const ReadmeEntrance = ({
|
||||
)}
|
||||
|
||||
<button
|
||||
id={triggerId}
|
||||
type="button"
|
||||
onClick={handleReadmeClick}
|
||||
className="flex w-full items-center justify-start gap-1 rounded-sm text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden"
|
||||
className="flex w-full items-center justify-start gap-1 text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only"
|
||||
>
|
||||
<div className="relative flex h-3 w-3 items-center justify-center overflow-hidden">
|
||||
<span aria-hidden="true" className="i-ri-book-read-line h-3 w-3" />
|
||||
<RiBookReadLine className="h-3 w-3" />
|
||||
</div>
|
||||
<span className="text-xs leading-4 font-normal">
|
||||
{!showShortTip ? t('readmeInfo.needHelpCheckReadme', { ns: 'plugin' }) : t('readmeInfo.title', { ns: 'plugin' })}
|
||||
|
||||
@ -1,38 +1,124 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiBookReadLine, RiCloseLine } from '@remixicon/react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { usePluginReadme } from '@/service/use-plugins'
|
||||
import DetailHeader from '../plugin-detail-panel/detail-header'
|
||||
import { ReadmeShowType, useReadmePanelStore } from './store'
|
||||
|
||||
import { ReadmeDialog } from './dialog'
|
||||
import { ReadmeDrawer } from './drawer'
|
||||
import { useReadmePanelStore } from './store'
|
||||
const ReadmePanel: FC = () => {
|
||||
const { currentPluginDetail, setCurrentPluginDetail } = useReadmePanelStore()
|
||||
const { detail, showType } = currentPluginDetail || {}
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
|
||||
export default function ReadmePanel() {
|
||||
const currentPanel = useReadmePanelStore(s => s.currentPanel)
|
||||
const closeReadmePanel = useReadmePanelStore(s => s.closeReadmePanel)
|
||||
const pluginUniqueIdentifier = detail?.plugin_unique_identifier || ''
|
||||
|
||||
if (!currentPanel)
|
||||
const { data: readmeData, isLoading, error } = usePluginReadme(
|
||||
{ plugin_unique_identifier: pluginUniqueIdentifier, language: language === 'zh-Hans' ? undefined : language },
|
||||
)
|
||||
|
||||
const onClose = () => {
|
||||
setCurrentPluginDetail()
|
||||
}
|
||||
|
||||
if (!detail)
|
||||
return null
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (!open)
|
||||
closeReadmePanel()
|
||||
}
|
||||
const children = (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="rounded-t-xl bg-background-body px-4 py-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<RiBookReadLine className="h-3 w-3 text-text-tertiary" />
|
||||
<span className="text-xs font-medium text-text-tertiary uppercase">
|
||||
{t('readmeInfo.title', { ns: 'plugin' })}
|
||||
</span>
|
||||
</div>
|
||||
<ActionButton onClick={onClose}>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<DetailHeader detail={detail} isReadmeView={true} />
|
||||
</div>
|
||||
|
||||
if (currentPanel.presentation === 'dialog') {
|
||||
return (
|
||||
<ReadmeDialog
|
||||
detail={currentPanel.detail}
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
triggerId={currentPanel.triggerId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ReadmeDrawer
|
||||
detail={currentPanel.detail}
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
triggerId={currentPanel.triggerId}
|
||||
/>
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-8 text-center text-text-tertiary">
|
||||
<p>{t('readmeInfo.failedToFetch', { ns: 'plugin' })}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (readmeData?.readme) {
|
||||
return (
|
||||
<Markdown
|
||||
content={readmeData.readme}
|
||||
pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-8 text-center text-text-tertiary">
|
||||
<p>{t('readmeInfo.noReadmeAvailable', { ns: 'plugin' })}</p>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const portalContent = showType === ReadmeShowType.drawer
|
||||
? (
|
||||
<div className="fixed inset-0 z-1002 flex justify-start" onClick={onClose}>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-auto mt-16 mr-2 mb-2 ml-2 w-[600px] max-w-[600px] justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="fixed inset-0 z-1002 flex items-center justify-center p-2" onClick={onClose}>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-auto relative h-[calc(100vh-16px)] w-full max-w-[800px] rounded-2xl bg-components-panel-bg p-0 shadow-xl',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return createPortal(
|
||||
portalContent,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default ReadmePanel
|
||||
|
||||
@ -1,34 +1,27 @@
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type ReadmePanelPresentation = 'drawer' | 'dialog'
|
||||
|
||||
type ReadmePanelState = {
|
||||
detail: PluginDetail
|
||||
presentation: ReadmePanelPresentation
|
||||
triggerId?: string
|
||||
}
|
||||
|
||||
type OpenReadmePanelPayload = {
|
||||
detail: PluginDetail
|
||||
presentation?: ReadmePanelPresentation
|
||||
triggerId?: string
|
||||
export enum ReadmeShowType {
|
||||
drawer = 'drawer',
|
||||
modal = 'modal',
|
||||
}
|
||||
|
||||
type Shape = {
|
||||
currentPanel?: ReadmePanelState
|
||||
openReadmePanel: (payload: OpenReadmePanelPayload) => void
|
||||
closeReadmePanel: () => void
|
||||
currentPluginDetail?: {
|
||||
detail: PluginDetail
|
||||
showType: ReadmeShowType
|
||||
}
|
||||
setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => void
|
||||
}
|
||||
|
||||
export const useReadmePanelStore = create<Shape>(set => ({
|
||||
currentPanel: undefined,
|
||||
openReadmePanel: ({ detail, presentation = 'drawer', triggerId }) => set({
|
||||
currentPanel: {
|
||||
detail,
|
||||
presentation,
|
||||
triggerId,
|
||||
},
|
||||
currentPluginDetail: undefined,
|
||||
setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => set({
|
||||
currentPluginDetail: !detail
|
||||
? undefined
|
||||
: {
|
||||
detail,
|
||||
showType: showType ?? ReadmeShowType.drawer,
|
||||
},
|
||||
}),
|
||||
closeReadmePanel: () => set({ currentPanel: undefined }),
|
||||
}))
|
||||
|
||||
@ -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' }),
|
||||
]))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -68,13 +68,13 @@ const MenuDropdown: FC<Props> = ({
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
|
||||
<span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
)}
|
||||
render={<div />}
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
/>
|
||||
>
|
||||
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
|
||||
<span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement={placement || 'bottom-end'}
|
||||
sideOffset={4}
|
||||
|
||||
@ -34,28 +34,12 @@ vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({
|
||||
children,
|
||||
className,
|
||||
render,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
render?: React.ReactNode
|
||||
}) => {
|
||||
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
if (render) {
|
||||
return (
|
||||
<div onClick={() => setOpen(!open)}>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className={className} onClick={() => setOpen(!open)}>
|
||||
{children}
|
||||
</button>
|
||||
<div onClick={() => setOpen(!open)}>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -135,12 +119,6 @@ describe('LabelSelector', () => {
|
||||
expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the trigger as a native button', () => {
|
||||
render(<LabelSelector value={[]} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'tools.createTool.toolInput.labelPlaceholder' })).toHaveAttribute('type', 'button')
|
||||
})
|
||||
|
||||
it('should display selected labels as comma-separated list', () => {
|
||||
render(<LabelSelector value={['agent', 'rag']} onChange={mockOnChange} />)
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useMemo, useState } from 'react'
|
||||
@ -59,19 +60,22 @@ const LabelSelector: FC<LabelSelectorProps> = ({
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<div className="relative">
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 text-left hover:bg-components-input-bg-hover',
|
||||
open && 'bg-components-input-bg-hover hover:bg-components-input-bg-hover',
|
||||
render={(
|
||||
<div className={cn(
|
||||
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 hover:bg-components-input-bg-hover',
|
||||
open && '!hover:bg-components-input-bg-hover hover:bg-components-input-bg-hover',
|
||||
)}
|
||||
>
|
||||
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && 'text-text-quaternary!')}>
|
||||
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
|
||||
{!!value.length && selectedLabels}
|
||||
</div>
|
||||
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-4.5 text-text-secondary', !value.length && 'text-text-quaternary!')}>
|
||||
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
|
||||
{!!value.length && selectedLabels}
|
||||
</div>
|
||||
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
|
||||
<span className="i-ri-arrow-down-s-line h-4 w-4" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
|
||||
@ -133,8 +133,8 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
|
||||
<div data-testid="workflow-tool-drawer">
|
||||
default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
|
||||
<div data-testid="workflow-tool-modal">
|
||||
<button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}>Save</button>
|
||||
<button data-testid="wf-remove" onClick={onRemove}>Remove</button>
|
||||
<button data-testid="wf-close" onClick={onHide}>Close</button>
|
||||
@ -581,7 +581,7 @@ describe('ProviderDetail', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('saves workflow tool via workflow drawer', async () => {
|
||||
it('saves workflow tool via workflow modal', async () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection({ type: CollectionType.workflow })}
|
||||
@ -593,7 +593,7 @@ describe('ProviderDetail', () => {
|
||||
expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByText('tools.createTool.editAction'))
|
||||
expect(screen.getByTestId('workflow-tool-drawer'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('wf-save'))
|
||||
})
|
||||
@ -627,7 +627,7 @@ describe('ProviderDetail', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Overlay Close Actions', () => {
|
||||
describe('Modal Close Actions', () => {
|
||||
it('closes ConfigCredential when cancel is clicked', async () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
@ -665,7 +665,7 @@ describe('ProviderDetail', () => {
|
||||
expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes WorkflowToolDrawer via onHide', async () => {
|
||||
it('closes WorkflowToolModal via onHide', async () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection({ type: CollectionType.workflow })}
|
||||
@ -677,9 +677,9 @@ describe('ProviderDetail', () => {
|
||||
expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByText('tools.createTool.editAction'))
|
||||
expect(screen.getByTestId('workflow-tool-drawer'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('wf-close'))
|
||||
expect(screen.queryByTestId('workflow-tool-drawer')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('workflow-tool-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types'
|
||||
import type { WorkflowToolDrawerPayload } from '@/app/components/tools/workflow-tool'
|
||||
import type { WorkflowToolModalPayload } from '@/app/components/tools/workflow-tool'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@ -31,7 +31,7 @@ import OrgInfo from '@/app/components/plugins/card/base/org-info'
|
||||
import Title from '@/app/components/plugins/card/base/title'
|
||||
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
|
||||
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
|
||||
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
|
||||
import WorkflowToolModal from '@/app/components/tools/workflow-tool'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
@ -140,7 +140,7 @@ const ProviderDetail = ({
|
||||
setIsShowEditCustomCollectionModal(false)
|
||||
}
|
||||
// workflow provider
|
||||
const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
|
||||
const [isShowEditWorkflowToolModal, setIsShowEditWorkflowToolModal] = useState(false)
|
||||
const getWorkflowToolProvider = useCallback(async () => {
|
||||
setIsDetailLoading(true)
|
||||
const res = await fetchWorkflowToolDetail(collection.id)
|
||||
@ -164,7 +164,7 @@ const ProviderDetail = ({
|
||||
await deleteWorkflowTool(collection.id)
|
||||
onRefreshData()
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
setWorkflowToolDrawerOpen(false)
|
||||
setIsShowEditWorkflowToolModal(false)
|
||||
}
|
||||
const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{
|
||||
workflow_app_id: string
|
||||
@ -175,7 +175,7 @@ const ProviderDetail = ({
|
||||
onRefreshData()
|
||||
getWorkflowToolProvider()
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
setWorkflowToolDrawerOpen(false)
|
||||
setIsShowEditWorkflowToolModal(false)
|
||||
}
|
||||
const onClickCustomToolDelete = () => {
|
||||
setDeleteAction('customTool')
|
||||
@ -287,7 +287,7 @@ const ProviderDetail = ({
|
||||
</Button>
|
||||
<Button
|
||||
className={cn('my-3 w-[183px] shrink-0')}
|
||||
onClick={() => setWorkflowToolDrawerOpen(true)}
|
||||
onClick={() => setIsShowEditWorkflowToolModal(true)}
|
||||
disabled={!isCurrentWorkspaceManager}
|
||||
>
|
||||
<div className="system-sm-medium text-text-secondary">{t('createTool.editAction', { ns: 'tools' })}</div>
|
||||
@ -401,10 +401,10 @@ const ProviderDetail = ({
|
||||
onRemove={onClickCustomToolDelete}
|
||||
/>
|
||||
)}
|
||||
{workflowToolDrawerOpen && (
|
||||
<WorkflowToolDrawer
|
||||
payload={customCollection as unknown as WorkflowToolDrawerPayload}
|
||||
onHide={() => setWorkflowToolDrawerOpen(false)}
|
||||
{isShowEditWorkflowToolModal && (
|
||||
<WorkflowToolModal
|
||||
payload={customCollection as unknown as WorkflowToolModalPayload}
|
||||
onHide={() => setIsShowEditWorkflowToolModal(false)}
|
||||
onRemove={onClickWorkflowToolDelete}
|
||||
onSave={updateWorkflowToolProvider}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,22 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { WorkflowToolDrawerPayload } from '../index'
|
||||
import type { WorkflowToolModalPayload } from '../index'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WorkflowToolDrawer } from '../index'
|
||||
import WorkflowToolAsModal from '../index'
|
||||
|
||||
vi.mock('@/app/components/base/drawer-plus', () => ({
|
||||
default: ({ isShow, onHide, title, body }: { isShow: boolean, onHide: () => void, title: string, body: React.ReactNode }) => (
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="drawer" role="dialog">
|
||||
<span>{title}</span>
|
||||
<button data-testid="drawer-close" onClick={onHide}>Close</button>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
|
||||
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
|
||||
@ -33,8 +46,8 @@ vi.mock('@/app/components/base/tooltip', () => ({
|
||||
children,
|
||||
popupContent,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
popupContent?: ReactNode
|
||||
children?: React.ReactNode
|
||||
popupContent?: React.ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
@ -73,7 +86,7 @@ vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const createPayload = (overrides: Partial<WorkflowToolDrawerPayload> = {}): WorkflowToolDrawerPayload => ({
|
||||
const createPayload = (overrides: Partial<WorkflowToolModalPayload> = {}): WorkflowToolModalPayload => ({
|
||||
icon: { content: '🔧', background: '#ffffff' },
|
||||
label: 'My Tool',
|
||||
name: 'my_tool',
|
||||
@ -92,7 +105,7 @@ const createPayload = (overrides: Partial<WorkflowToolDrawerPayload> = {}): Work
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('WorkflowToolDrawer', () => {
|
||||
describe('WorkflowToolAsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@ -102,7 +115,7 @@ describe('WorkflowToolDrawer', () => {
|
||||
const onCreate = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowToolDrawer
|
||||
<WorkflowToolAsModal
|
||||
isAdd
|
||||
payload={createPayload()}
|
||||
onHide={vi.fn()}
|
||||
@ -131,7 +144,7 @@ describe('WorkflowToolDrawer', () => {
|
||||
const onCreate = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowToolDrawer
|
||||
<WorkflowToolAsModal
|
||||
isAdd
|
||||
payload={createPayload({ name: 'bad-name' })}
|
||||
onHide={vi.fn()}
|
||||
@ -152,7 +165,7 @@ describe('WorkflowToolDrawer', () => {
|
||||
const onSave = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowToolDrawer
|
||||
<WorkflowToolAsModal
|
||||
payload={createPayload()}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
@ -174,7 +187,7 @@ describe('WorkflowToolDrawer', () => {
|
||||
|
||||
it('should show duplicate reserved output warnings', () => {
|
||||
render(
|
||||
<WorkflowToolDrawer
|
||||
<WorkflowToolAsModal
|
||||
isAdd
|
||||
payload={createPayload()}
|
||||
onHide={vi.fn()}
|
||||
|
||||
@ -1,33 +1,70 @@
|
||||
'use client'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import WorkflowToolModal from '@/app/components/tools/workflow-tool'
|
||||
import Divider from '../../base/divider'
|
||||
import { useConfigureButton } from './hooks/use-configure-button'
|
||||
|
||||
type Props = {
|
||||
disabled: boolean
|
||||
published: boolean
|
||||
isLoading: boolean
|
||||
outdated: boolean
|
||||
isCurrentWorkspaceManager: boolean
|
||||
onConfigure: () => void
|
||||
detailNeedUpdate: boolean
|
||||
workflowAppId: string
|
||||
icon: Emoji
|
||||
name: string
|
||||
description: string
|
||||
inputs?: InputVar[]
|
||||
outputs?: Variable[]
|
||||
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
|
||||
onRefreshData?: () => void
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
const WorkflowToolConfigureButton = ({
|
||||
disabled,
|
||||
published,
|
||||
isLoading,
|
||||
outdated,
|
||||
isCurrentWorkspaceManager,
|
||||
onConfigure,
|
||||
detailNeedUpdate,
|
||||
workflowAppId,
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
inputs,
|
||||
outputs,
|
||||
handlePublish,
|
||||
onRefreshData,
|
||||
disabledReason,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const {
|
||||
showModal,
|
||||
isLoading,
|
||||
outdated,
|
||||
payload,
|
||||
isCurrentWorkspaceManager,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
navigateToTools,
|
||||
} = useConfigureButton({
|
||||
published,
|
||||
detailNeedUpdate,
|
||||
workflowAppId,
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
inputs,
|
||||
outputs,
|
||||
handlePublish,
|
||||
onRefreshData,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -43,12 +80,9 @@ const WorkflowToolConfigureButton = ({
|
||||
? (
|
||||
<div
|
||||
className="flex items-center justify-start gap-2 p-2 pl-2.5"
|
||||
onClick={() => {
|
||||
if (!disabled && !published)
|
||||
onConfigure()
|
||||
}}
|
||||
onClick={() => !disabled && !published && openModal()}
|
||||
>
|
||||
<span className={cn('relative i-ri-hammer-line h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
|
||||
<RiHammerLine className={cn('relative h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
|
||||
<div
|
||||
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
|
||||
className={cn('shrink grow basis-0 truncate system-sm-medium text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')}
|
||||
@ -66,7 +100,7 @@ const WorkflowToolConfigureButton = ({
|
||||
<div
|
||||
className="flex items-center justify-start gap-2 p-2 pl-2.5"
|
||||
>
|
||||
<span className="i-ri-hammer-line h-4 w-4 text-text-tertiary" />
|
||||
<RiHammerLine className="h-4 w-4 text-text-tertiary" />
|
||||
<div
|
||||
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
|
||||
className="shrink grow basis-0 truncate system-sm-medium text-text-tertiary"
|
||||
@ -86,7 +120,7 @@ const WorkflowToolConfigureButton = ({
|
||||
<Button
|
||||
size="small"
|
||||
className="w-[140px]"
|
||||
onClick={onConfigure}
|
||||
onClick={openModal}
|
||||
disabled={!isCurrentWorkspaceManager || disabled}
|
||||
>
|
||||
{t('common.configure', { ns: 'workflow' })}
|
||||
@ -95,11 +129,11 @@ const WorkflowToolConfigureButton = ({
|
||||
<Button
|
||||
size="small"
|
||||
className="w-[140px]"
|
||||
onClick={() => router.push('/tools?category=workflow')}
|
||||
onClick={navigateToTools}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('common.manageInTools', { ns: 'workflow' })}
|
||||
<span className="ml-1 i-ri-arrow-right-up-line h-4 w-4" />
|
||||
<RiArrowRightUpLine className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{outdated && (
|
||||
@ -112,6 +146,15 @@ const WorkflowToolConfigureButton = ({
|
||||
</div>
|
||||
)}
|
||||
{published && isLoading && <div className="pt-2"><Loading type="app" /></div>}
|
||||
{showModal && (
|
||||
<WorkflowToolModal
|
||||
isAdd={!published}
|
||||
payload={payload}
|
||||
onHide={closeModal}
|
||||
onCreate={handleCreate}
|
||||
onSave={handleUpdate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,6 +4,11 @@ import { act, renderHook } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { isParametersOutdated, useConfigureButton } from '../use-configure-button'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
const mockIsCurrentWorkspaceManager = vi.fn(() => true)
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
@ -93,7 +98,6 @@ const createMockDetail = (overrides: Partial<WorkflowToolProviderResponse> = {})
|
||||
})
|
||||
|
||||
const createDefaultOptions = (overrides = {}) => ({
|
||||
enabled: true,
|
||||
published: false,
|
||||
detailNeedUpdate: false,
|
||||
workflowAppId: 'app-123',
|
||||
@ -209,9 +213,9 @@ describe('useConfigureButton', () => {
|
||||
})
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should return workflow tool state without owning drawer visibility', () => {
|
||||
it('should return showModal as false by default', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
expect(result.current.payload).toMatchObject({ workflow_app_id: 'app-123' })
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should forward isCurrentWorkspaceManager from context', () => {
|
||||
@ -235,11 +239,6 @@ describe('useConfigureButton', () => {
|
||||
renderHook(() => useConfigureButton(createDefaultOptions({ published: false })))
|
||||
expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false)
|
||||
})
|
||||
|
||||
it('should call query hook with enabled=false when controller is disabled', () => {
|
||||
renderHook(() => useConfigureButton(createDefaultOptions({ enabled: false, published: true })))
|
||||
expect(mockUseWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123', false)
|
||||
})
|
||||
})
|
||||
|
||||
// Computed values
|
||||
@ -349,13 +348,46 @@ describe('useConfigureButton', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Modal controls
|
||||
describe('Modal Controls', () => {
|
||||
it('should open modal via openModal', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
act(() => {
|
||||
result.current.openModal()
|
||||
})
|
||||
expect(result.current.showModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should close modal via closeModal', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
act(() => {
|
||||
result.current.openModal()
|
||||
})
|
||||
act(() => {
|
||||
result.current.closeModal()
|
||||
})
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should navigate to tools page', () => {
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions()))
|
||||
act(() => {
|
||||
result.current.navigateToTools()
|
||||
})
|
||||
expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow')
|
||||
})
|
||||
})
|
||||
|
||||
// Mutation handlers
|
||||
describe('handleCreate', () => {
|
||||
it('should create provider, invalidate caches, refresh, and notify configured', async () => {
|
||||
it('should create provider, invalidate caches, refresh, and close modal', async () => {
|
||||
mockCreateWorkflowToolProvider.mockResolvedValue({})
|
||||
const onRefreshData = vi.fn()
|
||||
const onConfigured = vi.fn()
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData, onConfigured })))
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({ onRefreshData })))
|
||||
|
||||
act(() => {
|
||||
result.current.openModal()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreate(createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { workflow_app_id: string })
|
||||
@ -366,7 +398,7 @@ describe('useConfigureButton', () => {
|
||||
expect(onRefreshData).toHaveBeenCalled()
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) })
|
||||
expect(onConfigured).toHaveBeenCalled()
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
@ -382,18 +414,20 @@ describe('useConfigureButton', () => {
|
||||
})
|
||||
|
||||
describe('handleUpdate', () => {
|
||||
it('should publish, save, invalidate caches, and notify configured', async () => {
|
||||
it('should publish, save, invalidate caches, and close modal', async () => {
|
||||
mockSaveWorkflowToolProvider.mockResolvedValue({})
|
||||
const handlePublish = vi.fn().mockResolvedValue(undefined)
|
||||
const onRefreshData = vi.fn()
|
||||
const onConfigured = vi.fn()
|
||||
const { result } = renderHook(() => useConfigureButton(createDefaultOptions({
|
||||
published: true,
|
||||
handlePublish,
|
||||
onRefreshData,
|
||||
onConfigured,
|
||||
})))
|
||||
|
||||
act(() => {
|
||||
result.current.openModal()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdate(createMockRequest({ workflow_tool_id: 'tool-456' }) as WorkflowToolProviderRequest & Partial<{ workflow_app_id: string, workflow_tool_id: string }>)
|
||||
})
|
||||
@ -403,7 +437,7 @@ describe('useConfigureButton', () => {
|
||||
expect(onRefreshData).toHaveBeenCalled()
|
||||
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
|
||||
expect(onConfigured).toHaveBeenCalled()
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should show error toast when publish fails', async () => {
|
||||
@ -457,16 +491,6 @@ describe('useConfigureButton', () => {
|
||||
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not invalidate detail while disabled', () => {
|
||||
renderHook(() => useConfigureButton(createDefaultOptions({
|
||||
enabled: false,
|
||||
published: true,
|
||||
detailNeedUpdate: true,
|
||||
})))
|
||||
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
|
||||
@ -2,9 +2,10 @@ import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderPa
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools'
|
||||
import { useInvalidateAllWorkflowTools, useInvalidateWorkflowToolDetailByAppID, useWorkflowToolDetailByAppID } from '@/service/use-tools'
|
||||
|
||||
@ -88,7 +89,6 @@ function buildExistingOutputParameters(
|
||||
// endregion
|
||||
|
||||
type UseConfigureButtonOptions = {
|
||||
enabled: boolean
|
||||
published: boolean
|
||||
detailNeedUpdate: boolean
|
||||
workflowAppId: string
|
||||
@ -99,12 +99,10 @@ type UseConfigureButtonOptions = {
|
||||
outputs?: Variable[]
|
||||
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
|
||||
onRefreshData?: () => void
|
||||
onConfigured?: () => void
|
||||
}
|
||||
|
||||
export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
const {
|
||||
enabled,
|
||||
published,
|
||||
detailNeedUpdate,
|
||||
workflowAppId,
|
||||
@ -115,14 +113,16 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
outputs,
|
||||
handlePublish,
|
||||
onRefreshData,
|
||||
onConfigured,
|
||||
} = options
|
||||
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
// Data fetching via React Query
|
||||
const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, enabled && published)
|
||||
const { data: detail, isLoading } = useWorkflowToolDetailByAppID(workflowAppId, published)
|
||||
|
||||
// Invalidation functions (store in ref for stable effect dependency)
|
||||
const invalidateDetail = useInvalidateWorkflowToolDetailByAppID()
|
||||
@ -133,9 +133,9 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
|
||||
// Refetch when detailNeedUpdate becomes true
|
||||
useEffect(() => {
|
||||
if (enabled && detailNeedUpdate)
|
||||
if (detailNeedUpdate)
|
||||
invalidateDetailRef.current(workflowAppId)
|
||||
}, [detailNeedUpdate, enabled, workflowAppId])
|
||||
}, [detailNeedUpdate, workflowAppId])
|
||||
|
||||
// Computed values
|
||||
const outdated = useMemo(
|
||||
@ -173,6 +173,14 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
}
|
||||
}, [detail, published, workflowAppId, icon, name, description, inputs, outputs])
|
||||
|
||||
// Modal controls (stable callbacks)
|
||||
const openModal = useCallback(() => setShowModal(true), [])
|
||||
const closeModal = useCallback(() => setShowModal(false), [])
|
||||
const navigateToTools = useCallback(
|
||||
() => router.push('/tools?category=workflow'),
|
||||
[router],
|
||||
)
|
||||
|
||||
// Mutation handlers (not memoized — only used in conditionally-rendered modal)
|
||||
const handleCreate = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => {
|
||||
try {
|
||||
@ -181,7 +189,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
onRefreshData?.()
|
||||
invalidateDetail(workflowAppId)
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
onConfigured?.()
|
||||
setShowModal(false)
|
||||
}
|
||||
catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
@ -198,7 +206,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
onRefreshData?.()
|
||||
invalidateAllWorkflowTools()
|
||||
invalidateDetail(workflowAppId)
|
||||
onConfigured?.()
|
||||
setShowModal(false)
|
||||
}
|
||||
catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
@ -206,11 +214,15 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
}
|
||||
|
||||
return {
|
||||
showModal,
|
||||
isLoading,
|
||||
outdated,
|
||||
payload,
|
||||
isCurrentWorkspaceManager,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
navigateToTools,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +1,9 @@
|
||||
'use client'
|
||||
import type { DrawerRootProps } from '@langgenius/dify-ui/drawer'
|
||||
import type { FC } from 'react'
|
||||
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { produce } from 'immer'
|
||||
@ -36,7 +26,7 @@ import {
|
||||
isWorkflowToolNameValid,
|
||||
} from './helpers'
|
||||
|
||||
export type WorkflowToolDrawerPayload = {
|
||||
export type WorkflowToolModalPayload = {
|
||||
icon: Emoji
|
||||
label: string
|
||||
name: string
|
||||
@ -52,9 +42,9 @@ export type WorkflowToolDrawerPayload = {
|
||||
workflow_app_id?: string
|
||||
}
|
||||
|
||||
export type WorkflowToolDrawerProps = {
|
||||
type Props = {
|
||||
isAdd?: boolean
|
||||
payload: WorkflowToolDrawerPayload
|
||||
payload: WorkflowToolModalPayload
|
||||
onHide: () => void
|
||||
onRemove?: () => void
|
||||
onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
|
||||
@ -64,9 +54,8 @@ export type WorkflowToolDrawerProps = {
|
||||
}>) => void
|
||||
}
|
||||
|
||||
type WorkflowToolDrawerFrameProps = {
|
||||
type WorkflowToolDrawerProps = {
|
||||
title: string
|
||||
closeLabel: string
|
||||
onHide: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
@ -88,45 +77,39 @@ const InfoTooltip = ({ children }: { children: React.ReactNode }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const WorkflowToolDrawerFrame = ({ title, closeLabel, onHide, children }: WorkflowToolDrawerFrameProps) => {
|
||||
const handleOpenChange = React.useCallback<NonNullable<DrawerRootProps['onOpenChange']>>((open) => {
|
||||
if (!open)
|
||||
onHide()
|
||||
}, [onHide])
|
||||
|
||||
const WorkflowToolDrawer = ({ title, onHide, children }: WorkflowToolDrawerProps) => {
|
||||
return (
|
||||
<Drawer open modal disablePointerDismissal swipeDirection="right" onOpenChange={handleOpenChange}>
|
||||
<DrawerPortal>
|
||||
<DrawerBackdrop />
|
||||
<DrawerViewport>
|
||||
<DrawerPopup
|
||||
data-testid="drawer"
|
||||
className={cn(
|
||||
'data-[swipe-direction=right]:top-2 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-[calc(100dvh-16px)] data-[swipe-direction=right]:w-160 data-[swipe-direction=right]:max-w-[calc(100vw-16px)]',
|
||||
'data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-r-[0.5px] data-[swipe-direction=right]:border-divider-subtle',
|
||||
)}
|
||||
>
|
||||
<DrawerContent className="flex min-h-0 flex-1 flex-col overflow-hidden p-0 pb-0">
|
||||
<div className="shrink-0 border-b border-divider-subtle py-4">
|
||||
<div className="flex h-6 items-center justify-between pr-5 pl-6">
|
||||
<DrawerTitle data-testid="drawer-title" className="min-w-0 truncate system-xl-semibold text-text-primary">
|
||||
{title}
|
||||
</DrawerTitle>
|
||||
<DrawerCloseButton
|
||||
data-testid="drawer-close"
|
||||
className="h-6 w-6 rounded-md"
|
||||
aria-label={closeLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>
|
||||
<Dialog open disablePointerDismissal>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'top-2 right-2 bottom-2 left-auto h-[calc(100dvh-16px)] max-h-[calc(100dvh-16px)] w-[640px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! overflow-hidden rounded-xl border-none bg-transparent p-0 shadow-none',
|
||||
'data-ending-style:translate-x-4 data-ending-style:scale-100 data-starting-style:translate-x-4 data-starting-style:scale-100',
|
||||
)}
|
||||
backdropClassName="bg-background-overlay"
|
||||
>
|
||||
<div data-testid="drawer" className="flex h-full w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl">
|
||||
<div className="shrink-0 border-b border-divider-subtle py-4">
|
||||
<div className="flex h-6 items-center justify-between pr-5 pl-6">
|
||||
<DialogTitle data-testid="drawer-title" className="system-xl-semibold text-text-primary">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="drawer-close"
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover"
|
||||
aria-label="Close"
|
||||
onClick={onHide}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -175,14 +158,15 @@ const WorkflowToolEmojiPicker = ({ onSelect, onClose }: WorkflowToolEmojiPickerP
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkflowToolDrawer({
|
||||
// Add and Edit
|
||||
const WorkflowToolAsModal: FC<Props> = ({
|
||||
isAdd,
|
||||
payload,
|
||||
onHide,
|
||||
onRemove,
|
||||
onSave,
|
||||
onCreate,
|
||||
}: WorkflowToolDrawerProps) {
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false)
|
||||
@ -216,7 +200,7 @@ export function WorkflowToolDrawer({
|
||||
setLabels(value)
|
||||
}
|
||||
const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy)
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const onConfirm = () => {
|
||||
let errorMessage = ''
|
||||
@ -259,10 +243,9 @@ export function WorkflowToolDrawer({
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkflowToolDrawerFrame
|
||||
<WorkflowToolDrawer
|
||||
onHide={onHide}
|
||||
title={t('common.workflowAsTool', { ns: 'workflow' })!}
|
||||
closeLabel={t('operation.close', { ns: 'common' })!}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
|
||||
@ -444,7 +427,7 @@ export function WorkflowToolDrawer({
|
||||
if (isAdd)
|
||||
onConfirm()
|
||||
else
|
||||
setConfirmModalOpen(true)
|
||||
setShowModal(true)
|
||||
}}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
@ -452,7 +435,7 @@ export function WorkflowToolDrawer({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WorkflowToolDrawerFrame>
|
||||
</WorkflowToolDrawer>
|
||||
{showEmojiPicker && (
|
||||
<WorkflowToolEmojiPicker
|
||||
onSelect={(icon, icon_background) => {
|
||||
@ -464,10 +447,10 @@ export function WorkflowToolDrawer({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{confirmModalOpen && (
|
||||
{showModal && (
|
||||
<ConfirmModal
|
||||
show={confirmModalOpen}
|
||||
onClose={() => setConfirmModalOpen(false)}
|
||||
show={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
)}
|
||||
@ -475,3 +458,4 @@ export function WorkflowToolDrawer({
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(WorkflowToolAsModal)
|
||||
|
||||
@ -124,7 +124,7 @@ vi.mock('@/app/components/app/app-publisher', () => ({
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}>
|
||||
publisher-publish
|
||||
</button>
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ url: '/apps/app-1/workflows/publish', title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
|
||||
publisher-publish-with-params
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -271,7 +271,6 @@ const NodePanel: FC<Props> = ({
|
||||
<div className={cn('mb-1')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
showFileList
|
||||
title={<div>{processDataTitle}</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={nodeInfo.process_data}
|
||||
@ -283,7 +282,6 @@ const NodePanel: FC<Props> = ({
|
||||
<div>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
showFileList
|
||||
title={<div>{outputTitle}</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={nodeInfo.outputs}
|
||||
|
||||
@ -143,7 +143,6 @@ const ResultPanel: FC<ResultPanelProps> = ({
|
||||
{process_data && (
|
||||
<CodeEditor
|
||||
readOnly
|
||||
showFileList
|
||||
title={<div>{t('common.processData', { ns: 'workflow' }).toLocaleUpperCase()}</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={process_data}
|
||||
@ -154,7 +153,6 @@ const ResultPanel: FC<ResultPanelProps> = ({
|
||||
{(outputs || status === 'running') && (
|
||||
<CodeEditor
|
||||
readOnly
|
||||
showFileList
|
||||
title={<div>{t('common.output', { ns: 'workflow' }).toLocaleUpperCase()}</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={outputs}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user