Compare commits

..

8 Commits

Author SHA1 Message Date
910554266e fix(docker): update middleware env setup
Create docker/middleware.env from the split envs/middleware.env.example template in local setup flows.

Make dev-clean tolerate a missing generated middleware env file and remove current middleware data directories.
2026-05-09 04:16:07 +08:00
19bf36a716 chore: dep inject for session (#35934)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-08 17:48:33 +00:00
48d27e250b refactor: split docker-compose env config into separate files (#31586)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-08 15:36:20 +00:00
d06b5529b3 chore(docker): clean up env examples (#35938) 2026-05-08 12:53:13 +00:00
8132c444dc feat: support SQLALCHEMY_POOL_RESET_ON_RETURN config (#31156) 2026-05-08 12:25:46 +00:00
cb0356e9d7 chore(i18n): sync translations with en-US (#35933)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-05-08 11:25:15 +00:00
4d80892d7b refactor: convert isinstance chains to match/case (#35902) (#35922)
Signed-off-by: EvanYao826 <2869018789@qq.com>
2026-05-08 09:51:10 +00:00
af754f497a chore: add query generator before lauch webapp (#35416)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-05-08 09:49:43 +00:00
143 changed files with 4648 additions and 3155 deletions

View File

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

View File

@ -37,7 +37,7 @@ jobs:
- name: Prepare middleware env
run: |
cd docker
cp middleware.env.example middleware.env
cp envs/middleware.env.example middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
@ -87,7 +87,7 @@ jobs:
- name: Prepare middleware env for MySQL
run: |
cd docker
cp middleware.env.example middleware.env
cp envs/middleware.env.example middleware.env
sed -i 's/DB_TYPE=postgresql/DB_TYPE=mysql/' middleware.env
sed -i 's/DB_HOST=db_postgres/DB_HOST=db_mysql/' middleware.env
sed -i 's/DB_PORT=5432/DB_PORT=3306/' middleware.env

View File

@ -56,10 +56,8 @@ jobs:
- 'api/**'
- '.github/workflows/api-tests.yml'
- '.github/workflows/expose_service_ports.sh'
- 'docker/.env.all'
- 'docker/.env.example'
- 'docker/init-env.sh'
- 'docker/middleware.env.example'
- 'docker/envs/middleware.env.example'
- 'docker/docker-compose.middleware.yaml'
- 'docker/docker-compose-template.yaml'
- 'docker/generate_docker_compose'
@ -86,7 +84,7 @@ jobs:
- 'pnpm-workspace.yaml'
- '.nvmrc'
- 'docker/docker-compose.middleware.yaml'
- 'docker/middleware.env.example'
- 'docker/envs/middleware.env.example'
- '.github/workflows/web-e2e.yml'
- '.github/actions/setup-web/**'
vdb:
@ -95,10 +93,8 @@ jobs:
- 'api/providers/vdb/*/tests/**'
- '.github/workflows/vdb-tests.yml'
- '.github/workflows/expose_service_ports.sh'
- 'docker/.env.all'
- 'docker/.env.example'
- 'docker/init-env.sh'
- 'docker/middleware.env.example'
- 'docker/envs/middleware.env.example'
- 'docker/docker-compose.yaml'
- 'docker/docker-compose-template.yaml'
- 'docker/generate_docker_compose'
@ -120,7 +116,7 @@ jobs:
- '.github/workflows/db-migration-test.yml'
- '.github/workflows/expose_service_ports.sh'
- 'docker/.env.example'
- 'docker/middleware.env.example'
- 'docker/envs/middleware.env.example'
- 'docker/docker-compose.middleware.yaml'
- 'docker/docker-compose-template.yaml'
- 'docker/generate_docker_compose'

View File

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

View File

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

View File

@ -3,6 +3,10 @@ DOCKER_REGISTRY=langgenius
WEB_IMAGE=$(DOCKER_REGISTRY)/dify-web
API_IMAGE=$(DOCKER_REGISTRY)/dify-api
VERSION=latest
DOCKER_DIR=docker
DOCKER_MIDDLEWARE_ENV=$(DOCKER_DIR)/middleware.env
DOCKER_MIDDLEWARE_ENV_EXAMPLE=$(DOCKER_DIR)/envs/middleware.env.example
DOCKER_MIDDLEWARE_PROJECT=dify-middlewares-dev
# Default target - show help
.DEFAULT_GOAL := help
@ -17,8 +21,13 @@ dev-setup: prepare-docker prepare-web prepare-api
# Step 1: Prepare Docker middleware
prepare-docker:
@echo "🐳 Setting up Docker middleware..."
@cp -n docker/middleware.env.example docker/middleware.env 2>/dev/null || echo "Docker middleware.env already exists"
@cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev up -d
@if [ ! -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \
cp "$(DOCKER_MIDDLEWARE_ENV_EXAMPLE)" "$(DOCKER_MIDDLEWARE_ENV)"; \
echo "Docker middleware.env created"; \
else \
echo "Docker middleware.env already exists"; \
fi
@cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) up -d
@echo "✅ Docker middleware started"
# Step 2: Prepare web environment
@ -39,12 +48,18 @@ prepare-api:
# Clean dev environment
dev-clean:
@echo "⚠️ Stopping Docker containers..."
@cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev down
@if [ -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \
cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) down; \
else \
echo "Docker middleware.env does not exist, skipping compose down"; \
fi
@echo "🗑️ Removing volumes..."
@rm -rf docker/volumes/db
@rm -rf docker/volumes/mysql
@rm -rf docker/volumes/redis
@rm -rf docker/volumes/plugin_daemon
@rm -rf docker/volumes/weaviate
@rm -rf docker/volumes/sandbox/dependencies
@rm -rf api/storage
@echo "✅ Cleanup complete"
@ -132,7 +147,7 @@ help:
@echo " make prepare-docker - Set up Docker middleware"
@echo " make prepare-web - Set up web environment"
@echo " make prepare-api - Set up API environment"
@echo " make dev-clean - Stop Docker middleware containers"
@echo " make dev-clean - Stop Docker middleware containers and remove dev data"
@echo ""
@echo "Backend Code Quality:"
@echo " make format - Format code with ruff"

View File

@ -76,14 +76,7 @@ The easiest way to start the Dify server is through [Docker Compose](docker/dock
```bash
cd dify
cd docker
./init-env.sh
docker compose up -d
```
On Windows PowerShell, initialize `.env`, then run `docker compose up -d` from the `docker` directory.
```powershell
.\init-env.ps1
cp .env.example .env
docker compose up -d
```
@ -144,7 +137,7 @@ Star Dify on GitHub and be instantly notified of new releases.
### Custom configurations
If you need to customize the configuration, edit `docker/.env` after running the initialization script. The full reference remains in [`docker/.env.all`](docker/.env.all). After making any changes, re-run `docker compose up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
If you need to customize the configuration, edit `docker/.env`. The essential startup defaults live in [`docker/.env.example`](docker/.env.example), and optional advanced variables are split under `docker/envs/` by theme. After making any changes, re-run `docker compose up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
### Metrics Monitoring with Grafana

View File

@ -98,6 +98,8 @@ 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...
@ -381,7 +383,7 @@ VIKINGDB_ACCESS_KEY=your-ak
VIKINGDB_SECRET_KEY=your-sk
VIKINGDB_REGION=cn-shanghai
VIKINGDB_HOST=api-vikingdb.xxx.volces.com
VIKINGDB_SCHEMA=http
VIKINGDB_SCHEME=http
VIKINGDB_CONNECTION_TIMEOUT=30
VIKINGDB_SOCKET_TIMEOUT=30
@ -432,8 +434,6 @@ UPLOAD_FILE_EXTENSION_BLACKLIST=
# Model configuration
MULTIMODAL_SEND_FORMAT=base64
PROMPT_GENERATION_MAX_TOKENS=512
CODE_GENERATION_MAX_TOKENS=1024
PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false
# Mail configuration, support: resend, smtp, sendgrid

View File

@ -114,7 +114,7 @@ class SQLAlchemyEngineOptionsDict(TypedDict):
pool_pre_ping: bool
connect_args: dict[str, str]
pool_use_lifo: bool
pool_reset_on_return: None
pool_reset_on_return: Literal["commit", "rollback", None]
pool_timeout: int
@ -223,6 +223,11 @@ 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,
@ -252,7 +257,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": None,
"pool_reset_on_return": self.SQLALCHEMY_POOL_RESET_ON_RETURN,
"pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT,
}
return result

View File

@ -25,6 +25,7 @@ from controllers.console.wraps import (
is_admin_or_owner_required,
setup_required,
)
from core.db.session_factory import session_factory
from core.ops.ops_trace_manager import OpsTraceManager
from core.rag.entities import PreProcessingRule, Rule, Segmentation
from core.rag.retrieval.retrieval_methods import RetrievalMethod
@ -841,7 +842,8 @@ class AppTraceApi(Resource):
@account_initialization_required
def get(self, app_id):
"""Get app trace"""
app_trace_config = OpsTraceManager.get_app_tracing_config(app_id=app_id)
with session_factory.create_session() as session:
app_trace_config = OpsTraceManager.get_app_tracing_config(app_id, session)
return app_trace_config

View File

@ -842,24 +842,24 @@ class WorkflowResponseConverter:
return []
files: list[Mapping[str, Any]] = []
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)
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 file:
files.append(file)
elif isinstance(
value,
dict,
):
file = cls._get_file_var_from_value(value)
if file:
files.append(file)
case _:
pass
return files

View File

@ -569,13 +569,13 @@ class OpsTraceManager:
db.session.commit()
@classmethod
def get_app_tracing_config(cls, app_id: str):
def get_app_tracing_config(cls, app_id: str, session: Session):
"""
Get app tracing config
:param app_id: app id
:return:
"""
app: App | None = db.session.get(App, app_id)
app: App | None = session.get(App, app_id)
if not app:
raise ValueError("App not found")
if not app.tracing:

View File

@ -53,24 +53,27 @@ class PromptMessageUtil:
files = []
if isinstance(prompt_message.content, list):
for content in prompt_message.content:
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,
}
)
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
else:
text = cast(str, prompt_message.content)

View File

@ -23,36 +23,37 @@ _TOOL_FILE_URL_PATTERN = re.compile(r"(?:^|/+)files/tools/(?P<tool_file_id>[^/?#
def safe_json_value(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
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
def safe_json_dict(d: dict[str, Any]):

View File

@ -194,14 +194,15 @@ class VariableTruncator(BaseTruncator):
result: _PartResult[Any]
# Apply type-specific truncation with target size
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.")
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.")
return _PartResult(
value=segment.model_copy(update={"value": result.value}),
@ -219,40 +220,41 @@ class VariableTruncator(BaseTruncator):
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
if depth > _MAX_DEPTH:
raise MaxDepthExceededError()
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)}")
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)}")
def _truncate_string(self, value: str, target_size: int) -> _PartResult[str]:
if (size := self.calculate_json_size(value)) < target_size:
@ -419,22 +421,23 @@ class VariableTruncator(BaseTruncator):
target_size: int,
) -> _PartResult[Any]:
"""Truncate a value within an object to fit within budget."""
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.")
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.")
class DummyVariableTruncator(BaseTruncator):

View File

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

View File

@ -407,18 +407,18 @@ def test_update_app_tracing_config_success(mock_db):
def test_get_app_tracing_config_errors_when_missing(mock_db):
mock_db.get.return_value = None
with pytest.raises(ValueError, match="App not found"):
OpsTraceManager.get_app_tracing_config("app")
OpsTraceManager.get_app_tracing_config("app", mock_db)
def test_get_app_tracing_config_returns_defaults(mock_db):
mock_db.get.return_value = SimpleNamespace(tracing=None)
assert OpsTraceManager.get_app_tracing_config("app-id") == {"enabled": False, "tracing_provider": None}
assert OpsTraceManager.get_app_tracing_config("app-id", mock_db) == {"enabled": False, "tracing_provider": None}
def test_get_app_tracing_config_returns_payload(mock_db):
payload = {"enabled": True, "tracing_provider": "dummy"}
mock_db.get.return_value = SimpleNamespace(tracing=json.dumps(payload))
assert OpsTraceManager.get_app_tracing_config("app-id") == payload
assert OpsTraceManager.get_app_tracing_config("app-id", mock_db) == payload
def test_check_and_project_helpers(monkeypatch):

View File

@ -92,32 +92,30 @@ BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF: frozenset[str] = frozenset(
)
API_CONFIG_SET = set(dotenv_values(Path("api") / Path(".env.example")).keys())
DOCKER_CONFIG_SET = set(dotenv_values(Path("docker") / Path(".env.all")).keys())
DOCKER_COMPOSE_CONFIG_SET = set()
DOCKER_CONFIG_SET = set(dotenv_values(Path("docker") / Path(".env.example")).keys())
DOCKER_COMPOSE_CONFIG_SET = set(DOCKER_CONFIG_SET)
with open(Path("docker") / Path("docker-compose.yaml")) as f:
DOCKER_COMPOSE_CONFIG_SET = set(yaml.safe_load(f.read())["x-shared-env"].keys())
# Read environment variables from the split env files used by docker-compose
# Walk through all .env.example files in subdirectories (per-module structure)
envs_dir = Path("docker") / Path("envs")
if envs_dir.exists():
for env_file_path in envs_dir.rglob("*.env.example"):
env_keys = set(dotenv_values(env_file_path).keys())
DOCKER_CONFIG_SET.update(env_keys)
DOCKER_COMPOSE_CONFIG_SET.update(env_keys)
def test_yaml_config():
# python set == operator is used to compare two sets
DIFF_API_WITH_DOCKER = (
API_CONFIG_SET - DOCKER_CONFIG_SET - BASE_API_AND_DOCKER_CONFIG_SET_DIFF
)
DIFF_API_WITH_DOCKER = API_CONFIG_SET - DOCKER_CONFIG_SET - BASE_API_AND_DOCKER_CONFIG_SET_DIFF
if DIFF_API_WITH_DOCKER:
print(
f"API and Docker config sets are different with key: {DIFF_API_WITH_DOCKER}"
)
print(f"API and Docker config sets are different with key: {DIFF_API_WITH_DOCKER}")
raise Exception("API and Docker config sets are different")
DIFF_API_WITH_DOCKER_COMPOSE = (
API_CONFIG_SET
- DOCKER_COMPOSE_CONFIG_SET
- BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF
API_CONFIG_SET - DOCKER_COMPOSE_CONFIG_SET - BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF
)
if DIFF_API_WITH_DOCKER_COMPOSE:
print(
f"API and Docker Compose config sets are different with key: {DIFF_API_WITH_DOCKER_COMPOSE}"
)
print(f"API and Docker Compose config sets are different with key: {DIFF_API_WITH_DOCKER_COMPOSE}")
raise Exception("API and Docker Compose config sets are different")
print("All tests passed!")

View File

@ -14,8 +14,8 @@ export OPENDAL_FS_ROOT=${OPENDAL_FS_ROOT:-/tmp/dify-storage}
mkdir -p "${OPENDAL_FS_ROOT}"
# Prepare env files like CI
./docker/init-env.sh
cp -n docker/middleware.env.example docker/middleware.env || true
cp -n docker/.env.example docker/.env || true
cp -n docker/envs/middleware.env.example docker/middleware.env || true
cp -n api/tests/integration_tests/.env.example api/tests/integration_tests/.env || true
# Expose service ports (same as CI) without leaving the repo dirty

View File

@ -8,7 +8,7 @@ API_ENV_EXAMPLE="$ROOT/api/.env.example"
API_ENV="$ROOT/api/.env"
WEB_ENV_EXAMPLE="$ROOT/web/.env.example"
WEB_ENV="$ROOT/web/.env.local"
MIDDLEWARE_ENV_EXAMPLE="$ROOT/docker/middleware.env.example"
MIDDLEWARE_ENV_EXAMPLE="$ROOT/docker/envs/middleware.env.example"
MIDDLEWARE_ENV="$ROOT/docker/middleware.env"
# 1) Copy api/.env.example -> api/.env
@ -17,7 +17,7 @@ cp "$API_ENV_EXAMPLE" "$API_ENV"
# 2) Copy web/.env.example -> web/.env.local
cp "$WEB_ENV_EXAMPLE" "$WEB_ENV"
# 3) Copy docker/middleware.env.example -> docker/middleware.env
# 3) Copy docker/envs/middleware.env.example -> docker/middleware.env
cp "$MIDDLEWARE_ENV_EXAMPLE" "$MIDDLEWARE_ENV"
# 4) Install deps

File diff suppressed because it is too large Load Diff

View File

@ -1,54 +1,251 @@
# ------------------------------------------------------------------
# Minimal environment template for Docker Compose deployments.
# Essential defaults for Docker Compose deployments.
#
# Run ./init-env.sh, or .\init-env.ps1 on Windows, to create .env
# and generate SECRET_KEY. Use .env.all as the full reference for
# advanced and service-specific settings.
# For a default deployment, copy this file to .env and run:
# docker compose up -d
#
# Optional and provider-specific variables live under docker/envs/.
# Copy an optional *.env.example file beside itself without the
# .example suffix when you need those advanced settings.
# Values in docker/.env take precedence over docker/envs/*.env files.
# ------------------------------------------------------------------
# SECRET_KEY is generated into .env by init-env.sh / init-env.ps1.
# Public URLs used when Dify generates links. Change these together when
# exposing Dify under another hostname, IP address, or port.
CONSOLE_WEB_URL=http://localhost
SERVICE_API_URL=http://localhost
APP_WEB_URL=http://localhost
FILES_URL=http://localhost
INTERNAL_FILES_URL=http://api:5001
# Core service URLs
CONSOLE_API_URL=
CONSOLE_WEB_URL=
SERVICE_API_URL=
TRIGGER_URL=http://localhost
APP_API_URL=
APP_WEB_URL=
FILES_URL=
INTERNAL_FILES_URL=
ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id}
NEXT_PUBLIC_SOCKET_URL=ws://localhost
EXPOSE_PLUGIN_DEBUGGING_HOST=localhost
EXPOSE_PLUGIN_DEBUGGING_PORT=5003
# Built-in metadata database defaults.
# Runtime and security
LANG=C.UTF-8
LC_ALL=C.UTF-8
PYTHONIOENCODING=utf-8
UV_CACHE_DIR=/tmp/.uv-cache
SECRET_KEY=sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U
INIT_PASSWORD=
DEPLOY_ENV=PRODUCTION
CHECK_UPDATE_URL=https://updates.dify.ai
OPENAI_API_BASE=https://api.openai.com/v1
MIGRATION_ENABLED=true
FILES_ACCESS_TIMEOUT=300
ENABLE_COLLABORATION_MODE=false
# Logging and server workers
LOG_LEVEL=INFO
LOG_OUTPUT_FORMAT=text
LOG_FILE=/app/logs/server.log
LOG_FILE_MAX_SIZE=20
LOG_FILE_BACKUP_COUNT=5
LOG_DATEFORMAT=%Y-%m-%d %H:%M:%S
LOG_TZ=UTC
DEBUG=false
FLASK_DEBUG=false
ENABLE_REQUEST_LOGGING=False
DIFY_BIND_ADDRESS=0.0.0.0
DIFY_PORT=5001
SERVER_WORKER_AMOUNT=1
SERVER_WORKER_CLASS=gevent
SERVER_WORKER_CONNECTIONS=10
GUNICORN_TIMEOUT=360
CELERY_WORKER_CLASS=
CELERY_WORKER_AMOUNT=4
CELERY_AUTO_SCALE=false
CELERY_MAX_WORKERS=
CELERY_MIN_WORKERS=
COMPOSE_WORKER_HEALTHCHECK_DISABLED=true
COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s
COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s
# Database
DB_TYPE=postgresql
DB_USERNAME=postgres
DB_PASSWORD=difyai123456
DB_HOST=db_postgres
DB_PORT=5432
DB_DATABASE=dify
SQLALCHEMY_POOL_SIZE=30
SQLALCHEMY_MAX_OVERFLOW=10
SQLALCHEMY_POOL_RECYCLE=3600
SQLALCHEMY_ECHO=false
SQLALCHEMY_POOL_PRE_PING=false
SQLALCHEMY_POOL_USE_LIFO=false
SQLALCHEMY_POOL_TIMEOUT=30
SQLALCHEMY_POOL_RESET_ON_RETURN=rollback
PGDATA=/var/lib/postgresql/data/pgdata
POSTGRES_MAX_CONNECTIONS=200
POSTGRES_SHARED_BUFFERS=128MB
POSTGRES_WORK_MEM=4MB
POSTGRES_MAINTENANCE_WORK_MEM=64MB
POSTGRES_EFFECTIVE_CACHE_SIZE=4096MB
POSTGRES_STATEMENT_TIMEOUT=0
POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0
# Built-in Redis defaults.
# Redis and Celery
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_USERNAME=
REDIS_PASSWORD=difyai123456
REDIS_USE_SSL=false
REDIS_SSL_CERT_REQS=CERT_NONE
REDIS_SSL_CA_CERTS=
REDIS_SSL_CERTFILE=
REDIS_SSL_KEYFILE=
REDIS_DB=0
REDIS_KEY_PREFIX=
REDIS_MAX_CONNECTIONS=
REDIS_RETRY_RETRIES=3
REDIS_RETRY_BACKOFF_BASE=1.0
REDIS_RETRY_BACKOFF_CAP=10.0
REDIS_SOCKET_TIMEOUT=5.0
REDIS_SOCKET_CONNECT_TIMEOUT=5.0
REDIS_HEALTH_CHECK_INTERVAL=30
CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1
CELERY_BACKEND=redis
BROKER_USE_SSL=false
CELERY_TASK_ANNOTATIONS=null
EVENT_BUS_REDIS_URL=
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
EVENT_BUS_REDIS_USE_CLUSTERS=false
# Default file storage.
# Web and app limits
WEB_API_CORS_ALLOW_ORIGINS=*
CONSOLE_CORS_ALLOW_ORIGINS=*
COOKIE_DOMAIN=
NEXT_PUBLIC_COOKIE_DOMAIN=
NEXT_PUBLIC_BATCH_CONCURRENCY=5
API_SENTRY_DSN=
API_SENTRY_TRACES_SAMPLE_RATE=1.0
API_SENTRY_PROFILES_SAMPLE_RATE=1.0
WEB_SENTRY_DSN=
AMPLITUDE_API_KEY=
TEXT_GENERATION_TIMEOUT_MS=60000
CSP_WHITELIST=
ALLOW_EMBED=false
ALLOW_INLINE_STYLES=false
ALLOW_UNSAFE_DATA_SCHEME=false
TOP_K_MAX_VALUE=10
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
LOOP_NODE_MAX_COUNT=100
MAX_TOOLS_NUM=10
MAX_PARALLEL_LIMIT=10
MAX_ITERATIONS_NUM=99
MAX_TREE_DEPTH=50
ENABLE_WEBSITE_JINAREADER=true
ENABLE_WEBSITE_FIRECRAWL=true
ENABLE_WEBSITE_WATERCRAWL=true
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
EXPERIMENTAL_ENABLE_VINEXT=false
# Storage and default vector store
STORAGE_TYPE=opendal
OPENDAL_SCHEME=fs
OPENDAL_FS_ROOT=storage
# Default vector database.
VECTOR_STORE=weaviate
VECTOR_INDEX_NAME_PREFIX=Vector_index
WEAVIATE_ENDPOINT=http://weaviate:8080
WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
WEAVIATE_GRPC_ENDPOINT=grpc://weaviate:50051
WEAVIATE_TOKENIZATION=word
WEAVIATE_PERSISTENCE_DATA_PATH=/var/lib/weaviate
WEAVIATE_QUERY_DEFAULTS_LIMIT=25
WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true
WEAVIATE_DEFAULT_VECTORIZER_MODULE=none
WEAVIATE_CLUSTER_HOSTNAME=node1
WEAVIATE_AUTHENTICATION_APIKEY_ENABLED=true
WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
WEAVIATE_AUTHENTICATION_APIKEY_USERS=hello@dify.ai
WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED=true
WEAVIATE_AUTHORIZATION_ADMINLIST_USERS=hello@dify.ai
WEAVIATE_DISABLE_TELEMETRY=false
WEAVIATE_ENABLE_TOKENIZER_GSE=false
WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA=false
WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR=false
# Internal service authentication. Paired values must match.
# Sandbox and SSRF proxy
CODE_EXECUTION_ENDPOINT=http://sandbox:8194
CODE_EXECUTION_API_KEY=dify-sandbox
CODE_EXECUTION_SSL_VERIFY=True
CODE_EXECUTION_POOL_MAX_CONNECTIONS=100
CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS=20
CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY=5.0
CODE_EXECUTION_CONNECT_TIMEOUT=10
CODE_EXECUTION_READ_TIMEOUT=60
CODE_EXECUTION_WRITE_TIMEOUT=10
SANDBOX_API_KEY=dify-sandbox
SANDBOX_GIN_MODE=release
SANDBOX_WORKER_TIMEOUT=15
SANDBOX_ENABLE_NETWORK=true
SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128
SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128
SANDBOX_PORT=8194
PIP_MIRROR_URL=
SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128
SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128
SSRF_HTTP_PORT=3128
SSRF_COREDUMP_DIR=/var/spool/squid
SSRF_REVERSE_PROXY_PORT=8194
SSRF_SANDBOX_HOST=sandbox
SSRF_DEFAULT_TIME_OUT=5
SSRF_DEFAULT_CONNECT_TIME_OUT=5
SSRF_DEFAULT_READ_TIME_OUT=5
SSRF_DEFAULT_WRITE_TIME_OUT=5
SSRF_POOL_MAX_CONNECTIONS=100
SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS=20
SSRF_POOL_KEEPALIVE_EXPIRY=5.0
# Plugin daemon
DB_PLUGIN_DATABASE=dify_plugin
EXPOSE_PLUGIN_DAEMON_PORT=5002
PLUGIN_DAEMON_PORT=5002
PLUGIN_DAEMON_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi
PLUGIN_DAEMON_URL=http://plugin_daemon:5002
PLUGIN_MAX_PACKAGE_SIZE=52428800
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
PLUGIN_PPROF_ENABLED=false
PLUGIN_DEBUGGING_HOST=0.0.0.0
PLUGIN_DEBUGGING_PORT=5003
EXPOSE_PLUGIN_DEBUGGING_HOST=localhost
EXPOSE_PLUGIN_DEBUGGING_PORT=5003
PLUGIN_DIFY_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
PLUGIN_DIFY_INNER_API_URL=http://api:5001
FORCE_VERIFYING_SIGNATURE=true
PLUGIN_STDIO_BUFFER_SIZE=1024
PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880
PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120
PLUGIN_MAX_EXECUTION_TIMEOUT=600
PLUGIN_STORAGE_TYPE=local
PLUGIN_STORAGE_LOCAL_ROOT=/app/storage
PLUGIN_WORKING_PATH=/app/storage/cwd
PLUGIN_INSTALLED_PATH=plugin
PLUGIN_PACKAGE_CACHE_PATH=plugin_packages
PLUGIN_MEDIA_CACHE_PATH=assets
PLUGIN_STORAGE_OSS_BUCKET=
PLUGIN_SENTRY_ENABLED=false
PLUGIN_SENTRY_DSN=
MARKETPLACE_ENABLED=true
MARKETPLACE_API_URL=https://marketplace.dify.ai
MARKETPLACE_URL=
# Host ports.
# Nginx and Docker Compose
NGINX_SERVER_NAME=_
NGINX_HTTPS_ENABLED=false
NGINX_PORT=80
NGINX_SSL_PORT=443
NGINX_SSL_CERT_FILENAME=dify.crt
NGINX_SSL_CERT_KEY_FILENAME=dify.key
NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3
NGINX_WORKER_PROCESSES=auto
NGINX_CLIENT_MAX_BODY_SIZE=100M
NGINX_KEEPALIVE_TIMEOUT=65
NGINX_PROXY_READ_TIMEOUT=3600s
NGINX_PROXY_SEND_TIMEOUT=3600s
NGINX_ENABLE_CERTBOT_CHALLENGE=false
EXPOSE_NGINX_PORT=80
EXPOSE_NGINX_SSL_PORT=443
# Docker Compose profiles for bundled services.
COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql}

