mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 20:08:06 +08:00
Compare commits
2 Commits
codex/upda
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
| 79598403b3 | |||
| c8e0899668 |
4
.github/workflows/api-tests.yml
vendored
4
.github/workflows/api-tests.yml
vendored
@ -98,8 +98,8 @@ jobs:
|
||||
|
||||
- name: Set up dotenvs
|
||||
run: |
|
||||
cp docker/.env.example docker/.env
|
||||
cp docker/envs/middleware.env.example docker/middleware.env
|
||||
./docker/init-env.sh
|
||||
cp docker/middleware.env.example docker/middleware.env
|
||||
|
||||
- name: Expose Service Ports
|
||||
run: sh .github/workflows/expose_service_ports.sh
|
||||
|
||||
4
.github/workflows/db-migration-test.yml
vendored
4
.github/workflows/db-migration-test.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
||||
- name: Prepare middleware env
|
||||
run: |
|
||||
cd docker
|
||||
cp envs/middleware.env.example middleware.env
|
||||
cp 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 envs/middleware.env.example middleware.env
|
||||
cp 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
|
||||
|
||||
12
.github/workflows/main-ci.yml
vendored
12
.github/workflows/main-ci.yml
vendored
@ -56,8 +56,10 @@ jobs:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
- '.github/workflows/expose_service_ports.sh'
|
||||
- 'docker/.env.all'
|
||||
- 'docker/.env.example'
|
||||
- 'docker/envs/middleware.env.example'
|
||||
- 'docker/init-env.sh'
|
||||
- 'docker/middleware.env.example'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/docker-compose-template.yaml'
|
||||
- 'docker/generate_docker_compose'
|
||||
@ -84,7 +86,7 @@ jobs:
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.nvmrc'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/envs/middleware.env.example'
|
||||
- 'docker/middleware.env.example'
|
||||
- '.github/workflows/web-e2e.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
vdb:
|
||||
@ -93,8 +95,10 @@ jobs:
|
||||
- 'api/providers/vdb/*/tests/**'
|
||||
- '.github/workflows/vdb-tests.yml'
|
||||
- '.github/workflows/expose_service_ports.sh'
|
||||
- 'docker/.env.all'
|
||||
- 'docker/.env.example'
|
||||
- 'docker/envs/middleware.env.example'
|
||||
- 'docker/init-env.sh'
|
||||
- 'docker/middleware.env.example'
|
||||
- 'docker/docker-compose.yaml'
|
||||
- 'docker/docker-compose-template.yaml'
|
||||
- 'docker/generate_docker_compose'
|
||||
@ -116,7 +120,7 @@ jobs:
|
||||
- '.github/workflows/db-migration-test.yml'
|
||||
- '.github/workflows/expose_service_ports.sh'
|
||||
- 'docker/.env.example'
|
||||
- 'docker/envs/middleware.env.example'
|
||||
- 'docker/middleware.env.example'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/docker-compose-template.yaml'
|
||||
- 'docker/generate_docker_compose'
|
||||
|
||||
4
.github/workflows/vdb-tests-full.yml
vendored
4
.github/workflows/vdb-tests-full.yml
vendored
@ -50,8 +50,8 @@ jobs:
|
||||
|
||||
- name: Set up dotenvs
|
||||
run: |
|
||||
cp docker/.env.example docker/.env
|
||||
cp docker/envs/middleware.env.example docker/middleware.env
|
||||
./docker/init-env.sh
|
||||
cp docker/middleware.env.example docker/middleware.env
|
||||
|
||||
- name: Expose Service Ports
|
||||
run: sh .github/workflows/expose_service_ports.sh
|
||||
|
||||
4
.github/workflows/vdb-tests.yml
vendored
4
.github/workflows/vdb-tests.yml
vendored
@ -47,8 +47,8 @@ jobs:
|
||||
|
||||
- name: Set up dotenvs
|
||||
run: |
|
||||
cp docker/.env.example docker/.env
|
||||
cp docker/envs/middleware.env.example docker/middleware.env
|
||||
./docker/init-env.sh
|
||||
cp docker/middleware.env.example docker/middleware.env
|
||||
|
||||
- name: Expose Service Ports
|
||||
run: sh .github/workflows/expose_service_ports.sh
|
||||
|
||||
23
Makefile
23
Makefile
@ -3,10 +3,6 @@ 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
|
||||
@ -21,13 +17,8 @@ dev-setup: prepare-docker prepare-web prepare-api
|
||||
# Step 1: Prepare Docker middleware
|
||||
prepare-docker:
|
||||
@echo "🐳 Setting up Docker middleware..."
|
||||
@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
|
||||
@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
|
||||
@echo "✅ Docker middleware started"
|
||||
|
||||
# Step 2: Prepare web environment
|
||||
@ -48,18 +39,12 @@ prepare-api:
|
||||
# Clean dev environment
|
||||
dev-clean:
|
||||
@echo "⚠️ Stopping Docker containers..."
|
||||
@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
|
||||
@cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev down
|
||||
@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"
|
||||
|
||||
@ -147,7 +132,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 and remove dev data"
|
||||
@echo " make dev-clean - Stop Docker middleware containers"
|
||||
@echo ""
|
||||
@echo "Backend Code Quality:"
|
||||
@echo " make format - Format code with ruff"
|
||||
|
||||
11
README.md
11
README.md
@ -76,7 +76,14 @@ The easiest way to start the Dify server is through [Docker Compose](docker/dock
|
||||
```bash
|
||||
cd dify
|
||||
cd docker
|
||||
cp .env.example .env
|
||||
./init-env.sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
On Windows PowerShell, initialize `.env`, then run `docker compose up -d` from the `docker` directory.
|
||||
|
||||
```powershell
|
||||
.\init-env.ps1
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@ -137,7 +144,7 @@ Star Dify on GitHub and be instantly notified of new releases.
|
||||
|
||||
### Custom configurations
|
||||
|
||||
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).
|
||||
If you need to customize the configuration, edit `docker/.env` after running the initialization script. The full reference remains in [`docker/.env.all`](docker/.env.all). After making any changes, re-run `docker compose up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
||||
|
||||
### Metrics Monitoring with Grafana
|
||||
|
||||
|
||||
@ -98,8 +98,6 @@ DB_DATABASE=dify
|
||||
|
||||
SQLALCHEMY_POOL_PRE_PING=true
|
||||
SQLALCHEMY_POOL_TIMEOUT=30
|
||||
# Connection pool reset behavior on return
|
||||
SQLALCHEMY_POOL_RESET_ON_RETURN=rollback
|
||||
|
||||
# Storage configuration
|
||||
# use for store upload files, private keys...
|
||||
@ -383,7 +381,7 @@ VIKINGDB_ACCESS_KEY=your-ak
|
||||
VIKINGDB_SECRET_KEY=your-sk
|
||||
VIKINGDB_REGION=cn-shanghai
|
||||
VIKINGDB_HOST=api-vikingdb.xxx.volces.com
|
||||
VIKINGDB_SCHEME=http
|
||||
VIKINGDB_SCHEMA=http
|
||||
VIKINGDB_CONNECTION_TIMEOUT=30
|
||||
VIKINGDB_SOCKET_TIMEOUT=30
|
||||
|
||||
@ -434,6 +432,8 @@ UPLOAD_FILE_EXTENSION_BLACKLIST=
|
||||
|
||||
# Model configuration
|
||||
MULTIMODAL_SEND_FORMAT=base64
|
||||
PROMPT_GENERATION_MAX_TOKENS=512
|
||||
CODE_GENERATION_MAX_TOKENS=1024
|
||||
PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false
|
||||
|
||||
# Mail configuration, support: resend, smtp, sendgrid
|
||||
|
||||
@ -114,7 +114,7 @@ class SQLAlchemyEngineOptionsDict(TypedDict):
|
||||
pool_pre_ping: bool
|
||||
connect_args: dict[str, str]
|
||||
pool_use_lifo: bool
|
||||
pool_reset_on_return: Literal["commit", "rollback", None]
|
||||
pool_reset_on_return: None
|
||||
pool_timeout: int
|
||||
|
||||
|
||||
@ -223,11 +223,6 @@ class DatabaseConfig(BaseSettings):
|
||||
default=30,
|
||||
)
|
||||
|
||||
SQLALCHEMY_POOL_RESET_ON_RETURN: Literal["commit", "rollback", None] = Field(
|
||||
description="Connection pool reset behavior on return. Options: 'commit', 'rollback', or None",
|
||||
default="rollback",
|
||||
)
|
||||
|
||||
RETRIEVAL_SERVICE_EXECUTORS: NonNegativeInt = Field(
|
||||
description="Number of processes for the retrieval service, default to CPU cores.",
|
||||
default=os.cpu_count() or 1,
|
||||
@ -257,7 +252,7 @@ class DatabaseConfig(BaseSettings):
|
||||
"pool_pre_ping": self.SQLALCHEMY_POOL_PRE_PING,
|
||||
"connect_args": connect_args,
|
||||
"pool_use_lifo": self.SQLALCHEMY_POOL_USE_LIFO,
|
||||
"pool_reset_on_return": self.SQLALCHEMY_POOL_RESET_ON_RETURN,
|
||||
"pool_reset_on_return": None,
|
||||
"pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT,
|
||||
}
|
||||
return result
|
||||
|
||||
@ -25,7 +25,6 @@ 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
|
||||
@ -842,8 +841,7 @@ class AppTraceApi(Resource):
|
||||
@account_initialization_required
|
||||
def get(self, app_id):
|
||||
"""Get app trace"""
|
||||
with session_factory.create_session() as session:
|
||||
app_trace_config = OpsTraceManager.get_app_tracing_config(app_id, session)
|
||||
app_trace_config = OpsTraceManager.get_app_tracing_config(app_id=app_id)
|
||||
|
||||
return app_trace_config
|
||||
|
||||
|
||||
@ -842,24 +842,24 @@ class WorkflowResponseConverter:
|
||||
return []
|
||||
|
||||
files: list[Mapping[str, Any]] = []
|
||||
match value:
|
||||
case FileSegment():
|
||||
files.append(value.value.to_dict())
|
||||
case ArrayFileSegment():
|
||||
files.extend([i.to_dict() for i in value.value])
|
||||
case File():
|
||||
files.append(value.to_dict())
|
||||
case list():
|
||||
for item in value:
|
||||
file = cls._get_file_var_from_value(item)
|
||||
if file:
|
||||
files.append(file)
|
||||
case dict():
|
||||
file = cls._get_file_var_from_value(value)
|
||||
if isinstance(value, FileSegment):
|
||||
files.append(value.value.to_dict())
|
||||
elif isinstance(value, ArrayFileSegment):
|
||||
files.extend([i.to_dict() for i in value.value])
|
||||
elif isinstance(value, File):
|
||||
files.append(value.to_dict())
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
file = cls._get_file_var_from_value(item)
|
||||
if file:
|
||||
files.append(file)
|
||||
case _:
|
||||
pass
|
||||
elif isinstance(
|
||||
value,
|
||||
dict,
|
||||
):
|
||||
file = cls._get_file_var_from_value(value)
|
||||
if file:
|
||||
files.append(file)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
@ -569,13 +569,13 @@ class OpsTraceManager:
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_app_tracing_config(cls, app_id: str, session: Session):
|
||||
def get_app_tracing_config(cls, app_id: str):
|
||||
"""
|
||||
Get app tracing config
|
||||
:param app_id: app id
|
||||
:return:
|
||||
"""
|
||||
app: App | None = session.get(App, app_id)
|
||||
app: App | None = db.session.get(App, app_id)
|
||||
if not app:
|
||||
raise ValueError("App not found")
|
||||
if not app.tracing:
|
||||
|
||||
@ -53,27 +53,24 @@ class PromptMessageUtil:
|
||||
files = []
|
||||
if isinstance(prompt_message.content, list):
|
||||
for content in prompt_message.content:
|
||||
match content:
|
||||
case TextPromptMessageContent():
|
||||
text += content.data
|
||||
case ImagePromptMessageContent():
|
||||
files.append(
|
||||
{
|
||||
"type": "image",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"detail": content.detail.value,
|
||||
}
|
||||
)
|
||||
case AudioPromptMessageContent():
|
||||
files.append(
|
||||
{
|
||||
"type": "audio",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"format": content.format,
|
||||
}
|
||||
)
|
||||
case _:
|
||||
continue
|
||||
if isinstance(content, TextPromptMessageContent):
|
||||
text += content.data
|
||||
elif isinstance(content, ImagePromptMessageContent):
|
||||
files.append(
|
||||
{
|
||||
"type": "image",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"detail": content.detail.value,
|
||||
}
|
||||
)
|
||||
elif isinstance(content, AudioPromptMessageContent):
|
||||
files.append(
|
||||
{
|
||||
"type": "audio",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"format": content.format,
|
||||
}
|
||||
)
|
||||
else:
|
||||
text = cast(str, prompt_message.content)
|
||||
|
||||
|
||||
@ -23,37 +23,36 @@ _TOOL_FILE_URL_PATTERN = re.compile(r"(?:^|/+)files/tools/(?P<tool_file_id>[^/?#
|
||||
|
||||
|
||||
def safe_json_value(v):
|
||||
match v:
|
||||
case datetime():
|
||||
tz_name = "UTC"
|
||||
if isinstance(current_user, Account) and current_user.timezone is not None:
|
||||
tz_name = current_user.timezone
|
||||
return v.astimezone(pytz.timezone(tz_name)).isoformat()
|
||||
case date():
|
||||
return v.isoformat()
|
||||
case UUID():
|
||||
return str(v)
|
||||
case Decimal():
|
||||
return float(v)
|
||||
case bytes():
|
||||
try:
|
||||
return v.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return v.hex()
|
||||
case memoryview():
|
||||
return v.tobytes().hex()
|
||||
case np.integer():
|
||||
return int(v)
|
||||
case np.floating():
|
||||
return float(v)
|
||||
case np.ndarray():
|
||||
return v.tolist()
|
||||
case dict():
|
||||
return safe_json_dict(v)
|
||||
case list() | tuple() | set():
|
||||
return [safe_json_value(i) for i in v]
|
||||
case _:
|
||||
return v
|
||||
if isinstance(v, datetime):
|
||||
tz_name = "UTC"
|
||||
if isinstance(current_user, Account) and current_user.timezone is not None:
|
||||
tz_name = current_user.timezone
|
||||
return v.astimezone(pytz.timezone(tz_name)).isoformat()
|
||||
elif isinstance(v, date):
|
||||
return v.isoformat()
|
||||
elif isinstance(v, UUID):
|
||||
return str(v)
|
||||
elif isinstance(v, Decimal):
|
||||
return float(v)
|
||||
elif isinstance(v, bytes):
|
||||
try:
|
||||
return v.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return v.hex()
|
||||
elif isinstance(v, memoryview):
|
||||
return v.tobytes().hex()
|
||||
elif isinstance(v, np.integer):
|
||||
return int(v)
|
||||
elif isinstance(v, np.floating):
|
||||
return float(v)
|
||||
elif isinstance(v, np.ndarray):
|
||||
return v.tolist()
|
||||
elif isinstance(v, dict):
|
||||
return safe_json_dict(v)
|
||||
elif isinstance(v, list | tuple | set):
|
||||
return [safe_json_value(i) for i in v]
|
||||
else:
|
||||
return v
|
||||
|
||||
|
||||
def safe_json_dict(d: dict[str, Any]):
|
||||
|
||||
@ -194,15 +194,14 @@ class VariableTruncator(BaseTruncator):
|
||||
|
||||
result: _PartResult[Any]
|
||||
# Apply type-specific truncation with target size
|
||||
match segment:
|
||||
case ArraySegment():
|
||||
result = self._truncate_array(segment.value, target_size)
|
||||
case StringSegment():
|
||||
result = self._truncate_string(segment.value, target_size)
|
||||
case ObjectSegment():
|
||||
result = self._truncate_object(segment.value, target_size)
|
||||
case _:
|
||||
raise AssertionError("this should be unreachable.")
|
||||
if isinstance(segment, ArraySegment):
|
||||
result = self._truncate_array(segment.value, target_size)
|
||||
elif isinstance(segment, StringSegment):
|
||||
result = self._truncate_string(segment.value, target_size)
|
||||
elif isinstance(segment, ObjectSegment):
|
||||
result = self._truncate_object(segment.value, target_size)
|
||||
else:
|
||||
raise AssertionError("this should be unreachable.")
|
||||
|
||||
return _PartResult(
|
||||
value=segment.model_copy(update={"value": result.value}),
|
||||
@ -220,41 +219,40 @@ class VariableTruncator(BaseTruncator):
|
||||
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
|
||||
if depth > _MAX_DEPTH:
|
||||
raise MaxDepthExceededError()
|
||||
match value:
|
||||
case str():
|
||||
# Ideally, the size of strings should be calculated based on their utf-8 encoded length.
|
||||
# However, this adds complexity as we would need to compute encoded sizes consistently
|
||||
# throughout the code. Therefore, we approximate the size using the string's length.
|
||||
# Rough estimate: number of characters, plus 2 for quotes
|
||||
return len(value) + 2
|
||||
case bool():
|
||||
return 4 if value else 5 # "true" or "false"
|
||||
case int() | float():
|
||||
return len(str(value))
|
||||
case None:
|
||||
return 4 # "null"
|
||||
case list():
|
||||
# Size = sum of elements + separators + brackets
|
||||
total = 2 # "[]"
|
||||
for i, item in enumerate(value):
|
||||
if i > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(item, depth=depth + 1)
|
||||
return total
|
||||
case dict():
|
||||
# Size = sum of keys + values + separators + brackets
|
||||
total = 2 # "{}"
|
||||
for index, key in enumerate(value.keys()):
|
||||
if index > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string
|
||||
total += 1 # ":"
|
||||
total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1)
|
||||
return total
|
||||
case File():
|
||||
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
|
||||
case _:
|
||||
raise UnknownTypeError(f"got unknown type {type(value)}")
|
||||
if isinstance(value, str):
|
||||
# Ideally, the size of strings should be calculated based on their utf-8 encoded length.
|
||||
# However, this adds complexity as we would need to compute encoded sizes consistently
|
||||
# throughout the code. Therefore, we approximate the size using the string's length.
|
||||
# Rough estimate: number of characters, plus 2 for quotes
|
||||
return len(value) + 2
|
||||
elif isinstance(value, (int, float)):
|
||||
return len(str(value))
|
||||
elif isinstance(value, bool):
|
||||
return 4 if value else 5 # "true" or "false"
|
||||
elif value is None:
|
||||
return 4 # "null"
|
||||
elif isinstance(value, list):
|
||||
# Size = sum of elements + separators + brackets
|
||||
total = 2 # "[]"
|
||||
for i, item in enumerate(value):
|
||||
if i > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(item, depth=depth + 1)
|
||||
return total
|
||||
elif isinstance(value, dict):
|
||||
# Size = sum of keys + values + separators + brackets
|
||||
total = 2 # "{}"
|
||||
for index, key in enumerate(value.keys()):
|
||||
if index > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string
|
||||
total += 1 # ":"
|
||||
total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1)
|
||||
return total
|
||||
elif isinstance(value, File):
|
||||
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
|
||||
else:
|
||||
raise UnknownTypeError(f"got unknown type {type(value)}")
|
||||
|
||||
def _truncate_string(self, value: str, target_size: int) -> _PartResult[str]:
|
||||
if (size := self.calculate_json_size(value)) < target_size:
|
||||
@ -421,23 +419,22 @@ class VariableTruncator(BaseTruncator):
|
||||
target_size: int,
|
||||
) -> _PartResult[Any]:
|
||||
"""Truncate a value within an object to fit within budget."""
|
||||
match val:
|
||||
case UpdatedVariable():
|
||||
# TODO(Workflow): push UpdatedVariable normalization closer to its producer.
|
||||
return self._truncate_object(val.model_dump(), target_size)
|
||||
case str():
|
||||
return self._truncate_string(val, target_size)
|
||||
case list():
|
||||
return self._truncate_array(val, target_size)
|
||||
case dict():
|
||||
return self._truncate_object(val, target_size)
|
||||
case File():
|
||||
# File objects should not be truncated, return as-is
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
case None | bool() | int() | float():
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
case _:
|
||||
raise AssertionError("this statement should be unreachable.")
|
||||
if isinstance(val, UpdatedVariable):
|
||||
# TODO(Workflow): push UpdatedVariable normalization closer to its producer.
|
||||
return self._truncate_object(val.model_dump(), target_size)
|
||||
elif isinstance(val, str):
|
||||
return self._truncate_string(val, target_size)
|
||||
elif isinstance(val, list):
|
||||
return self._truncate_array(val, target_size)
|
||||
elif isinstance(val, dict):
|
||||
return self._truncate_object(val, target_size)
|
||||
elif isinstance(val, File):
|
||||
# File objects should not be truncated, return as-is
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
elif val is None or isinstance(val, (bool, int, float)):
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
else:
|
||||
raise AssertionError("this statement should be unreachable.")
|
||||
|
||||
|
||||
class DummyVariableTruncator(BaseTruncator):
|
||||
|
||||
@ -114,8 +114,8 @@ def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
|
||||
"pool_recycle": 3600,
|
||||
"pool_size": 30,
|
||||
"pool_use_lifo": False,
|
||||
"pool_reset_on_return": None,
|
||||
"pool_timeout": 30,
|
||||
"pool_reset_on_return": "rollback",
|
||||
}
|
||||
|
||||
assert config["CONSOLE_WEB_URL"] == "https://example.com"
|
||||
|
||||
@ -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", mock_db)
|
||||
OpsTraceManager.get_app_tracing_config("app")
|
||||
|
||||
|
||||
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", mock_db) == {"enabled": False, "tracing_provider": None}
|
||||
assert OpsTraceManager.get_app_tracing_config("app-id") == {"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", mock_db) == payload
|
||||
assert OpsTraceManager.get_app_tracing_config("app-id") == payload
|
||||
|
||||
|
||||
def test_check_and_project_helpers(monkeypatch):
|
||||
|
||||
@ -92,30 +92,32 @@ BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF: frozenset[str] = frozenset(
|
||||
)
|
||||
|
||||
API_CONFIG_SET = set(dotenv_values(Path("api") / Path(".env.example")).keys())
|
||||
DOCKER_CONFIG_SET = set(dotenv_values(Path("docker") / Path(".env.example")).keys())
|
||||
DOCKER_COMPOSE_CONFIG_SET = set(DOCKER_CONFIG_SET)
|
||||
DOCKER_CONFIG_SET = set(dotenv_values(Path("docker") / Path(".env.all")).keys())
|
||||
DOCKER_COMPOSE_CONFIG_SET = set()
|
||||
|
||||
# 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)
|
||||
with open(Path("docker") / Path("docker-compose.yaml")) as f:
|
||||
DOCKER_COMPOSE_CONFIG_SET = set(yaml.safe_load(f.read())["x-shared-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!")
|
||||
|
||||
|
||||
@ -14,8 +14,8 @@ export OPENDAL_FS_ROOT=${OPENDAL_FS_ROOT:-/tmp/dify-storage}
|
||||
mkdir -p "${OPENDAL_FS_ROOT}"
|
||||
|
||||
# Prepare env files like CI
|
||||
cp -n docker/.env.example docker/.env || true
|
||||
cp -n docker/envs/middleware.env.example docker/middleware.env || true
|
||||
./docker/init-env.sh
|
||||
cp -n docker/middleware.env.example docker/middleware.env || true
|
||||
cp -n api/tests/integration_tests/.env.example api/tests/integration_tests/.env || true
|
||||
|
||||
# Expose service ports (same as CI) without leaving the repo dirty
|
||||
|
||||
@ -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/envs/middleware.env.example"
|
||||
MIDDLEWARE_ENV_EXAMPLE="$ROOT/docker/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/envs/middleware.env.example -> docker/middleware.env
|
||||
# 3) Copy docker/middleware.env.example -> docker/middleware.env
|
||||
cp "$MIDDLEWARE_ENV_EXAMPLE" "$MIDDLEWARE_ENV"
|
||||
|
||||
# 4) Install deps
|
||||
|
||||
1631
docker/.env.all
Normal file
1631
docker/.env.all
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,251 +1,54 @@
|
||||
# ------------------------------------------------------------------
|
||||
# Essential defaults for Docker Compose deployments.
|
||||
# Minimal environment template for Docker Compose deployments.
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Core service URLs
|
||||
CONSOLE_API_URL=
|
||||
CONSOLE_WEB_URL=
|
||||
SERVICE_API_URL=
|
||||
# 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
|
||||
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
|
||||
|
||||
# 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
|
||||
# Built-in metadata database defaults.
|
||||
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
|
||||
|
||||
# Redis and Celery
|
||||
# Built-in Redis defaults.
|
||||
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
|
||||
|
||||
# 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
|
||||
# Default file storage.
|
||||
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
|
||||
|
||||
# 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
|
||||
# Internal service authentication. Paired values must match.
|
||||
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=
|
||||
|
||||
# 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
|
||||
# Host ports.
|
||||
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
3
docker/.gitignore
vendored
@ -1,3 +0,0 @@
|
||||
# Ignore actual .env files (keep only .env.example files in git)
|
||||
*.env
|
||||
!*.env.example
|
||||
@ -7,31 +7,39 @@ 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**: Essential startup defaults are provided in `.env.example`, while local values are stored in `.env`, ensuring that your configurations persist across deployments.
|
||||
- **Persistent Environment Variables**: Default deployment values are provided in `.env.example`. Initialize `.env` from it and keep local changes there so your configuration persists across deployments.
|
||||
|
||||
> What is `.env`? </br> </br>
|
||||
> The `.env` file is the local startup file. Copy it from `.env.example` for a default deployment. Optional advanced settings live in `envs/*.env.example` files.
|
||||
> The `.env` file is your local Docker Compose environment file. Start from `.env.example`, then customize it as needed. Use `.env.all` as the full reference when you need advanced configuration.
|
||||
|
||||
- **Unified Vector Database Services**: All vector database services are now managed from a single Docker Compose file `docker-compose.yaml`. You can switch between different vector databases by setting the `VECTOR_STORE` environment variable in your `.env` file.
|
||||
|
||||
- **Full Configuration Reference**: `.env.all` keeps the complete variable list for advanced and service-specific settings, while `.env.example` stays focused on the default self-hosted deployment path.
|
||||
|
||||
### How to Deploy Dify with `docker-compose.yaml`
|
||||
|
||||
1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system.
|
||||
1. **Environment Setup**:
|
||||
- Navigate to the `docker` directory.
|
||||
- Copy `.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.
|
||||
- Create `.env` and generate a deployment-specific `SECRET_KEY`:
|
||||
|
||||
```bash
|
||||
./init-env.sh
|
||||
```
|
||||
|
||||
On Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
.\init-env.ps1
|
||||
```
|
||||
|
||||
- Customize `.env` only when you need to override defaults. Refer to `.env.all` for the full list of available variables.
|
||||
- **Optional (for advanced deployments)**:
|
||||
If you maintain a full `.env` file copied from `.env.example`, you may use the environment synchronization tool to keep it aligned with the latest `.env.example` updates while preserving your custom settings.
|
||||
If you maintain a full `.env` file copied from `.env.all`, you may use the environment synchronization tool to keep it aligned with the latest `.env.all` updates while preserving your custom settings.
|
||||
See the [Environment Variables Synchronization](#environment-variables-synchronization) section below.
|
||||
1. **Running the Services**:
|
||||
- Execute `docker compose up -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**:
|
||||
@ -43,7 +51,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 envs/middleware.env.example middleware.env` (refer to the `envs/middleware.env.example` file).
|
||||
- Ensure the `middleware.env` file is created by running `cp middleware.env.example middleware.env` (refer to the `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.
|
||||
@ -60,13 +68,11 @@ For users migrating from the `docker-legacy` setup:
|
||||
1. **Data Migration**:
|
||||
- Ensure that data from services like databases and caches is backed up and migrated appropriately to the new structure if necessary.
|
||||
|
||||
### Overview of `.env`, `.env.example`, and `envs/`
|
||||
### Overview of `.env.example`, `.env`, and `.env.all`
|
||||
|
||||
- `.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.
|
||||
- `.env.example` contains the minimal default configuration for Docker Compose deployments.
|
||||
- `.env` is your local copy. It contains the generated `SECRET_KEY` plus any local changes.
|
||||
- `.env.all` is the full reference for advanced configuration.
|
||||
|
||||
#### Key Modules and Customization
|
||||
|
||||
@ -76,7 +82,7 @@ Docker Compose reads `envs/*.env` files when present, then reads `.env` last so
|
||||
|
||||
#### Other notable 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:
|
||||
The `.env.all` file provided in the Docker setup is extensive and covers a wide range of configuration options. It is structured into several sections, each pertaining to different aspects of the application and its services. Here are some of the key sections and variables:
|
||||
|
||||
1. **Common Variables**:
|
||||
|
||||
@ -104,7 +110,7 @@ The root `.env.example` file contains the essential startup settings. Optional a
|
||||
|
||||
1. **Storage Configuration**:
|
||||
|
||||
- `STORAGE_TYPE`, `OPENDAL_SCHEME`, `OPENDAL_FS_ROOT`: Default local file storage settings. Optional storage backends are configured from the files under `envs/`.
|
||||
- `STORAGE_TYPE`, `S3_BUCKET_NAME`, `AZURE_BLOB_ACCOUNT_NAME`: Settings for file storage options like local, S3, Azure Blob, etc.
|
||||
|
||||
1. **Vector Database Configuration**:
|
||||
|
||||
@ -126,25 +132,25 @@ The root `.env.example` file contains the essential startup settings. Optional a
|
||||
|
||||
### Environment Variables Synchronization
|
||||
|
||||
When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example` or the optional files under `envs/`.
|
||||
When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example` or `.env.all`.
|
||||
|
||||
If you use the default workflow, review `.env.example` and keep your `.env` aligned with essential startup values.
|
||||
If you use the default workflow, review `.env.example` and add only the values you need to customize to `.env`.
|
||||
|
||||
If you maintain a customized `.env` file copied from `.env.example`, an optional environment variables synchronization tool is provided.
|
||||
If you maintain a full `.env` file copied from `.env.all`, an optional environment variables synchronization tool is provided.
|
||||
|
||||
> This tool performs a **one-way synchronization** from `.env.example` to `.env`.
|
||||
> This tool performs a **one-way synchronization** from `.env.all` to `.env`.
|
||||
> Existing values in `.env` are never overwritten automatically.
|
||||
|
||||
#### `dify-env-sync.sh` (Optional)
|
||||
|
||||
This script compares your current `.env` file with the latest `.env.example` template and helps safely apply new or updated environment variables.
|
||||
This script compares your current `.env` file with the latest `.env.all` template and helps safely apply new or updated environment variables.
|
||||
|
||||
**What it does**
|
||||
|
||||
- Creates a backup of the current `.env` file before making any changes
|
||||
- Synchronizes newly added environment variables from `.env.example`
|
||||
- Synchronizes newly added environment variables from `.env.all`
|
||||
- Preserves all existing custom values in `.env`
|
||||
- Displays differences and variables removed from `.env.example` for review
|
||||
- Displays differences and variables removed from `.env.all` for review
|
||||
|
||||
**Backup behavior**
|
||||
|
||||
@ -154,8 +160,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.example` has been updated with new environment variables
|
||||
- When managing a large or heavily customized `.env` file copied from `.env.example`
|
||||
- When `.env.all` has been updated with new environment variables
|
||||
- When managing a large or heavily customized `.env` file copied from `.env.all`
|
||||
|
||||
**Usage**
|
||||
|
||||
@ -170,6 +176,6 @@ chmod +x dify-env-sync.sh
|
||||
### Additional Information
|
||||
|
||||
- **Continuous Improvement Phase**: We are actively seeking feedback from the community to refine and enhance the deployment process. As more users adopt this new method, we will continue to make improvements based on your experiences and suggestions.
|
||||
- **Support**: For detailed configuration options and environment variable settings, refer to the `.env.example` file and the Docker Compose configuration files in the `docker` directory.
|
||||
- **Support**: For detailed configuration options and environment variable settings, refer to the `.env.all` file and the Docker Compose configuration files in the `docker` directory.
|
||||
|
||||
This README aims to guide you through the deployment process using the new Docker Compose setup. For any issues or further assistance, please refer to the official documentation or contact support.
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
# Dify Environment Variables Synchronization Script
|
||||
#
|
||||
# Features:
|
||||
# - Synchronize latest settings from .env.example to .env
|
||||
# - Synchronize latest settings from .env.all to .env
|
||||
# - Preserve custom settings in existing .env
|
||||
# - Add new environment variables
|
||||
# - Detect removed environment variables
|
||||
@ -93,25 +93,25 @@ def parse_env_file(path: Path) -> dict[str, str]:
|
||||
|
||||
|
||||
def check_files(work_dir: Path) -> None:
|
||||
"""Verify required files exist; create .env from .env.example if absent.
|
||||
"""Verify required files exist; create .env from .env.all if absent.
|
||||
|
||||
Args:
|
||||
work_dir: Directory that must contain .env.example (and optionally .env).
|
||||
work_dir: Directory that must contain .env.all (and optionally .env).
|
||||
|
||||
Raises:
|
||||
SystemExit: If .env.example does not exist.
|
||||
SystemExit: If .env.all does not exist.
|
||||
"""
|
||||
log_info("Checking required files...")
|
||||
|
||||
example_file = work_dir / ".env.example"
|
||||
example_file = work_dir / ".env.all"
|
||||
env_file = work_dir / ".env"
|
||||
|
||||
if not example_file.exists():
|
||||
log_error(".env.example file not found")
|
||||
log_error(".env.all file not found")
|
||||
sys.exit(1)
|
||||
|
||||
if not env_file.exists():
|
||||
log_warning(".env file does not exist. Creating from .env.example.")
|
||||
log_warning(".env file does not exist. Creating from .env.all.")
|
||||
shutil.copy2(example_file, env_file)
|
||||
log_success(".env file created")
|
||||
|
||||
@ -147,7 +147,7 @@ def analyze_value_change(current: str, recommended: str) -> str | None:
|
||||
|
||||
Args:
|
||||
current: Value currently set in .env.
|
||||
recommended: Value present in .env.example.
|
||||
recommended: Value present in .env.all.
|
||||
|
||||
Returns:
|
||||
A human-readable description string, or None when no analysis applies.
|
||||
@ -199,20 +199,20 @@ def analyze_value_change(current: str, recommended: str) -> str | None:
|
||||
|
||||
|
||||
def detect_differences(env_vars: dict[str, str], example_vars: dict[str, str]) -> dict[str, tuple[str, str]]:
|
||||
"""Find variables whose values differ between .env and .env.example.
|
||||
"""Find variables whose values differ between .env and .env.all.
|
||||
|
||||
Only variables present in *both* files are compared; new or removed
|
||||
variables are handled by separate functions.
|
||||
|
||||
Args:
|
||||
env_vars: Parsed key/value pairs from .env.
|
||||
example_vars: Parsed key/value pairs from .env.example.
|
||||
example_vars: Parsed key/value pairs from .env.all.
|
||||
|
||||
Returns:
|
||||
Mapping of key -> (env_value, example_value) for every key whose
|
||||
values differ.
|
||||
"""
|
||||
log_info("Detecting differences between .env and .env.example...")
|
||||
log_info("Detecting differences between .env and .env.all...")
|
||||
|
||||
diffs: dict[str, tuple[str, str]] = {}
|
||||
for key, example_value in example_vars.items():
|
||||
@ -248,11 +248,11 @@ def show_differences_detail(diffs: dict[str, tuple[str, str]]) -> None:
|
||||
if use_colors:
|
||||
print(f"{YELLOW}[{count}] {key}{NC}")
|
||||
print(f" {GREEN}.env (current){NC} : {env_value}")
|
||||
print(f" {BLUE}.env.example (recommended){NC} : {example_value}")
|
||||
print(f" {BLUE}.env.all (recommended){NC} : {example_value}")
|
||||
else:
|
||||
print(f"[{count}] {key}")
|
||||
print(f" .env (current) : {env_value}")
|
||||
print(f" .env.example (recommended) : {example_value}")
|
||||
print(f" .env.all (recommended) : {example_value}")
|
||||
|
||||
analysis = analyze_value_change(env_value, example_value)
|
||||
if analysis:
|
||||
@ -266,21 +266,21 @@ def show_differences_detail(diffs: dict[str, tuple[str, str]]) -> None:
|
||||
|
||||
|
||||
def detect_removed_variables(env_vars: dict[str, str], example_vars: dict[str, str]) -> list[str]:
|
||||
"""Identify variables present in .env but absent from .env.example.
|
||||
"""Identify variables present in .env but absent from .env.all.
|
||||
|
||||
Args:
|
||||
env_vars: Parsed key/value pairs from .env.
|
||||
example_vars: Parsed key/value pairs from .env.example.
|
||||
example_vars: Parsed key/value pairs from .env.all.
|
||||
|
||||
Returns:
|
||||
Sorted list of variable names that no longer appear in .env.example.
|
||||
Sorted list of variable names that no longer appear in .env.all.
|
||||
"""
|
||||
log_info("Detecting removed environment variables...")
|
||||
|
||||
removed = sorted(set(env_vars) - set(example_vars))
|
||||
|
||||
if removed:
|
||||
log_warning("The following environment variables have been removed from .env.example:")
|
||||
log_warning("The following environment variables have been removed from .env.all:")
|
||||
for var in removed:
|
||||
log_warning(f" - {var}")
|
||||
log_warning("Consider manually removing these variables from .env")
|
||||
@ -291,22 +291,22 @@ def detect_removed_variables(env_vars: dict[str, str], example_vars: dict[str, s
|
||||
|
||||
|
||||
def sync_env_file(work_dir: Path, env_vars: dict[str, str], diffs: dict[str, tuple[str, str]]) -> None:
|
||||
"""Rewrite .env based on .env.example while preserving custom values.
|
||||
"""Rewrite .env based on .env.all while preserving custom values.
|
||||
|
||||
The output file follows the exact line structure of .env.example
|
||||
The output file follows the exact line structure of .env.all
|
||||
(preserving comments, blank lines, and ordering). For every variable
|
||||
that exists in .env with a different value from the example, the
|
||||
current .env value is kept. Variables that are new in .env.example
|
||||
current .env value is kept. Variables that are new in .env.all
|
||||
(not present in .env at all) are added with the example's default.
|
||||
|
||||
Args:
|
||||
work_dir: Directory containing .env and .env.example.
|
||||
work_dir: Directory containing .env and .env.all.
|
||||
env_vars: Parsed key/value pairs from the original .env.
|
||||
diffs: Keys whose .env values differ from .env.example (to preserve).
|
||||
diffs: Keys whose .env values differ from .env.all (to preserve).
|
||||
"""
|
||||
log_info("Starting partial synchronization of .env file...")
|
||||
|
||||
example_file = work_dir / ".env.example"
|
||||
example_file = work_dir / ".env.all"
|
||||
new_env_file = work_dir / ".env.new"
|
||||
|
||||
# Keys whose current .env value should override the example default
|
||||
@ -350,24 +350,24 @@ def sync_env_file(work_dir: Path, env_vars: dict[str, str], diffs: dict[str, tup
|
||||
log_success("Successfully created new .env file")
|
||||
log_success("Partial synchronization of .env file completed")
|
||||
log_info(f" Preserved .env values: {preserved_count}")
|
||||
log_info(f" Updated to .env.example values: {updated_count}")
|
||||
log_info(f" Updated to .env.all values: {updated_count}")
|
||||
|
||||
|
||||
def show_statistics(work_dir: Path) -> None:
|
||||
"""Print a summary of variable counts from both env files.
|
||||
|
||||
Args:
|
||||
work_dir: Directory containing .env and .env.example.
|
||||
work_dir: Directory containing .env and .env.all.
|
||||
"""
|
||||
log_info("Synchronization statistics:")
|
||||
|
||||
example_file = work_dir / ".env.example"
|
||||
example_file = work_dir / ".env.all"
|
||||
env_file = work_dir / ".env"
|
||||
|
||||
example_count = len(parse_env_file(example_file)) if example_file.exists() else 0
|
||||
env_count = len(parse_env_file(env_file)) if env_file.exists() else 0
|
||||
|
||||
log_info(f" .env.example environment variables: {example_count}")
|
||||
log_info(f" .env.all environment variables: {example_count}")
|
||||
log_info(f" .env environment variables: {env_count}")
|
||||
|
||||
|
||||
@ -380,7 +380,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="dify-env-sync",
|
||||
description=(
|
||||
"Synchronize .env with .env.example: add new variables, "
|
||||
"Synchronize .env with .env.all: add new variables, "
|
||||
"preserve custom values, and report removed variables."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
@ -396,7 +396,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
"--dir",
|
||||
metavar="DIRECTORY",
|
||||
default=".",
|
||||
help="Working directory containing .env and .env.example (default: current directory)",
|
||||
help="Working directory containing .env and .env.all (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-backup",
|
||||
@ -427,7 +427,7 @@ def main() -> None:
|
||||
|
||||
# 3. Parse both files
|
||||
env_vars = parse_env_file(work_dir / ".env")
|
||||
example_vars = parse_env_file(work_dir / ".env.example")
|
||||
example_vars = parse_env_file(work_dir / ".env.all")
|
||||
|
||||
# 4. Report differences (values that changed in the example)
|
||||
diffs = detect_differences(env_vars, example_vars)
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
# Dify Environment Variables Synchronization Script
|
||||
#
|
||||
# Features:
|
||||
# - Synchronize latest settings from .env.example to .env
|
||||
# - Synchronize latest settings from .env.all to .env
|
||||
# - Preserve custom settings in existing .env
|
||||
# - Add new environment variables
|
||||
# - Detect removed environment variables
|
||||
@ -61,18 +61,18 @@ log_error() {
|
||||
}
|
||||
|
||||
# Check for required files and create .env if missing
|
||||
# Verifies that .env.example exists and creates .env from template if needed
|
||||
# Verifies that .env.all exists and creates .env from template if needed
|
||||
check_files() {
|
||||
log_info "Checking required files..."
|
||||
|
||||
if [[ ! -f ".env.example" ]]; then
|
||||
log_error ".env.example file not found"
|
||||
if [[ ! -f ".env.all" ]]; then
|
||||
log_error ".env.all file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f ".env" ]]; then
|
||||
log_warning ".env file does not exist. Creating from .env.example."
|
||||
cp ".env.example" ".env"
|
||||
log_warning ".env file does not exist. Creating from .env.all."
|
||||
cp ".env.all" ".env"
|
||||
log_success ".env file created"
|
||||
fi
|
||||
|
||||
@ -98,9 +98,9 @@ create_backup() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect differences between .env and .env.example (optimized for large files)
|
||||
# Detect differences between .env and .env.all (optimized for large files)
|
||||
detect_differences() {
|
||||
log_info "Detecting differences between .env and .env.example..."
|
||||
log_info "Detecting differences between .env and .env.all..."
|
||||
|
||||
# Create secure temporary directory
|
||||
local temp_dir=$(mktemp -d)
|
||||
@ -140,7 +140,7 @@ detect_differences() {
|
||||
}
|
||||
}
|
||||
END { print diff_count }
|
||||
' .env .env.example)
|
||||
' .env .env.all)
|
||||
|
||||
if [[ $diff_count -gt 0 ]]; then
|
||||
log_success "Detected differences in $diff_count environment variables"
|
||||
@ -201,7 +201,7 @@ show_differences_detail() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}[$count] $key${NC}"
|
||||
echo -e " ${GREEN}.env (current)${NC} : ${env_value}"
|
||||
echo -e " ${BLUE}.env.example (recommended)${NC}: ${example_value}"
|
||||
echo -e " ${BLUE}.env.all (recommended)${NC}: ${example_value}"
|
||||
|
||||
# Analyze value changes
|
||||
analyze_value_change "$env_value" "$example_value"
|
||||
@ -261,8 +261,8 @@ analyze_value_change() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Synchronize .env file with .env.example while preserving custom values
|
||||
# Creates a new .env file based on .env.example structure, preserving existing custom values
|
||||
# Synchronize .env file with .env.all while preserving custom values
|
||||
# Creates a new .env file based on .env.all structure, preserving existing custom values
|
||||
# Global variables used: DIFF_FILE, TEMP_DIR
|
||||
sync_env_file() {
|
||||
log_info "Starting partial synchronization of .env file..."
|
||||
@ -281,7 +281,7 @@ sync_env_file() {
|
||||
fi
|
||||
|
||||
# Use AWK for efficient processing (much faster than bash loop for large files)
|
||||
log_info "Processing $(wc -l < .env.example) lines with AWK..."
|
||||
log_info "Processing $(wc -l < .env.all) lines with AWK..."
|
||||
|
||||
local preserved_keys_file="${TEMP_DIR}/preserved_keys"
|
||||
local awk_preserved_count_file="${TEMP_DIR}/awk_preserved_count"
|
||||
@ -332,7 +332,7 @@ sync_env_file() {
|
||||
print preserved_count > preserved_count_file
|
||||
print updated_count > updated_count_file
|
||||
}
|
||||
' .env.example > "$new_env_file"
|
||||
' .env.all > "$new_env_file"
|
||||
|
||||
# Read counters and preserved keys
|
||||
if [[ -f "$awk_preserved_count_file" ]]; then
|
||||
@ -372,7 +372,7 @@ sync_env_file() {
|
||||
|
||||
log_success "Partial synchronization of .env file completed"
|
||||
log_info " Preserved .env values: $preserved_count"
|
||||
log_info " Updated to .env.example values: $updated_count"
|
||||
log_info " Updated to .env.all values: $updated_count"
|
||||
}
|
||||
|
||||
# Detect removed environment variables
|
||||
@ -394,8 +394,8 @@ detect_removed_variables() {
|
||||
cleanup_temp_dir="$temp_dir"
|
||||
fi
|
||||
|
||||
# Get keys from .env.example and .env, sorted for comm
|
||||
awk -F= '!/^[[:space:]]*#/ && /=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1}' .env.example | sort > "$temp_example_keys"
|
||||
# Get keys from .env.all and .env, sorted for comm
|
||||
awk -F= '!/^[[:space:]]*#/ && /=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1}' .env.all | sort > "$temp_example_keys"
|
||||
awk -F= '!/^[[:space:]]*#/ && /=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1}' .env | sort > "$temp_current_keys"
|
||||
|
||||
# Get keys from existing .env and check for removals
|
||||
@ -410,7 +410,7 @@ detect_removed_variables() {
|
||||
fi
|
||||
|
||||
if [[ ${#removed_vars[@]} -gt 0 ]]; then
|
||||
log_warning "The following environment variables have been removed from .env.example:"
|
||||
log_warning "The following environment variables have been removed from .env.all:"
|
||||
for var in "${removed_vars[@]}"; do
|
||||
log_warning " - $var"
|
||||
done
|
||||
@ -424,10 +424,10 @@ detect_removed_variables() {
|
||||
show_statistics() {
|
||||
log_info "Synchronization statistics:"
|
||||
|
||||
local total_example=$(grep -c "^[^#]*=" .env.example 2>/dev/null || echo "0")
|
||||
local total_example=$(grep -c "^[^#]*=" .env.all 2>/dev/null || echo "0")
|
||||
local total_env=$(grep -c "^[^#]*=" .env 2>/dev/null || echo "0")
|
||||
|
||||
log_info " .env.example environment variables: $total_example"
|
||||
log_info " .env.all environment variables: $total_example"
|
||||
log_info " .env environment variables: $total_env"
|
||||
}
|
||||
|
||||
|
||||
@ -1,202 +1,4 @@
|
||||
# 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
|
||||
|
||||
x-shared-env: &shared-api-worker-env
|
||||
services:
|
||||
# Init container to fix permissions
|
||||
init_permissions:
|
||||
@ -219,9 +21,12 @@ 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}
|
||||
@ -264,9 +69,12 @@ 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}
|
||||
@ -307,9 +115,12 @@ 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:
|
||||
@ -343,12 +154,6 @@ 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:-}
|
||||
@ -423,7 +228,7 @@ services:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-dify}
|
||||
command: >
|
||||
--max_connections=${MYSQL_MAX_CONNECTIONS:-1000}
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
|
||||
--innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M}
|
||||
--innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2}
|
||||
@ -465,12 +270,6 @@ 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.
|
||||
@ -495,24 +294,9 @@ 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}
|
||||
|
||||
@ -51,7 +51,7 @@ services:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-dify}
|
||||
command: >
|
||||
--max_connections=${MYSQL_MAX_CONNECTIONS:-1000}
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
|
||||
--innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M}
|
||||
--innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,13 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,23 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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=
|
||||
@ -1,17 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,469 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,30 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,8 +0,0 @@
|
||||
# ------------------------------
|
||||
# Worker Beat Configuration
|
||||
# ------------------------------
|
||||
|
||||
MODE=beat
|
||||
COMPOSE_WORKER_HEALTHCHECK_DISABLED=true
|
||||
COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s
|
||||
COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s
|
||||
@ -1,13 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,9 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,26 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,35 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,7 +0,0 @@
|
||||
# ------------------------------
|
||||
# Certbot Configuration
|
||||
# ------------------------------
|
||||
|
||||
CERTBOT_EMAIL=your_email@example.com
|
||||
CERTBOT_DOMAIN=your_domain.com
|
||||
CERTBOT_OPTIONS=
|
||||
@ -1,4 +0,0 @@
|
||||
# ------------------------------
|
||||
# Etcd Configuration
|
||||
# ------------------------------
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
# ------------------------------
|
||||
# Milvus Standalone Configuration
|
||||
# ------------------------------
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
# ------------------------------
|
||||
# Minio Configuration
|
||||
# ------------------------------
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,17 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,40 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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=
|
||||
@ -1,13 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,9 +0,0 @@
|
||||
# ------------------------------
|
||||
# Couchbase Configuration
|
||||
# ------------------------------
|
||||
|
||||
COUCHBASE_PASSWORD=password
|
||||
COUCHBASE_BUCKET_NAME=Embeddings
|
||||
COUCHBASE_SCOPE_NAME=_default
|
||||
COUCHBASE_CONNECTION_STRING=couchbase://couchbase-server
|
||||
COUCHBASE_USER=Administrator
|
||||
@ -1,17 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,17 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,9 +0,0 @@
|
||||
# ------------------------------
|
||||
# Matrixone Configuration
|
||||
# ------------------------------
|
||||
|
||||
MATRIXONE_PASSWORD=111
|
||||
MATRIXONE_HOST=matrixone
|
||||
MATRIXONE_PORT=6001
|
||||
MATRIXONE_USER=dump
|
||||
MATRIXONE_DATABASE=dify
|
||||
@ -1,13 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,10 +0,0 @@
|
||||
# ------------------------------
|
||||
# Myscale Configuration
|
||||
# ------------------------------
|
||||
|
||||
MYSCALE_PASSWORD=
|
||||
MYSCALE_DATABASE=dify
|
||||
MYSCALE_FTS_PARAMS=
|
||||
MYSCALE_HOST=myscale
|
||||
MYSCALE_PORT=8123
|
||||
MYSCALE_USER=default
|
||||
@ -1,6 +0,0 @@
|
||||
# ------------------------------
|
||||
# Oceanbase Configuration
|
||||
# ------------------------------
|
||||
|
||||
OCEANBASE_CLUSTER_NAME=difyai
|
||||
OCEANBASE_MEMORY_LIMIT=6G
|
||||
@ -1,12 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,22 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,13 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,9 +0,0 @@
|
||||
# ------------------------------
|
||||
# Pgvecto Rs Configuration
|
||||
# ------------------------------
|
||||
|
||||
PGVECTO_RS_HOST=pgvecto-rs
|
||||
PGVECTO_RS_PORT=5432
|
||||
PGVECTO_RS_USER=postgres
|
||||
PGVECTO_RS_PASSWORD=difyai123456
|
||||
PGVECTO_RS_DATABASE=dify
|
||||
@ -1,8 +0,0 @@
|
||||
# ------------------------------
|
||||
# Pgvector Configuration
|
||||
# ------------------------------
|
||||
|
||||
PGVECTOR_PGUSER=postgres
|
||||
PGVECTOR_POSTGRES_PASSWORD=difyai123456
|
||||
PGVECTOR_POSTGRES_DB=dify
|
||||
PGVECTOR_PGDATA=/var/lib/postgresql/data/pgdata
|
||||
@ -1,4 +0,0 @@
|
||||
# ------------------------------
|
||||
# Qdrant Configuration
|
||||
# ------------------------------
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
# ------------------------------
|
||||
# Seekdb Configuration
|
||||
# ------------------------------
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -1,18 +0,0 @@
|
||||
# ------------------------------
|
||||
# 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
|
||||
@ -18,9 +18,9 @@ SHARED_ENV_EXCLUDE = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def parse_env_example(file_path):
|
||||
def parse_env_all(file_path):
|
||||
"""
|
||||
Parses the .env.example file and returns a dictionary with variable names as keys and default values as values.
|
||||
Parses the .env.all file and returns a dictionary with variable names as keys and default values as values.
|
||||
"""
|
||||
env_vars = {}
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
@ -53,6 +53,11 @@ 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}:-}}")
|
||||
@ -64,61 +69,25 @@ def generate_shared_env_block(env_vars, anchor_name="shared-api-worker-env"):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_env_files_from_example(env_example_path):
|
||||
def insert_shared_env(template_path, output_path, shared_env_block, header_comments):
|
||||
"""
|
||||
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.
|
||||
Inserts the shared environment variables block and header comments into the template file,
|
||||
removing any existing x-shared-env anchors, and generates the final docker-compose.yaml file.
|
||||
Always writes with LF line endings.
|
||||
"""
|
||||
with open(template_path, "r", encoding="utf-8") as f:
|
||||
template_content = f.read()
|
||||
|
||||
# Prepare the final content with header comments
|
||||
final_content = f"{header_comments}\n{template_content}"
|
||||
# Remove existing x-shared-env: &shared-api-worker-env lines
|
||||
template_content = re.sub(
|
||||
r"^x-shared-env: &shared-api-worker-env\s*\n?",
|
||||
"",
|
||||
template_content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# Prepare the final content with header comments and shared env block
|
||||
final_content = f"{header_comments}\n{shared_env_block}\n\n{template_content}"
|
||||
|
||||
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(final_content)
|
||||
@ -126,34 +95,37 @@ def insert_shared_env(template_path, output_path, header_comments):
|
||||
|
||||
|
||||
def main():
|
||||
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")
|
||||
env_all_path = ".env.all"
|
||||
template_path = "docker-compose-template.yaml"
|
||||
output_path = "docker-compose.yaml"
|
||||
anchor_name = "shared-api-worker-env" # Can be modified as needed
|
||||
|
||||
# Define header comments to be added at the top of docker-compose.yaml
|
||||
header_comments = (
|
||||
"# ==================================================================\n"
|
||||
"# WARNING: This file is auto-generated by generate_docker_compose\n"
|
||||
"# Do not modify this file directly. Instead, update the .env.example\n"
|
||||
"# Do not modify this file directly. Instead, update the .env.all\n"
|
||||
"# or docker-compose-template.yaml and regenerate this file.\n"
|
||||
"# ==================================================================\n"
|
||||
)
|
||||
|
||||
# Check if required files exist
|
||||
for path in [env_example_path, template_path]:
|
||||
for path in [env_all_path, template_path]:
|
||||
if not os.path.isfile(path):
|
||||
print(f"Error: File {path} does not exist.")
|
||||
sys.exit(1)
|
||||
|
||||
# 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)
|
||||
# Parse .env.all file
|
||||
env_vars = parse_env_all(env_all_path)
|
||||
|
||||
# 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 not env_vars:
|
||||
print("Warning: No environment variables found in .env.all.")
|
||||
|
||||
# Generate shared environment variables block
|
||||
shared_env_block = generate_shared_env_block(env_vars, anchor_name)
|
||||
|
||||
# Insert shared environment variables block and header comments into the template
|
||||
insert_shared_env(template_path, output_path, shared_env_block, header_comments)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
101
docker/init-env.ps1
Normal file
101
docker/init-env.ps1
Normal file
@ -0,0 +1,101 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $ScriptDir
|
||||
|
||||
$EnvExampleFile = ".env.example"
|
||||
$EnvFile = ".env"
|
||||
|
||||
function New-SecretKey {
|
||||
$bytes = New-Object byte[] 42
|
||||
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
|
||||
[Convert]::ToBase64String($bytes)
|
||||
}
|
||||
|
||||
function Get-EnvValue {
|
||||
param([string]$Key)
|
||||
|
||||
if (-not (Test-Path $EnvFile)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$result = ""
|
||||
foreach ($line in Get-Content $EnvFile) {
|
||||
if ($line -match "^\s*#" -or $line -notmatch "=") {
|
||||
continue
|
||||
}
|
||||
|
||||
$parts = $line.Split("=", 2)
|
||||
if ($parts[0].Trim() -eq $Key) {
|
||||
$value = $parts[1].Trim()
|
||||
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
$result = $value
|
||||
}
|
||||
}
|
||||
|
||||
$result
|
||||
}
|
||||
|
||||
function Set-EnvValue {
|
||||
param(
|
||||
[string]$Key,
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
$output = New-Object System.Collections.Generic.List[string]
|
||||
$replaced = $false
|
||||
|
||||
if (Test-Path $EnvFile) {
|
||||
foreach ($line in Get-Content $EnvFile) {
|
||||
if ($line -match "^\s*#" -or $line -notmatch "=") {
|
||||
$output.Add($line)
|
||||
continue
|
||||
}
|
||||
|
||||
$parts = $line.Split("=", 2)
|
||||
if ($parts[0].Trim() -eq $Key) {
|
||||
if (-not $replaced) {
|
||||
$output.Add("$Key=$Value")
|
||||
$replaced = $true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
$output.Add($line)
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $replaced) {
|
||||
$output.Add("$Key=$Value")
|
||||
}
|
||||
|
||||
$fullPath = Join-Path $ScriptDir $EnvFile
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::WriteAllLines($fullPath, [string[]]$output, $utf8NoBom)
|
||||
}
|
||||
|
||||
if (Test-Path $EnvFile) {
|
||||
Write-Output "Using existing $EnvFile."
|
||||
}
|
||||
else {
|
||||
if (-not (Test-Path $EnvExampleFile)) {
|
||||
Write-Error "$EnvExampleFile is missing."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Copy-Item $EnvExampleFile $EnvFile
|
||||
Write-Output "Created $EnvFile from $EnvExampleFile."
|
||||
}
|
||||
|
||||
$currentSecretKey = Get-EnvValue "SECRET_KEY"
|
||||
if ($currentSecretKey) {
|
||||
Write-Output "SECRET_KEY already exists in $EnvFile."
|
||||
}
|
||||
else {
|
||||
Set-EnvValue "SECRET_KEY" (New-SecretKey)
|
||||
Write-Output "Generated SECRET_KEY in $EnvFile."
|
||||
}
|
||||
|
||||
Write-Output "Environment is ready. Run docker compose up -d to start Dify."
|
||||
117
docker/init-env.sh
Executable file
117
docker/init-env.sh
Executable file
@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
ENV_EXAMPLE_FILE=".env.example"
|
||||
ENV_FILE=".env"
|
||||
|
||||
log() {
|
||||
printf '%s\n' "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'Error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
generate_secret_key() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -base64 42
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v dd >/dev/null 2>&1 && command -v base64 >/dev/null 2>&1; then
|
||||
dd if=/dev/urandom bs=42 count=1 2>/dev/null | base64 | tr -d '\n'
|
||||
printf '\n'
|
||||
return
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
env_value() {
|
||||
local key="$1"
|
||||
awk -F= -v target="$key" '
|
||||
/^[[:space:]]*#/ || !/=/{ next }
|
||||
{
|
||||
key = $1
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", key)
|
||||
if (key == target) {
|
||||
value = substr($0, index($0, "=") + 1)
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||
if ((value ~ /^".*"$/) || (value ~ /^'\''.*'\''$/)) {
|
||||
value = substr(value, 2, length(value) - 2)
|
||||
}
|
||||
result = value
|
||||
}
|
||||
}
|
||||
END { print result }
|
||||
' "$ENV_FILE"
|
||||
}
|
||||
|
||||
set_env_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local temp_file
|
||||
|
||||
temp_file="$(mktemp "${TMPDIR:-/tmp}/dify-env.XXXXXX")"
|
||||
if awk -F= -v target="$key" -v replacement="$key=$value" '
|
||||
BEGIN { replaced = 0 }
|
||||
/^[[:space:]]*#/ || !/=/{ print; next }
|
||||
{
|
||||
key = $1
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", key)
|
||||
if (key == target) {
|
||||
if (!replaced) {
|
||||
print replacement
|
||||
replaced = 1
|
||||
}
|
||||
next
|
||||
}
|
||||
print
|
||||
}
|
||||
END {
|
||||
if (!replaced) {
|
||||
print replacement
|
||||
}
|
||||
}
|
||||
' "$ENV_FILE" >"$temp_file"; then
|
||||
mv "$temp_file" "$ENV_FILE"
|
||||
else
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_env_file() {
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
log "Using existing $ENV_FILE."
|
||||
return
|
||||
fi
|
||||
|
||||
[[ -f "$ENV_EXAMPLE_FILE" ]] || die "$ENV_EXAMPLE_FILE is missing."
|
||||
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
|
||||
log "Created $ENV_FILE from $ENV_EXAMPLE_FILE."
|
||||
}
|
||||
|
||||
ensure_secret_key() {
|
||||
local current_secret_key
|
||||
local secret_key
|
||||
|
||||
current_secret_key="$(env_value SECRET_KEY)"
|
||||
if [[ -n "$current_secret_key" ]]; then
|
||||
log "SECRET_KEY already exists in $ENV_FILE."
|
||||
return
|
||||
fi
|
||||
|
||||
secret_key="$(generate_secret_key)" || die "Unable to generate SECRET_KEY. Install openssl or set SECRET_KEY in $ENV_FILE."
|
||||
set_env_value SECRET_KEY "$secret_key"
|
||||
log "Generated SECRET_KEY in $ENV_FILE."
|
||||
}
|
||||
|
||||
ensure_env_file
|
||||
ensure_secret_key
|
||||
log "Environment is ready. Run docker compose up -d to start Dify."
|
||||
@ -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, 'envs', 'middleware.env.example')
|
||||
export const middlewareEnvExampleFile = path.join(dockerDir, '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')
|
||||
|
||||
@ -39,9 +39,7 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const getSigninUrl = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('message')
|
||||
const query = params.toString()
|
||||
const fullPath = query ? `${pathname}?${query}` : pathname
|
||||
params.set('redirect_url', fullPath)
|
||||
params.set('redirect_url', pathname)
|
||||
return `/webapp-signin?${params.toString()}`
|
||||
}, [searchParams, pathname])
|
||||
|
||||
|
||||
@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
|
||||
<ContentDialog
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
className="absolute top-2 bottom-2 left-2 flex w-[452px] max-w-[calc(100vw-1rem)] flex-col rounded-2xl p-0!"
|
||||
className="absolute top-2 bottom-2 left-2 flex w-[420px] flex-col rounded-2xl p-0!"
|
||||
>
|
||||
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
|
||||
<div className="flex items-center gap-3 self-stretch">
|
||||
|
||||
@ -20,7 +20,6 @@ const mockOpenAsyncWindow = vi.fn()
|
||||
const mockFetchInstalledAppList = vi.fn()
|
||||
const mockFetchAppDetailDirect = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockWindowOpen = vi.fn()
|
||||
const mockInvalidateAppWorkflow = vi.fn()
|
||||
|
||||
const sectionProps = vi.hoisted(() => ({
|
||||
@ -38,7 +37,6 @@ vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null,
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
@ -169,12 +167,6 @@ vi.mock('../sections', () => ({
|
||||
<div>
|
||||
<button onClick={props.handleEmbed}>publisher-embed</button>
|
||||
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
|
||||
{props.handleOpenRunConfig && (
|
||||
<>
|
||||
<button onClick={() => props.handleOpenRunConfig(props.appURL)}>publisher-run-config</button>
|
||||
<button onClick={() => props.handleOpenRunConfig(`${props.appURL}?mode=batch`)}>publisher-batch-run-config</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={props.onConfigureWorkflowTool}>publisher-workflow-tool</button>
|
||||
</div>
|
||||
)
|
||||
@ -208,10 +200,6 @@ describe('AppPublisher', () => {
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
|
||||
await resolver()
|
||||
})
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the publish popover and refetch access permission data', async () => {
|
||||
@ -268,75 +256,6 @@ describe('AppPublisher', () => {
|
||||
expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collect hidden inputs before opening published run links from config actions', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
inputs={[{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
hide: true,
|
||||
default: '',
|
||||
} as any]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-run-config'))
|
||||
|
||||
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Secret'), {
|
||||
target: { value: 'top-secret' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/chat/token-1?secret=${encodeURIComponent('top-secret')}`,
|
||||
'_blank',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should open batch run config links with the configured hidden inputs', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
inputs={[{
|
||||
variable: 'batch_secret',
|
||||
label: 'Batch Secret',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
hide: true,
|
||||
default: '',
|
||||
} as any]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-batch-run-config'))
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Batch Secret'), {
|
||||
target: { value: 'batch-value' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/workflow/token-1?mode=batch&batch_secret=${encodeURIComponent('batch-value')}`,
|
||||
'_blank',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep workflow tool drawer mounted after closing the publish popover', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
|
||||
@ -18,32 +18,8 @@ vi.mock('../publish-with-multiple-model', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../suggested-action', () => ({
|
||||
default: ({
|
||||
children,
|
||||
onClick,
|
||||
link,
|
||||
disabled,
|
||||
actionButton,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
link?: string
|
||||
disabled?: boolean
|
||||
actionButton?: { ariaLabel: string, onClick: () => void }
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>{children}</button>
|
||||
{actionButton && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={actionButton.ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={actionButton.onClick}
|
||||
>
|
||||
{actionButton.ariaLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => (
|
||||
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>{children}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -194,25 +170,9 @@ describe('app-publisher sections', () => {
|
||||
expect(render(<AccessModeDisplay />).container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should hide access control content when enabled is false', () => {
|
||||
render(
|
||||
<PublisherAccessSection
|
||||
enabled={false}
|
||||
isAppAccessSet
|
||||
isLoading={false}
|
||||
accessMode={AccessMode.PUBLIC}
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('publishApp.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('accessControlDialog.accessItems.anyone')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow actions, batch run links, and workflow tool configuration', () => {
|
||||
const handleOpenInExplore = vi.fn()
|
||||
const handleEmbed = vi.fn()
|
||||
const handleOpenRunConfig = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<PublisherActionsSection
|
||||
@ -230,15 +190,10 @@ 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}
|
||||
@ -250,10 +205,6 @@ 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()
|
||||
@ -271,12 +222,9 @@ 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
|
||||
@ -298,12 +246,9 @@ 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
|
||||
|
||||
@ -46,47 +46,4 @@ describe('SuggestedAction', () => {
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render and trigger the trailing action button when configured', () => {
|
||||
const handleActionClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/docs"
|
||||
actionButton={{
|
||||
ariaLabel: 'Configure action',
|
||||
icon: <span>config</span>,
|
||||
onClick: handleActionClick,
|
||||
}}
|
||||
>
|
||||
Configurable action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(handleActionClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should block action button clicks when disabled', () => {
|
||||
const handleActionClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction
|
||||
link="https://example.com/docs"
|
||||
disabled
|
||||
actionButton={{
|
||||
ariaLabel: 'Configure action',
|
||||
icon: <span>config</span>,
|
||||
onClick: handleActionClick,
|
||||
}}
|
||||
>
|
||||
Disabled with action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
|
||||
expect(handleActionClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
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'
|
||||
@ -10,7 +8,6 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
|
||||
memo,
|
||||
use,
|
||||
useCallback,
|
||||
@ -19,13 +16,6 @@ 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'
|
||||
@ -121,9 +111,6 @@ 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)
|
||||
@ -135,22 +122,6 @@ const AppPublisher = ({
|
||||
|
||||
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
|
||||
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
|
||||
const hiddenLaunchVariables = useMemo<WorkflowHiddenStartVariable[]>(
|
||||
() => (inputs ?? []).filter(input => input.hide === true),
|
||||
[inputs],
|
||||
)
|
||||
const supportedWorkflowLaunchVariables = useMemo(
|
||||
() => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported),
|
||||
[hiddenLaunchVariables],
|
||||
)
|
||||
const unsupportedWorkflowLaunchVariables = useMemo(
|
||||
() => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)),
|
||||
[hiddenLaunchVariables],
|
||||
)
|
||||
const initialWorkflowLaunchValues = useMemo(
|
||||
() => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables),
|
||||
[supportedWorkflowLaunchVariables],
|
||||
)
|
||||
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
@ -260,31 +231,6 @@ const AppPublisher = ({
|
||||
}
|
||||
}, [appDetail, setAppDetail])
|
||||
|
||||
const handleOpenWorkflowLaunchDialog = useCallback((targetUrl: string) => {
|
||||
setWorkflowLaunchValues(initialWorkflowLaunchValues)
|
||||
setWorkflowLaunchTargetUrl(targetUrl)
|
||||
setWorkflowLaunchDialogOpen(true)
|
||||
}, [initialWorkflowLaunchValues])
|
||||
|
||||
const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => {
|
||||
setWorkflowLaunchValues(prev => ({
|
||||
...prev,
|
||||
[variable]: value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleWorkflowLaunchConfirm = useCallback(async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
const targetUrl = await buildWorkflowLaunchUrl({
|
||||
accessibleUrl: workflowLaunchTargetUrl,
|
||||
variables: supportedWorkflowLaunchVariables,
|
||||
values: workflowLaunchValues,
|
||||
})
|
||||
|
||||
window.open(targetUrl, '_blank')
|
||||
setWorkflowLaunchDialogOpen(false)
|
||||
}, [supportedWorkflowLaunchVariables, workflowLaunchTargetUrl, workflowLaunchValues])
|
||||
const handlePublishToMarketplace = useCallback(async () => {
|
||||
if (!appDetail?.id || publishingToMarketplace)
|
||||
return
|
||||
@ -431,15 +377,10 @@ 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}
|
||||
@ -469,19 +410,8 @@ 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
|
||||
|
||||
@ -8,7 +8,6 @@ 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'
|
||||
@ -63,11 +62,6 @@ 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
|
||||
@ -259,13 +253,10 @@ export const PublisherActionsSection = ({
|
||||
disabledFunctionTooltip,
|
||||
handleEmbed,
|
||||
handleOpenInExplore,
|
||||
handleOpenRunConfig,
|
||||
hasHumanInputNode = false,
|
||||
hasTriggerNode = false,
|
||||
missingStartNode = false,
|
||||
publishedAt,
|
||||
showBatchRunConfig = false,
|
||||
showRunConfig = false,
|
||||
toolPublished,
|
||||
workflowToolAvailable = true,
|
||||
workflowToolIsLoading,
|
||||
@ -289,13 +280,6 @@ export const PublisherActionsSection = ({
|
||||
disabled={disabledFunctionButton}
|
||||
link={appURL}
|
||||
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
|
||||
actionButton={showRunConfig
|
||||
? {
|
||||
ariaLabel: t('operation.config', { ns: 'common' }),
|
||||
icon: <RiSettings2Line className="h-4 w-4" />,
|
||||
onClick: () => handleOpenRunConfig?.(appURL),
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{t('common.runApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
@ -308,13 +292,6 @@ export const PublisherActionsSection = ({
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
|
||||
actionButton={showBatchRunConfig
|
||||
? {
|
||||
ariaLabel: t('operation.config', { ns: 'common' }),
|
||||
icon: <RiSettings2Line className="h-4 w-4" />,
|
||||
onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`),
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{t('common.batchRunApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
|
||||
@ -1,93 +1,33 @@
|
||||
import type { HTMLProps, PropsWithChildren, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import type { HTMLProps, PropsWithChildren } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
|
||||
type SuggestedActionButton = {
|
||||
ariaLabel: string
|
||||
icon: React.ReactNode
|
||||
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement> & {
|
||||
icon?: React.ReactNode
|
||||
link?: string
|
||||
disabled?: boolean
|
||||
actionButton?: SuggestedActionButton
|
||||
}>
|
||||
|
||||
const SuggestedAction = ({
|
||||
icon,
|
||||
link,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
actionButton,
|
||||
...props
|
||||
}: SuggestedActionProps) => {
|
||||
const handleClick = (event: ReactMouseEvent<HTMLAnchorElement>) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (disabled)
|
||||
return
|
||||
}
|
||||
|
||||
onClick?.(event)
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
const handleActionClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
actionButton?.onClick(event)
|
||||
}
|
||||
|
||||
const mainAction = (
|
||||
return (
|
||||
<a
|
||||
href={disabled ? undefined : link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'flex min-w-0 items-center justify-start gap-2 px-2.5 py-2 text-text-secondary transition-colors',
|
||||
actionButton ? 'flex-1 rounded-l-lg' : 'rounded-lg bg-background-section-burn not-first:mt-1',
|
||||
disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
|
||||
)}
|
||||
className={cn('flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors not-first:mt-1', disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer text-text-secondary hover:bg-state-accent-hover hover:text-text-accent', className)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative h-4 w-4 shrink-0">{icon}</div>
|
||||
<div className="relative h-4 w-4">{icon}</div>
|
||||
<div className="shrink grow basis-0 system-sm-medium">{children}</div>
|
||||
<RiArrowRightUpLine className="h-3.5 w-3.5 shrink-0" />
|
||||
<RiArrowRightUpLine className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)
|
||||
|
||||
if (!actionButton)
|
||||
return mainAction
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-stretch rounded-lg bg-background-section-burn not-first:mt-1',
|
||||
disabled ? 'opacity-30 shadow-xs' : '',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{mainAction}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={actionButton.ariaLabel}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary transition-colors',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
|
||||
)}
|
||||
onClick={handleActionClick}
|
||||
>
|
||||
{actionButton.icon}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SuggestedAction
|
||||
|
||||
@ -4,29 +4,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import ConfigModalFormFields from '../form-fields'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const ns = options?.ns as string | undefined
|
||||
return ns ? `${ns}.${key}` : key
|
||||
},
|
||||
i18n: { language: 'en', changeLanguage: vi.fn() },
|
||||
}),
|
||||
Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, ReactNode> }) => (
|
||||
<span data-i18n-key={i18nKey}>
|
||||
{i18nKey}
|
||||
{components?.docLink}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path?: string) => `https://docs.example.com${path || ''}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: ({
|
||||
onChange,
|
||||
@ -97,12 +74,6 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../field', () => ({
|
||||
default: ({ children, title }: { children: ReactNode, title: string }) => (
|
||||
<div>
|
||||
@ -205,18 +176,7 @@ describe('ConfigModalFormFields', () => {
|
||||
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta')
|
||||
})
|
||||
|
||||
it('should wire file, json schema, and visibility controls', async () => {
|
||||
const textInputProps = createBaseProps()
|
||||
const textInputView = render(<ConfigModalFormFields {...textInputProps} />)
|
||||
expect(screen.getByText('variableConfig.hidden')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'variableConfig.hiddenDescription' }))
|
||||
expect(await screen.findByText('variableConfig.hiddenDescription')).toBeInTheDocument()
|
||||
const docLink = await screen.findByRole('link')
|
||||
expect(docLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/nodes/user-input#hide-and-pre-fill-input-fields')
|
||||
expect(docLink).toHaveAttribute('target', '_blank')
|
||||
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
textInputView.unmount()
|
||||
|
||||
it('should wire file, json schema, and visibility controls', () => {
|
||||
const singleFileProps = createBaseProps()
|
||||
singleFileProps.tempPayload = {
|
||||
...singleFileProps.tempPayload,
|
||||
@ -225,20 +185,18 @@ describe('ConfigModalFormFields', () => {
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_upload_methods: ['remote_url'],
|
||||
}
|
||||
const singleFileView = render(<ConfigModalFormFields {...singleFileProps} />)
|
||||
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument()
|
||||
render(<ConfigModalFormFields {...singleFileProps} />)
|
||||
fireEvent.click(screen.getByText('single-file-setting'))
|
||||
fireEvent.click(screen.getByText('upload-file'))
|
||||
fireEvent.click(screen.getAllByText('unchecked')[0]!)
|
||||
fireEvent.click(screen.getAllByText('unchecked')[1]!)
|
||||
|
||||
expect(singleFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 1 })
|
||||
expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileId: 'file-1',
|
||||
}))
|
||||
expect(singleFileProps.payloadChangeHandlers.required).toHaveBeenCalledWith(true)
|
||||
expect(singleFileProps.payloadChangeHandlers.hide).not.toHaveBeenCalled()
|
||||
singleFileView.unmount()
|
||||
expect(singleFileProps.payloadChangeHandlers.hide).toHaveBeenCalledWith(true)
|
||||
|
||||
const multiFileProps = createBaseProps()
|
||||
multiFileProps.tempPayload = {
|
||||
@ -249,9 +207,8 @@ describe('ConfigModalFormFields', () => {
|
||||
allowed_file_upload_methods: ['remote_url'],
|
||||
}
|
||||
render(<ConfigModalFormFields {...multiFileProps} />)
|
||||
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('multi-file-setting'))
|
||||
fireEvent.click(screen.getAllByText('upload-file')[0]!)
|
||||
fireEvent.click(screen.getAllByText('upload-file')[1]!)
|
||||
expect(multiFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 3 })
|
||||
expect(multiFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ fileId: 'file-1' }),
|
||||
@ -410,23 +367,4 @@ describe('ConfigModalFormFields', () => {
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(null)
|
||||
})
|
||||
|
||||
it('should disable hide checkbox when required is true and disable required when hide is true', () => {
|
||||
const requiredProps = createBaseProps()
|
||||
requiredProps.tempPayload = { ...requiredProps.tempPayload, type: InputVarType.textInput, required: true, hide: false }
|
||||
const { unmount } = render(<ConfigModalFormFields {...requiredProps} />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const hideButton = buttons.find(btn => btn.textContent === 'unchecked' && btn !== buttons[0])
|
||||
expect(hideButton).toBeDefined()
|
||||
unmount()
|
||||
|
||||
const hideProps = createBaseProps()
|
||||
hideProps.tempPayload = { ...hideProps.tempPayload, type: InputVarType.textInput, required: false, hide: true }
|
||||
render(<ConfigModalFormFields {...hideProps} />)
|
||||
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
const checkedHideButton = allButtons.find(btn => btn.textContent === 'checked')
|
||||
expect(checkedHideButton).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -25,7 +25,6 @@ vi.mock('../form-fields', () => ({
|
||||
return (
|
||||
<div data-testid="config-form-fields">
|
||||
<div data-testid="payload-type">{String(props.tempPayload.type)}</div>
|
||||
<div data-testid="payload-hide">{String(props.tempPayload.hide)}</div>
|
||||
<div data-testid="payload-label">{String(props.tempPayload.label ?? '')}</div>
|
||||
<div data-testid="payload-schema">{String(props.tempPayload.json_schema ?? '')}</div>
|
||||
<div data-testid="payload-default">{String(props.tempPayload.default ?? '')}</div>
|
||||
@ -116,7 +115,7 @@ describe('ConfigModal logic', () => {
|
||||
})
|
||||
|
||||
it('should derive payload fields from mocked form-field callbacks', async () => {
|
||||
renderConfigModal(createPayload({ hide: true }))
|
||||
renderConfigModal()
|
||||
|
||||
fireEvent.click(screen.getByTestId('valid-key-blur'))
|
||||
await waitFor(() => {
|
||||
@ -139,7 +138,6 @@ describe('ConfigModal logic', () => {
|
||||
fireEvent.click(screen.getByTestId('type-change'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('payload-type')).toHaveTextContent(InputVarType.singleFile)
|
||||
expect(screen.getByTestId('payload-hide')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('file-payload-change'))
|
||||
|
||||
@ -49,13 +49,11 @@ describe('config-modal utils', () => {
|
||||
const payload = createInputVar({
|
||||
type: InputVarType.textInput,
|
||||
default: 'hello',
|
||||
hide: true,
|
||||
})
|
||||
|
||||
const nextPayload = createPayloadForType(payload, InputVarType.multiFiles)
|
||||
|
||||
expect(nextPayload.type).toBe(InputVarType.multiFiles)
|
||||
expect(nextPayload.hide).toBe(false)
|
||||
expect(nextPayload.max_length).toBe(DEFAULT_FILE_UPLOAD_SETTING.max_length)
|
||||
expect(nextPayload.allowed_file_types).toEqual(DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types)
|
||||
expect(nextPayload.default).toBe('hello')
|
||||
@ -251,24 +249,6 @@ describe('config-modal utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should force file inputs to stay visible when saving', () => {
|
||||
const result = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.singleFile,
|
||||
hide: true,
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: [],
|
||||
}),
|
||||
payload: createInputVar(),
|
||||
checkVariableName: () => true,
|
||||
t,
|
||||
})
|
||||
|
||||
expect(result.payloadToSave).toEqual(expect.objectContaining({
|
||||
hide: false,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should stop validation when the variable name checker rejects the payload', () => {
|
||||
const result = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
|
||||
@ -13,17 +13,14 @@ import {
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import * as React from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ConfigSelect from '../config-select'
|
||||
import ConfigString from '../config-string'
|
||||
@ -71,9 +68,6 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
t,
|
||||
}) => {
|
||||
const { type, label, variable } = tempPayload
|
||||
const isFileInput = [InputVarType.singleFile, InputVarType.multiFiles].includes(type)
|
||||
const docLink = useDocLink()
|
||||
const hiddenDescriptionAriaLabel = t('variableConfig.hiddenDescription', { ns: 'appDebug' }).replace(/<[^>]+>/g, '')
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@ -111,7 +105,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
{type === InputVarType.textInput && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={typeof tempPayload.default === 'string' ? tempPayload.default : ''}
|
||||
value={tempPayload.default || ''}
|
||||
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
@ -132,7 +126,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
type="number"
|
||||
value={typeof tempPayload.default === 'number' || typeof tempPayload.default === 'string' ? tempPayload.default : ''}
|
||||
value={tempPayload.default || ''}
|
||||
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
@ -192,7 +186,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{isFileInput && (
|
||||
{[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && (
|
||||
<>
|
||||
<FileUploadSetting
|
||||
payload={tempPayload as UploadFileSetting}
|
||||
@ -233,37 +227,14 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
)}
|
||||
|
||||
<div className="mt-5! flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.required} disabled={!isFileInput && tempPayload.hide} onCheck={() => onPayloadChange('required')(!tempPayload.required)} />
|
||||
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => onPayloadChange('required')(!tempPayload.required)} />
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
|
||||
{!isFileInput && (
|
||||
<div className="mt-5! flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => onPayloadChange('hide')(!tempPayload.hide)} />
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.hidden', { ns: 'appDebug' })}</span>
|
||||
<Infotip
|
||||
aria-label={hiddenDescriptionAriaLabel}
|
||||
popupClassName="max-w-[300px]"
|
||||
>
|
||||
<Trans
|
||||
i18nKey="variableConfig.hiddenDescription"
|
||||
ns="appDebug"
|
||||
components={{
|
||||
docLink: (
|
||||
<a
|
||||
href={docLink('/use-dify/nodes/user-input#hide-and-pre-fill-input-fields')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-text-accent hover:underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Infotip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5! flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => onPayloadChange('hide')(!tempPayload.hide)} />
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.hide', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -88,9 +88,7 @@ export const createPayloadForType = (payload: InputVar, type: InputVarType) => {
|
||||
draft.default = undefined
|
||||
|
||||
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
|
||||
draft.hide = false
|
||||
const fileUploadSettingKeys = Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array<keyof typeof DEFAULT_FILE_UPLOAD_SETTING>
|
||||
fileUploadSettingKeys.forEach((key) => {
|
||||
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array<keyof typeof DEFAULT_FILE_UPLOAD_SETTING>).forEach((key) => {
|
||||
if (key !== 'max_length')
|
||||
draft[key] = DEFAULT_FILE_UPLOAD_SETTING[key] as never
|
||||
})
|
||||
@ -160,41 +158,38 @@ export const validateConfigModalPayload = ({
|
||||
checkVariableName,
|
||||
t,
|
||||
}: ValidateConfigModalPayloadOptions): ValidateConfigModalPayloadResult => {
|
||||
const normalizedTempPayload = [InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)
|
||||
? { ...tempPayload, hide: false }
|
||||
: tempPayload
|
||||
const jsonSchemaValue = tempPayload.json_schema
|
||||
const schemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
|
||||
const normalizedJsonSchema = schemaEmpty ? undefined : jsonSchemaValue
|
||||
const payloadToSave = normalizedTempPayload.type === InputVarType.jsonObject && schemaEmpty
|
||||
? { ...normalizedTempPayload, json_schema: undefined }
|
||||
: normalizedTempPayload
|
||||
const payloadToSave = tempPayload.type === InputVarType.jsonObject && schemaEmpty
|
||||
? { ...tempPayload, json_schema: undefined }
|
||||
: tempPayload
|
||||
|
||||
const moreInfo = normalizedTempPayload.variable === payload?.variable
|
||||
const moreInfo = tempPayload.variable === payload?.variable
|
||||
? undefined
|
||||
: {
|
||||
type: ChangeType.changeVarName,
|
||||
payload: { beforeKey: payload?.variable || '', afterKey: normalizedTempPayload.variable },
|
||||
payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable },
|
||||
}
|
||||
|
||||
if (!checkVariableName(normalizedTempPayload.variable))
|
||||
if (!checkVariableName(tempPayload.variable))
|
||||
return {}
|
||||
|
||||
if (!normalizedTempPayload.label) {
|
||||
if (!tempPayload.label) {
|
||||
return {
|
||||
errorMessage: t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }),
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTempPayload.type === InputVarType.select) {
|
||||
if (!normalizedTempPayload.options?.length) {
|
||||
if (tempPayload.type === InputVarType.select) {
|
||||
if (!tempPayload.options?.length) {
|
||||
return {
|
||||
errorMessage: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }),
|
||||
}
|
||||
}
|
||||
|
||||
const duplicated = new Set<string>()
|
||||
const hasRepeatedItem = normalizedTempPayload.options.some((option) => {
|
||||
const hasRepeatedItem = tempPayload.options.some((option) => {
|
||||
if (duplicated.has(option))
|
||||
return true
|
||||
|
||||
@ -209,8 +204,8 @@ export const validateConfigModalPayload = ({
|
||||
}
|
||||
}
|
||||
|
||||
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(normalizedTempPayload.type)) {
|
||||
if (!normalizedTempPayload.allowed_file_types?.length) {
|
||||
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)) {
|
||||
if (!tempPayload.allowed_file_types?.length) {
|
||||
return {
|
||||
errorMessage: t('errorMsg.fieldRequired', {
|
||||
ns: 'workflow',
|
||||
@ -219,7 +214,7 @@ export const validateConfigModalPayload = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !normalizedTempPayload.allowed_file_extensions?.length) {
|
||||
if (tempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
|
||||
return {
|
||||
errorMessage: t('errorMsg.fieldRequired', {
|
||||
ns: 'workflow',
|
||||
@ -229,7 +224,7 @@ export const validateConfigModalPayload = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') {
|
||||
if (tempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') {
|
||||
try {
|
||||
const schema = JSON.parse(normalizedJsonSchema)
|
||||
if (schema?.type !== 'object') {
|
||||
|
||||
@ -1,38 +1,8 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppCardAccessControlSection, AppCardDialogs, AppCardOperations, AppCardUrlSection, createAppCardOperations, WorkflowLaunchDialog } from '../app-card-sections'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../settings', () => ({
|
||||
default: () => <div data-testid="settings-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('../embedded', () => ({
|
||||
default: () => <div data-testid="embedded-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('../customize', () => ({
|
||||
default: () => <div data-testid="customize-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => ({
|
||||
default: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => (
|
||||
<div data-testid="access-control">
|
||||
<button type="button" onClick={onClose}>close-access</button>
|
||||
<button type="button" onClick={onConfirm}>confirm-access</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
import { AppCardAccessControlSection, AppCardOperations, AppCardUrlSection, createAppCardOperations } from '../app-card-sections'
|
||||
|
||||
describe('app-card-sections', () => {
|
||||
const t = (key: string) => key
|
||||
@ -82,7 +52,6 @@ describe('app-card-sections', () => {
|
||||
|
||||
it('should render operation buttons and execute enabled actions', () => {
|
||||
const onLaunch = vi.fn()
|
||||
const onLaunchConfig = vi.fn()
|
||||
const operations = createAppCardOperations({
|
||||
operationKeys: ['launch', 'embedded'],
|
||||
t: t as never,
|
||||
@ -99,19 +68,12 @@ describe('app-card-sections', () => {
|
||||
<AppCardOperations
|
||||
t={t as never}
|
||||
operations={operations}
|
||||
launchConfigAction={{
|
||||
label: 'operation.config',
|
||||
disabled: false,
|
||||
onClick: onLaunchConfig,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.config/i }))
|
||||
|
||||
expect(onLaunch).toHaveBeenCalledTimes(1)
|
||||
expect(onLaunchConfig).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -165,127 +127,4 @@ describe('app-card-sections', () => {
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /operation\.confirm/i }))
|
||||
expect(onRegenerate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable all operations when triggerModeDisabled is true', () => {
|
||||
const operations = createAppCardOperations({
|
||||
operationKeys: ['launch', 'settings'],
|
||||
t: t as never,
|
||||
runningStatus: true,
|
||||
triggerModeDisabled: true,
|
||||
onLaunch: vi.fn(),
|
||||
onEmbedded: vi.fn(),
|
||||
onCustomize: vi.fn(),
|
||||
onSettings: vi.fn(),
|
||||
onDevelop: vi.fn(),
|
||||
})
|
||||
|
||||
expect(operations[0]!.disabled).toBe(true)
|
||||
expect(operations[1]!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should render WorkflowLaunchDialog and submit values', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const onValueChange = vi.fn()
|
||||
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
render(
|
||||
<WorkflowLaunchDialog
|
||||
t={t as never}
|
||||
open
|
||||
hiddenVariables={[{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
}]}
|
||||
unsupportedVariables={[]}
|
||||
values={{ secret: 'hello' }}
|
||||
onOpenChange={onOpenChange}
|
||||
onValueChange={onValueChange}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
|
||||
fireEvent.submit(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }).closest('form')!)
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return null for WorkflowLaunchDialog when no variables are provided', () => {
|
||||
const { container } = render(
|
||||
<WorkflowLaunchDialog
|
||||
t={t as never}
|
||||
open
|
||||
hiddenVariables={[]}
|
||||
unsupportedVariables={[]}
|
||||
values={{}}
|
||||
onOpenChange={vi.fn()}
|
||||
onValueChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render AppCardDialogs with all modals for web apps', () => {
|
||||
const appInfo = {
|
||||
id: 'app-1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
enable_site: true,
|
||||
enable_api: false,
|
||||
site: { app_base_url: 'https://example.com', access_token: 'token-1' },
|
||||
api_base_url: 'https://api.example.com',
|
||||
} as never
|
||||
|
||||
render(
|
||||
<AppCardDialogs
|
||||
isApp
|
||||
appInfo={appInfo}
|
||||
appMode={AppModeEnum.CHAT}
|
||||
showSettingsModal
|
||||
showEmbedded
|
||||
showCustomizeModal
|
||||
showAccessControl
|
||||
appDetail={{ id: 'app-1', access_mode: AccessMode.PUBLIC } as AppDetailResponse}
|
||||
onCloseSettings={vi.fn()}
|
||||
onCloseEmbedded={vi.fn()}
|
||||
onCloseCustomize={vi.fn()}
|
||||
onCloseAccessControl={vi.fn()}
|
||||
onSaveSiteConfig={vi.fn()}
|
||||
onConfirmAccessControl={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('settings-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('embedded-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('customize-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('access-control')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null for AppCardDialogs when not an app', () => {
|
||||
const { container } = render(
|
||||
<AppCardDialogs
|
||||
isApp={false}
|
||||
appInfo={{} as never}
|
||||
appMode={AppModeEnum.CHAT}
|
||||
showSettingsModal={false}
|
||||
showEmbedded={false}
|
||||
showCustomizeModal={false}
|
||||
showAccessControl={false}
|
||||
appDetail={null}
|
||||
onCloseSettings={vi.fn()}
|
||||
onCloseEmbedded={vi.fn()}
|
||||
onCloseCustomize={vi.fn()}
|
||||
onCloseAccessControl={vi.fn()}
|
||||
onSaveSiteConfig={vi.fn()}
|
||||
onConfirmAccessControl={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,22 +1,9 @@
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import {
|
||||
buildWorkflowLaunchUrl,
|
||||
compressAndEncodeBase64,
|
||||
createWorkflowLaunchInitialValues,
|
||||
getAppCardDisplayState,
|
||||
getAppCardOperationKeys,
|
||||
getAppHiddenLaunchVariables,
|
||||
getEmbeddedIframeSnippet,
|
||||
getEmbeddedScriptSnippet,
|
||||
getWorkflowHiddenStartVariables,
|
||||
hasWorkflowStartNode,
|
||||
isAppAccessConfigured,
|
||||
isWorkflowLaunchInputSupported,
|
||||
} from '../app-card-utils'
|
||||
import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils'
|
||||
|
||||
describe('app-card-utils', () => {
|
||||
const baseAppInfo = {
|
||||
@ -46,108 +33,6 @@ describe('app-card-utils', () => {
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('should return hidden workflow start variables and their initial launch values', () => {
|
||||
const hiddenVariables = getWorkflowHiddenStartVariables({
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
variables: [
|
||||
{
|
||||
variable: 'visible',
|
||||
label: 'Visible',
|
||||
type: InputVarType.textInput,
|
||||
hide: false,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
default: 'prefilled',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
variable: 'enabled',
|
||||
label: 'Enabled',
|
||||
type: InputVarType.checkbox,
|
||||
hide: true,
|
||||
default: true,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
expect(hiddenVariables.map(variable => variable.variable)).toEqual(['secret', 'enabled'])
|
||||
expect(createWorkflowLaunchInitialValues(hiddenVariables)).toEqual({
|
||||
secret: 'prefilled',
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return hidden advanced-chat launch variables from the workflow start node first', () => {
|
||||
const hiddenVariables = getAppHiddenLaunchVariables({
|
||||
appInfo: {
|
||||
...baseAppInfo,
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
model_config: {
|
||||
user_input_form: [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'Visible',
|
||||
variable: 'visible',
|
||||
required: true,
|
||||
max_length: 48,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
checkbox: {
|
||||
label: 'Hidden Toggle',
|
||||
variable: 'hidden_toggle',
|
||||
required: false,
|
||||
default: true,
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as AppDetailResponse,
|
||||
currentWorkflow: {
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
variables: [
|
||||
{
|
||||
variable: 'start_secret',
|
||||
label: 'Start Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
default: 'from-start',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(hiddenVariables).toEqual([
|
||||
expect.objectContaining({
|
||||
variable: 'start_secret',
|
||||
type: InputVarType.textInput,
|
||||
default: 'from-start',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should build the display state for a published web app', () => {
|
||||
const state = getAppCardDisplayState({
|
||||
appInfo: baseAppInfo,
|
||||
@ -219,108 +104,4 @@ describe('app-card-utils', () => {
|
||||
isCurrentWorkspaceEditor: false,
|
||||
})).toEqual(['launch', 'embedded', 'customize'])
|
||||
})
|
||||
|
||||
it('should build a workflow launch URL with serialized parameters', async () => {
|
||||
const url = await buildWorkflowLaunchUrl({
|
||||
accessibleUrl: 'https://example.com/app/workflow/token-1',
|
||||
variables: [
|
||||
{ variable: 'name', label: 'Name', type: InputVarType.textInput, hide: true, required: false },
|
||||
{ variable: 'enabled', label: 'Enabled', type: InputVarType.checkbox, hide: true, required: false },
|
||||
],
|
||||
values: { name: 'Alice', enabled: true },
|
||||
})
|
||||
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.get('name')).toBe('Alice')
|
||||
expect(parsed.searchParams.get('enabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('should serialize checkbox false and empty string values in launch URL', async () => {
|
||||
const url = await buildWorkflowLaunchUrl({
|
||||
accessibleUrl: 'https://example.com/app/workflow/token-1',
|
||||
variables: [
|
||||
{ variable: 'flag', label: 'Flag', type: InputVarType.checkbox, hide: true, required: false },
|
||||
{ variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false },
|
||||
],
|
||||
values: { flag: false, empty: '' },
|
||||
})
|
||||
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.get('flag')).toBe('false')
|
||||
expect(parsed.searchParams.get('empty')).toBe('')
|
||||
})
|
||||
|
||||
it('should generate an iframe snippet with the provided URL', () => {
|
||||
const snippet = getEmbeddedIframeSnippet('https://example.com/chatbot/token-1')
|
||||
expect(snippet).toContain('src="https://example.com/chatbot/token-1"')
|
||||
expect(snippet).toContain('frameborder="0"')
|
||||
expect(snippet).toContain('allow="microphone"')
|
||||
})
|
||||
|
||||
it('should generate an embedded script snippet with inputs', () => {
|
||||
const snippet = getEmbeddedScriptSnippet({
|
||||
url: 'https://example.com',
|
||||
token: 'abc123',
|
||||
primaryColor: '#FF0000',
|
||||
isTestEnv: true,
|
||||
inputValues: { name: 'Alice', count: '5' },
|
||||
})
|
||||
|
||||
expect(snippet).toContain('token: \'abc123\'')
|
||||
expect(snippet).toContain('isDev: true')
|
||||
expect(snippet).toContain('name: "Alice"')
|
||||
expect(snippet).toContain('count: "5"')
|
||||
expect(snippet).toContain('background-color: #FF0000')
|
||||
})
|
||||
|
||||
it('should generate an embedded script snippet with empty inputs comment', () => {
|
||||
const snippet = getEmbeddedScriptSnippet({
|
||||
url: 'https://example.com',
|
||||
token: 'abc123',
|
||||
primaryColor: '#1C64F2',
|
||||
inputValues: {},
|
||||
})
|
||||
|
||||
expect(snippet).toContain('// You can define the inputs from the Start node here')
|
||||
expect(snippet).not.toContain('isDev: true')
|
||||
})
|
||||
|
||||
it('should compress and encode base64 using CompressionStream when available', async () => {
|
||||
const result = await compressAndEncodeBase64('hello')
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should fallback to plain base64 when CompressionStream is unavailable', async () => {
|
||||
const original = globalThis.CompressionStream
|
||||
// @ts-expect-error remove for test
|
||||
delete globalThis.CompressionStream
|
||||
|
||||
const result = await compressAndEncodeBase64('hello')
|
||||
expect(result).toBe(btoa('hello'))
|
||||
|
||||
globalThis.CompressionStream = original
|
||||
})
|
||||
|
||||
it('should identify supported workflow launch input types', () => {
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.textInput, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.paragraph, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.select, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.number, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.checkbox, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.json, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.jsonObject, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.url, hide: true, required: false })).toBe(true)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.files, hide: true, required: false })).toBe(false)
|
||||
expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.singleFile, hide: true, required: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('should coerce numeric defaults to string in createWorkflowLaunchInitialValues', () => {
|
||||
const result = createWorkflowLaunchInitialValues([
|
||||
{ variable: 'count', label: 'Count', type: InputVarType.number, hide: true, required: false, default: 42 },
|
||||
{ variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false },
|
||||
])
|
||||
|
||||
expect(result).toEqual({ count: '42', empty: '' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,7 +2,6 @@ import type { ReactElement, ReactNode } from 'react'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
@ -18,7 +17,7 @@ const mockSetAppDetail = vi.fn()
|
||||
const mockOnChangeStatus = vi.fn()
|
||||
const mockOnGenerateCode = vi.fn()
|
||||
|
||||
let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array<Record<string, unknown>> } }> } } | null = null
|
||||
let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string } }> } } | null = null
|
||||
let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] }
|
||||
let mockAppDetail: AppDetailResponse | undefined
|
||||
|
||||
@ -26,7 +25,6 @@ vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
@ -166,182 +164,6 @@ describe('AppCard', () => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com${basePath}/chat/access-token`, '_blank')
|
||||
})
|
||||
|
||||
it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => {
|
||||
mockWorkflow = {
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: 'start',
|
||||
variables: [
|
||||
{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<AppCard
|
||||
appInfo={{
|
||||
...appInfo,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}}
|
||||
onChangeStatus={mockOnChangeStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('overview.appInfo.launch'))
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/workflow/access-token`,
|
||||
'_blank',
|
||||
)
|
||||
expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collect hidden workflow inputs from the config action before launching the workflow web app', async () => {
|
||||
mockWorkflow = {
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: 'start',
|
||||
variables: [
|
||||
{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<AppCard
|
||||
appInfo={{
|
||||
...appInfo,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}}
|
||||
onChangeStatus={mockOnChangeStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.config' }))
|
||||
|
||||
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Secret'), {
|
||||
target: { value: 'top-secret' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/workflow/access-token?secret=${encodeURIComponent('top-secret')}`,
|
||||
'_blank',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the chat web app directly when launch is clicked even with hidden inputs', () => {
|
||||
mockWorkflow = {
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: 'start',
|
||||
variables: [
|
||||
{
|
||||
variable: 'chat_secret',
|
||||
label: 'Chat Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<AppCard
|
||||
appInfo={{
|
||||
...appInfo,
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
} as AppDetailResponse}
|
||||
onChangeStatus={mockOnChangeStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('overview.appInfo.launch'))
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/chat/access-token`,
|
||||
'_blank',
|
||||
)
|
||||
expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collect hidden chatflow inputs from the config action before launching the chat web app', async () => {
|
||||
mockWorkflow = {
|
||||
graph: {
|
||||
nodes: [{
|
||||
data: {
|
||||
type: 'start',
|
||||
variables: [
|
||||
{
|
||||
variable: 'chat_secret',
|
||||
label: 'Chat Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<AppCard
|
||||
appInfo={{
|
||||
...appInfo,
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
} as AppDetailResponse}
|
||||
onChangeStatus={mockOnChangeStatus}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.config' }))
|
||||
|
||||
expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Chat Secret'), {
|
||||
target: { value: 'chat-secret' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
`https://example.com${basePath}/chat/access-token?chat_secret=${encodeURIComponent('chat-secret')}`,
|
||||
'_blank',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show the access-control not-set badge when specific access has no subjects', () => {
|
||||
render(
|
||||
<AppCard
|
||||
@ -480,7 +302,7 @@ describe('AppCard', () => {
|
||||
})
|
||||
|
||||
it('should report refresh failures from access control updates', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockFetchAppDetailDirect.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
|
||||
render(
|
||||
|
||||
@ -1,214 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import WorkflowHiddenInputFields from '../workflow-hidden-input-fields'
|
||||
|
||||
describe('WorkflowHiddenInputFields', () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render a text input with label and placeholder', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'name',
|
||||
label: 'Full Name',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
}]}
|
||||
values={{ name: 'Alice' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByLabelText('Full Name')
|
||||
expect(input).toHaveValue('Alice')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Bob' } })
|
||||
expect(onValueChange).toHaveBeenCalledWith('name', 'Bob')
|
||||
})
|
||||
|
||||
it('should render a number input for number-typed variables', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'count',
|
||||
label: 'Count',
|
||||
type: InputVarType.number,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ count: '5' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByLabelText('Count')
|
||||
expect(input).toHaveAttribute('type', 'number')
|
||||
|
||||
fireEvent.change(input, { target: { value: '10' } })
|
||||
expect(onValueChange).toHaveBeenCalledWith('count', '10')
|
||||
})
|
||||
|
||||
it('should render a checkbox input without a separate label element above', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'enabled',
|
||||
label: 'Enable Feature',
|
||||
type: InputVarType.checkbox,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ enabled: true }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
expect(checkbox).toBeChecked()
|
||||
expect(screen.getByText('Enable Feature')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox)
|
||||
expect(onValueChange).toHaveBeenCalledWith('enabled', false)
|
||||
})
|
||||
|
||||
it('should render a select dropdown for select-typed variables', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'color',
|
||||
label: 'Color',
|
||||
type: InputVarType.select,
|
||||
hide: true,
|
||||
required: false,
|
||||
options: ['red', 'green', 'blue'],
|
||||
}]}
|
||||
values={{ color: 'red' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Color' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a textarea for paragraph-typed variables', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'description',
|
||||
label: 'Description',
|
||||
type: InputVarType.paragraph,
|
||||
hide: true,
|
||||
required: false,
|
||||
max_length: 500,
|
||||
}]}
|
||||
values={{ description: 'Hello world' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Description')
|
||||
expect(textarea).toHaveValue('Hello world')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'Updated' } })
|
||||
expect(onValueChange).toHaveBeenCalledWith('description', 'Updated')
|
||||
})
|
||||
|
||||
it('should render a textarea for json-typed variables', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'config',
|
||||
label: 'Config JSON',
|
||||
type: InputVarType.json,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ config: '{"key": "value"}' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Config JSON')
|
||||
expect(textarea).toHaveValue('{"key": "value"}')
|
||||
})
|
||||
|
||||
it('should render a textarea for jsonObject-typed variables', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'schema',
|
||||
label: 'Schema',
|
||||
type: InputVarType.jsonObject,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ schema: '{}' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Schema')
|
||||
expect(textarea).toHaveValue('{}')
|
||||
})
|
||||
|
||||
it('should use the variable key as label when label is not a string', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'my_var',
|
||||
label: { nodeType: 'start' as never, nodeName: 'Start', variable: 'my_var' },
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ my_var: '' }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('my_var')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use the custom fieldIdPrefix for element ids', () => {
|
||||
const { container } = render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'token',
|
||||
label: 'Token',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ token: 'abc' }}
|
||||
onValueChange={onValueChange}
|
||||
fieldIdPrefix="custom-prefix"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('#custom-prefix-token')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty string for non-string fieldValue in text inputs', () => {
|
||||
render(
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={[{
|
||||
variable: 'flag',
|
||||
label: 'Flag',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: false,
|
||||
}]}
|
||||
values={{ flag: true as never }}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByLabelText('Flag')
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
})
|
||||
@ -1,11 +1,7 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { ComponentType, FormEvent, ReactNode } from 'react'
|
||||
import type {
|
||||
OverviewOperationKey,
|
||||
WorkflowHiddenStartVariable,
|
||||
WorkflowLaunchInputValue,
|
||||
} from './app-card-utils'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { OverviewOperationKey } from './app-card-utils'
|
||||
import type { ConfigParams } from './settings'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
@ -19,19 +15,12 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiSettings2Line, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react'
|
||||
import CopyFeedback from '@/app/components/base/copy-feedback'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import ShareQRCode from '@/app/components/base/qrcode'
|
||||
@ -42,7 +31,6 @@ import CustomizeModal from './customize'
|
||||
import EmbeddedModal from './embedded'
|
||||
import SettingsModal from './settings'
|
||||
import style from './style.module.css'
|
||||
import WorkflowHiddenInputFields from './workflow-hidden-input-fields'
|
||||
|
||||
type AppInfo = AppDetailResponse & Partial<AppSSO>
|
||||
|
||||
@ -62,12 +50,6 @@ type AppCardOperation = {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type LaunchConfigAction = {
|
||||
label: string
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const OPERATION_ICON_MAP: Record<OverviewOperationKey, OperationIcon> = {
|
||||
launch: RiExternalLinkLine,
|
||||
embedded: RiWindowLine,
|
||||
@ -114,65 +96,6 @@ const MaybeTooltip = ({
|
||||
)
|
||||
}
|
||||
|
||||
export const WorkflowLaunchDialog = ({
|
||||
t,
|
||||
open,
|
||||
hiddenVariables,
|
||||
unsupportedVariables,
|
||||
values,
|
||||
onOpenChange,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
t: TFunction
|
||||
open: boolean
|
||||
hiddenVariables: WorkflowHiddenStartVariable[]
|
||||
unsupportedVariables: WorkflowHiddenStartVariable[]
|
||||
values: Record<string, WorkflowLaunchInputValue>
|
||||
onOpenChange: (open: boolean) => void
|
||||
onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||
}) => {
|
||||
if (!hiddenVariables.length && !unsupportedVariables.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[560px]! max-w-[calc(100vw-2rem)]! p-0!">
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t('overview.appInfo.workflowLaunchHiddenInputs.title', { ns: 'appOverview' })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="system-md-regular text-text-tertiary">
|
||||
<Trans
|
||||
i18nKey="overview.appInfo.workflowLaunchHiddenInputs.description"
|
||||
ns="appOverview"
|
||||
components={{ bold: <span className="system-md-medium" /> }}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-4 px-6 pb-4">
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={hiddenVariables}
|
||||
values={values}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-divider-subtle px-6 py-4">
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary">
|
||||
{t('overview.appInfo.launch', { ns: 'appOverview' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export const createAppCardOperations = ({
|
||||
operationKeys,
|
||||
t,
|
||||
@ -328,15 +251,20 @@ export const AppCardAccessControlSection = ({
|
||||
export const AppCardOperations = ({
|
||||
t,
|
||||
operations,
|
||||
launchConfigAction,
|
||||
}: {
|
||||
t: TFunction
|
||||
operations: AppCardOperation[]
|
||||
launchConfigAction?: LaunchConfigAction
|
||||
}) => (
|
||||
<>
|
||||
{operations.map(({ key, label, Icon, disabled, onClick }) => {
|
||||
const buttonContent = (
|
||||
{operations.map(({ key, label, Icon, disabled, onClick }) => (
|
||||
<Button
|
||||
className="mr-1 min-w-[88px]"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
key={key}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MaybeTooltip
|
||||
content={t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''}
|
||||
tooltipClassName="mt-[-8px]"
|
||||
@ -347,72 +275,8 @@ export const AppCardOperations = ({
|
||||
<div className={`${disabled ? 'text-components-button-ghost-text-disabled' : 'text-text-tertiary'} px-[3px] system-xs-medium`}>{label}</div>
|
||||
</div>
|
||||
</MaybeTooltip>
|
||||
)
|
||||
|
||||
if (key === 'launch' && launchConfigAction) {
|
||||
return (
|
||||
<MaybeTooltip
|
||||
key={key}
|
||||
content={t('overview.appInfo.preUseReminder', { ns: 'appOverview' }) ?? ''}
|
||||
tooltipClassName="mt-[-8px]"
|
||||
show={disabled}
|
||||
>
|
||||
<Button
|
||||
className="mr-1 border-0 px-0 py-0 shadow-none backdrop-blur-none hover:bg-components-button-secondary-bg"
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex h-full min-w-[88px] items-center justify-center rounded-l-md px-2 hover:bg-components-button-secondary-bg-hover">
|
||||
<div className="flex items-center justify-center gap-px">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<div className="px-[3px] system-xs-medium">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-4 w-px shrink-0 bg-divider-regular opacity-100"
|
||||
/>
|
||||
<div
|
||||
className="flex h-full w-8 shrink-0 items-center justify-center rounded-r-md hover:bg-components-button-secondary-bg-hover"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
launchConfigAction.onClick()
|
||||
}}
|
||||
aria-label={launchConfigAction.label}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={(event) => {
|
||||
if (disabled)
|
||||
return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
launchConfigAction.onClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RiSettings2Line className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</Button>
|
||||
</MaybeTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="mr-1 min-w-[88px]"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
key={key}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -431,7 +295,6 @@ export const AppCardDialogs = ({
|
||||
onCloseAccessControl,
|
||||
onSaveSiteConfig,
|
||||
onConfirmAccessControl,
|
||||
hiddenInputs,
|
||||
}: {
|
||||
isApp: boolean
|
||||
appInfo: AppInfo
|
||||
@ -447,7 +310,6 @@ export const AppCardDialogs = ({
|
||||
onCloseAccessControl: () => void
|
||||
onSaveSiteConfig?: (params: ConfigParams) => Promise<void>
|
||||
onConfirmAccessControl: () => Promise<void>
|
||||
hiddenInputs?: WorkflowHiddenStartVariable[]
|
||||
}) => {
|
||||
if (!isApp)
|
||||
return null
|
||||
@ -467,7 +329,6 @@ export const AppCardDialogs = ({
|
||||
onClose={onCloseEmbedded}
|
||||
appBaseUrl={appInfo.site?.app_base_url}
|
||||
accessToken={appInfo.site?.access_token}
|
||||
hiddenInputs={hiddenInputs}
|
||||
/>
|
||||
<CustomizeModal
|
||||
isShow={showCustomizeModal}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
@ -10,11 +8,6 @@ import { basePath } from '@/utils/var'
|
||||
type OverviewCardType = 'api' | 'webapp'
|
||||
|
||||
export type OverviewOperationKey = 'launch' | 'embedded' | 'customize' | 'settings' | 'develop'
|
||||
export type WorkflowLaunchInputValue = string | boolean
|
||||
export type WorkflowHiddenStartVariable = Pick<
|
||||
InputVar,
|
||||
'default' | 'hide' | 'label' | 'max_length' | 'options' | 'required' | 'type' | 'variable'
|
||||
>
|
||||
|
||||
type AppInfo = AppDetailResponse & Partial<AppSSO>
|
||||
|
||||
@ -23,7 +16,6 @@ type WorkflowLike = {
|
||||
nodes?: Array<{
|
||||
data?: {
|
||||
type?: string
|
||||
variables?: InputVar[]
|
||||
}
|
||||
}>
|
||||
}
|
||||
@ -50,173 +42,10 @@ const getCardAppMode = (mode: AppModeEnum) => {
|
||||
return (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : mode
|
||||
}
|
||||
|
||||
const SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES = new Set<InputVarType>([
|
||||
InputVarType.textInput,
|
||||
InputVarType.paragraph,
|
||||
InputVarType.select,
|
||||
InputVarType.number,
|
||||
InputVarType.checkbox,
|
||||
InputVarType.json,
|
||||
InputVarType.jsonObject,
|
||||
InputVarType.url,
|
||||
])
|
||||
|
||||
const coerceWorkflowLaunchDefaultValue = (variable: WorkflowHiddenStartVariable): WorkflowLaunchInputValue => {
|
||||
if (variable.type === InputVarType.checkbox) {
|
||||
if (typeof variable.default === 'boolean')
|
||||
return variable.default
|
||||
|
||||
return String(variable.default).toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
if (typeof variable.default === 'number')
|
||||
return String(variable.default)
|
||||
|
||||
return String(variable.default ?? '')
|
||||
}
|
||||
|
||||
export const hasWorkflowStartNode = (currentWorkflow: WorkflowLike) => {
|
||||
return currentWorkflow?.graph?.nodes?.some(node => node.data?.type === BlockEnum.Start) ?? false
|
||||
}
|
||||
|
||||
export const getWorkflowHiddenStartVariables = (currentWorkflow: WorkflowLike): WorkflowHiddenStartVariable[] => {
|
||||
const startNode = currentWorkflow?.graph?.nodes?.find(node => node.data?.type === BlockEnum.Start)
|
||||
return (startNode?.data?.variables ?? []).filter(variable => variable.hide === true)
|
||||
}
|
||||
|
||||
export const getAppHiddenLaunchVariables = ({
|
||||
appInfo,
|
||||
currentWorkflow,
|
||||
}: {
|
||||
appInfo: AppInfo
|
||||
currentWorkflow: WorkflowLike
|
||||
}) => {
|
||||
if ([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT].includes(appInfo.mode))
|
||||
return getWorkflowHiddenStartVariables(currentWorkflow)
|
||||
}
|
||||
|
||||
export const isWorkflowLaunchInputSupported = (variable: WorkflowHiddenStartVariable) => {
|
||||
return SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES.has(variable.type)
|
||||
}
|
||||
|
||||
export const createWorkflowLaunchInitialValues = (variables: WorkflowHiddenStartVariable[]) => {
|
||||
return variables.reduce<Record<string, WorkflowLaunchInputValue>>((acc, variable) => {
|
||||
acc[variable.variable] = coerceWorkflowLaunchDefaultValue(variable)
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const buildWorkflowLaunchUrl = async ({
|
||||
accessibleUrl,
|
||||
variables,
|
||||
values,
|
||||
}: {
|
||||
accessibleUrl: string
|
||||
variables: WorkflowHiddenStartVariable[]
|
||||
values: Record<string, WorkflowLaunchInputValue>
|
||||
}) => {
|
||||
const targetUrl = new URL(accessibleUrl, window.location.origin)
|
||||
variables.forEach((variable) => {
|
||||
const rawValue = values[variable.variable]
|
||||
const serializedValue = variable.type === InputVarType.checkbox
|
||||
? String(Boolean(rawValue))
|
||||
: String(rawValue ?? '')
|
||||
|
||||
targetUrl.searchParams.set(variable.variable, serializedValue)
|
||||
})
|
||||
|
||||
return targetUrl.toString()
|
||||
}
|
||||
|
||||
export const getEmbeddedIframeSnippet = (iframeUrl: string) =>
|
||||
`<iframe
|
||||
src="${iframeUrl}"
|
||||
style="width: 100%; height: 100%; min-height: 700px"
|
||||
frameborder="0"
|
||||
allow="microphone">
|
||||
</iframe>`
|
||||
|
||||
const getScriptInputsContent = (values: Record<string, WorkflowLaunchInputValue>) => {
|
||||
const entries = Object.entries(values)
|
||||
|
||||
if (!entries.length) {
|
||||
return `{
|
||||
// You can define the inputs from the Start node here
|
||||
// key is the variable name
|
||||
// e.g.
|
||||
// name: "NAME"
|
||||
}`
|
||||
}
|
||||
|
||||
return `{
|
||||
${entries.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join('\n')}
|
||||
}`
|
||||
}
|
||||
|
||||
export const getEmbeddedScriptSnippet = ({
|
||||
url,
|
||||
token,
|
||||
primaryColor,
|
||||
isTestEnv,
|
||||
inputValues,
|
||||
}: {
|
||||
url: string
|
||||
token: string
|
||||
primaryColor: string
|
||||
isTestEnv?: boolean
|
||||
inputValues: Record<string, WorkflowLaunchInputValue>
|
||||
}) =>
|
||||
`<script>
|
||||
window.difyChatbotConfig = {
|
||||
token: '${token}'${isTestEnv
|
||||
? `,
|
||||
isDev: true`
|
||||
: ''}${IS_CE_EDITION
|
||||
? `,
|
||||
baseUrl: '${url}${basePath}'`
|
||||
: ''},
|
||||
inputs: ${getScriptInputsContent(inputValues)},
|
||||
systemVariables: {
|
||||
// user_id: 'YOU CAN DEFINE USER ID HERE',
|
||||
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
|
||||
},
|
||||
userVariables: {
|
||||
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
|
||||
// name: 'YOU CAN DEFINE USER NAME HERE',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<script
|
||||
src="${url}${basePath}/embed.min.js"
|
||||
id="${token}"
|
||||
defer>
|
||||
</script>
|
||||
<style>
|
||||
#dify-chatbot-bubble-button {
|
||||
background-color: ${primaryColor} !important;
|
||||
}
|
||||
#dify-chatbot-bubble-window {
|
||||
width: 24rem !important;
|
||||
height: 40rem !important;
|
||||
}
|
||||
</style>`
|
||||
|
||||
export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${iframeUrl}`
|
||||
|
||||
export const compressAndEncodeBase64 = async (input: string) => {
|
||||
const uint8Array = new TextEncoder().encode(input)
|
||||
if (typeof CompressionStream === 'undefined')
|
||||
return btoa(String.fromCharCode(...uint8Array))
|
||||
|
||||
const compressedStream = new Response(
|
||||
new Blob([uint8Array])
|
||||
.stream()
|
||||
.pipeThrough(new CompressionStream('gzip')),
|
||||
).arrayBuffer()
|
||||
const compressedUint8Array = new Uint8Array(await compressedStream)
|
||||
return btoa(String.fromCharCode(...compressedUint8Array))
|
||||
}
|
||||
|
||||
export const getAppCardDisplayState = ({
|
||||
appInfo,
|
||||
cardType,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
'use client'
|
||||
import type { WorkflowLaunchInputValue } from './app-card-utils'
|
||||
import type { ConfigParams } from './settings'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
@ -29,16 +28,11 @@ import {
|
||||
AppCardOperations,
|
||||
AppCardUrlSection,
|
||||
createAppCardOperations,
|
||||
WorkflowLaunchDialog,
|
||||
} from './app-card-sections'
|
||||
import {
|
||||
buildWorkflowLaunchUrl,
|
||||
createWorkflowLaunchInitialValues,
|
||||
getAppCardDisplayState,
|
||||
getAppCardOperationKeys,
|
||||
getAppHiddenLaunchVariables,
|
||||
isAppAccessConfigured,
|
||||
isWorkflowLaunchInputSupported,
|
||||
} from './app-card-utils'
|
||||
|
||||
export type IAppCardProps = {
|
||||
@ -69,8 +63,7 @@ function AppCard({
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
|
||||
const shouldFetchWorkflow = appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT
|
||||
const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '')
|
||||
const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '')
|
||||
const docLink = useDocLink()
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||
@ -80,8 +73,6 @@ function AppCard({
|
||||
const [genLoading, setGenLoading] = useState(false)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showAccessControl, setShowAccessControl] = useState(false)
|
||||
const [showWorkflowLaunchDialog, setShowWorkflowLaunchDialog] = useState(false)
|
||||
const [workflowLaunchValues, setWorkflowLaunchValues] = useState<Record<string, WorkflowLaunchInputValue>>({})
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: appAccessSubjects } = useAppWhiteListSubjects(
|
||||
@ -107,25 +98,6 @@ function AppCard({
|
||||
() => isAppAccessConfigured(appDetail, appAccessSubjects),
|
||||
[appAccessSubjects, appDetail],
|
||||
)
|
||||
const hiddenLaunchVariables = useMemo(
|
||||
() => getAppHiddenLaunchVariables({
|
||||
appInfo,
|
||||
currentWorkflow,
|
||||
}) || [],
|
||||
[appInfo, currentWorkflow],
|
||||
)
|
||||
const supportedWorkflowLaunchVariables = useMemo(
|
||||
() => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported),
|
||||
[hiddenLaunchVariables],
|
||||
)
|
||||
const unsupportedWorkflowLaunchVariables = useMemo(
|
||||
() => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)),
|
||||
[hiddenLaunchVariables],
|
||||
)
|
||||
const initialWorkflowLaunchValues = useMemo(
|
||||
() => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables),
|
||||
[supportedWorkflowLaunchVariables],
|
||||
)
|
||||
|
||||
const onGenCode = async () => {
|
||||
if (!onGenerateCode)
|
||||
@ -167,31 +139,6 @@ function AppCard({
|
||||
window.open(cardState.accessibleUrl, '_blank')
|
||||
}, [cardState.accessibleUrl])
|
||||
|
||||
const handleOpenWorkflowLaunchDialog = useCallback(() => {
|
||||
setWorkflowLaunchValues(initialWorkflowLaunchValues)
|
||||
setShowWorkflowLaunchDialog(true)
|
||||
}, [initialWorkflowLaunchValues])
|
||||
|
||||
const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => {
|
||||
setWorkflowLaunchValues(prev => ({
|
||||
...prev,
|
||||
[variable]: value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleWorkflowLaunchConfirm = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
const targetUrl = await buildWorkflowLaunchUrl({
|
||||
accessibleUrl: cardState.accessibleUrl,
|
||||
variables: supportedWorkflowLaunchVariables,
|
||||
values: workflowLaunchValues,
|
||||
})
|
||||
|
||||
window.open(targetUrl, '_blank')
|
||||
setShowWorkflowLaunchDialog(false)
|
||||
}, [cardState.accessibleUrl, supportedWorkflowLaunchVariables, workflowLaunchValues])
|
||||
|
||||
const handleOpenCustomize = useCallback(() => {
|
||||
setShowCustomizeModal(true)
|
||||
}, [])
|
||||
@ -357,17 +304,7 @@ function AppCard({
|
||||
{!cardState.isMinimalState && (
|
||||
<div className="flex items-center gap-1 self-stretch p-3">
|
||||
{!isApp && <SecretKeyButton appId={appInfo.id} />}
|
||||
<AppCardOperations
|
||||
t={t}
|
||||
operations={operations}
|
||||
launchConfigAction={hiddenLaunchVariables.length > 0
|
||||
? {
|
||||
label: t('operation.config', { ns: 'common' }),
|
||||
disabled: triggerModeDisabled || !cardState.runningStatus,
|
||||
onClick: handleOpenWorkflowLaunchDialog,
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
<AppCardOperations t={t} operations={operations} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -386,17 +323,6 @@ function AppCard({
|
||||
onCloseAccessControl={() => setShowAccessControl(false)}
|
||||
onSaveSiteConfig={onSaveSiteConfig}
|
||||
onConfirmAccessControl={handleAccessControlUpdate}
|
||||
hiddenInputs={hiddenLaunchVariables}
|
||||
/>
|
||||
<WorkflowLaunchDialog
|
||||
t={t}
|
||||
open={showWorkflowLaunchDialog}
|
||||
hiddenVariables={supportedWorkflowLaunchVariables}
|
||||
unsupportedVariables={unsupportedWorkflowLaunchVariables}
|
||||
values={workflowLaunchValues}
|
||||
onOpenChange={setShowWorkflowLaunchDialog}
|
||||
onValueChange={handleWorkflowLaunchValueChange}
|
||||
onSubmit={handleWorkflowLaunchConfirm}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { act } from 'react'
|
||||
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { act } from 'react'
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Embedded from '../index'
|
||||
|
||||
vi.mock('../style.module.css', () => ({
|
||||
@ -47,7 +46,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
}))
|
||||
const mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const mockedCopy = vi.mocked(copy)
|
||||
const originalCompressionStream = globalThis.CompressionStream
|
||||
|
||||
const siteInfo: SiteInfo = {
|
||||
title: 'test site',
|
||||
@ -72,22 +70,6 @@ const getCopyButton = () => {
|
||||
}
|
||||
|
||||
describe('Embedded', () => {
|
||||
beforeAll(() => {
|
||||
class MockCompressionStream {
|
||||
readable: ReadableStream<Uint8Array>
|
||||
writable: WritableStream<Uint8Array>
|
||||
|
||||
constructor() {
|
||||
const transformStream = new TransformStream<Uint8Array, Uint8Array>()
|
||||
this.readable = transformStream.readable
|
||||
this.writable = transformStream.writable
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error test polyfill
|
||||
globalThis.CompressionStream = MockCompressionStream
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWindowOpen.mockClear()
|
||||
@ -95,7 +77,6 @@ describe('Embedded', () => {
|
||||
|
||||
afterAll(() => {
|
||||
mockWindowOpen.mockRestore()
|
||||
globalThis.CompressionStream = originalCompressionStream
|
||||
})
|
||||
|
||||
it('builds theme and copies iframe snippet', async () => {
|
||||
@ -103,20 +84,14 @@ describe('Embedded', () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText((content, node) => node?.tagName.toLowerCase() === 'pre' && content.includes('/chatbot/token'))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const actionButton = getCopyButton()
|
||||
const innerDiv = actionButton.querySelector('div')
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
fireEvent.click(innerDiv ?? actionButton)
|
||||
})
|
||||
|
||||
expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(siteInfo.chat_color_theme, siteInfo.chat_color_theme_inverted)
|
||||
await waitFor(() => {
|
||||
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
|
||||
})
|
||||
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
|
||||
})
|
||||
|
||||
it('opens chrome plugin store link when chrome option selected', async () => {
|
||||
@ -141,106 +116,4 @@ describe('Embedded', () => {
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps hidden inputs collapsed by default and updates iframe and script content when values change', async () => {
|
||||
render(
|
||||
<Embedded
|
||||
{...baseProps}
|
||||
hiddenInputs={[{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: '',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('appOverview.overview.appInfo.embedded.hiddenInputs.title').closest('button')!)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Secret')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText('Secret'), {
|
||||
target: { value: 'top-secret' },
|
||||
})
|
||||
})
|
||||
|
||||
expect(document.querySelector('pre')?.textContent ?? '').toContain('/chatbot/token')
|
||||
|
||||
await waitFor(() => {
|
||||
const codeBlock = document.querySelector('pre')
|
||||
expect(codeBlock?.textContent ?? '').toContain('/chatbot/token?secret=dG9wLXNlY3JldA%3D%3D')
|
||||
})
|
||||
|
||||
const optionButtons = document.body.querySelectorAll('[class*="option"]')
|
||||
act(() => {
|
||||
fireEvent.click(optionButtons[1]!)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const codeBlock = document.querySelector('pre')
|
||||
expect(codeBlock?.textContent ?? '').toContain('secret: "top-secret"')
|
||||
})
|
||||
})
|
||||
|
||||
it('copies script content when scripts option is selected', async () => {
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
|
||||
const optionButtons = document.body.querySelectorAll('[class*="option"]')
|
||||
act(() => {
|
||||
fireEvent.click(optionButtons[1]!)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const codeBlock = document.querySelector('pre')
|
||||
expect(codeBlock?.textContent ?? '').toContain('token: \'token\'')
|
||||
})
|
||||
|
||||
const actionButton = getCopyButton()
|
||||
const innerDiv = actionButton.querySelector('div')
|
||||
await act(async () => {
|
||||
fireEvent.click(innerDiv ?? actionButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('token: \'token\''))
|
||||
})
|
||||
})
|
||||
|
||||
it('copies chrome plugin URL (without prefix) when chromePlugin option is selected', async () => {
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
})
|
||||
|
||||
const optionButtons = document.body.querySelectorAll('[class*="option"]')
|
||||
act(() => {
|
||||
fireEvent.click(optionButtons[2]!)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const codeBlock = document.querySelector('pre')
|
||||
expect(codeBlock?.textContent ?? '').toContain('ChatBot URL:')
|
||||
})
|
||||
|
||||
const actionButton = getCopyButton()
|
||||
const innerDiv = actionButton.querySelector('div')
|
||||
await act(async () => {
|
||||
fireEvent.click(innerDiv ?? actionButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token'))
|
||||
expect(mockedCopy).not.toHaveBeenCalledWith(expect.stringContaining('ChatBot URL:'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,46 +1,88 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
} from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { basePath } from '@/utils/var'
|
||||
import {
|
||||
compressAndEncodeBase64,
|
||||
createWorkflowLaunchInitialValues,
|
||||
getChromePluginContent,
|
||||
getEmbeddedIframeSnippet,
|
||||
getEmbeddedScriptSnippet,
|
||||
isWorkflowLaunchInputSupported,
|
||||
} from '../app-card-utils'
|
||||
import WorkflowHiddenInputFields from '../workflow-hidden-input-fields'
|
||||
import style from './style.module.css'
|
||||
|
||||
type Props = {
|
||||
siteInfo?: SiteInfo
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
accessToken?: string
|
||||
appBaseUrl?: string
|
||||
hiddenInputs?: WorkflowHiddenStartVariable[]
|
||||
accessToken: string
|
||||
appBaseUrl: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const
|
||||
const OPTION_MAP = {
|
||||
iframe: {
|
||||
getContent: (url: string, token: string) =>
|
||||
`<iframe
|
||||
src="${url}${basePath}/chatbot/${token}"
|
||||
style="width: 100%; height: 100%; min-height: 700px"
|
||||
frameborder="0"
|
||||
allow="microphone">
|
||||
</iframe>`,
|
||||
},
|
||||
scripts: {
|
||||
getContent: (url: string, token: string, primaryColor: string, isTestEnv?: boolean) =>
|
||||
`<script>
|
||||
window.difyChatbotConfig = {
|
||||
token: '${token}'${isTestEnv
|
||||
? `,
|
||||
isDev: true`
|
||||
: ''}${IS_CE_EDITION
|
||||
? `,
|
||||
baseUrl: '${url}${basePath}'`
|
||||
: ''},
|
||||
inputs: {
|
||||
// You can define the inputs from the Start node here
|
||||
// key is the variable name
|
||||
// e.g.
|
||||
// name: "NAME"
|
||||
},
|
||||
systemVariables: {
|
||||
// user_id: 'YOU CAN DEFINE USER ID HERE',
|
||||
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
|
||||
},
|
||||
userVariables: {
|
||||
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
|
||||
// name: 'YOU CAN DEFINE USER NAME HERE',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<script
|
||||
src="${url}${basePath}/embed.min.js"
|
||||
id="${token}"
|
||||
defer>
|
||||
</script>
|
||||
<style>
|
||||
#dify-chatbot-bubble-button {
|
||||
background-color: ${primaryColor} !important;
|
||||
}
|
||||
#dify-chatbot-bubble-window {
|
||||
width: 24rem !important;
|
||||
height: 40rem !important;
|
||||
}
|
||||
</style>`,
|
||||
},
|
||||
chromePlugin: {
|
||||
getContent: (url: string, token: string) => `ChatBot URL: ${url}${basePath}/chatbot/${token}`,
|
||||
},
|
||||
}
|
||||
const prefixEmbedded = 'overview.appInfo.embedded'
|
||||
|
||||
type Option = typeof OPTION_KEYS[number]
|
||||
type Option = keyof typeof OPTION_MAP
|
||||
|
||||
const OPTIONS: Option[] = ['iframe', 'scripts', 'chromePlugin']
|
||||
|
||||
const optionIconClassName: Record<Option, string> = {
|
||||
iframe: style.iframeIcon!,
|
||||
@ -48,274 +90,38 @@ const optionIconClassName: Record<Option, string> = {
|
||||
chromePlugin: style.chromePluginIcon!,
|
||||
}
|
||||
|
||||
const getSerializedHiddenInputValue = (
|
||||
variable: WorkflowHiddenStartVariable,
|
||||
values: Record<string, WorkflowLaunchInputValue>,
|
||||
) => {
|
||||
const rawValue = values[variable.variable]
|
||||
if (variable.type === InputVarType.checkbox)
|
||||
return String(Boolean(rawValue))
|
||||
|
||||
return String(rawValue ?? '')
|
||||
}
|
||||
|
||||
const buildEmbeddedIframeUrl = async ({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
variables,
|
||||
values,
|
||||
}: {
|
||||
appBaseUrl: string
|
||||
accessToken: string
|
||||
variables: WorkflowHiddenStartVariable[]
|
||||
values: Record<string, WorkflowLaunchInputValue>
|
||||
}) => {
|
||||
const iframeUrl = new URL(`${appBaseUrl}${basePath}/chatbot/${accessToken}`, window.location.origin)
|
||||
|
||||
await Promise.all(variables.map(async (variable) => {
|
||||
iframeUrl.searchParams.set(variable.variable, await compressAndEncodeBase64(getSerializedHiddenInputValue(variable, values)))
|
||||
}))
|
||||
|
||||
return iframeUrl.toString()
|
||||
}
|
||||
|
||||
const AsyncEmbeddedOptionContent = ({
|
||||
option,
|
||||
iframeUrlPromise,
|
||||
latestResolvedIframeUrlRef,
|
||||
}: {
|
||||
option: Option
|
||||
iframeUrlPromise: Promise<string>
|
||||
latestResolvedIframeUrlRef: MutableRefObject<string>
|
||||
}) => {
|
||||
const iframeUrl = use(iframeUrlPromise)
|
||||
latestResolvedIframeUrlRef.current = iframeUrl
|
||||
|
||||
if (option === 'chromePlugin')
|
||||
return getChromePluginContent(iframeUrl)
|
||||
|
||||
return getEmbeddedIframeSnippet(iframeUrl)
|
||||
}
|
||||
|
||||
const EmbeddedContent = ({
|
||||
siteInfo,
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
hiddenInputs,
|
||||
}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> & Pick<Props, 'siteInfo' | 'hiddenInputs'>) => {
|
||||
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, className }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const supportedHiddenInputs = useMemo<WorkflowHiddenStartVariable[]>(
|
||||
() => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported),
|
||||
[hiddenInputs],
|
||||
)
|
||||
const initialHiddenInputValues = useMemo(
|
||||
() => createWorkflowLaunchInitialValues(supportedHiddenInputs),
|
||||
[supportedHiddenInputs],
|
||||
)
|
||||
const [option, setOption] = useState<Option>('iframe')
|
||||
const [copiedOption, setCopiedOption] = useState<Option | null>(null)
|
||||
const [hiddenInputsCollapsed, setHiddenInputsCollapsed] = useState(true)
|
||||
const [hiddenInputValues, setHiddenInputValues] = useState<Record<string, WorkflowLaunchInputValue>>(
|
||||
() => initialHiddenInputValues,
|
||||
)
|
||||
const [previewIframeUrlPromise, setPreviewIframeUrlPromise] = useState<Promise<string>>(
|
||||
() => buildEmbeddedIframeUrl({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
variables: supportedHiddenInputs,
|
||||
values: initialHiddenInputValues,
|
||||
}),
|
||||
)
|
||||
const latestResolvedIframeUrlRef = useRef('')
|
||||
|
||||
const { langGeniusVersionInfo } = useAppContext()
|
||||
const themeBuilder = useThemeContext()
|
||||
themeBuilder.buildTheme(siteInfo?.chat_color_theme ?? null, siteInfo?.chat_color_theme_inverted ?? false)
|
||||
const isTestEnv = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT'
|
||||
|
||||
const handleHiddenInputValueChange = (variable: string, value: WorkflowLaunchInputValue) => {
|
||||
const nextHiddenInputValues = {
|
||||
...hiddenInputValues,
|
||||
[variable]: value,
|
||||
}
|
||||
|
||||
setCopiedOption(null)
|
||||
setHiddenInputValues(nextHiddenInputValues)
|
||||
setPreviewIframeUrlPromise(buildEmbeddedIframeUrl({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
variables: supportedHiddenInputs,
|
||||
values: nextHiddenInputValues,
|
||||
}))
|
||||
}
|
||||
const scriptsContent = useMemo(() => getEmbeddedScriptSnippet({
|
||||
url: appBaseUrl,
|
||||
token: accessToken,
|
||||
primaryColor: themeBuilder.theme?.primaryColor ?? '#1C64F2',
|
||||
isTestEnv,
|
||||
inputValues: hiddenInputValues,
|
||||
}), [accessToken, appBaseUrl, hiddenInputValues, isTestEnv, themeBuilder.theme?.primaryColor])
|
||||
|
||||
const onClickCopy = async () => {
|
||||
const latestIframeUrl = await buildEmbeddedIframeUrl({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
variables: supportedHiddenInputs,
|
||||
values: hiddenInputValues,
|
||||
})
|
||||
|
||||
const onClickCopy = () => {
|
||||
if (option === 'chromePlugin') {
|
||||
const splitUrl = getChromePluginContent(latestIframeUrl).split(': ')
|
||||
const splitUrl = OPTION_MAP[option].getContent(appBaseUrl, accessToken).split(': ')
|
||||
if (splitUrl.length > 1)
|
||||
copy(splitUrl[1]!)
|
||||
}
|
||||
else if (option === 'iframe') {
|
||||
copy(getEmbeddedIframeSnippet(latestIframeUrl))
|
||||
}
|
||||
else {
|
||||
copy(scriptsContent)
|
||||
copy(OPTION_MAP[option].getContent(appBaseUrl, accessToken, themeBuilder.theme?.primaryColor ?? '#1C64F2', isTestEnv))
|
||||
}
|
||||
setCopiedOption(option)
|
||||
}
|
||||
const previewFallback = latestResolvedIframeUrlRef.current
|
||||
? (option === 'chromePlugin'
|
||||
? getChromePluginContent(latestResolvedIframeUrlRef.current)
|
||||
: getEmbeddedIframeSnippet(latestResolvedIframeUrlRef.current))
|
||||
: ''
|
||||
|
||||
const navigateToChromeUrl = () => {
|
||||
window.open('https://chrome.google.com/webstore/detail/dify-chatbot/ceehdapohffmjmkdcifjofadiaoeggaf', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
themeBuilder.buildTheme(siteInfo?.chat_color_theme ?? null, siteInfo?.chat_color_theme_inverted ?? false)
|
||||
}, [siteInfo?.chat_color_theme, siteInfo?.chat_color_theme_inverted, themeBuilder])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-8 mb-4 system-sm-medium text-text-primary">
|
||||
{t(`${prefixEmbedded}.explanation`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
{supportedHiddenInputs.length > 0 && (
|
||||
<div className="mb-6 rounded-xl border-[0.5px] border-components-panel-border bg-background-section">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
||||
onClick={() => setHiddenInputsCollapsed(prev => !prev)}
|
||||
>
|
||||
<div>
|
||||
<div className="system-sm-medium text-text-primary">
|
||||
{t(`${prefixEmbedded}.hiddenInputs.title`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(`${prefixEmbedded}.hiddenInputs.description`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
</div>
|
||||
{hiddenInputsCollapsed
|
||||
? <RiArrowRightSLine className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
: <RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-tertiary" />}
|
||||
</button>
|
||||
{!hiddenInputsCollapsed && (
|
||||
<div className="max-h-72 space-y-4 overflow-y-auto border-t-[0.5px] border-divider-subtle px-4 py-4">
|
||||
<WorkflowHiddenInputFields
|
||||
hiddenVariables={supportedHiddenInputs}
|
||||
values={hiddenInputValues}
|
||||
onValueChange={handleHiddenInputValueChange}
|
||||
fieldIdPrefix="embedded-hidden-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-2">
|
||||
{OPTION_KEYS.map((v) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={v}
|
||||
aria-label={t(`${prefixEmbedded}.${v}`, { ns: 'appOverview' }) || v}
|
||||
className={cn(
|
||||
style.option,
|
||||
optionIconClassName[v],
|
||||
option === v && style.active,
|
||||
)}
|
||||
onClick={() => {
|
||||
setOption(v)
|
||||
setCopiedOption(null)
|
||||
}}
|
||||
>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{option === 'chromePlugin' && (
|
||||
<div className="mt-6 w-full">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('inline-flex w-full items-center justify-center gap-2 rounded-lg py-3', 'shrink-0 bg-primary-600 text-white hover:bg-primary-600/75 hover:shadow-sm')}
|
||||
onClick={navigateToChromeUrl}
|
||||
>
|
||||
<div className={`relative h-4 w-4 ${style.pluginInstallIcon}`}></div>
|
||||
<div className="font-['Inter'] text-sm leading-tight font-medium text-white">{t(`${prefixEmbedded}.chromePlugin`, { ns: 'appOverview' })}</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('inline-flex w-full flex-col items-start justify-start rounded-lg border-[0.5px] border-components-panel-border bg-background-section', 'mt-6')}>
|
||||
<div className="inline-flex items-center justify-start gap-2 self-stretch rounded-t-lg bg-background-section-burn py-1 pr-1 pl-3">
|
||||
<div className="shrink-0 grow system-sm-medium text-text-secondary">
|
||||
{t(`${prefixEmbedded}.${option}`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
aria-label={(copiedOption === option
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
|
||||
onClick={() => void onClickCopy()}
|
||||
>
|
||||
{copiedOption === option && <span aria-hidden="true" className="i-ri-clipboard-fill h-4 w-4" />}
|
||||
{copiedOption !== option && <span aria-hidden="true" className="i-ri-clipboard-line h-4 w-4" />}
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{(copiedOption === option
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex max-h-[clamp(180px,calc(100dvh-320px),360px)] w-full items-start justify-start gap-2 overflow-auto p-3">
|
||||
<div className="shrink grow basis-0 font-mono text-[13px] leading-tight text-text-secondary">
|
||||
<pre className="select-text">
|
||||
{option === 'scripts'
|
||||
? scriptsContent
|
||||
: (
|
||||
<Suspense fallback={previewFallback}>
|
||||
<AsyncEmbeddedOptionContent
|
||||
option={option}
|
||||
iframeUrlPromise={previewIframeUrlPromise}
|
||||
latestResolvedIframeUrlRef={latestResolvedIframeUrlRef}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenInputs, className }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isShow}
|
||||
onOpenChange={(open) => {
|
||||
if (open)
|
||||
return
|
||||
setCopiedOption(null)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
@ -324,16 +130,73 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenIn
|
||||
{t(`${prefixEmbedded}.title`, { ns: 'appOverview' })}
|
||||
</DialogTitle>
|
||||
<DialogCloseButton />
|
||||
<div className="max-h-[calc(90vh-88px)] overflow-y-auto">
|
||||
{isShow && (
|
||||
<EmbeddedContent
|
||||
key={`${appBaseUrl ?? ''}:${accessToken ?? ''}:${JSON.stringify(hiddenInputs ?? [])}`}
|
||||
siteInfo={siteInfo}
|
||||
appBaseUrl={appBaseUrl ?? ''}
|
||||
accessToken={accessToken ?? ''}
|
||||
hiddenInputs={hiddenInputs}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-8 mb-4 system-sm-medium text-text-primary">
|
||||
{t(`${prefixEmbedded}.explanation`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-2">
|
||||
{OPTIONS.map((v) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={v}
|
||||
aria-label={t(`${prefixEmbedded}.${v}`, { ns: 'appOverview' }) || v}
|
||||
className={cn(
|
||||
style.option,
|
||||
optionIconClassName[v],
|
||||
option === v && style.active,
|
||||
)}
|
||||
onClick={() => {
|
||||
setOption(v)
|
||||
setCopiedOption(null)
|
||||
}}
|
||||
>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{option === 'chromePlugin' && (
|
||||
<div className="mt-6 w-full">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('inline-flex w-full items-center justify-center gap-2 rounded-lg py-3', 'shrink-0 bg-primary-600 text-white hover:bg-primary-600/75 hover:shadow-sm')}
|
||||
onClick={navigateToChromeUrl}
|
||||
>
|
||||
<div className={`relative h-4 w-4 ${style.pluginInstallIcon}`}></div>
|
||||
<div className="font-['Inter'] text-sm leading-tight font-medium text-white">{t(`${prefixEmbedded}.chromePlugin`, { ns: 'appOverview' })}</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('inline-flex w-full flex-col items-start justify-start rounded-lg border-[0.5px] border-components-panel-border bg-background-section', 'mt-6')}>
|
||||
<div className="inline-flex items-center justify-start gap-2 self-stretch rounded-t-lg bg-background-section-burn py-1 pr-1 pl-3">
|
||||
<div className="shrink-0 grow system-sm-medium text-text-secondary">
|
||||
{t(`${prefixEmbedded}.${option}`, { ns: 'appOverview' })}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
aria-label={(copiedOption === option
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
|
||||
onClick={onClickCopy}
|
||||
>
|
||||
{copiedOption === option && <span aria-hidden="true" className="i-ri-clipboard-fill h-4 w-4" />}
|
||||
{copiedOption !== option && <span aria-hidden="true" className="i-ri-clipboard-line h-4 w-4" />}
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{(copiedOption === option
|
||||
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
|
||||
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex max-h-[clamp(180px,calc(100dvh-320px),360px)] w-full items-start justify-start gap-2 overflow-auto p-3">
|
||||
<div className="shrink grow basis-0 font-mono text-[13px] leading-tight text-text-secondary">
|
||||
<pre className="select-text">{OPTION_MAP[option].getContent(appBaseUrl, accessToken, themeBuilder.theme?.primaryColor ?? '#1C64F2', isTestEnv)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -1,116 +0,0 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from './app-card-utils'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
type WorkflowHiddenInputFieldsProps = {
|
||||
hiddenVariables: WorkflowHiddenStartVariable[]
|
||||
values: Record<string, WorkflowLaunchInputValue>
|
||||
onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void
|
||||
fieldIdPrefix?: string
|
||||
}
|
||||
|
||||
const WorkflowHiddenInputFields = ({
|
||||
hiddenVariables,
|
||||
values,
|
||||
onValueChange,
|
||||
fieldIdPrefix = 'workflow-launch-hidden-input',
|
||||
}: WorkflowHiddenInputFieldsProps) => {
|
||||
const renderField = (variable: WorkflowHiddenStartVariable) => {
|
||||
const fieldId = `${fieldIdPrefix}-${variable.variable}`
|
||||
const fieldValue = values[variable.variable]
|
||||
const label = typeof variable.label === 'string' ? variable.label : variable.variable
|
||||
|
||||
if (variable.type === InputVarType.select) {
|
||||
return (
|
||||
<Select
|
||||
value={typeof fieldValue === 'string' ? fieldValue : ''}
|
||||
onValueChange={value => onValueChange(variable.variable, value ?? '')}
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label={label}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(variable.options ?? []).map(option => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (variable.type === InputVarType.checkbox) {
|
||||
return (
|
||||
<label className="flex min-h-10 w-full cursor-pointer items-center gap-3 rounded-lg bg-components-input-bg-normal px-3 py-2">
|
||||
<input
|
||||
id={fieldId}
|
||||
type="checkbox"
|
||||
checked={Boolean(fieldValue)}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.checked)}
|
||||
className="h-4 w-4 rounded border-divider-subtle"
|
||||
/>
|
||||
<span className="system-sm-regular text-text-secondary">{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
variable.type === InputVarType.paragraph
|
||||
|| variable.type === InputVarType.json
|
||||
|| variable.type === InputVarType.jsonObject
|
||||
) {
|
||||
return (
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
value={typeof fieldValue === 'string' ? fieldValue : ''}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onValueChange(variable.variable, event.target.value)}
|
||||
placeholder={label}
|
||||
maxLength={variable.max_length}
|
||||
className="min-h-24"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={fieldId}
|
||||
type={variable.type === InputVarType.number ? 'number' : 'text'}
|
||||
value={typeof fieldValue === 'string' ? fieldValue : ''}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(variable.variable, event.target.value)}
|
||||
placeholder={label}
|
||||
maxLength={variable.max_length}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hiddenVariables.map(variable => (
|
||||
<div key={variable.variable} className="space-y-1.5">
|
||||
{variable.type !== InputVarType.checkbox && (
|
||||
<label
|
||||
htmlFor={`${fieldIdPrefix}-${variable.variable}`}
|
||||
className="block system-sm-medium text-text-secondary"
|
||||
>
|
||||
{typeof variable.label === 'string' ? variable.label : variable.variable}
|
||||
</label>
|
||||
)}
|
||||
{renderField(variable)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowHiddenInputFields
|
||||
@ -321,86 +321,47 @@ describe('chat utils - url params and answer helpers', () => {
|
||||
expect(res).toEqual({ custom: '123', encoded: 'a b' })
|
||||
})
|
||||
|
||||
it('getRawInputsFromUrlParams keeps encoded launch params as decoded plain values', async () => {
|
||||
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}`)
|
||||
const res = await getRawInputsFromUrlParams()
|
||||
expect(res).toEqual({ custom: 'YWJjZA==' })
|
||||
})
|
||||
|
||||
it('getRawUserVariablesFromUrlParams extracts only user. prefixed params', async () => {
|
||||
setSearch('?custom=123&sys.param=456&user.param=789&user.encoded=a%20b')
|
||||
const res = await getRawUserVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: '789', encoded: 'a b' })
|
||||
})
|
||||
|
||||
it('getRawUserVariablesFromUrlParams keeps encoded user values as decoded plain values', async () => {
|
||||
setSearch(`?user.param=${encodeURIComponent('YWJjZA==')}`)
|
||||
const res = await getRawUserVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: 'YWJjZA==' })
|
||||
})
|
||||
|
||||
it('getProcessedInputsFromUrlParams decompresses base64 inputs', async () => {
|
||||
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}&sys.param=456&user.param=789`)
|
||||
setSearch('?custom=123&sys.param=456&user.param=789')
|
||||
const res = await getProcessedInputsFromUrlParams()
|
||||
expect(res).toEqual({ custom: 'decompressed_text' })
|
||||
})
|
||||
|
||||
it('getProcessedInputsFromUrlParams returns undefined for plain decoded values', async () => {
|
||||
vi.stubGlobal('atob', () => {
|
||||
throw new Error('invalid')
|
||||
})
|
||||
setSearch('?custom=a%20b')
|
||||
const res = await getProcessedInputsFromUrlParams()
|
||||
expect(res).toEqual({ custom: undefined })
|
||||
})
|
||||
|
||||
it('getProcessedSystemVariablesFromUrlParams decompresses sys. prefixed params', async () => {
|
||||
setSearch(`?custom=123&sys.param=${encodeURIComponent('YWJjZA==')}&user.param=789`)
|
||||
setSearch('?custom=123&sys.param=456&user.param=789')
|
||||
const res = await getProcessedSystemVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: 'decompressed_text' })
|
||||
})
|
||||
|
||||
it('getProcessedSystemVariablesFromUrlParams returns undefined for plain decoded values', async () => {
|
||||
vi.stubGlobal('atob', () => {
|
||||
throw new Error('invalid')
|
||||
})
|
||||
setSearch('?sys.param=a%20b')
|
||||
const res = await getProcessedSystemVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: undefined })
|
||||
})
|
||||
|
||||
it('getProcessedSystemVariablesFromUrlParams parses redirect_url without query string', async () => {
|
||||
setSearch(`?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=${encodeURIComponent('YWJjZA==')}`)
|
||||
setSearch(`?redirect_url=${encodeURIComponent('http://example.com')}&sys.param=456`)
|
||||
const res = await getProcessedSystemVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: 'decompressed_text' })
|
||||
})
|
||||
|
||||
it('getProcessedSystemVariablesFromUrlParams parses redirect_url', async () => {
|
||||
setSearch(`?redirect_url=${encodeURIComponent(`http://example.com?sys.redirected=${encodeURIComponent('YWJjZA==')}`)}&sys.param=${encodeURIComponent('YWJjZA==')}`)
|
||||
setSearch(`?redirect_url=${encodeURIComponent('http://example.com?sys.redirected=abc')}&sys.param=456`)
|
||||
const res = await getProcessedSystemVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: 'decompressed_text', redirected: 'decompressed_text' })
|
||||
})
|
||||
|
||||
it('getProcessedUserVariablesFromUrlParams decompresses user. prefixed params', async () => {
|
||||
setSearch(`?custom=123&sys.param=456&user.param=${encodeURIComponent('YWJjZA==')}`)
|
||||
setSearch('?custom=123&sys.param=456&user.param=789')
|
||||
const res = await getProcessedUserVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: 'decompressed_text' })
|
||||
})
|
||||
|
||||
it('getProcessedUserVariablesFromUrlParams returns undefined for plain decoded values', async () => {
|
||||
vi.stubGlobal('atob', () => {
|
||||
throw new Error('invalid')
|
||||
})
|
||||
setSearch('?user.param=a%20b')
|
||||
const res = await getProcessedUserVariablesFromUrlParams()
|
||||
expect(res).toEqual({ param: undefined })
|
||||
})
|
||||
|
||||
it('decodeBase64AndDecompress failure returns undefined softly', async () => {
|
||||
vi.stubGlobal('atob', () => {
|
||||
throw new Error('invalid')
|
||||
})
|
||||
setSearch(`?custom=${encodeURIComponent('YWJjZA==')}`)
|
||||
setSearch('?custom=invalid_base64')
|
||||
const res = await getProcessedInputsFromUrlParams()
|
||||
expect(res).toEqual({ custom: undefined })
|
||||
})
|
||||
|
||||
@ -19,13 +19,11 @@ async function getRawInputsFromUrlParams(): Promise<Record<string, any>> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const inputs: Record<string, any> = {}
|
||||
const entriesArray = Array.from(urlParams.entries())
|
||||
await Promise.all(entriesArray.map(async ([key, value]) => {
|
||||
entriesArray.forEach(([key, value]) => {
|
||||
const prefixArray = ['sys.', 'user.']
|
||||
if (prefixArray.some(prefix => key.startsWith(prefix)))
|
||||
return
|
||||
|
||||
inputs[key] = decodeURIComponent(value)
|
||||
}))
|
||||
if (!prefixArray.some(prefix => key.startsWith(prefix)))
|
||||
inputs[key] = decodeURIComponent(value)
|
||||
})
|
||||
return inputs
|
||||
}
|
||||
|
||||
@ -83,12 +81,10 @@ async function getRawUserVariablesFromUrlParams(): Promise<Record<string, any>>
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const userVariables: Record<string, any> = {}
|
||||
const entriesArray = Array.from(urlParams.entries())
|
||||
await Promise.all(entriesArray.map(async ([key, value]) => {
|
||||
if (!key.startsWith('user.'))
|
||||
return
|
||||
|
||||
userVariables[key.slice(5)] = decodeURIComponent(value)
|
||||
}))
|
||||
entriesArray.forEach(([key, value]) => {
|
||||
if (key.startsWith('user.'))
|
||||
userVariables[key.slice(5)] = decodeURIComponent(value)
|
||||
})
|
||||
return userVariables
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,6 @@ const renderHook = <Result, Props = void>(callback: (props: Props) => Result) =>
|
||||
const {
|
||||
changeLanguageMock,
|
||||
fetchSavedMessageMock,
|
||||
getRawInputsFromUrlParamsMock,
|
||||
notifyMock,
|
||||
removeMessageMock,
|
||||
saveMessageMock,
|
||||
@ -20,7 +19,6 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
changeLanguageMock: vi.fn(() => Promise.resolve()),
|
||||
fetchSavedMessageMock: vi.fn(),
|
||||
getRawInputsFromUrlParamsMock: vi.fn(),
|
||||
notifyMock: vi.fn(),
|
||||
removeMessageMock: vi.fn(),
|
||||
saveMessageMock: vi.fn(),
|
||||
@ -52,10 +50,6 @@ vi.mock('@/i18n-config/client', () => ({
|
||||
changeLanguage: changeLanguageMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/utils', () => ({
|
||||
getRawInputsFromUrlParams: getRawInputsFromUrlParamsMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
|
||||
return {
|
||||
@ -108,7 +102,7 @@ const defaultAppParams = {
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
] as Record<string, Record<string, unknown>>[],
|
||||
],
|
||||
more_like_this: {
|
||||
enabled: true,
|
||||
},
|
||||
@ -181,7 +175,6 @@ describe('useTextGenerationAppState', () => {
|
||||
})
|
||||
removeMessageMock.mockResolvedValue(undefined)
|
||||
saveMessageMock.mockResolvedValue(undefined)
|
||||
getRawInputsFromUrlParamsMock.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('should initialize app state and fetch saved messages for non-workflow web apps', async () => {
|
||||
@ -302,239 +295,4 @@ describe('useTextGenerationAppState', () => {
|
||||
enable: false,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should apply workflow launch inputs from the url to hidden prompt variables', async () => {
|
||||
mockWebAppState.appParams = {
|
||||
...defaultAppParams,
|
||||
user_input_form: [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'Visible',
|
||||
variable: 'visible',
|
||||
required: true,
|
||||
max_length: 48,
|
||||
default: 'Shown',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
'text-input': {
|
||||
label: 'Hidden Secret',
|
||||
variable: 'secret',
|
||||
required: true,
|
||||
max_length: 48,
|
||||
default: '',
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
getRawInputsFromUrlParamsMock.mockResolvedValue({
|
||||
secret: 'prefilled-secret',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGenerationAppState({
|
||||
isInstalledApp: false,
|
||||
isWorkflow: true,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'visible',
|
||||
default: 'Shown',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'secret',
|
||||
hide: true,
|
||||
default: 'prefilled-secret',
|
||||
}),
|
||||
]))
|
||||
})
|
||||
|
||||
expect(getRawInputsFromUrlParamsMock).toHaveBeenCalled()
|
||||
expect(fetchSavedMessageMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should coerce checkbox url defaults from string and boolean values', async () => {
|
||||
mockWebAppState.appParams = {
|
||||
...defaultAppParams,
|
||||
user_input_form: [
|
||||
{
|
||||
checkbox: {
|
||||
label: 'Bool True',
|
||||
variable: 'bool_true',
|
||||
required: false,
|
||||
default: false,
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
checkbox: {
|
||||
label: 'String True',
|
||||
variable: 'str_true',
|
||||
required: false,
|
||||
default: false,
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
checkbox: {
|
||||
label: 'String False',
|
||||
variable: 'str_false',
|
||||
required: false,
|
||||
default: true,
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
checkbox: {
|
||||
label: 'Invalid',
|
||||
variable: 'invalid_cb',
|
||||
required: false,
|
||||
default: false,
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
getRawInputsFromUrlParamsMock.mockResolvedValue({
|
||||
bool_true: true,
|
||||
str_true: 'true',
|
||||
str_false: 'false',
|
||||
invalid_cb: 'invalid',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGenerationAppState({
|
||||
isInstalledApp: false,
|
||||
isWorkflow: true,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'bool_true', default: true }),
|
||||
expect.objectContaining({ key: 'str_true', default: true }),
|
||||
expect.objectContaining({ key: 'str_false', default: false }),
|
||||
expect.objectContaining({ key: 'invalid_cb', default: false }),
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
it('should coerce number url defaults and ignore NaN values', async () => {
|
||||
mockWebAppState.appParams = {
|
||||
...defaultAppParams,
|
||||
user_input_form: [
|
||||
{
|
||||
number: {
|
||||
label: 'Valid Number',
|
||||
variable: 'num_valid',
|
||||
required: false,
|
||||
default: 0,
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
number: {
|
||||
label: 'NaN Number',
|
||||
variable: 'num_nan',
|
||||
required: false,
|
||||
default: 0,
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
getRawInputsFromUrlParamsMock.mockResolvedValue({
|
||||
num_valid: '42',
|
||||
num_nan: 'not-a-number',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGenerationAppState({
|
||||
isInstalledApp: false,
|
||||
isWorkflow: true,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'num_valid', default: 42 }),
|
||||
expect.objectContaining({ key: 'num_nan', default: 0 }),
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
it('should coerce select url defaults and ignore invalid options', async () => {
|
||||
mockWebAppState.appParams = {
|
||||
...defaultAppParams,
|
||||
user_input_form: [
|
||||
{
|
||||
select: {
|
||||
label: 'Valid Option',
|
||||
variable: 'sel_valid',
|
||||
required: false,
|
||||
default: '',
|
||||
options: ['alpha', 'beta'],
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
select: {
|
||||
label: 'Invalid Option',
|
||||
variable: 'sel_invalid',
|
||||
required: false,
|
||||
default: 'alpha',
|
||||
options: ['alpha', 'beta'],
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
getRawInputsFromUrlParamsMock.mockResolvedValue({
|
||||
sel_valid: 'beta',
|
||||
sel_invalid: 'gamma',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGenerationAppState({
|
||||
isInstalledApp: false,
|
||||
isWorkflow: true,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'sel_valid', default: 'beta' }),
|
||||
expect.objectContaining({ key: 'sel_invalid', default: 'alpha' }),
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore non-string url values for text inputs', async () => {
|
||||
mockWebAppState.appParams = {
|
||||
...defaultAppParams,
|
||||
user_input_form: [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'Text Field',
|
||||
variable: 'text_field',
|
||||
required: false,
|
||||
max_length: 48,
|
||||
default: 'original',
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
getRawInputsFromUrlParamsMock.mockResolvedValue({
|
||||
text_field: 12345,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGenerationAppState({
|
||||
isInstalledApp: false,
|
||||
isWorkflow: true,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.promptConfig?.prompt_variables).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'text_field', default: 'original' }),
|
||||
]))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,7 +6,6 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getRawInputsFromUrlParams } from '@/app/components/base/chat/utils'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useAppFavicon } from '@/hooks/use-app-favicon'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
@ -32,44 +31,6 @@ type ShareAppParams = {
|
||||
image_file_size_limit?: number
|
||||
}
|
||||
}
|
||||
|
||||
const coerceWorkflowUrlDefault = (
|
||||
promptVariable: NonNullable<PromptConfig['prompt_variables']>[number],
|
||||
rawValue: unknown,
|
||||
) => {
|
||||
if (rawValue === undefined || rawValue === null)
|
||||
return undefined
|
||||
|
||||
if (promptVariable.type === 'checkbox') {
|
||||
if (typeof rawValue === 'boolean')
|
||||
return rawValue
|
||||
|
||||
const normalized = String(rawValue).toLowerCase()
|
||||
if (normalized === 'true')
|
||||
return true
|
||||
if (normalized === 'false')
|
||||
return false
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (promptVariable.type === 'number') {
|
||||
const numericValue = Number(rawValue)
|
||||
return Number.isNaN(numericValue) ? undefined : numericValue
|
||||
}
|
||||
|
||||
if (typeof rawValue !== 'string')
|
||||
return undefined
|
||||
|
||||
if (promptVariable.type === 'select')
|
||||
return promptVariable.options?.includes(rawValue) ? rawValue : undefined
|
||||
|
||||
if (promptVariable.max_length)
|
||||
return rawValue.slice(0, promptVariable.max_length)
|
||||
|
||||
return rawValue
|
||||
}
|
||||
|
||||
export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTextGenerationAppStateOptions) => {
|
||||
const { t } = useTranslation()
|
||||
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
|
||||
@ -123,15 +84,6 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex
|
||||
setCustomConfig((custom_config || null) as TextGenerationCustomConfig | null)
|
||||
await changeLanguage(site.default_language)
|
||||
const { user_input_form, more_like_this, file_upload, text_to_speech } = appParams as unknown as ShareAppParams
|
||||
const promptVariables = userInputsFormToPromptVariables(user_input_form)
|
||||
if (isWorkflow && !isInstalledApp) {
|
||||
const workflowUrlInputs = await getRawInputsFromUrlParams()
|
||||
promptVariables.forEach((promptVariable) => {
|
||||
const workflowDefault = coerceWorkflowUrlDefault(promptVariable, workflowUrlInputs[promptVariable.key])
|
||||
if (workflowDefault !== undefined)
|
||||
promptVariable.default = workflowDefault
|
||||
})
|
||||
}
|
||||
if (cancelled)
|
||||
return
|
||||
setVisionConfig({
|
||||
@ -142,7 +94,7 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex
|
||||
} as VisionSettings)
|
||||
setPromptConfig({
|
||||
prompt_template: '',
|
||||
prompt_variables: promptVariables,
|
||||
prompt_variables: userInputsFormToPromptVariables(user_input_form),
|
||||
} as PromptConfig)
|
||||
setMoreLikeThisConfig(more_like_this)
|
||||
setTextToSpeechConfig(text_to_speech)
|
||||
@ -153,7 +105,7 @@ export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTex
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [appData, appParams, fetchSavedMessages, isInstalledApp, isWorkflow])
|
||||
}, [appData, appParams, fetchSavedMessages, isWorkflow])
|
||||
useDocumentTitle(siteInfo?.title || t('generation.title', { ns: 'share' }))
|
||||
useAppFavicon({
|
||||
enable: !isInstalledApp,
|
||||
|
||||
@ -214,7 +214,7 @@ export type InputVar = {
|
||||
}
|
||||
variable: string
|
||||
max_length?: number
|
||||
default?: string | number | boolean
|
||||
default?: string | number
|
||||
required: boolean
|
||||
hint?: string
|
||||
options?: string[]
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "صورة",
|
||||
"variableConfig.file.supportFileTypes": "أنواع الملفات المدعومة",
|
||||
"variableConfig.file.video.name": "فيديو",
|
||||
"variableConfig.hidden": "مخفي ومُعبَّأ مسبقاً",
|
||||
"variableConfig.hiddenDescription": "أخفِ هذا الحقل عن المستخدمين النهائيين وأمد بقيمته بنفسك. خيار يستبعد خيار مطلوب. <docLink>تعلم المزيد</docLink>",
|
||||
"variableConfig.hide": "إخفاء",
|
||||
"variableConfig.inputPlaceholder": "يرجى الإدخال",
|
||||
"variableConfig.json": "كود JSON",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"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": "تضمين في الموقع",
|
||||
@ -106,8 +104,6 @@
|
||||
"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": "في الخدمة",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"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",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"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",
|
||||
@ -106,8 +104,6 @@
|
||||
"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
Reference in New Issue
Block a user