mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 11:58:06 +08:00
Compare commits
2 Commits
codex/reve
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
| 79598403b3 | |||
| c8e0899668 |
2
.github/workflows/api-tests.yml
vendored
2
.github/workflows/api-tests.yml
vendored
@ -98,7 +98,7 @@ jobs:
|
||||
|
||||
- name: Set up dotenvs
|
||||
run: |
|
||||
cp docker/.env.example docker/.env
|
||||
./docker/init-env.sh
|
||||
cp docker/middleware.env.example docker/middleware.env
|
||||
|
||||
- name: Expose Service Ports
|
||||
|
||||
4
.github/workflows/main-ci.yml
vendored
4
.github/workflows/main-ci.yml
vendored
@ -56,7 +56,9 @@ jobs:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
- '.github/workflows/expose_service_ports.sh'
|
||||
- 'docker/.env.all'
|
||||
- 'docker/.env.example'
|
||||
- 'docker/init-env.sh'
|
||||
- 'docker/middleware.env.example'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/docker-compose-template.yaml'
|
||||
@ -93,7 +95,9 @@ jobs:
|
||||
- 'api/providers/vdb/*/tests/**'
|
||||
- '.github/workflows/vdb-tests.yml'
|
||||
- '.github/workflows/expose_service_ports.sh'
|
||||
- 'docker/.env.all'
|
||||
- 'docker/.env.example'
|
||||
- 'docker/init-env.sh'
|
||||
- 'docker/middleware.env.example'
|
||||
- 'docker/docker-compose.yaml'
|
||||
- 'docker/docker-compose-template.yaml'
|
||||
|
||||
2
.github/workflows/vdb-tests-full.yml
vendored
2
.github/workflows/vdb-tests-full.yml
vendored
@ -50,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Set up dotenvs
|
||||
run: |
|
||||
cp docker/.env.example docker/.env
|
||||
./docker/init-env.sh
|
||||
cp docker/middleware.env.example docker/middleware.env
|
||||
|
||||
- name: Expose Service Ports
|
||||
|
||||
2
.github/workflows/vdb-tests.yml
vendored
2
.github/workflows/vdb-tests.yml
vendored
@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Set up dotenvs
|
||||
run: |
|
||||
cp docker/.env.example docker/.env
|
||||
./docker/init-env.sh
|
||||
cp docker/middleware.env.example docker/middleware.env
|
||||
|
||||
- name: Expose Service Ports
|
||||
|
||||
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, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
|
||||
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
|
||||
|
||||
@ -842,24 +842,24 @@ class WorkflowResponseConverter:
|
||||
return []
|
||||
|
||||
files: list[Mapping[str, Any]] = []
|
||||
match value:
|
||||
case FileSegment():
|
||||
files.append(value.value.to_dict())
|
||||
case ArrayFileSegment():
|
||||
files.extend([i.to_dict() for i in value.value])
|
||||
case File():
|
||||
files.append(value.to_dict())
|
||||
case list():
|
||||
for item in value:
|
||||
file = cls._get_file_var_from_value(item)
|
||||
if file:
|
||||
files.append(file)
|
||||
case dict():
|
||||
file = cls._get_file_var_from_value(value)
|
||||
if isinstance(value, FileSegment):
|
||||
files.append(value.value.to_dict())
|
||||
elif isinstance(value, ArrayFileSegment):
|
||||
files.extend([i.to_dict() for i in value.value])
|
||||
elif isinstance(value, File):
|
||||
files.append(value.to_dict())
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
file = cls._get_file_var_from_value(item)
|
||||
if file:
|
||||
files.append(file)
|
||||
case _:
|
||||
pass
|
||||
elif isinstance(
|
||||
value,
|
||||
dict,
|
||||
):
|
||||
file = cls._get_file_var_from_value(value)
|
||||
if file:
|
||||
files.append(file)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
@ -53,27 +53,24 @@ class PromptMessageUtil:
|
||||
files = []
|
||||
if isinstance(prompt_message.content, list):
|
||||
for content in prompt_message.content:
|
||||
match content:
|
||||
case TextPromptMessageContent():
|
||||
text += content.data
|
||||
case ImagePromptMessageContent():
|
||||
files.append(
|
||||
{
|
||||
"type": "image",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"detail": content.detail.value,
|
||||
}
|
||||
)
|
||||
case AudioPromptMessageContent():
|
||||
files.append(
|
||||
{
|
||||
"type": "audio",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"format": content.format,
|
||||
}
|
||||
)
|
||||
case _:
|
||||
continue
|
||||
if isinstance(content, TextPromptMessageContent):
|
||||
text += content.data
|
||||
elif isinstance(content, ImagePromptMessageContent):
|
||||
files.append(
|
||||
{
|
||||
"type": "image",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"detail": content.detail.value,
|
||||
}
|
||||
)
|
||||
elif isinstance(content, AudioPromptMessageContent):
|
||||
files.append(
|
||||
{
|
||||
"type": "audio",
|
||||
"data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:],
|
||||
"format": content.format,
|
||||
}
|
||||
)
|
||||
else:
|
||||
text = cast(str, prompt_message.content)
|
||||
|
||||
|
||||
@ -23,37 +23,36 @@ _TOOL_FILE_URL_PATTERN = re.compile(r"(?:^|/+)files/tools/(?P<tool_file_id>[^/?#
|
||||
|
||||
|
||||
def safe_json_value(v):
|
||||
match v:
|
||||
case datetime():
|
||||
tz_name = "UTC"
|
||||
if isinstance(current_user, Account) and current_user.timezone is not None:
|
||||
tz_name = current_user.timezone
|
||||
return v.astimezone(pytz.timezone(tz_name)).isoformat()
|
||||
case date():
|
||||
return v.isoformat()
|
||||
case UUID():
|
||||
return str(v)
|
||||
case Decimal():
|
||||
return float(v)
|
||||
case bytes():
|
||||
try:
|
||||
return v.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return v.hex()
|
||||
case memoryview():
|
||||
return v.tobytes().hex()
|
||||
case np.integer():
|
||||
return int(v)
|
||||
case np.floating():
|
||||
return float(v)
|
||||
case np.ndarray():
|
||||
return v.tolist()
|
||||
case dict():
|
||||
return safe_json_dict(v)
|
||||
case list() | tuple() | set():
|
||||
return [safe_json_value(i) for i in v]
|
||||
case _:
|
||||
return v
|
||||
if isinstance(v, datetime):
|
||||
tz_name = "UTC"
|
||||
if isinstance(current_user, Account) and current_user.timezone is not None:
|
||||
tz_name = current_user.timezone
|
||||
return v.astimezone(pytz.timezone(tz_name)).isoformat()
|
||||
elif isinstance(v, date):
|
||||
return v.isoformat()
|
||||
elif isinstance(v, UUID):
|
||||
return str(v)
|
||||
elif isinstance(v, Decimal):
|
||||
return float(v)
|
||||
elif isinstance(v, bytes):
|
||||
try:
|
||||
return v.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return v.hex()
|
||||
elif isinstance(v, memoryview):
|
||||
return v.tobytes().hex()
|
||||
elif isinstance(v, np.integer):
|
||||
return int(v)
|
||||
elif isinstance(v, np.floating):
|
||||
return float(v)
|
||||
elif isinstance(v, np.ndarray):
|
||||
return v.tolist()
|
||||
elif isinstance(v, dict):
|
||||
return safe_json_dict(v)
|
||||
elif isinstance(v, list | tuple | set):
|
||||
return [safe_json_value(i) for i in v]
|
||||
else:
|
||||
return v
|
||||
|
||||
|
||||
def safe_json_dict(d: dict[str, Any]):
|
||||
|
||||
@ -194,15 +194,14 @@ class VariableTruncator(BaseTruncator):
|
||||
|
||||
result: _PartResult[Any]
|
||||
# Apply type-specific truncation with target size
|
||||
match segment:
|
||||
case ArraySegment():
|
||||
result = self._truncate_array(segment.value, target_size)
|
||||
case StringSegment():
|
||||
result = self._truncate_string(segment.value, target_size)
|
||||
case ObjectSegment():
|
||||
result = self._truncate_object(segment.value, target_size)
|
||||
case _:
|
||||
raise AssertionError("this should be unreachable.")
|
||||
if isinstance(segment, ArraySegment):
|
||||
result = self._truncate_array(segment.value, target_size)
|
||||
elif isinstance(segment, StringSegment):
|
||||
result = self._truncate_string(segment.value, target_size)
|
||||
elif isinstance(segment, ObjectSegment):
|
||||
result = self._truncate_object(segment.value, target_size)
|
||||
else:
|
||||
raise AssertionError("this should be unreachable.")
|
||||
|
||||
return _PartResult(
|
||||
value=segment.model_copy(update={"value": result.value}),
|
||||
@ -220,41 +219,40 @@ class VariableTruncator(BaseTruncator):
|
||||
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
|
||||
if depth > _MAX_DEPTH:
|
||||
raise MaxDepthExceededError()
|
||||
match value:
|
||||
case str():
|
||||
# Ideally, the size of strings should be calculated based on their utf-8 encoded length.
|
||||
# However, this adds complexity as we would need to compute encoded sizes consistently
|
||||
# throughout the code. Therefore, we approximate the size using the string's length.
|
||||
# Rough estimate: number of characters, plus 2 for quotes
|
||||
return len(value) + 2
|
||||
case bool():
|
||||
return 4 if value else 5 # "true" or "false"
|
||||
case int() | float():
|
||||
return len(str(value))
|
||||
case None:
|
||||
return 4 # "null"
|
||||
case list():
|
||||
# Size = sum of elements + separators + brackets
|
||||
total = 2 # "[]"
|
||||
for i, item in enumerate(value):
|
||||
if i > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(item, depth=depth + 1)
|
||||
return total
|
||||
case dict():
|
||||
# Size = sum of keys + values + separators + brackets
|
||||
total = 2 # "{}"
|
||||
for index, key in enumerate(value.keys()):
|
||||
if index > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string
|
||||
total += 1 # ":"
|
||||
total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1)
|
||||
return total
|
||||
case File():
|
||||
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
|
||||
case _:
|
||||
raise UnknownTypeError(f"got unknown type {type(value)}")
|
||||
if isinstance(value, str):
|
||||
# Ideally, the size of strings should be calculated based on their utf-8 encoded length.
|
||||
# However, this adds complexity as we would need to compute encoded sizes consistently
|
||||
# throughout the code. Therefore, we approximate the size using the string's length.
|
||||
# Rough estimate: number of characters, plus 2 for quotes
|
||||
return len(value) + 2
|
||||
elif isinstance(value, (int, float)):
|
||||
return len(str(value))
|
||||
elif isinstance(value, bool):
|
||||
return 4 if value else 5 # "true" or "false"
|
||||
elif value is None:
|
||||
return 4 # "null"
|
||||
elif isinstance(value, list):
|
||||
# Size = sum of elements + separators + brackets
|
||||
total = 2 # "[]"
|
||||
for i, item in enumerate(value):
|
||||
if i > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(item, depth=depth + 1)
|
||||
return total
|
||||
elif isinstance(value, dict):
|
||||
# Size = sum of keys + values + separators + brackets
|
||||
total = 2 # "{}"
|
||||
for index, key in enumerate(value.keys()):
|
||||
if index > 0:
|
||||
total += 1 # ","
|
||||
total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string
|
||||
total += 1 # ":"
|
||||
total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1)
|
||||
return total
|
||||
elif isinstance(value, File):
|
||||
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
|
||||
else:
|
||||
raise UnknownTypeError(f"got unknown type {type(value)}")
|
||||
|
||||
def _truncate_string(self, value: str, target_size: int) -> _PartResult[str]:
|
||||
if (size := self.calculate_json_size(value)) < target_size:
|
||||
@ -421,23 +419,22 @@ class VariableTruncator(BaseTruncator):
|
||||
target_size: int,
|
||||
) -> _PartResult[Any]:
|
||||
"""Truncate a value within an object to fit within budget."""
|
||||
match val:
|
||||
case UpdatedVariable():
|
||||
# TODO(Workflow): push UpdatedVariable normalization closer to its producer.
|
||||
return self._truncate_object(val.model_dump(), target_size)
|
||||
case str():
|
||||
return self._truncate_string(val, target_size)
|
||||
case list():
|
||||
return self._truncate_array(val, target_size)
|
||||
case dict():
|
||||
return self._truncate_object(val, target_size)
|
||||
case File():
|
||||
# File objects should not be truncated, return as-is
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
case None | bool() | int() | float():
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
case _:
|
||||
raise AssertionError("this statement should be unreachable.")
|
||||
if isinstance(val, UpdatedVariable):
|
||||
# TODO(Workflow): push UpdatedVariable normalization closer to its producer.
|
||||
return self._truncate_object(val.model_dump(), target_size)
|
||||
elif isinstance(val, str):
|
||||
return self._truncate_string(val, target_size)
|
||||
elif isinstance(val, list):
|
||||
return self._truncate_array(val, target_size)
|
||||
elif isinstance(val, dict):
|
||||
return self._truncate_object(val, target_size)
|
||||
elif isinstance(val, File):
|
||||
# File objects should not be truncated, return as-is
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
elif val is None or isinstance(val, (bool, int, float)):
|
||||
return _PartResult(val, self.calculate_json_size(val), False)
|
||||
else:
|
||||
raise AssertionError("this statement should be unreachable.")
|
||||
|
||||
|
||||
class DummyVariableTruncator(BaseTruncator):
|
||||
|
||||
@ -114,8 +114,8 @@ def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
|
||||
"pool_recycle": 3600,
|
||||
"pool_size": 30,
|
||||
"pool_use_lifo": False,
|
||||
"pool_reset_on_return": None,
|
||||
"pool_timeout": 30,
|
||||
"pool_reset_on_return": "rollback",
|
||||
}
|
||||
|
||||
assert config["CONSOLE_WEB_URL"] == "https://example.com"
|
||||
|
||||
@ -92,7 +92,7 @@ BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF: frozenset[str] = frozenset(
|
||||
)
|
||||
|
||||
API_CONFIG_SET = set(dotenv_values(Path("api") / Path(".env.example")).keys())
|
||||
DOCKER_CONFIG_SET = set(dotenv_values(Path("docker") / Path(".env.example")).keys())
|
||||
DOCKER_CONFIG_SET = set(dotenv_values(Path("docker") / Path(".env.all")).keys())
|
||||
DOCKER_COMPOSE_CONFIG_SET = set()
|
||||
|
||||
with open(Path("docker") / Path("docker-compose.yaml")) as f:
|
||||
@ -101,15 +101,23 @@ with open(Path("docker") / Path("docker-compose.yaml")) as f:
|
||||
|
||||
def test_yaml_config():
|
||||
# python set == operator is used to compare two sets
|
||||
DIFF_API_WITH_DOCKER = API_CONFIG_SET - DOCKER_CONFIG_SET - BASE_API_AND_DOCKER_CONFIG_SET_DIFF
|
||||
DIFF_API_WITH_DOCKER = (
|
||||
API_CONFIG_SET - DOCKER_CONFIG_SET - BASE_API_AND_DOCKER_CONFIG_SET_DIFF
|
||||
)
|
||||
if DIFF_API_WITH_DOCKER:
|
||||
print(f"API and Docker config sets are different with key: {DIFF_API_WITH_DOCKER}")
|
||||
print(
|
||||
f"API and Docker config sets are different with key: {DIFF_API_WITH_DOCKER}"
|
||||
)
|
||||
raise Exception("API and Docker config sets are different")
|
||||
DIFF_API_WITH_DOCKER_COMPOSE = (
|
||||
API_CONFIG_SET - DOCKER_COMPOSE_CONFIG_SET - BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF
|
||||
API_CONFIG_SET
|
||||
- DOCKER_COMPOSE_CONFIG_SET
|
||||
- BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF
|
||||
)
|
||||
if DIFF_API_WITH_DOCKER_COMPOSE:
|
||||
print(f"API and Docker Compose config sets are different with key: {DIFF_API_WITH_DOCKER_COMPOSE}")
|
||||
print(
|
||||
f"API and Docker Compose config sets are different with key: {DIFF_API_WITH_DOCKER_COMPOSE}"
|
||||
)
|
||||
raise Exception("API and Docker Compose config sets are different")
|
||||
print("All tests passed!")
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ export OPENDAL_FS_ROOT=${OPENDAL_FS_ROOT:-/tmp/dify-storage}
|
||||
mkdir -p "${OPENDAL_FS_ROOT}"
|
||||
|
||||
# Prepare env files like CI
|
||||
cp -n docker/.env.example docker/.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
|
||||
|
||||
|
||||
1631
docker/.env.all
Normal file
1631
docker/.env.all
Normal file
File diff suppressed because it is too large
Load Diff
1624
docker/.env.example
1624
docker/.env.example
File diff suppressed because it is too large
Load Diff
@ -7,28 +7,38 @@ 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**: Environment variables are now managed through a `.env` file, 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 a crucial component in Docker and Docker Compose environments, serving as a centralized configuration file where you can define environment variables that are accessible to the containers at runtime. This file simplifies the management of environment settings across different stages of development, testing, and production, providing consistency and ease of configuration to deployments.
|
||||
> 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.
|
||||
|
||||
- **Mandatory .env File**: A `.env` file is now required to run `docker compose up`. This file is crucial for configuring your deployment and for any custom settings to persist through upgrades.
|
||||
- **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 the `.env.example` file to a new file named `.env` by running `cp .env.example .env`.
|
||||
- Customize the `.env` file as needed. Refer to the `.env.example` file for detailed configuration options.
|
||||
- **Optional (Recommended for upgrades)**:
|
||||
You may use the environment synchronization tool to help keep your `.env` file aligned with the latest `.env.example` updates, while preserving your custom settings.
|
||||
This is especially useful when upgrading Dify or managing a large, customized `.env` file.
|
||||
- Create `.env` and generate a deployment-specific `SECRET_KEY`:
|
||||
|
||||
```bash
|
||||
./init-env.sh
|
||||
```
|
||||
|
||||
On Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
.\init-env.ps1
|
||||
```
|
||||
|
||||
- Customize `.env` only when you need to override defaults. Refer to `.env.all` for the full list of available variables.
|
||||
- **Optional (for advanced deployments)**:
|
||||
If you maintain a full `.env` file copied from `.env.all`, you may use the environment synchronization tool to keep it aligned with the latest `.env.all` updates while preserving your custom settings.
|
||||
See the [Environment Variables Synchronization](#environment-variables-synchronization) section below.
|
||||
1. **Running the Services**:
|
||||
- Execute `docker compose up` from the `docker` directory to start 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`.
|
||||
1. **SSL Certificate Setup**:
|
||||
- Refer `docker/certbot/README.md` to set up SSL certificates using Certbot.
|
||||
@ -58,7 +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`
|
||||
### Overview of `.env.example`, `.env`, and `.env.all`
|
||||
|
||||
- `.env.example` contains the minimal default configuration for Docker Compose deployments.
|
||||
- `.env` is your local copy. It contains the generated `SECRET_KEY` plus any local changes.
|
||||
- `.env.all` is the full reference for advanced configuration.
|
||||
|
||||
#### Key Modules and Customization
|
||||
|
||||
@ -68,7 +82,7 @@ For users migrating from the `docker-legacy` setup:
|
||||
|
||||
#### Other notable variables
|
||||
|
||||
The `.env.example` file provided in the Docker setup is extensive and covers a wide range of configuration options. It is structured into several sections, each pertaining to different aspects of the application and its services. Here are some of the key sections and variables:
|
||||
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**:
|
||||
|
||||
@ -118,23 +132,25 @@ The `.env.example` file provided in the Docker setup is extensive and covers a w
|
||||
|
||||
### Environment Variables Synchronization
|
||||
|
||||
When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example`.
|
||||
When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example` or `.env.all`.
|
||||
|
||||
To help keep your existing `.env` file up to date **without losing your custom values**, an optional environment variables synchronization tool is provided.
|
||||
If you use the default workflow, review `.env.example` and add only the values you need to customize to `.env`.
|
||||
|
||||
> This tool performs a **one-way synchronization** from `.env.example` to `.env`.
|
||||
If you maintain a full `.env` file copied from `.env.all`, an optional environment variables synchronization tool is provided.
|
||||
|
||||
> This tool performs a **one-way synchronization** from `.env.all` to `.env`.
|
||||
> 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**
|
||||
|
||||
@ -143,9 +159,9 @@ Before synchronization, the current `.env` file is saved to the `env-backup/` di
|
||||
|
||||
**When to use**
|
||||
|
||||
- After upgrading Dify to a newer version
|
||||
- When `.env.example` has been updated with new environment variables
|
||||
- When managing a large or heavily customized `.env` file
|
||||
- After upgrading Dify to a newer version with a full `.env` file
|
||||
- When `.env.all` has been updated with new environment variables
|
||||
- When managing a large or heavily customized `.env` file copied from `.env.all`
|
||||
|
||||
**Usage**
|
||||
|
||||
@ -160,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"
|
||||
}
|
||||
|
||||
|
||||
@ -170,8 +170,8 @@ services:
|
||||
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
|
||||
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
|
||||
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
|
||||
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
|
||||
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-}
|
||||
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-10}
|
||||
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
|
||||
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
|
||||
MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10}
|
||||
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10}
|
||||
@ -228,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}
|
||||
@ -402,8 +402,8 @@ services:
|
||||
- ./certbot/update-cert.template.txt:/update-cert.template.txt
|
||||
- ./certbot/docker-entrypoint.sh:/docker-entrypoint.sh
|
||||
environment:
|
||||
- CERTBOT_EMAIL=${CERTBOT_EMAIL}
|
||||
- CERTBOT_DOMAIN=${CERTBOT_DOMAIN}
|
||||
- CERTBOT_EMAIL=${CERTBOT_EMAIL:-}
|
||||
- CERTBOT_DOMAIN=${CERTBOT_DOMAIN:-}
|
||||
- CERTBOT_OPTIONS=${CERTBOT_OPTIONS:-}
|
||||
entrypoint: ["/docker-entrypoint.sh"]
|
||||
command: ["tail", "-f", "/dev/null"]
|
||||
|
||||
@ -51,7 +51,7 @@ services:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-dify}
|
||||
command: >
|
||||
--max_connections=${MYSQL_MAX_CONNECTIONS:-1000}
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
|
||||
--innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M}
|
||||
--innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# ==================================================================
|
||||
# WARNING: This file is auto-generated by generate_docker_compose
|
||||
# Do not modify this file directly. Instead, update the .env.example
|
||||
# Do not modify this file directly. Instead, update the .env.all
|
||||
# or docker-compose-template.yaml and regenerate this file.
|
||||
# ==================================================================
|
||||
|
||||
@ -27,7 +27,7 @@ x-shared-env: &shared-api-worker-env
|
||||
DEBUG: ${DEBUG:-false}
|
||||
FLASK_DEBUG: ${FLASK_DEBUG:-false}
|
||||
ENABLE_REQUEST_LOGGING: ${ENABLE_REQUEST_LOGGING:-False}
|
||||
SECRET_KEY: ${SECRET_KEY:-sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set. Run ./init-env.sh, or .\\init-env.ps1 on Windows, to generate one in .env.}
|
||||
INIT_PASSWORD: ${INIT_PASSWORD:-}
|
||||
DEPLOY_ENV: ${DEPLOY_ENV:-PRODUCTION}
|
||||
CHECK_UPDATE_URL: ${CHECK_UPDATE_URL:-https://updates.dify.ai}
|
||||
@ -70,7 +70,6 @@ x-shared-env: &shared-api-worker-env
|
||||
SQLALCHEMY_POOL_PRE_PING: ${SQLALCHEMY_POOL_PRE_PING:-false}
|
||||
SQLALCHEMY_POOL_USE_LIFO: ${SQLALCHEMY_POOL_USE_LIFO:-false}
|
||||
SQLALCHEMY_POOL_TIMEOUT: ${SQLALCHEMY_POOL_TIMEOUT:-30}
|
||||
SQLALCHEMY_POOL_RESET_ON_RETURN: ${SQLALCHEMY_POOL_RESET_ON_RETURN:-rollback}
|
||||
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-200}
|
||||
POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-128MB}
|
||||
POSTGRES_WORK_MEM: ${POSTGRES_WORK_MEM:-4MB}
|
||||
@ -362,7 +361,7 @@ x-shared-env: &shared-api-worker-env
|
||||
VIKINGDB_SECRET_KEY: ${VIKINGDB_SECRET_KEY:-your-sk}
|
||||
VIKINGDB_REGION: ${VIKINGDB_REGION:-cn-shanghai}
|
||||
VIKINGDB_HOST: ${VIKINGDB_HOST:-api-vikingdb.xxx.volces.com}
|
||||
VIKINGDB_SCHEME: ${VIKINGDB_SCHEME:-http}
|
||||
VIKINGDB_SCHEMA: ${VIKINGDB_SCHEMA:-http}
|
||||
VIKINGDB_CONNECTION_TIMEOUT: ${VIKINGDB_CONNECTION_TIMEOUT:-30}
|
||||
VIKINGDB_SOCKET_TIMEOUT: ${VIKINGDB_SOCKET_TIMEOUT:-30}
|
||||
LINDORM_URL: ${LINDORM_URL:-http://localhost:30070}
|
||||
@ -424,6 +423,8 @@ x-shared-env: &shared-api-worker-env
|
||||
UNSTRUCTURED_API_URL: ${UNSTRUCTURED_API_URL:-}
|
||||
UNSTRUCTURED_API_KEY: ${UNSTRUCTURED_API_KEY:-}
|
||||
SCARF_NO_ANALYTICS: ${SCARF_NO_ANALYTICS:-true}
|
||||
PROMPT_GENERATION_MAX_TOKENS: ${PROMPT_GENERATION_MAX_TOKENS:-512}
|
||||
CODE_GENERATION_MAX_TOKENS: ${CODE_GENERATION_MAX_TOKENS:-1024}
|
||||
PLUGIN_BASED_TOKEN_COUNTING_ENABLED: ${PLUGIN_BASED_TOKEN_COUNTING_ENABLED:-false}
|
||||
MULTIMODAL_SEND_FORMAT: ${MULTIMODAL_SEND_FORMAT:-base64}
|
||||
UPLOAD_IMAGE_FILE_SIZE_LIMIT: ${UPLOAD_IMAGE_FILE_SIZE_LIMIT:-10}
|
||||
@ -440,10 +441,10 @@ x-shared-env: &shared-api-worker-env
|
||||
NOTION_CLIENT_SECRET: ${NOTION_CLIENT_SECRET:-}
|
||||
NOTION_CLIENT_ID: ${NOTION_CLIENT_ID:-}
|
||||
NOTION_INTERNAL_SECRET: ${NOTION_INTERNAL_SECRET:-}
|
||||
MAIL_TYPE: ${MAIL_TYPE:-resend}
|
||||
MAIL_TYPE: ${MAIL_TYPE:-}
|
||||
MAIL_DEFAULT_SEND_FROM: ${MAIL_DEFAULT_SEND_FROM:-}
|
||||
RESEND_API_URL: ${RESEND_API_URL:-https://api.resend.com}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-your-resend-api-key}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
SMTP_SERVER: ${SMTP_SERVER:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-465}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
@ -585,8 +586,8 @@ x-shared-env: &shared-api-worker-env
|
||||
NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s}
|
||||
NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s}
|
||||
NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false}
|
||||
CERTBOT_EMAIL: ${CERTBOT_EMAIL:-your_email@example.com}
|
||||
CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-your_domain.com}
|
||||
CERTBOT_EMAIL: ${CERTBOT_EMAIL:-}
|
||||
CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-}
|
||||
CERTBOT_OPTIONS: ${CERTBOT_OPTIONS:-}
|
||||
SSRF_HTTP_PORT: ${SSRF_HTTP_PORT:-3128}
|
||||
SSRF_COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
|
||||
@ -632,6 +633,7 @@ x-shared-env: &shared-api-worker-env
|
||||
CREATORS_PLATFORM_API_URL: ${CREATORS_PLATFORM_API_URL:-https://creators.dify.ai}
|
||||
CREATORS_PLATFORM_OAUTH_CLIENT_ID: ${CREATORS_PLATFORM_OAUTH_CLIENT_ID:-}
|
||||
FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true}
|
||||
ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES: ${ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES:-true}
|
||||
PLUGIN_STDIO_BUFFER_SIZE: ${PLUGIN_STDIO_BUFFER_SIZE:-1024}
|
||||
PLUGIN_STDIO_MAX_BUFFER_SIZE: ${PLUGIN_STDIO_MAX_BUFFER_SIZE:-5242880}
|
||||
PLUGIN_PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
|
||||
@ -892,8 +894,8 @@ services:
|
||||
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
|
||||
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
|
||||
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
|
||||
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
|
||||
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-}
|
||||
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-10}
|
||||
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
|
||||
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
|
||||
MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10}
|
||||
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10}
|
||||
@ -950,7 +952,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}
|
||||
@ -1124,8 +1126,8 @@ services:
|
||||
- ./certbot/update-cert.template.txt:/update-cert.template.txt
|
||||
- ./certbot/docker-entrypoint.sh:/docker-entrypoint.sh
|
||||
environment:
|
||||
- CERTBOT_EMAIL=${CERTBOT_EMAIL}
|
||||
- CERTBOT_DOMAIN=${CERTBOT_DOMAIN}
|
||||
- CERTBOT_EMAIL=${CERTBOT_EMAIL:-}
|
||||
- CERTBOT_DOMAIN=${CERTBOT_DOMAIN:-}
|
||||
- CERTBOT_OPTIONS=${CERTBOT_OPTIONS:-}
|
||||
entrypoint: ["/docker-entrypoint.sh"]
|
||||
command: ["tail", "-f", "/dev/null"]
|
||||
|
||||
@ -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}:-}}")
|
||||
@ -90,7 +95,7 @@ def insert_shared_env(template_path, output_path, shared_env_block, header_comme
|
||||
|
||||
|
||||
def main():
|
||||
env_example_path = ".env.example"
|
||||
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
|
||||
@ -99,22 +104,22 @@ def main():
|
||||
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)
|
||||
|
||||
# Parse .env.example file
|
||||
env_vars = parse_env_example(env_example_path)
|
||||
# Parse .env.all file
|
||||
env_vars = parse_env_all(env_all_path)
|
||||
|
||||
if not env_vars:
|
||||
print("Warning: No environment variables found in .env.example.")
|
||||
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)
|
||||
|
||||
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."
|
||||
@ -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",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Image",
|
||||
"variableConfig.file.supportFileTypes": "Support File Types",
|
||||
"variableConfig.file.video.name": "Video",
|
||||
"variableConfig.hidden": "Hidden & Pre-Filled",
|
||||
"variableConfig.hiddenDescription": "Hide this field from end users and supply its value yourself. Mutually exclusive with Required. <docLink>Learn more</docLink>",
|
||||
"variableConfig.hide": "Hide",
|
||||
"variableConfig.inputPlaceholder": "Please input",
|
||||
"variableConfig.json": "JSON Code",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Copy",
|
||||
"overview.appInfo.embedded.entry": "Embedded",
|
||||
"overview.appInfo.embedded.explanation": "Choose the way to embed chat app to your website",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Enter values for the hidden fields. The values are added to the iframe URL or the script's inputs object.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Pre-Fill Hidden Fields",
|
||||
"overview.appInfo.embedded.iframe": "To add the chat app any where on your website, add this iframe to your html code.",
|
||||
"overview.appInfo.embedded.scripts": "To add a chat app to the bottom right of your website add this code to your html.",
|
||||
"overview.appInfo.embedded.title": "Embed on website",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "Workflow Details",
|
||||
"overview.appInfo.settings.workflow.title": "Workflow",
|
||||
"overview.appInfo.title": "Web App",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Enter values for the hidden fields, then click <bold>Launch</bold> to open the WebApp with the values.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Pre-Fill Hidden Fields",
|
||||
"overview.disableTooltip.triggerMode": "The {{feature}} feature is not supported in Trigger Node mode.",
|
||||
"overview.status.disable": "Disabled",
|
||||
"overview.status.running": "In Service",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Imagen",
|
||||
"variableConfig.file.supportFileTypes": "Tipos de archivos de soporte",
|
||||
"variableConfig.file.video.name": "Vídeo",
|
||||
"variableConfig.hidden": "Oculto y Prerrellenado",
|
||||
"variableConfig.hiddenDescription": "Oculta este campo a los usuarios finales y proporciona su valor tú mismo. Mutuamente exclusivo con Requerido. <docLink>Más información</docLink>",
|
||||
"variableConfig.hide": "Ocultar",
|
||||
"variableConfig.inputPlaceholder": "Por favor ingresa",
|
||||
"variableConfig.json": "Código JSON",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Copiar",
|
||||
"overview.appInfo.embedded.entry": "Incrustado",
|
||||
"overview.appInfo.embedded.explanation": "Elige la forma de incrustar la aplicación de chat en tu sitio web",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Introduce valores para los campos ocultos. Los valores se añaden a la URL del iframe o al objeto de inputs del script.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Prerrellenar Campos Ocultos",
|
||||
"overview.appInfo.embedded.iframe": "Para agregar la aplicación de chat en cualquier lugar de tu sitio web, agrega este iframe a tu código HTML.",
|
||||
"overview.appInfo.embedded.scripts": "Para agregar una aplicación de chat en la esquina inferior derecha de tu sitio web, agrega este código a tu HTML.",
|
||||
"overview.appInfo.embedded.title": "Incrustar en el sitio web",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "Detalles del flujo de trabajo",
|
||||
"overview.appInfo.settings.workflow.title": "Pasos del flujo de trabajo",
|
||||
"overview.appInfo.title": "Aplicación web",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Introduce valores para los campos ocultos y haz clic en <bold>Iniciar</bold> para abrir el WebApp con los valores.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Prerrellenar Campos Ocultos",
|
||||
"overview.disableTooltip.triggerMode": "La función {{feature}} no es compatible en el modo Nodo de disparo.",
|
||||
"overview.status.disable": "Deshabilitar",
|
||||
"overview.status.running": "En servicio",
|
||||
|
||||
@ -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": "مقادیری برای فیلدهای پنهان وارد کنید. مقادیر به URL 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> کلیک کنید تا WebApp با مقادیر باز شود.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "پیشپر کردن فیلدهای پنهان",
|
||||
"overview.disableTooltip.triggerMode": "ویژگی {{feature}} در حالت گره تریگر پشتیبانی نمیشود.",
|
||||
"overview.status.disable": "غیرفعال",
|
||||
"overview.status.running": "در حال سرویسدهی",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Image",
|
||||
"variableConfig.file.supportFileTypes": "Types de fichiers de support",
|
||||
"variableConfig.file.video.name": "Vidéo",
|
||||
"variableConfig.hidden": "Masqué et pré-rempli",
|
||||
"variableConfig.hiddenDescription": "Masquez ce champ aux utilisateurs finaux et fournissez sa valeur vous-même. Incompatible avec Requis. <docLink>En savoir plus</docLink>",
|
||||
"variableConfig.hide": "Caché",
|
||||
"variableConfig.inputPlaceholder": "Please input",
|
||||
"variableConfig.json": "Code JSON",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Copier",
|
||||
"overview.appInfo.embedded.entry": "Intégré",
|
||||
"overview.appInfo.embedded.explanation": "Choisissez la manière d'intégrer l'application de chat à votre site Web",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Entrez des valeurs pour les champs masqués. Les valeurs sont ajoutées à l'URL de l'iframe ou à l'objet inputs du script.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Pré-remplir les champs masqués",
|
||||
"overview.appInfo.embedded.iframe": "Pour ajouter l'application de chat n'importe où sur votre site Web, ajoutez cette iframe à votre code HTML.",
|
||||
"overview.appInfo.embedded.scripts": "Pour ajouter une application de chat en bas à droite de votre site Web, ajoutez ce code à votre HTML.",
|
||||
"overview.appInfo.embedded.title": "Intégrer sur un site Web",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "Détails du flux de travail",
|
||||
"overview.appInfo.settings.workflow.title": "Étapes du workflow",
|
||||
"overview.appInfo.title": "Application Web",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Entrez des valeurs pour les champs masqués, puis cliquez sur <bold>Lancer</bold> pour ouvrir la WebApp avec ces valeurs.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Pré-remplir les champs masqués",
|
||||
"overview.disableTooltip.triggerMode": "La fonctionnalité {{feature}} n'est pas prise en charge en mode Nœud Déclencheur.",
|
||||
"overview.status.disable": "Désactiver",
|
||||
"overview.status.running": "En service",
|
||||
|
||||
@ -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 URL या स्क्रिप्ट के 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> पर क्लिक करके WebApp को मानों के साथ खोलें।",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "छुपे हुए फ़ील्ड पूर्व-भरें",
|
||||
"overview.disableTooltip.triggerMode": "ट्रिगर नोड मोड में {{feature}} फ़ीचर समर्थित नहीं है।",
|
||||
"overview.status.disable": "अक्षम करें",
|
||||
"overview.status.running": "सेवा में",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Citra",
|
||||
"variableConfig.file.supportFileTypes": "Jenis File Dukungan",
|
||||
"variableConfig.file.video.name": "Video",
|
||||
"variableConfig.hidden": "Tersembunyi dan Diisi Sebelumnya",
|
||||
"variableConfig.hiddenDescription": "Sembunyikan kolom ini dari pengguna akhir dan berikan nilainya sendiri. Saling eksklusif dengan Wajib. <docLink>Pelajari lebih lanjut</docLink>",
|
||||
"variableConfig.hide": "Menyembunyikan",
|
||||
"variableConfig.inputPlaceholder": "Silakan masukkan",
|
||||
"variableConfig.json": "Kode JSON",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Menyalin",
|
||||
"overview.appInfo.embedded.entry": "Tertanam",
|
||||
"overview.appInfo.embedded.explanation": "Pilih cara menyematkan aplikasi obrolan ke situs web Anda",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Masukkan nilai untuk kolom tersembunyi. Nilai ditambahkan ke URL iframe atau objek inputs skrip.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Isi Kolom Tersembunyi Sebelumnya",
|
||||
"overview.appInfo.embedded.iframe": "Untuk menambahkan aplikasi obrolan di mana saja di situs web Anda, tambahkan iframe ini ke kode html Anda.",
|
||||
"overview.appInfo.embedded.scripts": "Untuk menambahkan aplikasi obrolan ke kanan bawah situs web Anda, tambahkan kode ini ke html Anda.",
|
||||
"overview.appInfo.embedded.title": "Sematkan di situs web",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "Detail Alur Kerja",
|
||||
"overview.appInfo.settings.workflow.title": "Alur Kerja",
|
||||
"overview.appInfo.title": "Aplikasi Web",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Masukkan nilai untuk kolom tersembunyi, lalu klik <bold>Luncurkan</bold> untuk membuka WebApp dengan nilai tersebut.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Isi Kolom Tersembunyi Sebelumnya",
|
||||
"overview.disableTooltip.triggerMode": "Fitur {{feature}} tidak didukung dalam mode Node Pemicu.",
|
||||
"overview.status.disable": "Nonaktif",
|
||||
"overview.status.running": "Berjalan",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Immagine",
|
||||
"variableConfig.file.supportFileTypes": "Tipi di file di supporto",
|
||||
"variableConfig.file.video.name": "Video",
|
||||
"variableConfig.hidden": "Nascosto e precompilato",
|
||||
"variableConfig.hiddenDescription": "Nascondi questo campo agli utenti finali e fornisci il valore tu stesso. Incompatibile con Obbligatorio. <docLink>Per saperne di più</docLink>",
|
||||
"variableConfig.hide": "Nascondi",
|
||||
"variableConfig.inputPlaceholder": "Per favore inserisci",
|
||||
"variableConfig.json": "Codice JSON",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Copia",
|
||||
"overview.appInfo.embedded.entry": "Incorporato",
|
||||
"overview.appInfo.embedded.explanation": "Scegli come incorporare l'app chat nel tuo sito web",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Inserisci i valori per i campi nascosti. I valori vengono aggiunti all'URL dell'iframe o all'oggetto inputs dello script.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Precompila i campi nascosti",
|
||||
"overview.appInfo.embedded.iframe": "Per aggiungere l'app chat ovunque sul tuo sito web, aggiungi questo iframe al tuo codice HTML.",
|
||||
"overview.appInfo.embedded.scripts": "Per aggiungere un'app chat in basso a destra del tuo sito web, aggiungi questo codice al tuo HTML.",
|
||||
"overview.appInfo.embedded.title": "Incorpora sul sito web",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "Dettagli del flusso di lavoro",
|
||||
"overview.appInfo.settings.workflow.title": "Fasi del Workflow",
|
||||
"overview.appInfo.title": "App Web",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Inserisci i valori per i campi nascosti, quindi fai clic su <bold>Avvia</bold> per aprire la WebApp con i valori.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Precompila i campi nascosti",
|
||||
"overview.disableTooltip.triggerMode": "La funzionalità {{feature}} non è supportata in modalità Nodo Trigger.",
|
||||
"overview.status.disable": "Disabilita",
|
||||
"overview.status.running": "In servizio",
|
||||
|
||||
@ -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 の URL または埋め込みスクリプトの 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": "Web App",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "非表示フィールドに値を入力後、<bold>起動</bold>をクリックすると、事前入力された値が適用された WebApp が開きます。",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "非表示フィールドを事前入力",
|
||||
"overview.disableTooltip.triggerMode": "トリガーノードモードでは{{feature}}機能を使用できません。",
|
||||
"overview.status.disable": "無効",
|
||||
"overview.status.running": "稼働中",
|
||||
|
||||
@ -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 URL 또는 스크립트의 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>을 클릭하여 해당 값이 적용된 WebApp을 엽니다.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "숨김 필드 미리 채우기",
|
||||
"overview.disableTooltip.triggerMode": "트리거 노드 모드에서는 {{feature}} 기능이 지원되지 않습니다.",
|
||||
"overview.status.disable": "비활성",
|
||||
"overview.status.running": "서비스 중",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Image",
|
||||
"variableConfig.file.supportFileTypes": "Support File Types",
|
||||
"variableConfig.file.video.name": "Video",
|
||||
"variableConfig.hidden": "Verborgen en vooraf ingevuld",
|
||||
"variableConfig.hiddenDescription": "Verberg dit veld voor eindgebruikers en geef de waarde zelf op. Wederzijds exclusief met Verplicht. <docLink>Meer informatie</docLink>",
|
||||
"variableConfig.hide": "Hide",
|
||||
"variableConfig.inputPlaceholder": "Please input",
|
||||
"variableConfig.json": "JSON Code",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Copy",
|
||||
"overview.appInfo.embedded.entry": "Embedded",
|
||||
"overview.appInfo.embedded.explanation": "Choose the way to embed chat app to your website",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Voer waarden in voor de verborgen velden. De waarden worden toegevoegd aan de iframe-URL of het inputs-object van het script.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Verborgen velden vooraf invullen",
|
||||
"overview.appInfo.embedded.iframe": "To add the chat app any where on your website, add this iframe to your html code.",
|
||||
"overview.appInfo.embedded.scripts": "To add a chat app to the bottom right of your website add this code to your html.",
|
||||
"overview.appInfo.embedded.title": "Embed on website",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "Workflow Details",
|
||||
"overview.appInfo.settings.workflow.title": "Workflow",
|
||||
"overview.appInfo.title": "Web App",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Voer waarden in voor de verborgen velden en klik op <bold>Starten</bold> om de WebApp te openen met de waarden.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Verborgen velden vooraf invullen",
|
||||
"overview.disableTooltip.triggerMode": "The {{feature}} feature is not supported in Trigger Node mode.",
|
||||
"overview.status.disable": "Disabled",
|
||||
"overview.status.running": "In Service",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Obraz",
|
||||
"variableConfig.file.supportFileTypes": "Obsługa typów plików",
|
||||
"variableConfig.file.video.name": "Wideo",
|
||||
"variableConfig.hidden": "Ukryte i wstępnie wypełnione",
|
||||
"variableConfig.hiddenDescription": "Ukryj to pole przed użytkownikami końcowymi i sam podaj jego wartość. Wzajemnie wykluczające się z Wymagane. <docLink>Dowiedz się więcej</docLink>",
|
||||
"variableConfig.hide": "Ukryj",
|
||||
"variableConfig.inputPlaceholder": "Proszę wpisać",
|
||||
"variableConfig.json": "Kod JSON",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Kopiuj",
|
||||
"overview.appInfo.embedded.entry": "Osadzone",
|
||||
"overview.appInfo.embedded.explanation": "Wybierz sposób osadzenia aplikacji czatu na swojej stronie internetowej",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Wprowadź wartości dla ukrytych pól. Wartości są dodawane do adresu URL elementu iframe lub do obiektu inputs skryptu.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Wstępnie wypełnij ukryte pola",
|
||||
"overview.appInfo.embedded.iframe": "Aby dodać aplikację czatu w dowolnym miejscu na swojej stronie internetowej, dodaj ten kod iframe do swojego kodu HTML.",
|
||||
"overview.appInfo.embedded.scripts": "Aby dodać aplikację czatu w prawym dolnym rogu swojej strony internetowej, dodaj ten kod do swojego HTML.",
|
||||
"overview.appInfo.embedded.title": "Osadź na stronie internetowej",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "Szczegóły przepływu pracy",
|
||||
"overview.appInfo.settings.workflow.title": "Kroki przepływu pracy",
|
||||
"overview.appInfo.title": "Aplikacja internetowa",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Wprowadź wartości dla ukrytych pól, a następnie kliknij <bold>Uruchom</bold>, aby otworzyć WebApp z tymi wartościami.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Wstępnie wypełnij ukryte pola",
|
||||
"overview.disableTooltip.triggerMode": "Funkcja {{feature}} nie jest obsługiwana w trybie węzła wyzwalającego.",
|
||||
"overview.status.disable": "Wyłącz",
|
||||
"overview.status.running": "W usłudze",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Imagem",
|
||||
"variableConfig.file.supportFileTypes": "Tipos de arquivo de suporte",
|
||||
"variableConfig.file.video.name": "Vídeo",
|
||||
"variableConfig.hidden": "Oculto e Pré-preenchido",
|
||||
"variableConfig.hiddenDescription": "Oculte este campo dos usuários finais e forneça o valor você mesmo. Mutuamente exclusivo com Obrigatório. <docLink>Saiba mais</docLink>",
|
||||
"variableConfig.hide": "Ocultar",
|
||||
"variableConfig.inputPlaceholder": "Por favor, insira",
|
||||
"variableConfig.json": "Código JSON",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Copiar",
|
||||
"overview.appInfo.embedded.entry": "Embutido",
|
||||
"overview.appInfo.embedded.explanation": "Escolha a maneira de incorporar o aplicativo de chat ao seu site",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Insira valores para os campos ocultos. Os valores são adicionados à URL do iframe ou ao objeto de inputs do script.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Pré-preencher Campos Ocultos",
|
||||
"overview.appInfo.embedded.iframe": "Para adicionar o aplicativo de chat em qualquer lugar do seu site, adicione este iframe ao seu código HTML.",
|
||||
"overview.appInfo.embedded.scripts": "Para adicionar um aplicativo de chat no canto inferior direito do seu site, adicione este código ao seu HTML.",
|
||||
"overview.appInfo.embedded.title": "Incorporar no site",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "Detalhes do fluxo de trabalho",
|
||||
"overview.appInfo.settings.workflow.title": "Etapas do fluxo de trabalho",
|
||||
"overview.appInfo.title": "Aplicativo Web",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Insira valores para os campos ocultos e clique em <bold>Iniciar</bold> para abrir o WebApp com os valores.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Pré-preencher Campos Ocultos",
|
||||
"overview.disableTooltip.triggerMode": "O recurso {{feature}} não é compatível no modo Nó de Gatilho.",
|
||||
"overview.status.disable": "Desabilitar",
|
||||
"overview.status.running": "Em serviço",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Imagine",
|
||||
"variableConfig.file.supportFileTypes": "Tipuri de fișiere de asistență",
|
||||
"variableConfig.file.video.name": "Video",
|
||||
"variableConfig.hidden": "Ascuns și precompletat",
|
||||
"variableConfig.hiddenDescription": "Ascundeți acest câmp de la utilizatorii finali și furnizați valoarea dvs. înșivă. Incompatibil cu Obligatoriu. <docLink>Aflați mai multe</docLink>",
|
||||
"variableConfig.hide": "Ascundeți",
|
||||
"variableConfig.inputPlaceholder": "Vă rugăm să introduceți",
|
||||
"variableConfig.json": "Cod JSON",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Copiați",
|
||||
"overview.appInfo.embedded.entry": "Încorporat",
|
||||
"overview.appInfo.embedded.explanation": "Alegeți modul de încorporare a aplicației de chat pe site-ul web",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Introduceți valori pentru câmpurile ascunse. Valorile sunt adăugate la URL-ul iframe sau la obiectul inputs al scriptului.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Precompletați câmpurile ascunse",
|
||||
"overview.appInfo.embedded.iframe": "Pentru a adăuga aplicația de chat oriunde pe site-ul web, adăugați acest iframe la codul HTML.",
|
||||
"overview.appInfo.embedded.scripts": "Pentru a adăuga o aplicație de chat în colțul din dreapta jos al site-ului web, adăugați acest cod la codul HTML.",
|
||||
"overview.appInfo.embedded.title": "Încorporați pe site-ul web",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "Detalii despre fluxul de lucru",
|
||||
"overview.appInfo.settings.workflow.title": "Pași flux de lucru",
|
||||
"overview.appInfo.title": "Aplicație web",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Introduceți valori pentru câmpurile ascunse, apoi faceți clic pe <bold>Lansare</bold> pentru a deschide WebApp cu valorile respective.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Precompletați câmpurile ascunse",
|
||||
"overview.disableTooltip.triggerMode": "Funcționalitatea {{feature}} nu este suportată în modul Nod Trigger.",
|
||||
"overview.status.disable": "Dezactivat",
|
||||
"overview.status.running": "În service",
|
||||
|
||||
@ -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": "Введите значения для скрытых полей. Значения добавляются к URL 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>, чтобы открыть WebApp с указанными значениями.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Предзаполнить скрытые поля",
|
||||
"overview.disableTooltip.triggerMode": "Функция {{feature}} не поддерживается в режиме узла триггера.",
|
||||
"overview.status.disable": "Отключено",
|
||||
"overview.status.running": "В работе",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Podoba",
|
||||
"variableConfig.file.supportFileTypes": "Podporne vrste datotek",
|
||||
"variableConfig.file.video.name": "Video",
|
||||
"variableConfig.hidden": "Skrito in vnaprej izpolnjeno",
|
||||
"variableConfig.hiddenDescription": "Skrijte to polje pred končnimi uporabniki in sami navedite vrednost. Se medsebojno izključuje z Zahtevano. <docLink>Več informacij</docLink>",
|
||||
"variableConfig.hide": "Skriti",
|
||||
"variableConfig.inputPlaceholder": "Prosimo, vnesite",
|
||||
"variableConfig.json": "JSON koda",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Kopiraj",
|
||||
"overview.appInfo.embedded.entry": "Vdelano",
|
||||
"overview.appInfo.embedded.explanation": "Izberite način vdelave klepeta na svojo spletno stran",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Vnesite vrednosti za skrita polja. Vrednosti se dodajo v URL iframe ali v objekt inputs skripta.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Vnaprej izpolni skrita polja",
|
||||
"overview.appInfo.embedded.iframe": "Za dodajanje klepeta kjerkoli na vaši spletni strani dodajte to iframe v vašo HTML kodo.",
|
||||
"overview.appInfo.embedded.scripts": "Za dodajanje klepeta na spodnji desni del vaše spletne strani dodajte to kodo v vašo HTML kodo.",
|
||||
"overview.appInfo.embedded.title": "Vdelava na spletno stran",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "Podrobnosti poteka dela",
|
||||
"overview.appInfo.settings.workflow.title": "Potek dela",
|
||||
"overview.appInfo.title": "Spletna aplikacija",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Vnesite vrednosti za skrita polja, nato kliknite <bold>Zaženi</bold>, da odprete WebApp z vrednostmi.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Vnaprej izpolni skrita polja",
|
||||
"overview.disableTooltip.triggerMode": "Funkcija {{feature}} ni podprta v načinu vozlišča sprožilca.",
|
||||
"overview.status.disable": "Onemogočeno",
|
||||
"overview.status.running": "V storitvi",
|
||||
|
||||
@ -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": "ป้อนค่าสำหรับช่องที่ซ่อนอยู่ ค่าต่างๆ จะถูกเพิ่มไปยัง URL ของ 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> เพื่อเปิด WebApp พร้อมค่าดังกล่าว",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "กรอกช่องที่ซ่อนล่วงหน้า",
|
||||
"overview.disableTooltip.triggerMode": "โหมดโหนดทริกเกอร์ไม่รองรับฟีเจอร์ {{feature}}.",
|
||||
"overview.status.disable": "พิการ",
|
||||
"overview.status.running": "ให้บริการ",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Resim",
|
||||
"variableConfig.file.supportFileTypes": "Destek Dosya Türleri",
|
||||
"variableConfig.file.video.name": "Video",
|
||||
"variableConfig.hidden": "Gizlenmiş ve Önceden Doldurulmuş",
|
||||
"variableConfig.hiddenDescription": "Bu alanı son kullanıcılardan gizleyin ve değeri kendiniz girin. Gerekli ile karşılıklı olarak dışlayıcıdır. <docLink>Daha fazla bilgi</docLink>",
|
||||
"variableConfig.hide": "Gizle",
|
||||
"variableConfig.inputPlaceholder": "Lütfen girin",
|
||||
"variableConfig.json": "JSON Kodu",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Kopyala",
|
||||
"overview.appInfo.embedded.entry": "Gömülü",
|
||||
"overview.appInfo.embedded.explanation": "Sohbet uygulamasını web sitenize yerleştirmenin yollarını seçin",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Gizli alanlar için değerler girin. Değerler iframe URL'sine veya komut dosyasının inputs nesnesine eklenir.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Gizli Alanları Önceden Doldurun",
|
||||
"overview.appInfo.embedded.iframe": "Sohbet uygulamasını web sitenizin herhangi bir yerine eklemek için bu iframe'i HTML kodunuza ekleyin.",
|
||||
"overview.appInfo.embedded.scripts": "Sohbet uygulamasını web sitenizin sağ alt köşesine eklemek için bu kodu HTML'e ekleyin.",
|
||||
"overview.appInfo.embedded.title": "Siteye Yerleştir",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "İş Akışı Detayları",
|
||||
"overview.appInfo.settings.workflow.title": "İş Akışı Adımları",
|
||||
"overview.appInfo.title": "Web Uygulaması",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Gizli alanlar için değerler girin, ardından WebApp'i değerlerle açmak için <bold>Başlat</bold>'a tıklayın.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Gizli Alanları Önceden Doldurun",
|
||||
"overview.disableTooltip.triggerMode": "Trigger Düğümü modunda {{feature}} özelliği desteklenmiyor.",
|
||||
"overview.status.disable": "Devre Dışı",
|
||||
"overview.status.running": "Çalışıyor",
|
||||
|
||||
@ -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": "Введіть значення для прихованих полів. Значення додаються до URL 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>, щоб відкрити WebApp із введеними значеннями.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Попередньо заповнити приховані поля",
|
||||
"overview.disableTooltip.triggerMode": "Функція {{feature}} не підтримується в режимі вузла тригера.",
|
||||
"overview.status.disable": "Вимкнути",
|
||||
"overview.status.running": "У роботі",
|
||||
|
||||
@ -337,8 +337,6 @@
|
||||
"variableConfig.file.image.name": "Ảnh",
|
||||
"variableConfig.file.supportFileTypes": "Các loại tệp hỗ trợ",
|
||||
"variableConfig.file.video.name": "Video",
|
||||
"variableConfig.hidden": "Ẩn và điền sẵn",
|
||||
"variableConfig.hiddenDescription": "Ẩn trường này khỏi người dùng cuối và tự cung cấp giá trị. Loại trừ lẫn nhau với Bắt buộc. <docLink>Tìm hiểu thêm</docLink>",
|
||||
"variableConfig.hide": "Ẩn",
|
||||
"variableConfig.inputPlaceholder": "Vui lòng nhập",
|
||||
"variableConfig.json": "Mã JSON",
|
||||
|
||||
@ -56,8 +56,6 @@
|
||||
"overview.appInfo.embedded.copy": "Sao chép",
|
||||
"overview.appInfo.embedded.entry": "Nhúng",
|
||||
"overview.appInfo.embedded.explanation": "Chọn cách nhúng ứng dụng trò chuyện vào trang web của bạn",
|
||||
"overview.appInfo.embedded.hiddenInputs.description": "Nhập giá trị cho các trường ẩn. Các giá trị được thêm vào URL iframe hoặc đối tượng inputs của script.",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "Điền sẵn trường ẩn",
|
||||
"overview.appInfo.embedded.iframe": "Để thêm ứng dụng trò chuyện vào bất kỳ đâu trên trang web của bạn, hãy thêm iframe này vào mã HTML của bạn.",
|
||||
"overview.appInfo.embedded.scripts": "Để thêm ứng dụng trò chuyện vào góc dưới bên phải của trang web, thêm mã này vào mã HTML của bạn.",
|
||||
"overview.appInfo.embedded.title": "Nhúng vào trang web",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "Chi tiết quy trình làm việc",
|
||||
"overview.appInfo.settings.workflow.title": "Các bước quy trình",
|
||||
"overview.appInfo.title": "Ứng dụng web",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "Nhập giá trị cho các trường ẩn, sau đó nhấp <bold>Khởi chạy</bold> để mở WebApp với các giá trị đó.",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "Điền sẵn trường ẩn",
|
||||
"overview.disableTooltip.triggerMode": "Tính năng {{feature}} không được hỗ trợ trong chế độ Nút Kích hoạt.",
|
||||
"overview.status.disable": "Đã tắt",
|
||||
"overview.status.running": "Đang hoạt động",
|
||||
|
||||
@ -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 URL 或嵌入脚本的 inputs 对象中。",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "预填隐藏字段",
|
||||
"overview.appInfo.embedded.iframe": "将以下 iframe 嵌入到你的网站中的目标位置",
|
||||
"overview.appInfo.embedded.scripts": "将以下代码嵌入到你的网站中",
|
||||
"overview.appInfo.embedded.title": "嵌入到网站中",
|
||||
@ -106,8 +104,6 @@
|
||||
"overview.appInfo.settings.workflow.subTitle": "工作流详情",
|
||||
"overview.appInfo.settings.workflow.title": "工作流",
|
||||
"overview.appInfo.title": "Web App",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.description": "为隐藏字段输入值后,点击 <bold>启动</bold> 即可打开已应用这些值的 WebApp。",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "预填隐藏字段",
|
||||
"overview.disableTooltip.triggerMode": "触发节点模式下不支持{{feature}}功能。",
|
||||
"overview.status.disable": "已停用",
|
||||
"overview.status.running": "运行中",
|
||||
|
||||
@ -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 URL 或嵌入腳本的 inputs 物件中。",
|
||||
"overview.appInfo.embedded.hiddenInputs.title": "預填隱藏欄位",
|
||||
"overview.appInfo.embedded.iframe": "將以下 iframe 嵌入到你的網站中的目標位置",
|
||||
"overview.appInfo.embedded.scripts": "將以下程式碼嵌入到你的網站中",
|
||||
"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> 以使用這些值開啟 WebApp。",
|
||||
"overview.appInfo.workflowLaunchHiddenInputs.title": "預填隱藏欄位",
|
||||
"overview.disableTooltip.triggerMode": "觸發節點模式不支援 {{feature}} 功能。",
|
||||
"overview.status.disable": "已停用",
|
||||
"overview.status.running": "執行中",
|
||||
|
||||
@ -52,7 +52,7 @@ export type PromptVariable = {
|
||||
key: string
|
||||
name: string
|
||||
type: string // "string" | "number" | "select",
|
||||
default?: string | number | boolean
|
||||
default?: string | number
|
||||
required?: boolean
|
||||
options?: string[]
|
||||
max_length?: number
|
||||
|
||||
Reference in New Issue
Block a user