3
docker/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Ignore actual .env files (keep only .env.example files in git)
*.env
!*.env.example

View File

@ -7,39 +7,31 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
- **Certbot Container**: `docker-compose.yaml` now contains `certbot` for managing SSL certificates. This container automatically renews certificates and ensures secure HTTPS connections.\
For more information, refer `docker/certbot/README.md`.
- **Persistent Environment Variables**: Default deployment values are provided in `.env.example`. Initialize `.env` from it and keep local changes there so your configuration persists across deployments.
- **Persistent Environment Variables**: Essential startup defaults are provided in `.env.example`, while local values are stored in `.env`, ensuring that your configurations persist across deployments.
> What is `.env`? </br> </br>
> The `.env` file is your local Docker Compose environment file. Start from `.env.example`, then customize it as needed. Use `.env.all` as the full reference when you need advanced configuration.
> The `.env` file is the local startup file. Copy it from `.env.example` for a default deployment. Optional advanced settings live in `envs/*.env.example` files.
- **Unified Vector Database Services**: All vector database services are now managed from a single Docker Compose file `docker-compose.yaml`. You can switch between different vector databases by setting the `VECTOR_STORE` environment variable in your `.env` file.
- **Full Configuration Reference**: `.env.all` keeps the complete variable list for advanced and service-specific settings, while `.env.example` stays focused on the default self-hosted deployment path.
### How to Deploy Dify with `docker-compose.yaml`
1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system.
1. **Environment Setup**:
- Navigate to the `docker` directory.
- Create `.env` and generate a deployment-specific `SECRET_KEY`:
```bash
./init-env.sh
```
On Windows PowerShell:
```powershell
.\init-env.ps1
```
- Customize `.env` only when you need to override defaults. Refer to `.env.all` for the full list of available variables.
- Copy `.env.example` to `.env`.
- Customize `.env` when you need to change essential startup defaults. Copy optional files from `envs/` without the `.example` suffix when you need advanced settings.
- **Optional (for advanced deployments)**:
If you maintain a full `.env` file copied from `.env.all`, you may use the environment synchronization tool to keep it aligned with the latest `.env.all` updates while preserving your custom settings.
If you maintain a full `.env` file copied from `.env.example`, you may use the environment synchronization tool to keep it aligned with the latest `.env.example` updates while preserving your custom settings.
See the [Environment Variables Synchronization](#environment-variables-synchronization) section below.
1. **Running the Services**:
- Execute `docker compose up -d` from the `docker` directory to start the services.
- To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`.
```bash
cp .env.example .env
docker compose up -d
```
1. **SSL Certificate Setup**:
- Refer `docker/certbot/README.md` to set up SSL certificates using Certbot.
1. **OpenTelemetry Collector Setup**:
@ -51,7 +43,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
1. **Middleware Setup**:
- Use the `docker-compose.middleware.yaml` for setting up essential middleware services like databases and caches.
- Navigate to the `docker` directory.
- Ensure the `middleware.env` file is created by running `cp middleware.env.example middleware.env` (refer to the `middleware.env.example` file).
- Ensure the `middleware.env` file is created by running `cp envs/middleware.env.example middleware.env` (refer to the `envs/middleware.env.example` file).
1. **Running Middleware Services**:
- Navigate to the `docker` directory.
- Execute `docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d` to start PostgreSQL/MySQL (per `DB_TYPE`) plus the bundled Weaviate instance.
@ -68,11 +60,13 @@ For users migrating from the `docker-legacy` setup:
1. **Data Migration**:
- Ensure that data from services like databases and caches is backed up and migrated appropriately to the new structure if necessary.
### Overview of `.env.example`, `.env`, and `.env.all`
### Overview of `.env`, `.env.example`, and `envs/`
- `.env.example` contains the minimal default configuration for Docker Compose deployments.
- `.env` is your local copy. It contains the generated `SECRET_KEY` plus any local changes.
- `.env.all` is the full reference for advanced configuration.
- `.env.example` contains the essential default configuration for Docker Compose deployments.
- `.env` contains local startup values copied from `.env.example` and any local changes.
- `envs/*.env.example` files contain optional advanced configuration grouped by theme.
Docker Compose reads `envs/*.env` files when present, then reads `.env` last so values in `.env` take precedence.
#### Key Modules and Customization
@ -82,7 +76,7 @@ For users migrating from the `docker-legacy` setup:
#### Other notable variables
The `.env.all` file provided in the Docker setup is extensive and covers a wide range of configuration options. It is structured into several sections, each pertaining to different aspects of the application and its services. Here are some of the key sections and variables:
The root `.env.example` file contains the essential startup settings. Optional and provider-specific settings are grouped in `envs/*.env.example` files. Here are some of the key sections and variables:
1. **Common Variables**:
@ -110,7 +104,7 @@ The `.env.all` file provided in the Docker setup is extensive and covers a wide
1. **Storage Configuration**:
- `STORAGE_TYPE`, `S3_BUCKET_NAME`, `AZURE_BLOB_ACCOUNT_NAME`: Settings for file storage options like local, S3, Azure Blob, etc.
- `STORAGE_TYPE`, `OPENDAL_SCHEME`, `OPENDAL_FS_ROOT`: Default local file storage settings. Optional storage backends are configured from the files under `envs/`.
1. **Vector Database Configuration**:
@ -132,25 +126,25 @@ The `.env.all` file provided in the Docker setup is extensive and covers a wide
### Environment Variables Synchronization
When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example` or `.env.all`.
When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example` or the optional files under `envs/`.
If you use the default workflow, review `.env.example` and add only the values you need to customize to `.env`.
If you use the default workflow, review `.env.example` and keep your `.env` aligned with essential startup values.
If you maintain a full `.env` file copied from `.env.all`, an optional environment variables synchronization tool is provided.
If you maintain a customized `.env` file copied from `.env.example`, an optional environment variables synchronization tool is provided.
> This tool performs a **one-way synchronization** from `.env.all` to `.env`.
> This tool performs a **one-way synchronization** from `.env.example` to `.env`.
> Existing values in `.env` are never overwritten automatically.
#### `dify-env-sync.sh` (Optional)
This script compares your current `.env` file with the latest `.env.all` template and helps safely apply new or updated environment variables.
This script compares your current `.env` file with the latest `.env.example` template and helps safely apply new or updated environment variables.
**What it does**
- Creates a backup of the current `.env` file before making any changes
- Synchronizes newly added environment variables from `.env.all`
- Synchronizes newly added environment variables from `.env.example`
- Preserves all existing custom values in `.env`
- Displays differences and variables removed from `.env.all` for review
- Displays differences and variables removed from `.env.example` for review
**Backup behavior**
@ -160,8 +154,8 @@ Before synchronization, the current `.env` file is saved to the `env-backup/` di
**When to use**
- After upgrading Dify to a newer version with a full `.env` file
- When `.env.all` has been updated with new environment variables
- When managing a large or heavily customized `.env` file copied from `.env.all`
- When `.env.example` has been updated with new environment variables
- When managing a large or heavily customized `.env` file copied from `.env.example`
**Usage**
@ -176,6 +170,6 @@ chmod +x dify-env-sync.sh
### Additional Information
- **Continuous Improvement Phase**: We are actively seeking feedback from the community to refine and enhance the deployment process. As more users adopt this new method, we will continue to make improvements based on your experiences and suggestions.
- **Support**: For detailed configuration options and environment variable settings, refer to the `.env.all` file and the Docker Compose configuration files in the `docker` directory.
- **Support**: For detailed configuration options and environment variable settings, refer to the `.env.example` file and the Docker Compose configuration files in the `docker` directory.
This README aims to guide you through the deployment process using the new Docker Compose setup. For any issues or further assistance, please refer to the official documentation or contact support.

View File

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

View File

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

View File

@ -1,4 +1,202 @@
x-shared-env: &shared-api-worker-env
# Shared configuration using YAML anchors and env_file
x-shared-api-worker-config: &shared-api-worker-config
env_file:
- path: ./envs/core-services/shared.env
required: false
- path: ./envs/core-services/api.env
required: false
- path: ./envs/security.env
required: false
- path: ./envs/databases/db-postgres.env
required: false
- path: ./envs/databases/db-mysql.env
required: false
- path: ./envs/databases/redis.env
required: false
- path: ./envs/vectorstores/weaviate.env
required: false
- path: ./envs/vectorstores/qdrant.env
required: false
- path: ./envs/vectorstores/oceanbase.env
required: false
- path: ./envs/vectorstores/seekdb.env
required: false
- path: ./envs/vectorstores/couchbase.env
required: false
- path: ./envs/vectorstores/pgvector.env
required: false
- path: ./envs/vectorstores/vastbase.env
required: false
- path: ./envs/vectorstores/pgvecto-rs.env
required: false
- path: ./envs/vectorstores/chroma.env
required: false
- path: ./envs/vectorstores/iris.env
required: false
- path: ./envs/vectorstores/oracle.env
required: false
- path: ./envs/vectorstores/opengauss.env
required: false
- path: ./envs/vectorstores/myscale.env
required: false
- path: ./envs/vectorstores/matrixone.env
required: false
- path: ./envs/vectorstores/elasticsearch.env
required: false
- path: ./envs/vectorstores/opensearch.env
required: false
- path: ./envs/vectorstores/milvus.env
required: false
- path: ./envs/infrastructure/nginx.env
required: false
- path: ./envs/infrastructure/certbot.env
required: false
- path: ./envs/infrastructure/ssrf-proxy.env
required: false
- path: ./envs/infrastructure/etcd.env
required: false
- path: ./envs/infrastructure/minio.env
required: false
- path: ./envs/infrastructure/milvus-standalone.env
required: false
- ./.env
networks:
- ssrf_proxy_network
- default
restart: always
x-shared-worker-config: &shared-worker-config
env_file:
- path: ./envs/core-services/shared.env
required: false
- path: ./envs/core-services/worker.env
required: false
- path: ./envs/security.env
required: false
- path: ./envs/databases/db-postgres.env
required: false
- path: ./envs/databases/db-mysql.env
required: false
- path: ./envs/databases/redis.env
required: false
- path: ./envs/vectorstores/weaviate.env
required: false
- path: ./envs/vectorstores/qdrant.env
required: false
- path: ./envs/vectorstores/oceanbase.env
required: false
- path: ./envs/vectorstores/seekdb.env
required: false
- path: ./envs/vectorstores/couchbase.env
required: false
- path: ./envs/vectorstores/pgvector.env
required: false
- path: ./envs/vectorstores/vastbase.env
required: false
- path: ./envs/vectorstores/pgvecto-rs.env
required: false
- path: ./envs/vectorstores/chroma.env
required: false
- path: ./envs/vectorstores/iris.env
required: false
- path: ./envs/vectorstores/oracle.env
required: false
- path: ./envs/vectorstores/opengauss.env
required: false
- path: ./envs/vectorstores/myscale.env
required: false
- path: ./envs/vectorstores/matrixone.env
required: false
- path: ./envs/vectorstores/elasticsearch.env
required: false
- path: ./envs/vectorstores/opensearch.env
required: false
- path: ./envs/vectorstores/milvus.env
required: false
- path: ./envs/infrastructure/nginx.env
required: false
- path: ./envs/infrastructure/certbot.env
required: false
- path: ./envs/infrastructure/ssrf-proxy.env
required: false
- path: ./envs/infrastructure/etcd.env
required: false
- path: ./envs/infrastructure/minio.env
required: false
- path: ./envs/infrastructure/milvus-standalone.env
required: false
- ./.env
networks:
- ssrf_proxy_network
- default
restart: always
x-shared-worker-beat-config: &shared-worker-beat-config
env_file:
- path: ./envs/core-services/shared.env
required: false
- path: ./envs/core-services/worker-beat.env
required: false
- path: ./envs/security.env
required: false
- path: ./envs/databases/db-postgres.env
required: false
- path: ./envs/databases/db-mysql.env
required: false
- path: ./envs/databases/redis.env
required: false
- path: ./envs/vectorstores/weaviate.env
required: false
- path: ./envs/vectorstores/qdrant.env
required: false
- path: ./envs/vectorstores/oceanbase.env
required: false
- path: ./envs/vectorstores/seekdb.env
required: false
- path: ./envs/vectorstores/couchbase.env
required: false
- path: ./envs/vectorstores/pgvector.env
required: false
- path: ./envs/vectorstores/vastbase.env
required: false
- path: ./envs/vectorstores/pgvecto-rs.env
required: false
- path: ./envs/vectorstores/chroma.env
required: false
- path: ./envs/vectorstores/iris.env
required: false
- path: ./envs/vectorstores/oracle.env
required: false
- path: ./envs/vectorstores/opengauss.env
required: false
- path: ./envs/vectorstores/myscale.env
required: false
- path: ./envs/vectorstores/matrixone.env
required: false
- path: ./envs/vectorstores/elasticsearch.env
required: false
- path: ./envs/vectorstores/opensearch.env
required: false
- path: ./envs/vectorstores/milvus.env
required: false
- path: ./envs/infrastructure/nginx.env
required: false
- path: ./envs/infrastructure/certbot.env
required: false
- path: ./envs/infrastructure/ssrf-proxy.env
required: false
- path: ./envs/infrastructure/etcd.env
required: false
- path: ./envs/infrastructure/minio.env
required: false
- path: ./envs/infrastructure/milvus-standalone.env
required: false
- ./.env
networks:
- ssrf_proxy_network
- default
restart: always
services:
# Init container to fix permissions
init_permissions:
@ -21,12 +219,9 @@ services:
# API service
api:
<<: *shared-api-worker-config
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}
@ -69,12 +264,9 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
<<: *shared-worker-config
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}
@ -115,12 +307,9 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
<<: *shared-worker-beat-config
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:
@ -154,6 +343,12 @@ services:
web:
image: langgenius/dify-web:1.14.0
restart: always
env_file:
- path: ./envs/core-services/web.env
required: false
- path: ./envs/security.env
required: false
- ./.env
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
APP_API_URL: ${APP_API_URL:-}
@ -228,7 +423,7 @@ services:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
MYSQL_DATABASE: ${DB_DATABASE:-dify}
command: >
--max_connections=1000
--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}
@ -270,6 +465,12 @@ services:
sandbox:
image: langgenius/dify-sandbox:0.2.15
restart: always
env_file:
- path: ./envs/core-services/sandbox.env
required: false
- path: ./envs/security.env
required: false
- ./.env
environment:
# The DifySandbox configurations
# Make sure you are changing this key for your deployment with a strong key.
@ -294,9 +495,24 @@ services:
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.6.0-local
restart: always
env_file:
- path: ./envs/core-services/shared.env
required: false
- path: ./envs/core-services/plugin-daemon.env
required: false
- path: ./envs/security.env
required: false
- path: ./envs/databases/db-postgres.env
required: false
- path: ./envs/databases/db-mysql.env
required: false
- path: ./envs/databases/redis.env
required: false
- ./.env
networks:
- ssrf_proxy_network
- default
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}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
# ------------------------------
# Api Configuration
# ------------------------------
MODE=api
SENTRY_DSN=
SENTRY_TRACES_SAMPLE_RATE=1.0
SENTRY_PROFILES_SAMPLE_RATE=1.0
PLUGIN_REMOTE_INSTALL_HOST=localhost
PLUGIN_REMOTE_INSTALL_PORT=5003
PLUGIN_MAX_PACKAGE_SIZE=52428800
PLUGIN_DAEMON_TIMEOUT=600.0
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1

View File

@ -0,0 +1,23 @@
# ------------------------------
# Plugin Daemon Configuration
# ------------------------------
DB_PLUGIN_DATABASE=dify_plugin
PLUGIN_DAEMON_URL=http://plugin_daemon:5002
PLUGIN_PPROF_ENABLED=false
PLUGIN_DIFY_INNER_API_URL=http://api:5001
FORCE_VERIFYING_SIGNATURE=true
PLUGIN_STDIO_BUFFER_SIZE=1024
PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880
PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120
PLUGIN_MAX_EXECUTION_TIMEOUT=600
PLUGIN_DEBUGGING_HOST=0.0.0.0
PLUGIN_DEBUGGING_PORT=5003
PLUGIN_DAEMON_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi
PLUGIN_DIFY_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
PLUGIN_DAEMON_PORT=5002
CELERY_WORKER_CLASS=
PLUGIN_STORAGE_TYPE=local
PLUGIN_STORAGE_LOCAL_ROOT=/app/storage
PLUGIN_WORKING_PATH=/app/storage/cwd
PLUGIN_STORAGE_OSS_BUCKET=

View File

@ -0,0 +1,17 @@
# ------------------------------
# Sandbox Configuration
# ------------------------------
SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128
SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128
SANDBOX_PORT=8194
PIP_MIRROR_URL=
SANDBOX_API_KEY=dify-sandbox
SANDBOX_GIN_MODE=release
SANDBOX_WORKER_TIMEOUT=15
SANDBOX_ENABLE_NETWORK=true
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000

View File

@ -0,0 +1,469 @@
# ------------------------------
# Shared API/Worker Configuration
# ------------------------------
CONSOLE_WEB_URL=
SERVICE_API_URL=
TRIGGER_URL=http://localhost
APP_WEB_URL=
FILES_URL=
INTERNAL_FILES_URL=
LANG=C.UTF-8
LC_ALL=C.UTF-8
PYTHONIOENCODING=utf-8
UV_CACHE_DIR=/tmp/.uv-cache
CHECK_UPDATE_URL=https://updates.dify.ai
OPENAI_API_BASE=https://api.openai.com/v1
MIGRATION_ENABLED=true
FILES_ACCESS_TIMEOUT=300
ENABLE_COLLABORATION_MODE=false
CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1
CELERY_TASK_ANNOTATIONS=null
AZURE_BLOB_ACCOUNT_URL=https://<your_account_name>.blob.core.windows.net
SUPABASE_URL=your-server-url
TIDB_ON_QDRANT_URL=http://127.0.0.1
TIDB_ON_QDRANT_API_KEY=dify
TIDB_API_URL=http://127.0.0.1
TIDB_IAM_API_URL=http://127.0.0.1
TIDB_REGION=regions/aws-us-east-1
TIDB_PROJECT_ID=dify
TIDB_SPEND_LIMIT=100
TENCENT_VECTOR_DB_URL=http://127.0.0.1
TENCENT_VECTOR_DB_API_KEY=dify
LINDORM_URL=http://localhost:30070
LINDORM_USERNAME=admin
UPSTASH_VECTOR_URL=https://xxx-vector.upstash.io
UPLOAD_FILE_SIZE_LIMIT=15
UPLOAD_FILE_BATCH_LIMIT=5
UPLOAD_FILE_EXTENSION_BLACKLIST=
SINGLE_CHUNK_ATTACHMENT_LIMIT=10
IMAGE_FILE_BATCH_LIMIT=10
ATTACHMENT_IMAGE_FILE_SIZE_LIMIT=2
ATTACHMENT_IMAGE_DOWNLOAD_TIMEOUT=60
ETL_TYPE=dify
UNSTRUCTURED_API_URL=
MULTIMODAL_SEND_FORMAT=base64
UPLOAD_IMAGE_FILE_SIZE_LIMIT=10
UPLOAD_VIDEO_FILE_SIZE_LIMIT=100
UPLOAD_AUDIO_FILE_SIZE_LIMIT=50
API_SENTRY_DSN=
API_SENTRY_TRACES_SAMPLE_RATE=1.0
API_SENTRY_PROFILES_SAMPLE_RATE=1.0
WEB_SENTRY_DSN=
PLUGIN_SENTRY_ENABLED=false
PLUGIN_SENTRY_DSN=
NOTION_INTEGRATION_TYPE=public
RESEND_API_URL=https://api.resend.com
SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128
SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128
PGDATA=/var/lib/postgresql/data/pgdata
PLUGIN_MAX_PACKAGE_SIZE=52428800
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id}
LOG_LEVEL=INFO
LOG_OUTPUT_FORMAT=text
LOG_FILE=/app/logs/server.log
LOG_FILE_MAX_SIZE=20
LOG_FILE_BACKUP_COUNT=5
LOG_DATEFORMAT=%Y-%m-%d %H:%M:%S
LOG_TZ=UTC
DEBUG=false
FLASK_DEBUG=false
ENABLE_REQUEST_LOGGING=False
WORKFLOW_LOG_CLEANUP_ENABLED=false
WORKFLOW_LOG_RETENTION_DAYS=30
WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100
WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS=
EXPOSE_PLUGIN_DEBUGGING_HOST=localhost
EXPOSE_PLUGIN_DEBUGGING_PORT=5003
DEPLOY_ENV=PRODUCTION
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=30
APP_DEFAULT_ACTIVE_REQUESTS=0
APP_MAX_ACTIVE_REQUESTS=0
APP_MAX_EXECUTION_TIME=1200
DIFY_BIND_ADDRESS=0.0.0.0
DIFY_PORT=5001
SERVER_WORKER_AMOUNT=1
SERVER_WORKER_CLASS=gevent
SERVER_WORKER_CONNECTIONS=10
CELERY_SENTINEL_PASSWORD=
S3_ACCESS_KEY=
S3_SECRET_KEY=
ARCHIVE_STORAGE_ACCESS_KEY=
ARCHIVE_STORAGE_SECRET_KEY=
AZURE_BLOB_ACCOUNT_KEY=difyai
ALIYUN_OSS_ACCESS_KEY=your-access-key
ALIYUN_OSS_SECRET_KEY=your-secret-key
TENCENT_COS_SECRET_KEY=your-secret-key
TENCENT_COS_SECRET_ID=your-secret-id
OCI_ACCESS_KEY=your-access-key
OCI_SECRET_KEY=your-secret-key
HUAWEI_OBS_SECRET_KEY=your-secret-key
HUAWEI_OBS_ACCESS_KEY=your-access-key
VOLCENGINE_TOS_SECRET_KEY=your-secret-key
VOLCENGINE_TOS_ACCESS_KEY=your-access-key
BAIDU_OBS_SECRET_KEY=your-secret-key
BAIDU_OBS_ACCESS_KEY=your-access-key
SUPABASE_API_KEY=your-access-key
ALIBABACLOUD_MYSQL_PASSWORD=difyai123456
RELYT_PASSWORD=difyai123456
LINDORM_PASSWORD=admin
LINDORM_USING_UGC=True
LINDORM_QUERY_TIMEOUT=1
HUAWEI_CLOUD_PASSWORD=admin
UPSTASH_VECTOR_TOKEN=dify
TABLESTORE_ACCESS_KEY_ID=xxx
TABLESTORE_ACCESS_KEY_SECRET=xxx
TABLESTORE_NORMALIZE_FULLTEXT_BM25_SCORE=false
CLICKZETTA_PASSWORD=
CLICKZETTA_INSTANCE=
CLICKZETTA_SERVICE=api.clickzetta.com
CLICKZETTA_WORKSPACE=quick_start
CLICKZETTA_VCLUSTER=default_ap
CLICKZETTA_SCHEMA=dify
CLICKZETTA_BATCH_SIZE=100
CLICKZETTA_ENABLE_INVERTED_INDEX=true
CLICKZETTA_ANALYZER_TYPE=chinese
CLICKZETTA_ANALYZER_MODE=smart
UNSTRUCTURED_API_KEY=
SCARF_NO_ANALYTICS=true
PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false
NOTION_CLIENT_SECRET=
NOTION_CLIENT_ID=
NOTION_INTERNAL_SECRET=
MAIL_TYPE=resend
MAIL_DEFAULT_SEND_FROM=
RESEND_API_KEY=your-resend-api-key
SMTP_SERVER=
SMTP_PORT=465
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_USE_TLS=true
SMTP_OPPORTUNISTIC_TLS=false
SMTP_LOCAL_HOSTNAME=
SENDGRID_API_KEY=
INVITE_EXPIRY_HOURS=72
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
CODE_EXECUTION_ENDPOINT=http://sandbox:8194
CODE_EXECUTION_API_KEY=dify-sandbox
CODE_EXECUTION_SSL_VERIFY=True
CODE_EXECUTION_POOL_MAX_CONNECTIONS=100
CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS=20
CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY=5.0
CODE_MAX_NUMBER=9223372036854775807
CODE_MIN_NUMBER=-9223372036854775808
CODE_MAX_DEPTH=5
CODE_MAX_PRECISION=20
CODE_MAX_STRING_LENGTH=400000
CODE_MAX_STRING_ARRAY_LENGTH=30
CODE_MAX_OBJECT_ARRAY_LENGTH=30
CODE_MAX_NUMBER_ARRAY_LENGTH=1000
CODE_EXECUTION_CONNECT_TIMEOUT=10
CODE_EXECUTION_READ_TIMEOUT=60
CODE_EXECUTION_WRITE_TIMEOUT=10
TEMPLATE_TRANSFORM_MAX_LENGTH=400000
WORKFLOW_MAX_EXECUTION_STEPS=500
WORKFLOW_MAX_EXECUTION_TIME=1200
WORKFLOW_CALL_MAX_DEPTH=5
MAX_VARIABLE_SIZE=204800
WORKFLOW_FILE_UPLOAD_LIMIT=10
GRAPH_ENGINE_MIN_WORKERS=1
GRAPH_ENGINE_MAX_WORKERS=10
GRAPH_ENGINE_SCALE_UP_THRESHOLD=3
GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME=5.0
ALIYUN_SLS_ACCESS_KEY_ID=
ALIYUN_SLS_ACCESS_KEY_SECRET=
WEBHOOK_REQUEST_BODY_MAX_SIZE=10485760
RESPECT_XFORWARD_HEADERS_ENABLED=false
SSRF_HTTP_PORT=3128
SSRF_COREDUMP_DIR=/var/spool/squid
SSRF_REVERSE_PROXY_PORT=8194
SSRF_SANDBOX_HOST=sandbox
SSRF_DEFAULT_TIME_OUT=5
SSRF_DEFAULT_CONNECT_TIME_OUT=5
SSRF_DEFAULT_READ_TIME_OUT=5
SSRF_DEFAULT_WRITE_TIME_OUT=5
SSRF_POOL_MAX_CONNECTIONS=100
SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS=20
SSRF_POOL_KEEPALIVE_EXPIRY=5.0
PLUGIN_AWS_ACCESS_KEY=
PLUGIN_AWS_SECRET_KEY=
PLUGIN_AWS_REGION=
PLUGIN_TENCENT_COS_SECRET_KEY=
PLUGIN_TENCENT_COS_SECRET_ID=
PLUGIN_ALIYUN_OSS_ACCESS_KEY_ID=
PLUGIN_ALIYUN_OSS_ACCESS_KEY_SECRET=
PLUGIN_VOLCENGINE_TOS_ACCESS_KEY=
PLUGIN_VOLCENGINE_TOS_SECRET_KEY=
OTLP_API_KEY=
OTEL_EXPORTER_OTLP_PROTOCOL=
OTEL_EXPORTER_TYPE=otlp
OTEL_SAMPLING_RATE=0.1
OTEL_BATCH_EXPORT_SCHEDULE_DELAY=5000
OTEL_MAX_QUEUE_SIZE=2048
OTEL_MAX_EXPORT_BATCH_SIZE=512
OTEL_METRIC_EXPORT_INTERVAL=60000
OTEL_BATCH_EXPORT_TIMEOUT=10000
OTEL_METRIC_EXPORT_TIMEOUT=30000
QUEUE_MONITOR_THRESHOLD=200
QUEUE_MONITOR_ALERT_EMAILS=
QUEUE_MONITOR_INTERVAL=30
SWAGGER_UI_ENABLED=false
SWAGGER_UI_PATH=/swagger-ui.html
DSL_EXPORT_ENCRYPT_DATASET_ID=true
DATASET_MAX_SEGMENTS_PER_REQUEST=0
ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false
ENABLE_CLEAN_UNUSED_DATASETS_TASK=false
ENABLE_CREATE_TIDB_SERVERLESS_TASK=false
ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK=false
ENABLE_CLEAN_MESSAGES=false
ENABLE_WORKFLOW_RUN_CLEANUP_TASK=false
ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK=false
ENABLE_DATASETS_QUEUE_MONITOR=false
ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK=true
ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK=true
WORKFLOW_SCHEDULE_POLLER_INTERVAL=1
WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE=100
WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK=0
TENANT_ISOLATED_TASK_CONCURRENCY=1
ANNOTATION_IMPORT_FILE_SIZE_LIMIT=2
ANNOTATION_IMPORT_MAX_RECORDS=10000
ANNOTATION_IMPORT_MIN_RECORDS=1
ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
ANNOTATION_IMPORT_MAX_CONCURRENT=5
CREATORS_PLATFORM_FEATURES_ENABLED=true
CREATORS_PLATFORM_API_URL=https://creators.dify.ai
CREATORS_PLATFORM_OAUTH_CLIENT_ID=
TIDB_VECTOR_DATABASE=dify
ALIBABACLOUD_MYSQL_HOST=127.0.0.1
ALIBABACLOUD_MYSQL_PORT=3306
ALIBABACLOUD_MYSQL_USER=root
ALIBABACLOUD_MYSQL_DATABASE=dify
ALIBABACLOUD_MYSQL_MAX_CONNECTION=5
ALIBABACLOUD_MYSQL_HNSW_M=6
RELYT_DATABASE=postgres
TENCENT_VECTOR_DB_DATABASE=dify
BAIDU_VECTOR_DB_DATABASE=dify
EXPOSE_PLUGIN_DAEMON_PORT=5002
GUNICORN_TIMEOUT=360
CELERY_WORKER_AMOUNT=
CELERY_AUTO_SCALE=false
CELERY_MAX_WORKERS=
CELERY_MIN_WORKERS=
API_TOOL_DEFAULT_CONNECT_TIMEOUT=10
API_TOOL_DEFAULT_READ_TIMEOUT=60
CELERY_BACKEND=redis
CELERY_USE_SENTINEL=false
CELERY_SENTINEL_MASTER_NAME=
CELERY_SENTINEL_SOCKET_TIMEOUT=0.1
WEB_API_CORS_ALLOW_ORIGINS=*
CONSOLE_CORS_ALLOW_ORIGINS=*
COOKIE_DOMAIN=
OPENDAL_SCHEME=fs
OPENDAL_FS_ROOT=storage
CLICKZETTA_VOLUME_TYPE=user
CLICKZETTA_VOLUME_NAME=
CLICKZETTA_VOLUME_TABLE_PREFIX=dataset_
CLICKZETTA_VOLUME_DIFY_PREFIX=dify_km
S3_ENDPOINT=
S3_REGION=us-east-1
S3_BUCKET_NAME=difyai
S3_ADDRESS_STYLE=auto
S3_USE_AWS_MANAGED_IAM=false
ARCHIVE_STORAGE_ENABLED=false
ARCHIVE_STORAGE_ENDPOINT=
ARCHIVE_STORAGE_ARCHIVE_BUCKET=
ARCHIVE_STORAGE_EXPORT_BUCKET=
ARCHIVE_STORAGE_REGION=auto
AZURE_BLOB_ACCOUNT_NAME=difyai
AZURE_BLOB_CONTAINER_NAME=difyai-container
GOOGLE_STORAGE_BUCKET_NAME=your-bucket-name
GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64=
ALIYUN_OSS_BUCKET_NAME=your-bucket-name
ALIYUN_OSS_ENDPOINT=https://oss-ap-southeast-1-internal.aliyuncs.com
ALIYUN_OSS_REGION=ap-southeast-1
ALIYUN_OSS_AUTH_VERSION=v4
ALIYUN_OSS_PATH=your-path
ALIYUN_CLOUDBOX_ID=your-cloudbox-id
TENCENT_COS_BUCKET_NAME=your-bucket-name
TENCENT_COS_REGION=your-region
TENCENT_COS_SCHEME=your-scheme
TENCENT_COS_CUSTOM_DOMAIN=your-custom-domain
OCI_ENDPOINT=https://your-object-storage-namespace.compat.objectstorage.us-ashburn-1.oraclecloud.com
OCI_BUCKET_NAME=your-bucket-name
OCI_REGION=us-ashburn-1
HUAWEI_OBS_BUCKET_NAME=your-bucket-name
HUAWEI_OBS_SERVER=your-server-url
HUAWEI_OBS_PATH_STYLE=false
VOLCENGINE_TOS_BUCKET_NAME=your-bucket-name
VOLCENGINE_TOS_ENDPOINT=your-server-url
VOLCENGINE_TOS_REGION=your-region
BAIDU_OBS_BUCKET_NAME=your-bucket-name
BAIDU_OBS_ENDPOINT=your-server-url
SUPABASE_BUCKET_NAME=your-bucket-name
TENCENT_VECTOR_DB_TIMEOUT=30
TENCENT_VECTOR_DB_USERNAME=dify
TENCENT_VECTOR_DB_SHARD=1
TENCENT_VECTOR_DB_REPLICAS=2
TENCENT_VECTOR_DB_ENABLE_HYBRID_SEARCH=false
BAIDU_VECTOR_DB_ENDPOINT=http://127.0.0.1:5287
BAIDU_VECTOR_DB_CONNECTION_TIMEOUT_MS=30000
BAIDU_VECTOR_DB_ACCOUNT=root
BAIDU_VECTOR_DB_API_KEY=dify
BAIDU_VECTOR_DB_SHARD=1
BAIDU_VECTOR_DB_REPLICAS=3
BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER=DEFAULT_ANALYZER
BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE=COARSE_MODE
BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT=500
BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO=0.05
BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS=300
HUAWEI_CLOUD_HOSTS=https://127.0.0.1:9200
HUAWEI_CLOUD_USER=admin
WORKFLOW_NODE_EXECUTION_STORAGE=rdbms
CORE_WORKFLOW_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository
CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository
API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository
API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository
ALIYUN_SLS_ENDPOINT=
ALIYUN_SLS_REGION=
ALIYUN_SLS_PROJECT_NAME=
ALIYUN_SLS_LOGSTORE_TTL=365
LOGSTORE_DUAL_WRITE_ENABLED=false
LOGSTORE_DUAL_READ_ENABLED=true
LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true
HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760
HTTP_REQUEST_NODE_MAX_TEXT_SIZE=1048576
HTTP_REQUEST_NODE_SSL_VERIFY=True
HTTP_REQUEST_MAX_CONNECT_TIMEOUT=10
HTTP_REQUEST_MAX_READ_TIMEOUT=600
HTTP_REQUEST_MAX_WRITE_TIMEOUT=600
PLUGIN_INSTALLED_PATH=plugin
PLUGIN_PACKAGE_CACHE_PATH=plugin_packages
PLUGIN_MEDIA_CACHE_PATH=assets
PLUGIN_S3_USE_AWS=false
PLUGIN_S3_USE_AWS_MANAGED_IAM=false
PLUGIN_S3_ENDPOINT=
PLUGIN_S3_USE_PATH_STYLE=false
PLUGIN_AZURE_BLOB_STORAGE_CONTAINER_NAME=
PLUGIN_AZURE_BLOB_STORAGE_CONNECTION_STRING=
PLUGIN_TENCENT_COS_REGION=
PLUGIN_ALIYUN_OSS_REGION=
PLUGIN_ALIYUN_OSS_ENDPOINT=
PLUGIN_ALIYUN_OSS_AUTH_VERSION=v4
PLUGIN_ALIYUN_OSS_PATH=
PLUGIN_VOLCENGINE_TOS_ENDPOINT=
PLUGIN_VOLCENGINE_TOS_REGION=
ENABLE_OTEL=false
OTLP_TRACE_ENDPOINT=
OTLP_METRIC_ENDPOINT=
# Prefix used to create collection name in vector database
OTLP_BASE_ENDPOINT=http://localhost:4318
WEAVIATE_GRPC_ENDPOINT=grpc://weaviate:50051
ANALYTICDB_KEY_ID=your-ak
ANALYTICDB_KEY_SECRET=your-sk
ANALYTICDB_REGION_ID=cn-hangzhou
ANALYTICDB_INSTANCE_ID=gp-ab123456
ANALYTICDB_ACCOUNT=testaccount
ANALYTICDB_PASSWORD=testpassword
ANALYTICDB_NAMESPACE=dify
ANALYTICDB_NAMESPACE_PASSWORD=difypassword
ANALYTICDB_HOST=gp-test.aliyuncs.com
ANALYTICDB_PORT=5432
ANALYTICDB_MIN_CONNECTION=1
ANALYTICDB_MAX_CONNECTION=5
TIDB_VECTOR_HOST=tidb
TIDB_VECTOR_PORT=4000
TIDB_VECTOR_USER=
TIDB_VECTOR_PASSWORD=
TIDB_ON_QDRANT_CLIENT_TIMEOUT=20
TIDB_ON_QDRANT_GRPC_ENABLED=false
TIDB_ON_QDRANT_GRPC_PORT=6334
TIDB_PUBLIC_KEY=dify
TIDB_PRIVATE_KEY=dify
RELYT_HOST=db
RELYT_PORT=5432
RELYT_USER=postgres
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_CONNECTION_TIMEOUT=30
VIKINGDB_SOCKET_TIMEOUT=30
TABLESTORE_ENDPOINT=https://instance-name.cn-hangzhou.ots.aliyuncs.com
TABLESTORE_INSTANCE_NAME=instance-name
CLICKZETTA_USERNAME=
CLICKZETTA_VECTOR_DISTANCE_FUNCTION=cosine_distance
COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql}
EXPOSE_NGINX_PORT=80
EXPOSE_NGINX_SSL_PORT=443
POSITION_TOOL_PINS=
POSITION_TOOL_INCLUDES=
POSITION_TOOL_EXCLUDES=
POSITION_PROVIDER_PINS=
POSITION_PROVIDER_INCLUDES=
POSITION_PROVIDER_EXCLUDES=
CREATE_TIDB_SERVICE_JOB_ENABLED=false
MAX_SUBMIT_COUNT=100
# Vector Store Configuration
STORAGE_TYPE=opendal
VECTOR_STORE=weaviate
VECTOR_INDEX_NAME_PREFIX=Vector_index
WEAVIATE_ENDPOINT=http://weaviate:8080
WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
WEAVIATE_TOKENIZATION=word
OCEANBASE_VECTOR_HOST=oceanbase
OCEANBASE_VECTOR_PORT=2881
OCEANBASE_VECTOR_USER=root@test
OCEANBASE_VECTOR_PASSWORD=difyai123456
OCEANBASE_VECTOR_DATABASE=test
OCEANBASE_ENABLE_HYBRID_SEARCH=false
OCEANBASE_FULLTEXT_PARSER=ik
SEEKDB_MEMORY_LIMIT=2G
QDRANT_URL=http://qdrant:6333
QDRANT_API_KEY=difyai123456
QDRANT_CLIENT_TIMEOUT=20
QDRANT_GRPC_ENABLED=false
QDRANT_GRPC_PORT=6334
QDRANT_REPLICATION_FACTOR=1
MILVUS_URI=http://host.docker.internal:19530
MILVUS_TOKEN=
MILVUS_USER=
MILVUS_PASSWORD=
MILVUS_ANALYZER_PARAMS=
PGVECTOR_HOST=pgvector
PGVECTOR_PORT=5432
PGVECTOR_USER=postgres
PGVECTOR_PASSWORD=difyai123456
PGVECTOR_DATABASE=dify
PGVECTOR_MIN_CONNECTION=1
PGVECTOR_MAX_CONNECTION=5
PGVECTOR_PG_BIGM=false
PGVECTOR_PG_BIGM_VERSION=1.2-20240606
# Hologres Configuration
HOLOGRES_HOST=
HOLOGRES_PORT=80
HOLOGRES_DATABASE=
HOLOGRES_ACCESS_KEY_ID=
HOLOGRES_ACCESS_KEY_SECRET=
HOLOGRES_SCHEMA=public
HOLOGRES_TOKENIZER=jieba
HOLOGRES_DISTANCE_METHOD=Cosine
HOLOGRES_BASE_QUANTIZATION_TYPE=rabitq
HOLOGRES_MAX_DEGREE=64
HOLOGRES_EF_CONSTRUCTION=400
# Milvus API Configuration
MILVUS_DATABASE=
MILVUS_ENABLE_HYBRID_SEARCH=False
# Human Input Task Configuration
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1

View File

@ -0,0 +1,30 @@
# ------------------------------
# Web Configuration
# ------------------------------
CONSOLE_API_URL=
APP_API_URL=
SENTRY_DSN=
NEXT_PUBLIC_SOCKET_URL=ws://localhost
EXPERIMENTAL_ENABLE_VINEXT=false
LOOP_NODE_MAX_COUNT=100
MAX_TOOLS_NUM=10
MAX_PARALLEL_LIMIT=10
MAX_ITERATIONS_NUM=99
TEXT_GENERATION_TIMEOUT_MS=60000
ALLOW_INLINE_STYLES=false
ALLOW_UNSAFE_DATA_SCHEME=false
MAX_TREE_DEPTH=50
MARKETPLACE_ENABLED=true
MARKETPLACE_API_URL=https://marketplace.dify.ai
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
ALLOW_EMBED=false
AMPLITUDE_API_KEY=
ENABLE_WEBSITE_JINAREADER=true
ENABLE_WEBSITE_FIRECRAWL=true
ENABLE_WEBSITE_WATERCRAWL=true
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
NEXT_PUBLIC_COOKIE_DOMAIN=
NEXT_PUBLIC_BATCH_CONCURRENCY=5
CSP_WHITELIST=
TOP_K_MAX_VALUE=10

View File

@ -0,0 +1,8 @@
# ------------------------------
# Worker Beat Configuration
# ------------------------------
MODE=beat
COMPOSE_WORKER_HEALTHCHECK_DISABLED=true
COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s
COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s

View File

@ -0,0 +1,13 @@
# ------------------------------
# Worker Configuration
# ------------------------------
MODE=worker
SENTRY_DSN=
SENTRY_TRACES_SAMPLE_RATE=1.0
SENTRY_PROFILES_SAMPLE_RATE=1.0
PLUGIN_MAX_PACKAGE_SIZE=52428800
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
COMPOSE_WORKER_HEALTHCHECK_DISABLED=true
COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s
COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s

View File

@ -0,0 +1,9 @@
# ------------------------------
# Db Mysql Configuration
# ------------------------------
MYSQL_INNODB_LOG_FILE_SIZE=128M
MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT=2
MYSQL_MAX_CONNECTIONS=1000
MYSQL_INNODB_BUFFER_POOL_SIZE=512M
MYSQL_HOST_VOLUME=./volumes/mysql/data

View File

@ -0,0 +1,26 @@
# ------------------------------
# Db Postgres Configuration
# ------------------------------
PGDATA=/var/lib/postgresql/data/pgdata
DB_TYPE=postgresql
DB_USERNAME=postgres
DB_PASSWORD=difyai123456
DB_HOST=db_postgres
DB_PORT=5432
DB_DATABASE=dify
SQLALCHEMY_POOL_SIZE=30
SQLALCHEMY_MAX_OVERFLOW=10
SQLALCHEMY_POOL_RECYCLE=3600
SQLALCHEMY_ECHO=false
SQLALCHEMY_POOL_PRE_PING=false
SQLALCHEMY_POOL_USE_LIFO=false
SQLALCHEMY_POOL_TIMEOUT=30
SQLALCHEMY_POOL_RESET_ON_RETURN=rollback
POSTGRES_MAX_CONNECTIONS=100
POSTGRES_SHARED_BUFFERS=128MB
POSTGRES_WORK_MEM=4MB
POSTGRES_MAINTENANCE_WORK_MEM=64MB
POSTGRES_EFFECTIVE_CACHE_SIZE=4096MB
POSTGRES_STATEMENT_TIMEOUT=0
POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0

View File

@ -0,0 +1,35 @@
# ------------------------------
# Redis Configuration
# ------------------------------
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_USERNAME=
REDIS_PASSWORD=difyai123456
REDIS_USE_SSL=false
REDIS_SSL_CERT_REQS=CERT_NONE
REDIS_SSL_CA_CERTS=
REDIS_SSL_CERTFILE=
REDIS_SSL_KEYFILE=
REDIS_DB=0
REDIS_KEY_PREFIX=
REDIS_MAX_CONNECTIONS=
REDIS_USE_SENTINEL=false
REDIS_SENTINELS=
REDIS_SENTINEL_SERVICE_NAME=
REDIS_SENTINEL_USERNAME=
REDIS_SENTINEL_PASSWORD=
REDIS_SENTINEL_SOCKET_TIMEOUT=0.1
REDIS_USE_CLUSTERS=false
REDIS_CLUSTERS=
REDIS_CLUSTERS_PASSWORD=
REDIS_RETRY_RETRIES=3
REDIS_RETRY_BACKOFF_BASE=1.0
REDIS_RETRY_BACKOFF_CAP=10.0
REDIS_SOCKET_TIMEOUT=5.0
REDIS_SOCKET_CONNECT_TIMEOUT=5.0
REDIS_HEALTH_CHECK_INTERVAL=30
EVENT_BUS_REDIS_URL=
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
EVENT_BUS_REDIS_USE_CLUSTERS=false
BROKER_USE_SSL=false

View File

@ -0,0 +1,7 @@
# ------------------------------
# Certbot Configuration
# ------------------------------
CERTBOT_EMAIL=your_email@example.com
CERTBOT_DOMAIN=your_domain.com
CERTBOT_OPTIONS=

View File

@ -0,0 +1,4 @@
# ------------------------------
# Etcd Configuration
# ------------------------------

View File

@ -0,0 +1,4 @@
# ------------------------------
# Milvus Standalone Configuration
# ------------------------------

View File

@ -0,0 +1,4 @@
# ------------------------------
# Minio Configuration
# ------------------------------

View File

@ -0,0 +1,17 @@
# ------------------------------
# Nginx Configuration
# ------------------------------
NGINX_SERVER_NAME=_
NGINX_HTTPS_ENABLED=false
NGINX_PORT=80
NGINX_SSL_PORT=443
NGINX_SSL_CERT_FILENAME=dify.crt
NGINX_SSL_CERT_KEY_FILENAME=dify.key
NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3
NGINX_WORKER_PROCESSES=auto
NGINX_CLIENT_MAX_BODY_SIZE=100M
NGINX_KEEPALIVE_TIMEOUT=65
NGINX_PROXY_READ_TIMEOUT=3600s
NGINX_PROXY_SEND_TIMEOUT=3600s
NGINX_ENABLE_CERTBOT_CHALLENGE=false

View File

@ -0,0 +1,17 @@
# ------------------------------
# Ssrf Proxy Configuration
# ------------------------------
SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128
SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128
SSRF_HTTP_PORT=3128
SSRF_COREDUMP_DIR=/var/spool/squid
SSRF_REVERSE_PROXY_PORT=8194
SSRF_SANDBOX_HOST=sandbox
SSRF_DEFAULT_TIME_OUT=5
SSRF_DEFAULT_CONNECT_TIME_OUT=5
SSRF_DEFAULT_READ_TIME_OUT=5
SSRF_DEFAULT_WRITE_TIME_OUT=5
SSRF_POOL_MAX_CONNECTIONS=100
SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS=20
SSRF_POOL_KEEPALIVE_EXPIRY=5.0

View File

@ -0,0 +1,40 @@
# ------------------------------
# Security Configuration
# ------------------------------
TIDB_ON_QDRANT_API_KEY=dify
TENCENT_VECTOR_DB_API_KEY=dify
ALIBABACLOUD_MYSQL_PASSWORD=difyai123456
RELYT_PASSWORD=difyai123456
LINDORM_PASSWORD=admin
HUAWEI_CLOUD_PASSWORD=admin
UPSTASH_VECTOR_TOKEN=dify
TABLESTORE_ACCESS_KEY_ID=xxx
TABLESTORE_ACCESS_KEY_SECRET=xxx
UNSTRUCTURED_API_KEY=
PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false
NOTION_CLIENT_SECRET=
NOTION_INTERNAL_SECRET=
RESEND_API_KEY=your-resend-api-key
SMTP_PASSWORD=
SENDGRID_API_KEY=
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
CODE_EXECUTION_API_KEY=dify-sandbox
ALIYUN_SLS_ACCESS_KEY_ID=
ALIYUN_SLS_ACCESS_KEY_SECRET=
OTLP_API_KEY=
BAIDU_VECTOR_DB_API_KEY=dify
ANALYTICDB_KEY_ID=your-ak
ANALYTICDB_KEY_SECRET=your-sk
ANALYTICDB_PASSWORD=testpassword
ANALYTICDB_NAMESPACE_PASSWORD=difypassword
TIDB_VECTOR_PASSWORD=
TIDB_PUBLIC_KEY=dify
TIDB_PRIVATE_KEY=dify
VIKINGDB_ACCESS_KEY=your-ak
VIKINGDB_SECRET_KEY=your-sk
SECRET_KEY=sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U
INIT_PASSWORD=

View File

@ -0,0 +1,13 @@
# ------------------------------
# Chroma Configuration
# ------------------------------
CHROMA_DATABASE=default_database
CHROMA_AUTH_PROVIDER=chromadb.auth.token_authn.TokenAuthClientProvider
CHROMA_AUTH_CREDENTIALS=
CHROMA_HOST=127.0.0.1
CHROMA_PORT=8000
CHROMA_TENANT=default_tenant
CHROMA_SERVER_AUTHN_CREDENTIALS=difyai123456
CHROMA_SERVER_AUTHN_PROVIDER=chromadb.auth.token_authn.TokenAuthenticationServerProvider
CHROMA_IS_PERSISTENT=TRUE

View File

@ -0,0 +1,9 @@
# ------------------------------
# Couchbase Configuration
# ------------------------------
COUCHBASE_PASSWORD=password
COUCHBASE_BUCKET_NAME=Embeddings
COUCHBASE_SCOPE_NAME=_default
COUCHBASE_CONNECTION_STRING=couchbase://couchbase-server
COUCHBASE_USER=Administrator

View File

@ -0,0 +1,17 @@
# ------------------------------
# Elasticsearch Configuration
# ------------------------------
ELASTICSEARCH_CLOUD_URL=YOUR-ELASTICSEARCH_CLOUD_URL
ELASTICSEARCH_PASSWORD=elastic
KIBANA_PORT=5601
ELASTICSEARCH_USE_CLOUD=false
ELASTICSEARCH_API_KEY=YOUR-ELASTICSEARCH_API_KEY
ELASTICSEARCH_VERIFY_CERTS=False
ELASTICSEARCH_CA_CERTS=
ELASTICSEARCH_REQUEST_TIMEOUT=100000
ELASTICSEARCH_RETRY_ON_TIMEOUT=True
ELASTICSEARCH_MAX_RETRIES=10
ELASTICSEARCH_HOST=0.0.0.0
ELASTICSEARCH_PORT=9200
ELASTICSEARCH_USERNAME=elastic

View File

@ -0,0 +1,17 @@
# ------------------------------
# Iris Configuration
# ------------------------------
IRIS_CONNECTION_URL=
IRIS_MIN_CONNECTION=1
IRIS_MAX_CONNECTION=3
IRIS_TEXT_INDEX=true
IRIS_TEXT_INDEX_LANGUAGE=en
IRIS_TIMEZONE=UTC
IRIS_PASSWORD=Dify@1234
IRIS_DATABASE=USER
IRIS_SCHEMA=dify
IRIS_HOST=iris
IRIS_SUPER_SERVER_PORT=1972
IRIS_WEB_SERVER_PORT=52773
IRIS_USER=_SYSTEM

View File

@ -0,0 +1,9 @@
# ------------------------------
# Matrixone Configuration
# ------------------------------
MATRIXONE_PASSWORD=111
MATRIXONE_HOST=matrixone
MATRIXONE_PORT=6001
MATRIXONE_USER=dump
MATRIXONE_DATABASE=dify

View File

@ -0,0 +1,13 @@
# ------------------------------
# Milvus Configuration
# ------------------------------
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
ETCD_ENDPOINTS=etcd:2379
MINIO_ADDRESS=minio:9000
ETCD_AUTO_COMPACTION_MODE=revision
ETCD_AUTO_COMPACTION_RETENTION=1000
ETCD_QUOTA_BACKEND_BYTES=4294967296
ETCD_SNAPSHOT_COUNT=50000
MILVUS_AUTHORIZATION_ENABLED=true

View File

@ -0,0 +1,10 @@
# ------------------------------
# Myscale Configuration
# ------------------------------
MYSCALE_PASSWORD=
MYSCALE_DATABASE=dify
MYSCALE_FTS_PARAMS=
MYSCALE_HOST=myscale
MYSCALE_PORT=8123
MYSCALE_USER=default

View File

@ -0,0 +1,6 @@
# ------------------------------
# Oceanbase Configuration
# ------------------------------
OCEANBASE_CLUSTER_NAME=difyai
OCEANBASE_MEMORY_LIMIT=6G

View File

@ -0,0 +1,12 @@
# ------------------------------
# Opengauss Configuration
# ------------------------------
OPENGAUSS_PASSWORD=Dify@123
OPENGAUSS_DATABASE=dify
OPENGAUSS_MIN_CONNECTION=1
OPENGAUSS_MAX_CONNECTION=5
OPENGAUSS_ENABLE_PQ=false
OPENGAUSS_HOST=opengauss
OPENGAUSS_PORT=6600
OPENGAUSS_USER=postgres

View File

@ -0,0 +1,22 @@
# ------------------------------
# Opensearch Configuration
# ------------------------------
OPENSEARCH_PASSWORD=admin
OPENSEARCH_AWS_REGION=ap-southeast-1
OPENSEARCH_AWS_SERVICE=aoss
OPENSEARCH_INITIAL_ADMIN_PASSWORD=Qazwsxedc!@#123
OPENSEARCH_MEMLOCK_SOFT=-1
OPENSEARCH_MEMLOCK_HARD=-1
OPENSEARCH_NOFILE_SOFT=65536
OPENSEARCH_NOFILE_HARD=65536
OPENSEARCH_HOST=opensearch
OPENSEARCH_PORT=9200
OPENSEARCH_SECURE=true
OPENSEARCH_VERIFY_CERTS=true
OPENSEARCH_AUTH_METHOD=basic
OPENSEARCH_USER=admin
OPENSEARCH_DISCOVERY_TYPE=single-node
OPENSEARCH_BOOTSTRAP_MEMORY_LOCK=true
OPENSEARCH_JAVA_OPTS_MIN=512m
OPENSEARCH_JAVA_OPTS_MAX=1024m

View File

@ -0,0 +1,13 @@
# ------------------------------
# Oracle Configuration
# ------------------------------
ORACLE_PASSWORD=dify
ORACLE_DSN=oracle:1521/FREEPDB1
ORACLE_CONFIG_DIR=/app/api/storage/wallet
ORACLE_WALLET_LOCATION=/app/api/storage/wallet
ORACLE_WALLET_PASSWORD=dify
ORACLE_IS_AUTONOMOUS=false
ORACLE_USER=dify
ORACLE_PWD=Dify123456
ORACLE_CHARACTERSET=AL32UTF8

View File

@ -0,0 +1,9 @@
# ------------------------------
# Pgvecto Rs Configuration
# ------------------------------
PGVECTO_RS_HOST=pgvecto-rs
PGVECTO_RS_PORT=5432
PGVECTO_RS_USER=postgres
PGVECTO_RS_PASSWORD=difyai123456
PGVECTO_RS_DATABASE=dify

View File

@ -0,0 +1,8 @@
# ------------------------------
# Pgvector Configuration
# ------------------------------
PGVECTOR_PGUSER=postgres
PGVECTOR_POSTGRES_PASSWORD=difyai123456
PGVECTOR_POSTGRES_DB=dify
PGVECTOR_PGDATA=/var/lib/postgresql/data/pgdata

View File

@ -0,0 +1,4 @@
# ------------------------------
# Qdrant Configuration
# ------------------------------

View File

@ -0,0 +1,4 @@
# ------------------------------
# Seekdb Configuration
# ------------------------------

View File

@ -0,0 +1,11 @@
# ------------------------------
# Vastbase Configuration
# ------------------------------
VASTBASE_PASSWORD=Difyai123456
VASTBASE_DATABASE=dify
VASTBASE_MIN_CONNECTION=1
VASTBASE_MAX_CONNECTION=5
VASTBASE_HOST=vastbase
VASTBASE_PORT=5432
VASTBASE_USER=dify

View File

@ -0,0 +1,18 @@
# ------------------------------
# Weaviate Configuration
# ------------------------------
WEAVIATE_PERSISTENCE_DATA_PATH=/var/lib/weaviate
WEAVIATE_QUERY_DEFAULTS_LIMIT=25
WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true
WEAVIATE_DEFAULT_VECTORIZER_MODULE=none
WEAVIATE_CLUSTER_HOSTNAME=node1
WEAVIATE_AUTHENTICATION_APIKEY_ENABLED=true
WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
WEAVIATE_AUTHENTICATION_APIKEY_USERS=hello@dify.ai
WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED=true
WEAVIATE_AUTHORIZATION_ADMINLIST_USERS=hello@dify.ai
WEAVIATE_DISABLE_TELEMETRY=false
WEAVIATE_ENABLE_TOKENIZER_GSE=false
WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA=false
WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR=false

View File

@ -18,9 +18,9 @@ SHARED_ENV_EXCLUDE = frozenset(
)
def parse_env_all(file_path):
def parse_env_example(file_path):
"""
Parses the .env.all file and returns a dictionary with variable names as keys and default values as values.
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:
@ -53,11 +53,6 @@ def generate_shared_env_block(env_vars, anchor_name="shared-api-worker-env"):
for key, default in env_vars.items():
if key in SHARED_ENV_EXCLUDE:
continue
if key == "SECRET_KEY":
lines.append(
" SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set. Run ./init-env.sh, or .\\\\init-env.ps1 on Windows, to generate one in .env.}"
)
continue
# If default value is empty, use ${KEY:-}
if default == "":
lines.append(f" {key}: ${{{key}:-}}")
@ -69,25 +64,61 @@ def generate_shared_env_block(env_vars, anchor_name="shared-api-worker-env"):
return "\n".join(lines)
def insert_shared_env(template_path, output_path, shared_env_block, header_comments):
def create_env_files_from_example(env_example_path):
"""
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.
Creates actual env files from .env.example by copying the categorized .env.example files.
This allows docker-compose to use env_file references.
Supports per-module structure with subdirectories.
"""
base_dir = os.path.dirname(os.path.abspath(env_example_path))
root_env_file = os.path.join(base_dir, ".env")
if not os.path.exists(root_env_file):
with open(env_example_path, "r", encoding="utf-8") as src, open(
root_env_file, "w", encoding="utf-8", newline="\n"
) as dst:
dst.write(src.read())
print(f"Created {root_env_file}")
else:
print(f"{root_env_file} already exists, skipping")
envs_dir = os.path.join(base_dir, "envs")
if not os.path.isdir(envs_dir):
print(f"No envs directory found at {envs_dir}, skipping split env files")
return []
created_files = []
# Walk through all .env.example files in subdirectories
for root, dirs, files in os.walk(envs_dir):
for file in files:
if file.endswith('.env.example'):
example_file = os.path.join(root, file)
env_file = example_file.replace('.env.example', '.env')
if os.path.exists(env_file):
print(f"{env_file} already exists, skipping")
continue
# Copy .example to actual file
with open(example_file, "r", encoding="utf-8") as src, open(
env_file, "w", encoding="utf-8", newline="\n"
) as dst:
dst.write(src.read())
created_files.append(env_file)
print(f"Created {env_file}")
return created_files
def insert_shared_env(template_path, output_path, header_comments):
"""
Copies the template file to output path with header comments.
The template now uses env_file references instead of a huge YAML anchor.
"""
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}"
# Prepare the final content with header comments
final_content = f"{header_comments}\n{template_content}"
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write(final_content)
@ -95,37 +126,34 @@ def insert_shared_env(template_path, output_path, shared_env_block, header_comme
def main():
env_all_path = ".env.all"
template_path = "docker-compose-template.yaml"
output_path = "docker-compose.yaml"
anchor_name = "shared-api-worker-env" # Can be modified as needed
base_dir = os.path.dirname(os.path.abspath(__file__))
env_example_path = os.path.join(base_dir, ".env.example")
template_path = os.path.join(base_dir, "docker-compose-template.yaml")
output_path = os.path.join(base_dir, "docker-compose.yaml")
# Define header comments to be added at the top of docker-compose.yaml
header_comments = (
"# ==================================================================\n"
"# WARNING: This file is auto-generated by generate_docker_compose\n"
"# Do not modify this file directly. Instead, update the .env.all\n"
"# 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_all_path, template_path]:
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.all file
env_vars = parse_env_all(env_all_path)
# Create env files from categorized .env.example files
# These files are used by docker-compose's env_file directive
# This ensures .env files exist even in CI/CD environments
create_env_files_from_example(env_example_path)
if not env_vars:
print("Warning: No environment variables found in .env.all.")
# Generate shared environment variables block
shared_env_block = generate_shared_env_block(env_vars, anchor_name)
# Insert shared environment variables block and header comments into the template
insert_shared_env(template_path, output_path, shared_env_block, header_comments)
# Copy template to output with header comments
# The template now uses env_file references instead of a huge YAML anchor
insert_shared_env(template_path, output_path, header_comments)
if __name__ == "__main__":

View File

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

View File

@ -1,117 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ENV_EXAMPLE_FILE=".env.example"
ENV_FILE=".env"
log() {
printf '%s\n' "$*"
}
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
generate_secret_key() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 42
return
fi
if command -v dd >/dev/null 2>&1 && command -v base64 >/dev/null 2>&1; then
dd if=/dev/urandom bs=42 count=1 2>/dev/null | base64 | tr -d '\n'
printf '\n'
return
fi
return 1
}
env_value() {
local key="$1"
awk -F= -v target="$key" '
/^[[:space:]]*#/ || !/=/{ next }
{
key = $1
gsub(/^[[:space:]]+|[[:space:]]+$/, "", key)
if (key == target) {
value = substr($0, index($0, "=") + 1)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
if ((value ~ /^".*"$/) || (value ~ /^'\''.*'\''$/)) {
value = substr(value, 2, length(value) - 2)
}
result = value
}
}
END { print result }
' "$ENV_FILE"
}
set_env_value() {
local key="$1"
local value="$2"
local temp_file
temp_file="$(mktemp "${TMPDIR:-/tmp}/dify-env.XXXXXX")"
if awk -F= -v target="$key" -v replacement="$key=$value" '
BEGIN { replaced = 0 }
/^[[:space:]]*#/ || !/=/{ print; next }
{
key = $1
gsub(/^[[:space:]]+|[[:space:]]+$/, "", key)
if (key == target) {
if (!replaced) {
print replacement
replaced = 1
}
next
}
print
}
END {
if (!replaced) {
print replacement
}
}
' "$ENV_FILE" >"$temp_file"; then
mv "$temp_file" "$ENV_FILE"
else
rm -f "$temp_file"
return 1
fi
}
ensure_env_file() {
if [[ -f "$ENV_FILE" ]]; then
log "Using existing $ENV_FILE."
return
fi
[[ -f "$ENV_EXAMPLE_FILE" ]] || die "$ENV_EXAMPLE_FILE is missing."
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
log "Created $ENV_FILE from $ENV_EXAMPLE_FILE."
}
ensure_secret_key() {
local current_secret_key
local secret_key
current_secret_key="$(env_value SECRET_KEY)"
if [[ -n "$current_secret_key" ]]; then
log "SECRET_KEY already exists in $ENV_FILE."
return
fi
secret_key="$(generate_secret_key)" || die "Unable to generate SECRET_KEY. Install openssl or set SECRET_KEY in $ENV_FILE."
set_env_value SECRET_KEY "$secret_key"
log "Generated SECRET_KEY in $ENV_FILE."
}
ensure_env_file
ensure_secret_key
log "Environment is ready. Run docker compose up -d to start Dify."

View File

@ -36,7 +36,7 @@ export const webDir = path.join(rootDir, 'web')
export const middlewareComposeFile = path.join(dockerDir, 'docker-compose.middleware.yaml')
export const middlewareEnvFile = path.join(dockerDir, 'middleware.env')
export const middlewareEnvExampleFile = path.join(dockerDir, 'middleware.env.example')
export const middlewareEnvExampleFile = path.join(dockerDir, 'envs', 'middleware.env.example')
export const webEnvLocalFile = path.join(webDir, '.env.local')
export const webEnvExampleFile = path.join(webDir, '.env.example')
export const apiEnvExampleFile = path.join(apiDir, 'tests', 'integration_tests', '.env.example')

View File

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

View File

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

View File

@ -20,6 +20,7 @@ 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(() => ({
@ -37,6 +38,7 @@ vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null,
}))
vi.mock('ahooks', async () => {
@ -167,6 +169,12 @@ 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>
)
@ -200,6 +208,10 @@ 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 () => {
@ -256,6 +268,75 @@ 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,

View File

@ -18,8 +18,32 @@ vi.mock('../publish-with-multiple-model', () => ({
}))
vi.mock('../suggested-action', () => ({
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>
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>
),
}))
@ -170,9 +194,25 @@ 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
@ -190,10 +230,15 @@ describe('app-publisher sections', () => {
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode={false}
missingStartNode={false}
published={false}
publishedAt={Date.now()}
showBatchRunConfig
showRunConfig
toolPublished
workflowToolAvailable={false}
workflowToolIsLoading={false}
@ -205,6 +250,10 @@ describe('app-publisher sections', () => {
)
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()
@ -222,9 +271,12 @@ describe('app-publisher sections', () => {
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode={false}
missingStartNode
published={false}
publishedAt={Date.now()}
toolPublished={false}
workflowToolAvailable
@ -246,9 +298,12 @@ describe('app-publisher sections', () => {
disabledFunctionButton={false}
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode
missingStartNode={false}
published={false}
publishedAt={undefined}
toolPublished={false}
workflowToolAvailable

View File

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

View File

@ -1,4 +1,6 @@
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'
@ -8,6 +10,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useKeyPress } from 'ahooks'
import {
memo,
use,
useCallback,
@ -16,6 +19,13 @@ import {
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'
@ -111,6 +121,9 @@ const AppPublisher = ({
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)
@ -122,6 +135,22 @@ 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)
@ -231,6 +260,31 @@ 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
@ -377,10 +431,15 @@ const AppPublisher = ({
handleOpenChange(false)
handleOpenInExplore()
}}
handleOpenRunConfig={handleOpenWorkflowLaunchDialog}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
missingStartNode={missingStartNode}
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}
@ -410,8 +469,19 @@ 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

View File

@ -8,6 +8,7 @@ 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 Loading from '@/app/components/base/loading'
@ -62,6 +63,11 @@ 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
@ -253,10 +259,13 @@ export const PublisherActionsSection = ({
disabledFunctionTooltip,
handleEmbed,
handleOpenInExplore,
handleOpenRunConfig,
hasHumanInputNode = false,
hasTriggerNode = false,
missingStartNode = false,
publishedAt,
showBatchRunConfig = false,
showRunConfig = false,
toolPublished,
workflowToolAvailable = true,
workflowToolIsLoading,
@ -280,6 +289,13 @@ 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>
@ -292,6 +308,13 @@ 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>

View File

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

View File

@ -4,6 +4,29 @@ 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,
@ -74,6 +97,12 @@ 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>
@ -176,7 +205,18 @@ describe('ConfigModalFormFields', () => {
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta')
})
it('should wire file, json schema, and visibility controls', () => {
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()
const singleFileProps = createBaseProps()
singleFileProps.tempPayload = {
...singleFileProps.tempPayload,
@ -185,18 +225,20 @@ describe('ConfigModalFormFields', () => {
allowed_file_extensions: [],
allowed_file_upload_methods: ['remote_url'],
}
render(<ConfigModalFormFields {...singleFileProps} />)
const singleFileView = render(<ConfigModalFormFields {...singleFileProps} />)
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument()
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).toHaveBeenCalledWith(true)
expect(singleFileProps.payloadChangeHandlers.hide).not.toHaveBeenCalled()
singleFileView.unmount()
const multiFileProps = createBaseProps()
multiFileProps.tempPayload = {
@ -207,8 +249,9 @@ 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')[1]!)
fireEvent.click(screen.getAllByText('upload-file')[0]!)
expect(multiFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 3 })
expect(multiFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith([
expect.objectContaining({ fileId: 'file-1' }),
@ -367,4 +410,23 @@ describe('ConfigModalFormFields', () => {
expect(screen.getByRole('spinbutton')).toHaveValue(null)
})
it('should disable hide checkbox when required is true and disable required when hide is true', () => {
const requiredProps = createBaseProps()
requiredProps.tempPayload = { ...requiredProps.tempPayload, type: InputVarType.textInput, required: true, hide: false }
const { unmount } = render(<ConfigModalFormFields {...requiredProps} />)
const buttons = screen.getAllByRole('button')
const hideButton = buttons.find(btn => btn.textContent === 'unchecked' && btn !== buttons[0])
expect(hideButton).toBeDefined()
unmount()
const hideProps = createBaseProps()
hideProps.tempPayload = { ...hideProps.tempPayload, type: InputVarType.textInput, required: false, hide: true }
render(<ConfigModalFormFields {...hideProps} />)
const allButtons = screen.getAllByRole('button')
const checkedHideButton = allButtons.find(btn => btn.textContent === 'checked')
expect(checkedHideButton).toBeDefined()
})
})

View File

@ -25,6 +25,7 @@ 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>
@ -115,7 +116,7 @@ describe('ConfigModal logic', () => {
})
it('should derive payload fields from mocked form-field callbacks', async () => {
renderConfigModal()
renderConfigModal(createPayload({ hide: true }))
fireEvent.click(screen.getByTestId('valid-key-blur'))
await waitFor(() => {
@ -138,6 +139,7 @@ describe('ConfigModal logic', () => {
fireEvent.click(screen.getByTestId('type-change'))
await waitFor(() => {
expect(screen.getByTestId('payload-type')).toHaveTextContent(InputVarType.singleFile)
expect(screen.getByTestId('payload-hide')).toHaveTextContent('false')
})
fireEvent.click(screen.getByTestId('file-payload-change'))

View File

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

View File

@ -13,14 +13,17 @@ 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'
@ -68,6 +71,9 @@ 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">
@ -105,7 +111,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
{type === InputVarType.textInput && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
value={tempPayload.default || ''}
value={typeof tempPayload.default === 'string' ? tempPayload.default : ''}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
@ -126,7 +132,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
type="number"
value={tempPayload.default || ''}
value={typeof tempPayload.default === 'number' || typeof tempPayload.default === 'string' ? tempPayload.default : ''}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
@ -186,7 +192,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
</>
)}
{[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && (
{isFileInput && (
<>
<FileUploadSetting
payload={tempPayload as UploadFileSetting}
@ -227,14 +233,37 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
)}
<div className="mt-5! flex h-6 items-center space-x-2">
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => onPayloadChange('required')(!tempPayload.required)} />
<Checkbox checked={tempPayload.required} disabled={!isFileInput && tempPayload.hide} onCheck={() => onPayloadChange('required')(!tempPayload.required)} />
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
</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>
{!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>
)
}

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ 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'
@ -17,7 +18,7 @@ const mockSetAppDetail = vi.fn()
const mockOnChangeStatus = vi.fn()
const mockOnGenerateCode = vi.fn()
let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string } }> } } | null = null
let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array<Record<string, unknown>> } }> } } | null = null
let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] }
let mockAppDetail: AppDetailResponse | undefined
@ -25,6 +26,7 @@ vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null,
}))
vi.mock('@/context/app-context', () => ({
@ -164,6 +166,182 @@ 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
@ -302,7 +480,7 @@ describe('AppCard', () => {
})
it('should report refresh failures from access control updates', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
mockFetchAppDetailDirect.mockRejectedValueOnce(new Error('refresh failed'))
render(

View File

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

View File

@ -1,7 +1,11 @@
/* eslint-disable react-refresh/only-export-components */
import type { TFunction } from 'i18next'
import type { ComponentType, ReactNode } from 'react'
import type { OverviewOperationKey } from './app-card-utils'
import type { ComponentType, FormEvent, ReactNode } from 'react'
import type {
OverviewOperationKey,
WorkflowHiddenStartVariable,
WorkflowLaunchInputValue,
} from './app-card-utils'
import type { ConfigParams } from './settings'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
@ -15,12 +19,19 @@ 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, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react'
import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiSettings2Line, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react'
import { Trans } from 'react-i18next'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import ShareQRCode from '@/app/components/base/qrcode'
@ -31,6 +42,7 @@ 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>
@ -50,6 +62,12 @@ type AppCardOperation = {
onClick: () => void
}
type LaunchConfigAction = {
label: string
disabled: boolean
onClick: () => void
}
const OPERATION_ICON_MAP: Record<OverviewOperationKey, OperationIcon> = {
launch: RiExternalLinkLine,
embedded: RiWindowLine,
@ -96,6 +114,65 @@ 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,
@ -251,20 +328,15 @@ export const AppCardAccessControlSection = ({
export const AppCardOperations = ({
t,
operations,
launchConfigAction,
}: {
t: TFunction
operations: AppCardOperation[]
launchConfigAction?: LaunchConfigAction
}) => (
<>
{operations.map(({ key, label, Icon, disabled, onClick }) => (
<Button
className="mr-1 min-w-[88px]"
size="small"
variant="ghost"
key={key}
onClick={onClick}
disabled={disabled}
>
{operations.map(({ key, label, Icon, disabled, onClick }) => {
const buttonContent = (
<MaybeTooltip
content={t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''}
tooltipClassName="mt-[-8px]"
@ -275,8 +347,72 @@ export const AppCardOperations = ({
<div className={`${disabled ? 'text-components-button-ghost-text-disabled' : 'text-text-tertiary'} px-[3px] system-xs-medium`}>{label}</div>
</div>
</MaybeTooltip>
</Button>
))}
)
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>
)
})}
</>
)
@ -295,6 +431,7 @@ export const AppCardDialogs = ({
onCloseAccessControl,
onSaveSiteConfig,
onConfirmAccessControl,
hiddenInputs,
}: {
isApp: boolean
appInfo: AppInfo
@ -310,6 +447,7 @@ export const AppCardDialogs = ({
onCloseAccessControl: () => void
onSaveSiteConfig?: (params: ConfigParams) => Promise<void>
onConfirmAccessControl: () => Promise<void>
hiddenInputs?: WorkflowHiddenStartVariable[]
}) => {
if (!isApp)
return null
@ -329,6 +467,7 @@ export const AppCardDialogs = ({
onClose={onCloseEmbedded}
appBaseUrl={appInfo.site?.app_base_url}
accessToken={appInfo.site?.access_token}
hiddenInputs={hiddenInputs}
/>
<CustomizeModal
isShow={showCustomizeModal}

View File

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

View File

@ -1,4 +1,5 @@
'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'
@ -28,11 +29,16 @@ import {
AppCardOperations,
AppCardUrlSection,
createAppCardOperations,
WorkflowLaunchDialog,
} from './app-card-sections'
import {
buildWorkflowLaunchUrl,
createWorkflowLaunchInitialValues,
getAppCardDisplayState,
getAppCardOperationKeys,
getAppHiddenLaunchVariables,
isAppAccessConfigured,
isWorkflowLaunchInputSupported,
} from './app-card-utils'
export type IAppCardProps = {
@ -63,7 +69,8 @@ function AppCard({
const router = useRouter()
const pathname = usePathname()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '')
const shouldFetchWorkflow = appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT
const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '')
const docLink = useDocLink()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
@ -73,6 +80,8 @@ 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(
@ -98,6 +107,25 @@ 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)
@ -139,6 +167,31 @@ 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)
}, [])
@ -304,7 +357,17 @@ 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} />
<AppCardOperations
t={t}
operations={operations}
launchConfigAction={hiddenLaunchVariables.length > 0
? {
label: t('operation.config', { ns: 'common' }),
disabled: triggerModeDisabled || !cardState.runningStatus,
onClick: handleOpenWorkflowLaunchDialog,
}
: undefined}
/>
</div>
)}
</div>
@ -323,6 +386,17 @@ function AppCard({
onCloseAccessControl={() => setShowAccessControl(false)}
onSaveSiteConfig={onSaveSiteConfig}
onConfirmAccessControl={handleAccessControlUpdate}
hiddenInputs={hiddenLaunchVariables}
/>
<WorkflowLaunchDialog
t={t}
open={showWorkflowLaunchDialog}
hiddenVariables={supportedWorkflowLaunchVariables}
unsupportedVariables={unsupportedWorkflowLaunchVariables}
values={workflowLaunchValues}
onOpenChange={setShowWorkflowLaunchDialog}
onValueChange={handleWorkflowLaunchValueChange}
onSubmit={handleWorkflowLaunchConfirm}
/>
</div>
)

View File

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

View File

@ -1,88 +1,46 @@
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 * as React from 'react'
import { useState } from 'react'
import { Suspense, use, useEffect, useMemo, useRef, 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 { IS_CE_EDITION } from '@/config'
import { InputVarType } from '@/app/components/workflow/types'
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
accessToken?: string
appBaseUrl?: string
hiddenInputs?: WorkflowHiddenStartVariable[]
className?: string
}
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 OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const
const prefixEmbedded = 'overview.appInfo.embedded'
type Option = keyof typeof OPTION_MAP
const OPTIONS: Option[] = ['iframe', 'scripts', 'chromePlugin']
type Option = typeof OPTION_KEYS[number]
const optionIconClassName: Record<Option, string> = {
iframe: style.iframeIcon!,
@ -90,38 +48,274 @@ const optionIconClassName: Record<Option, string> = {
chromePlugin: style.chromePluginIcon!,
}
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, className }: Props) => {
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 { 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 onClickCopy = () => {
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,
})
if (option === 'chromePlugin') {
const splitUrl = OPTION_MAP[option].getContent(appBaseUrl, accessToken).split(': ')
const splitUrl = getChromePluginContent(latestIframeUrl).split(': ')
if (splitUrl.length > 1)
copy(splitUrl[1]!)
}
else if (option === 'iframe') {
copy(getEmbeddedIframeSnippet(latestIframeUrl))
}
else {
copy(OPTION_MAP[option].getContent(appBaseUrl, accessToken, themeBuilder.theme?.primaryColor ?? '#1C64F2', isTestEnv))
copy(scriptsContent)
}
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()
}}
>
@ -130,73 +324,16 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, classNam
{t(`${prefixEmbedded}.title`, { ns: 'appOverview' })}
</DialogTitle>
<DialogCloseButton />
<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 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>
</DialogContent>
</Dialog>

View File

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

View File

@ -321,47 +321,86 @@ 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=123&sys.param=456&user.param=789')
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}&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=456&user.param=789')
setSearch(`?custom=123&sys.param=${encodeURIComponent('YWJjZA==')}&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=456`)
setSearch(`?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=${encodeURIComponent('YWJjZA==')}`)
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=abc')}&sys.param=456`)
setSearch(`?redirect_url=${encodeURIComponent(`http://example.com?sys.redirected=${encodeURIComponent('YWJjZA==')}`)}&sys.param=${encodeURIComponent('YWJjZA==')}`)
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=789')
setSearch(`?custom=123&sys.param=456&user.param=${encodeURIComponent('YWJjZA==')}`)
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=invalid_base64')
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}`)
const res = await getProcessedInputsFromUrlParams()
expect(res).toEqual({ custom: undefined })
})

View File

@ -19,11 +19,13 @@ 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())
entriesArray.forEach(([key, value]) => {
await Promise.all(entriesArray.map(async ([key, value]) => {
const prefixArray = ['sys.', 'user.']
if (!prefixArray.some(prefix => key.startsWith(prefix)))
inputs[key] = decodeURIComponent(value)
})
if (prefixArray.some(prefix => key.startsWith(prefix)))
return
inputs[key] = decodeURIComponent(value)
}))
return inputs
}
@ -81,10 +83,12 @@ 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())
entriesArray.forEach(([key, value]) => {
if (key.startsWith('user.'))
userVariables[key.slice(5)] = decodeURIComponent(value)
})
await Promise.all(entriesArray.map(async ([key, value]) => {
if (!key.startsWith('user.'))
return
userVariables[key.slice(5)] = decodeURIComponent(value)
}))
return userVariables
}

View File

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

View File

@ -6,6 +6,7 @@ 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'
@ -31,6 +32,44 @@ 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
@ -84,6 +123,15 @@ 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({
@ -94,7 +142,7 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex
} as VisionSettings)
setPromptConfig({
prompt_template: '',
prompt_variables: userInputsFormToPromptVariables(user_input_form),
prompt_variables: promptVariables,
} as PromptConfig)
setMoreLikeThisConfig(more_like_this)
setTextToSpeechConfig(text_to_speech)
@ -105,7 +153,7 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex
return () => {
cancelled = true
}
}, [appData, appParams, fetchSavedMessages, isWorkflow])
}, [appData, appParams, fetchSavedMessages, isInstalledApp, isWorkflow])
useDocumentTitle(siteInfo?.title || t('generation.title', { ns: 'share' }))
useAppFavicon({
enable: !isInstalledApp,

View File

@ -214,7 +214,7 @@ export type InputVar = {
}
variable: string
max_length?: number
default?: string | number
default?: string | number | boolean
required: boolean
hint?: string
options?: string[]

View File

@ -337,6 +337,8 @@
"variableConfig.file.image.name": "صورة",
"variableConfig.file.supportFileTypes": "أنواع الملفات المدعومة",
"variableConfig.file.video.name": "فيديو",
"variableConfig.hidden": "مخفي ومُعبَّأ مسبقاً",
"variableConfig.hiddenDescription": "أخفِ هذا الحقل عن المستخدمين النهائيين وأمد بقيمته بنفسك. خيار يستبعد خيار مطلوب. <docLink>تعلم المزيد</docLink>",
"variableConfig.hide": "إخفاء",
"variableConfig.inputPlaceholder": "يرجى الإدخال",
"variableConfig.json": "كود JSON",

View File

@ -56,6 +56,8 @@
"overview.appInfo.embedded.copy": "نسخ",
"overview.appInfo.embedded.entry": "مضمن",
"overview.appInfo.embedded.explanation": "اختر طريقة لتضمين تطبيق الدردشة في موقعك",
"overview.appInfo.embedded.hiddenInputs.description": "أدخل قيمًا للحقول المخفية. تُضاف القيم إلى رابط iframe أو كائن inputs الخاص بالسكريبت.",
"overview.appInfo.embedded.hiddenInputs.title": "تعبئة مسبقة للحقول المخفية",
"overview.appInfo.embedded.iframe": "لإضافة تطبيق الدردشة في أي مكان على موقعك، أضف هذا iframe إلى كود html الخاص بك.",
"overview.appInfo.embedded.scripts": "لإضافة تطبيق دردشة إلى أسفل يمين موقعك، أضف هذا الكود إلى html الخاص بك.",
"overview.appInfo.embedded.title": "تضمين في الموقع",
@ -104,6 +106,8 @@
"overview.appInfo.settings.workflow.subTitle": "تفاصيل سير العمل",
"overview.appInfo.settings.workflow.title": "سير العمل",
"overview.appInfo.title": "تطبيق ويب",
"overview.appInfo.workflowLaunchHiddenInputs.description": "أدخل قيمًا للحقول المخفية، ثم انقر على <bold>تشغيل</bold> لفتح تطبيق الويب مع القيم المدخلة.",
"overview.appInfo.workflowLaunchHiddenInputs.title": "تعبئة مسبقة للحقول المخفية",
"overview.disableTooltip.triggerMode": "ميزة {{feature}} غير مدعومة في وضع عقدة المشغل.",
"overview.status.disable": "تعطيل",
"overview.status.running": "في الخدمة",

View File

@ -337,6 +337,8 @@
"variableConfig.file.image.name": "Bild",
"variableConfig.file.supportFileTypes": "Unterstützte Dateitypen",
"variableConfig.file.video.name": "Video",
"variableConfig.hidden": "Ausgeblendet und vorausgefüllt",
"variableConfig.hiddenDescription": "Blendet dieses Feld für Endbenutzer aus und stellt den Wert selbst bereit. Schließt sich gegenseitig mit Erforderlich aus. <docLink>Mehr erfahren</docLink>",
"variableConfig.hide": "Verstecken",
"variableConfig.inputPlaceholder": "Bitte geben Sie ein",
"variableConfig.json": "JSON-Code",

View File

@ -56,6 +56,8 @@
"overview.appInfo.embedded.copy": "Kopieren",
"overview.appInfo.embedded.entry": "Eingebettet",
"overview.appInfo.embedded.explanation": "Wählen Sie die Art und Weise, wie die Chat-App auf Ihrer Website eingebettet wird",
"overview.appInfo.embedded.hiddenInputs.description": "Geben Sie Werte für die ausgeblendeten Felder ein. Die Werte werden zur iframe-URL oder zum inputs-Objekt des Skripts hinzugefügt.",
"overview.appInfo.embedded.hiddenInputs.title": "Ausgeblendete Felder vorausfüllen",
"overview.appInfo.embedded.iframe": "Um die Chat-App an einer beliebigen Stelle auf Ihrer Website hinzuzufügen, fügen Sie diesen iframe in Ihren HTML-Code ein.",
"overview.appInfo.embedded.scripts": "Um eine Chat-App unten rechts auf Ihrer Website hinzuzufügen, fügen Sie diesen Code in Ihren HTML-Code ein.",
"overview.appInfo.embedded.title": "Einbetten auf der Website",
@ -104,6 +106,8 @@
"overview.appInfo.settings.workflow.subTitle": "Details zum Arbeitsablauf",
"overview.appInfo.settings.workflow.title": "Workflow-Schritte",
"overview.appInfo.title": "Webanwendung",
"overview.appInfo.workflowLaunchHiddenInputs.description": "Geben Sie Werte für die ausgeblendeten Felder ein und klicken Sie dann auf <bold>Starten</bold>, um die WebApp mit den Werten zu öffnen.",
"overview.appInfo.workflowLaunchHiddenInputs.title": "Ausgeblendete Felder vorausfüllen",
"overview.disableTooltip.triggerMode": "Die Funktion {{feature}} wird im Trigger-Knoten-Modus nicht unterstützt.",
"overview.status.disable": "Deaktivieren",
"overview.status.running": "In Betrieb",

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