Compare commits

..

8 Commits

Author SHA1 Message Date
94a7e4a06a Revert "chore: easier and simpler deploy" 2026-05-08 21:23:28 +08:00
d06b5529b3 chore(docker): clean up env examples (#35938) 2026-05-08 12:53:13 +00:00
8132c444dc feat: support SQLALCHEMY_POOL_RESET_ON_RETURN config (#31156) 2026-05-08 12:25:46 +00:00
cb0356e9d7 chore(i18n): sync translations with en-US (#35933)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-05-08 11:25:15 +00:00
4d80892d7b refactor: convert isinstance chains to match/case (#35902) (#35922)
Signed-off-by: EvanYao826 <2869018789@qq.com>
2026-05-08 09:51:10 +00:00
af754f497a chore: add query generator before lauch webapp (#35416)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-05-08 09:49:43 +00:00
yyh
8f93bb36ba feat(dify-ui): add drawer (#35917)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-08 08:53:32 +00:00
82f24b336d fix(workflow): handle file-preview URLs in node output display (#34150) 2026-05-08 07:55:46 +00:00
158 changed files with 4546 additions and 3766 deletions

View File

@ -76,11 +76,10 @@ The easiest way to start the Dify server is through [Docker Compose](docker/dock
```bash
cd dify
cd docker
./dify-compose up -d
cp .env.example .env
docker compose up -d
```
On Windows PowerShell, run `.\dify-compose.ps1 up -d` from the `docker` directory.
After running, you can access the Dify dashboard in your browser at [http://localhost/install](http://localhost/install) and start the initialization process.
#### Seeking help
@ -138,7 +137,7 @@ Star Dify on GitHub and be instantly notified of new releases.
### Custom configurations
If you need to customize the configuration, add only the values you want to override to `docker/.env`. The default values live in [`docker/.env.default`](docker/.env.default), and the full reference remains in [`docker/.env.example`](docker/.env.example). After making any changes, re-run `./dify-compose up -d` or `.\dify-compose.ps1 up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
If you need to customize the configuration, 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).
### Metrics Monitoring with Grafana

View File

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

View File

@ -33,7 +33,6 @@ from .vector import (
old_metadata_migration,
vdb_migrate,
)
from .vector_space import sample_vector_space_usage
__all__ = [
"add_qdrant_index",
@ -63,7 +62,6 @@ __all__ = [
"reset_encrypt_key_pair",
"reset_password",
"restore_workflow_runs",
"sample_vector_space_usage",
"setup_datasource_oauth_client",
"setup_system_tool_oauth_client",
"setup_system_trigger_oauth_client",

View File

@ -1,698 +0,0 @@
import csv
import json
import secrets
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import Any
import click
import httpx
import sqlalchemy as sa
from sqlalchemy import func, select
from configs import dify_config
from core.rag.datasource.vdb.vector_type import VectorType
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
from extensions.ext_database import db
from models.dataset import (
ChildChunk,
Dataset,
DatasetCollectionBinding,
DocumentSegment,
DocumentSegmentSummary,
SegmentAttachmentBinding,
TidbAuthBinding,
)
from models.dataset import Document as DatasetDocument
from models.enums import IndexingStatus, SegmentStatus, SummaryStatus, TidbAuthBindingStatus
from models.model import App, AppAnnotationSetting, MessageAnnotation
COMMON_EMBEDDING_MODEL_DIMS = {
# OpenAI
"text-embedding-ada-002": 1536,
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
# Cohere
"embed-english-v3.0": 1024,
"embed-multilingual-v3.0": 1024,
"embed-english-light-v3.0": 384,
"embed-multilingual-light-v3.0": 384,
# Google
"embedding-001": 768,
"text-embedding-004": 768,
# Voyage
"voyage-2": 1024,
"voyage-3": 1024,
"voyage-3-lite": 512,
"voyage-large-2": 1536,
"voyage-code-2": 1536,
# BAAI BGE
"bge-small-en": 384,
"bge-small-en-v1.5": 384,
"bge-small-zh": 512,
"bge-small-zh-v1.5": 512,
"bge-base-en": 768,
"bge-base-en-v1.5": 768,
"bge-base-zh": 768,
"bge-base-zh-v1.5": 768,
"bge-large-en": 1024,
"bge-large-en-v1.5": 1024,
"bge-large-zh": 1024,
"bge-large-zh-v1.5": 1024,
"bge-m3": 1024,
# E5
"multilingual-e5-small": 384,
"multilingual-e5-base": 768,
"multilingual-e5-large": 1024,
"e5-small-v2": 384,
"e5-base-v2": 768,
"e5-large-v2": 1024,
# M3E
"m3e-small": 512,
"m3e-base": 768,
"m3e-large": 1024,
# Jina
"jina-embeddings-v2-small-en": 512,
"jina-embeddings-v2-base-en": 768,
"jina-embeddings-v2-base-zh": 768,
"jina-embeddings-v3": 1024,
}
@dataclass(frozen=True)
class CollectionPointStats:
collection_name: str
source_type: str
source_id: str
model_provider: str | None
model_name: str | None
segment_points: int = 0
child_chunk_points: int = 0
summary_points: int = 0
attachment_points: int = 0
annotation_points: int = 0
@property
def total_points(self) -> int:
return (
self.segment_points
+ self.child_chunk_points
+ self.summary_points
+ self.attachment_points
+ self.annotation_points
)
def _parse_overheads(value: str) -> list[int]:
overheads = []
for item in value.split(","):
item = item.strip()
if not item:
continue
overheads.append(int(item))
if not overheads:
raise click.BadParameter("At least one overhead is required.")
return overheads
def _normalize_model_name(model_name: str) -> str:
return model_name.strip().split("/")[-1]
def _tidb_storage_usage_bytes(binding: TidbAuthBinding, timeout: float) -> int:
endpoint = _binding_qdrant_endpoint(binding, timeout)
if not endpoint:
raise ValueError("qdrant_endpoint is empty")
endpoint = endpoint.rstrip("/")
with httpx.Client(timeout=timeout, verify=False) as client:
response = client.get(f"{endpoint}/cluster", headers={"api-key": f"{binding.account}:{binding.password}"})
response.raise_for_status()
data = response.json()
storage = data.get("usage", {}).get("storage", {})
row_based = int(storage.get("row_based") or 0)
columnar = int(storage.get("columnar") or 0)
return row_based + columnar
def _extract_qdrant_endpoint(cluster_response: dict[str, Any]) -> str | None:
endpoints = cluster_response.get("endpoints") or {}
public = endpoints.get("public") or {}
host = public.get("host")
if host:
return f"https://qdrant-{host}"
return None
def _fetch_qdrant_endpoint(binding: TidbAuthBinding, timeout: float) -> str | None:
if not (dify_config.TIDB_API_URL and dify_config.TIDB_PUBLIC_KEY and dify_config.TIDB_PRIVATE_KEY):
return None
try:
with httpx.Client(timeout=timeout) as client:
response = client.get(
f"{dify_config.TIDB_API_URL.rstrip('/')}/clusters/{binding.cluster_id}",
auth=httpx.DigestAuth(dify_config.TIDB_PUBLIC_KEY, dify_config.TIDB_PRIVATE_KEY),
)
response.raise_for_status()
return _extract_qdrant_endpoint(response.json())
except Exception:
return None
def _binding_qdrant_endpoint(binding: TidbAuthBinding, timeout: float) -> str | None:
return binding.qdrant_endpoint or dify_config.TIDB_ON_QDRANT_URL or _fetch_qdrant_endpoint(binding, timeout)
def _extract_vector_size(collection_payload: dict[str, Any]) -> int | None:
vectors = (
collection_payload.get("result", {})
.get("config", {})
.get("params", {})
.get("vectors")
)
if isinstance(vectors, dict):
size = vectors.get("size")
if isinstance(size, int):
return size
for vector_config in vectors.values():
if isinstance(vector_config, dict) and isinstance(vector_config.get("size"), int):
return vector_config["size"]
return None
def _qdrant_collection_dim(
binding: TidbAuthBinding,
collection_name: str,
timeout: float,
dim_cache: dict[str, int | None],
) -> int | None:
if collection_name in dim_cache:
return dim_cache[collection_name]
endpoint = _binding_qdrant_endpoint(binding, timeout)
if not endpoint:
dim_cache[collection_name] = None
return None
endpoint = endpoint.rstrip("/")
try:
with httpx.Client(timeout=timeout, verify=False) as client:
response = client.get(
f"{endpoint}/collections/{collection_name}",
headers={"api-key": f"{binding.account}:{binding.password}"},
)
if response.status_code == 404:
dim_cache[collection_name] = None
return None
response.raise_for_status()
dim = _extract_vector_size(response.json())
dim_cache[collection_name] = dim
return dim
except Exception:
dim_cache[collection_name] = None
return None
def _dataset_vector_type(dataset: Dataset) -> str | None:
if dataset.index_struct_dict:
return dataset.index_struct_dict.get("type")
return dify_config.VECTOR_STORE
def _dataset_collection_name(dataset: Dataset) -> str:
if dataset.index_struct_dict:
vector_store = dataset.index_struct_dict.get("vector_store") or {}
collection_name = vector_store.get("class_prefix")
if collection_name:
return collection_name
if dataset.collection_binding_id:
binding = db.session.get(DatasetCollectionBinding, dataset.collection_binding_id)
if binding:
return binding.collection_name
return Dataset.gen_collection_name_by_id(dataset.id)
def _completed_document_filter() -> tuple[Any, ...]:
return (
DatasetDocument.indexing_status == IndexingStatus.COMPLETED,
DatasetDocument.enabled == True,
DatasetDocument.archived == False,
)
def _completed_segment_filter() -> tuple[Any, ...]:
return (
DocumentSegment.status == SegmentStatus.COMPLETED,
DocumentSegment.enabled == True,
DocumentSegment.index_node_id.is_not(None),
)
def _tenant_has_local_points(tenant_id: str) -> bool:
return bool(
db.session.scalar(
select(DocumentSegment.id)
.join(DatasetDocument, DatasetDocument.id == DocumentSegment.document_id)
.where(
DocumentSegment.tenant_id == tenant_id,
DatasetDocument.doc_form != IndexStructureType.PARENT_CHILD_INDEX,
*_completed_document_filter(),
*_completed_segment_filter(),
)
.limit(1)
)
)
def _active_tidb_bindings(
tenant_ids: tuple[str, ...],
limit: int,
offset: int,
candidate_page_size: int,
max_candidates: int,
random_offset: bool,
quiet: bool,
) -> list[TidbAuthBinding]:
active_binding_filters = (
TidbAuthBinding.tenant_id.is_not(None),
TidbAuthBinding.active == True,
TidbAuthBinding.status == TidbAuthBindingStatus.ACTIVE,
)
base_stmt = select(TidbAuthBinding).where(*active_binding_filters)
if tenant_ids:
stmt = base_stmt.where(TidbAuthBinding.tenant_id.in_(tenant_ids)).order_by(TidbAuthBinding.created_at.desc())
return list(db.session.scalars(stmt).all())
selected = []
scanned = 0
skipped_used = 0
active_binding_count = db.session.scalar(select(func.count(TidbAuthBinding.id)).where(*active_binding_filters)) or 0
if active_binding_count <= 0:
return []
scan_start_offset = offset
if random_offset:
max_start_offset = max(int(active_binding_count) - 1, 0)
scan_start_offset = secrets.randbelow(max_start_offset + 1)
_log(
f"Random active binding scan start: offset={scan_start_offset}, active_bindings={active_binding_count}.",
quiet,
)
page_offset = scan_start_offset
wrapped = False
while len(selected) < limit and scanned < max_candidates:
page_limit = min(candidate_page_size, max_candidates - scanned)
stmt = base_stmt.order_by(TidbAuthBinding.created_at.desc()).limit(page_limit).offset(page_offset)
candidates = list(db.session.scalars(stmt).all())
if not candidates and random_offset and not wrapped and scan_start_offset > 0:
page_offset = 0
wrapped = True
continue
if not candidates:
break
_log(
f"Scanning {len(candidates)} active TiDB binding candidate(s) "
f"from offset={page_offset}; selected={len(selected)}/{limit}.",
quiet,
)
for binding in candidates:
scanned += 1
if binding.tenant_id and _tenant_has_local_points(binding.tenant_id):
selected.append(binding)
if len(selected) >= limit:
break
else:
skipped_used += 1
page_offset += len(candidates)
_log(
f"Candidate scan finished: scanned={scanned}, selected={len(selected)}, skipped_empty={skipped_used}.",
quiet,
)
return selected
def _count_dataset_points(dataset: Dataset) -> CollectionPointStats:
segment_points = (
db.session.scalar(
select(func.count(DocumentSegment.id))
.join(DatasetDocument, DatasetDocument.id == DocumentSegment.document_id)
.where(
DocumentSegment.tenant_id == dataset.tenant_id,
DocumentSegment.dataset_id == dataset.id,
DatasetDocument.doc_form != IndexStructureType.PARENT_CHILD_INDEX,
*_completed_document_filter(),
*_completed_segment_filter(),
)
)
or 0
)
child_chunk_points = (
db.session.scalar(
select(func.count(ChildChunk.id))
.join(DatasetDocument, DatasetDocument.id == ChildChunk.document_id)
.where(
ChildChunk.tenant_id == dataset.tenant_id,
ChildChunk.dataset_id == dataset.id,
ChildChunk.index_node_id.is_not(None),
*_completed_document_filter(),
)
)
or 0
)
summary_points = (
db.session.scalar(
select(func.count(DocumentSegmentSummary.id))
.join(DatasetDocument, DatasetDocument.id == DocumentSegmentSummary.document_id)
.where(
DocumentSegmentSummary.dataset_id == dataset.id,
DocumentSegmentSummary.enabled == True,
DocumentSegmentSummary.status == SummaryStatus.COMPLETED,
DocumentSegmentSummary.summary_index_node_id.is_not(None),
*_completed_document_filter(),
)
)
or 0
)
attachment_points = 0
if dataset.is_multimodal:
attachment_points = (
db.session.scalar(
select(func.count(sa.distinct(SegmentAttachmentBinding.attachment_id)))
.join(DocumentSegment, DocumentSegment.id == SegmentAttachmentBinding.segment_id)
.join(DatasetDocument, DatasetDocument.id == SegmentAttachmentBinding.document_id)
.where(
SegmentAttachmentBinding.tenant_id == dataset.tenant_id,
SegmentAttachmentBinding.dataset_id == dataset.id,
*_completed_document_filter(),
*_completed_segment_filter(),
)
)
or 0
)
return CollectionPointStats(
collection_name=_dataset_collection_name(dataset),
source_type="dataset",
source_id=dataset.id,
model_provider=dataset.embedding_model_provider,
model_name=dataset.embedding_model,
segment_points=int(segment_points),
child_chunk_points=int(child_chunk_points),
summary_points=int(summary_points),
attachment_points=int(attachment_points),
)
def _dataset_stats_for_tenant(tenant_id: str) -> list[CollectionPointStats]:
datasets = db.session.scalars(
select(Dataset).where(
Dataset.tenant_id == tenant_id,
Dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY,
)
).all()
stats = []
for dataset in datasets:
if _dataset_vector_type(dataset) != VectorType.TIDB_ON_QDRANT:
continue
dataset_stats = _count_dataset_points(dataset)
if dataset_stats.total_points > 0:
stats.append(dataset_stats)
return stats
def _annotation_stats_for_tenant(tenant_id: str) -> list[CollectionPointStats]:
rows = db.session.execute(
select(
App.id,
DatasetCollectionBinding.provider_name,
DatasetCollectionBinding.model_name,
DatasetCollectionBinding.collection_name,
func.count(MessageAnnotation.id),
)
.join(AppAnnotationSetting, AppAnnotationSetting.app_id == App.id)
.join(DatasetCollectionBinding, DatasetCollectionBinding.id == AppAnnotationSetting.collection_binding_id)
.join(MessageAnnotation, MessageAnnotation.app_id == App.id)
.where(App.tenant_id == tenant_id)
.group_by(
App.id,
DatasetCollectionBinding.provider_name,
DatasetCollectionBinding.model_name,
DatasetCollectionBinding.collection_name,
)
).all()
return [
CollectionPointStats(
collection_name=row[3],
source_type="annotation",
source_id=row[0],
model_provider=row[1],
model_name=row[2],
annotation_points=int(row[4] or 0),
)
for row in rows
if int(row[4] or 0) > 0
]
def _resolve_dim(
stat: CollectionPointStats,
binding: TidbAuthBinding,
default_dim: int,
fetch_qdrant_dim: bool,
timeout: float,
dim_cache: dict[str, int | None],
) -> tuple[int, str]:
if stat.model_provider and stat.model_name:
builtin_dim = COMMON_EMBEDDING_MODEL_DIMS.get(_normalize_model_name(stat.model_name))
if builtin_dim:
return builtin_dim, "builtin_model_map"
if fetch_qdrant_dim:
qdrant_dim = _qdrant_collection_dim(binding, stat.collection_name, timeout, dim_cache)
if qdrant_dim:
return qdrant_dim, "qdrant"
return default_dim, "default"
def _mb(value: int | float | Decimal) -> float:
return round(float(value) / 1024 / 1024, 4)
def _log(message: str, quiet: bool) -> None:
if not quiet:
click.echo(message, err=True)
@click.command(
"sample-vector-space-usage",
help="Sample TiDB vector storage usage and compare it with local formula estimates.",
)
@click.option("--tenant-id", multiple=True, help="Tenant ID to sample. Can be repeated.")
@click.option(
"--limit",
default=20,
show_default=True,
help="Number of active TiDB tenants with local vector points to sample.",
)
@click.option("--offset", default=0, show_default=True, help="Offset when sampling active TiDB tenants.")
@click.option("--default-dim", default=3072, show_default=True, help="Fallback embedding dimension.")
@click.option(
"--overheads",
default="3584,5120,8192",
show_default=True,
help="Comma-separated per-point overhead bytes to compare.",
)
@click.option("--fetch-qdrant-dim/--no-fetch-qdrant-dim", default=True, show_default=True)
@click.option("--include-annotations/--exclude-annotations", default=True, show_default=True)
@click.option(
"--candidate-page-size",
default=200,
show_default=True,
help="Number of active TiDB bindings to inspect per candidate scan page.",
)
@click.option(
"--max-candidates",
default=2000,
show_default=True,
help="Maximum active TiDB bindings to inspect when tenant IDs are not specified.",
)
@click.option(
"--random-offset/--no-random-offset",
default=True,
show_default=True,
help="Start candidate scan from a random active TiDB binding offset.",
)
@click.option("--timeout", default=10.0, show_default=True, help="HTTP timeout for TiDB/Qdrant calls.")
@click.option("--output", type=click.Path(dir_okay=False, path_type=Path), help="CSV output path. Defaults to stdout.")
@click.option("--quiet", is_flag=True, help="Suppress progress logs. CSV output is unaffected.")
def sample_vector_space_usage(
tenant_id: tuple[str, ...],
limit: int,
offset: int,
default_dim: int,
overheads: str,
fetch_qdrant_dim: bool,
include_annotations: bool,
candidate_page_size: int,
max_candidates: int,
random_offset: bool,
timeout: float,
output: Path | None,
quiet: bool,
):
overhead_values = _parse_overheads(overheads)
bindings = _active_tidb_bindings(
tenant_id,
limit,
offset,
candidate_page_size,
max_candidates,
random_offset,
quiet,
)
sample_scope = (
f" for tenant_id={','.join(tenant_id)}"
if tenant_id
else f" with local vector points, limit={limit}, offset={offset}, max_candidates={max_candidates}"
)
_log(
f"Sampling {len(bindings)} active TiDB binding(s){sample_scope}.",
quiet,
)
if not bindings:
_log("No active TiDB bindings with local vector points found. Nothing to sample.", quiet)
fieldnames = [
"tenant_id",
"cluster_id",
"tidb_actual_mb",
"total_points",
"segment_points",
"child_chunk_points",
"summary_points",
"attachment_points",
"annotation_points",
"collection_count",
"dim_sources",
"dims",
"errors",
]
for overhead in overhead_values:
fieldnames.extend(
[
f"estimated_mb_o{overhead}",
f"diff_mb_o{overhead}",
f"ratio_o{overhead}",
]
)
output_file = output.open("w", newline="") if output else None
try:
writer = csv.DictWriter(output_file or click.get_text_stream("stdout"), fieldnames=fieldnames)
writer.writeheader()
for index, binding in enumerate(bindings, start=1):
assert binding.tenant_id is not None
tenant = binding.tenant_id
errors = []
dim_cache: dict[str, int | None] = {}
_log(f"[{index}/{len(bindings)}] tenant={tenant} cluster={binding.cluster_id}: fetching TiDB usage", quiet)
try:
actual_bytes = _tidb_storage_usage_bytes(binding, timeout)
_log(
f"[{index}/{len(bindings)}] tenant={tenant}: TiDB actual={_mb(actual_bytes)} MB",
quiet,
)
except Exception as exc:
actual_bytes = 0
errors.append(f"tidb_usage:{exc.__class__.__name__}:{exc}")
_log(
f"[{index}/{len(bindings)}] tenant={tenant}: failed to fetch TiDB usage: "
f"{exc.__class__.__name__}: {exc}",
quiet,
)
_log(f"[{index}/{len(bindings)}] tenant={tenant}: counting local vector points", quiet)
collection_stats = _dataset_stats_for_tenant(tenant)
if include_annotations:
collection_stats.extend(_annotation_stats_for_tenant(tenant))
total_points = 0
segment_points = 0
child_chunk_points = 0
summary_points = 0
attachment_points = 0
annotation_points = 0
dim_sources: dict[str, int] = {}
dims: dict[str, int] = {}
estimated_by_overhead = dict.fromkeys(overhead_values, 0)
for stat in collection_stats:
dim, dim_source = _resolve_dim(
stat,
binding,
default_dim,
fetch_qdrant_dim,
timeout,
dim_cache,
)
dim_sources[dim_source] = dim_sources.get(dim_source, 0) + 1
dims[str(dim)] = dims.get(str(dim), 0) + stat.total_points
total_points += stat.total_points
segment_points += stat.segment_points
child_chunk_points += stat.child_chunk_points
summary_points += stat.summary_points
attachment_points += stat.attachment_points
annotation_points += stat.annotation_points
for overhead in overhead_values:
estimated_by_overhead[overhead] += stat.total_points * (dim * 4 + overhead)
_log(
f"[{index}/{len(bindings)}] tenant={tenant}: points={total_points}, "
f"collections={len(collection_stats)}, dim_sources={json.dumps(dim_sources, sort_keys=True)}",
quiet,
)
row: dict[str, Any] = {
"tenant_id": tenant,
"cluster_id": binding.cluster_id,
"tidb_actual_mb": _mb(actual_bytes),
"total_points": total_points,
"segment_points": segment_points,
"child_chunk_points": child_chunk_points,
"summary_points": summary_points,
"attachment_points": attachment_points,
"annotation_points": annotation_points,
"collection_count": len(collection_stats),
"dim_sources": json.dumps(dim_sources, sort_keys=True),
"dims": json.dumps(dims, sort_keys=True),
"errors": ";".join(errors),
}
for overhead, estimated_bytes in estimated_by_overhead.items():
diff_bytes = estimated_bytes - actual_bytes
ratio = round(estimated_bytes / actual_bytes, 6) if actual_bytes > 0 else ""
row[f"estimated_mb_o{overhead}"] = _mb(estimated_bytes)
row[f"diff_mb_o{overhead}"] = _mb(diff_bytes)
row[f"ratio_o{overhead}"] = ratio
writer.writerow(row)
_log(f"[{index}/{len(bindings)}] tenant={tenant}: row written", quiet)
finally:
if output_file:
output_file.close()

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,6 @@ def init_app(app: DifyApp):
reset_encrypt_key_pair,
reset_password,
restore_workflow_runs,
sample_vector_space_usage,
setup_datasource_oauth_client,
setup_system_tool_oauth_client,
setup_system_trigger_oauth_client,
@ -69,7 +68,6 @@ def init_app(app: DifyApp):
clean_workflow_runs,
clean_expired_messages,
export_app_messages,
sample_vector_space_usage,
]
for cmd in cmds_to_register:
app.cli.add_command(cmd)

View File

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

View File

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

View File

@ -1,51 +0,0 @@
# ------------------------------------------------------------------
# Minimal defaults for Docker Compose deployments.
#
# Keep local changes in .env. Use .env.example as the full reference
# for advanced and service-specific settings.
# ------------------------------------------------------------------
# Public URLs used when Dify generates links. Change these together when
# exposing Dify under another hostname, IP address, or port.
CONSOLE_WEB_URL=http://localhost
SERVICE_API_URL=http://localhost
APP_WEB_URL=http://localhost
FILES_URL=http://localhost
INTERNAL_FILES_URL=http://api:5001
TRIGGER_URL=http://localhost
ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id}
NEXT_PUBLIC_SOCKET_URL=ws://localhost
EXPOSE_PLUGIN_DEBUGGING_HOST=localhost
EXPOSE_PLUGIN_DEBUGGING_PORT=5003
# Built-in metadata database defaults.
DB_TYPE=postgresql
DB_USERNAME=postgres
DB_PASSWORD=difyai123456
DB_HOST=db_postgres
DB_PORT=5432
DB_DATABASE=dify
# Built-in Redis defaults.
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=difyai123456
# Default file storage.
STORAGE_TYPE=opendal
OPENDAL_SCHEME=fs
OPENDAL_FS_ROOT=storage
# Default vector database.
VECTOR_STORE=weaviate
# Internal service authentication. Paired values must match.
PLUGIN_DAEMON_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi
PLUGIN_DIFY_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
# Host ports.
EXPOSE_NGINX_PORT=80
EXPOSE_NGINX_SSL_PORT=443
# Docker Compose profiles for bundled services.
COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql}

View File

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

View File

@ -7,28 +7,28 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
- **Certbot Container**: `docker-compose.yaml` now contains `certbot` for managing SSL certificates. This container automatically renews certificates and ensures secure HTTPS connections.\
For more information, refer `docker/certbot/README.md`.
- **Persistent Environment Variables**: Default environment variables are managed through `.env.default`, while local overrides are stored in `.env`, ensuring that your configurations persist across deployments.
- **Persistent Environment Variables**: Environment variables are now managed through a `.env` file, ensuring that your configurations persist across deployments.
> What is `.env`? </br> </br>
> The `.env` file is a local override file. Keep it small by adding only the values that differ from `.env.default`. Use `.env.example` as the full reference when you need advanced configuration.
> 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.
- **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.
- **Local .env Overrides**: The `dify-compose` and `dify-compose.ps1` wrappers create `.env` if it is missing and generate a persistent `SECRET_KEY` for this deployment.
- **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.
### 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.
- No copy step is required. The `dify-compose` wrappers create `.env` if it is missing and write a generated `SECRET_KEY` to it.
- When prompted on first run, press Enter to use the default deployment, or answer `y` to stop and edit `.env` first.
- Customize `.env` only when you need to override defaults from `.env.default`. Refer to `.env.example` for the full list of available variables.
- **Optional (for advanced deployments)**:
If you maintain a full `.env` file copied from `.env.example`, you may use the environment synchronization tool to keep it aligned with the latest `.env.example` updates while preserving your custom settings.
- 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.
See the [Environment Variables Synchronization](#environment-variables-synchronization) section below.
1. **Running the Services**:
- Execute `./dify-compose up -d` from the `docker` directory to start the services. On Windows PowerShell, run `.\dify-compose.ps1 up -d`.
- Execute `docker compose up` 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,13 +58,7 @@ 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.default`, `.env`, and `.env.example`
- `.env.default` contains the minimal default configuration for Docker Compose deployments.
- `.env` contains the generated `SECRET_KEY` plus any local overrides.
- `.env.example` is the full reference for advanced configuration.
The `dify-compose` wrappers merge `.env.default` and `.env` into a temporary environment file, append paired internal service keys when needed, and remove the temporary file after Docker Compose starts.
### Overview of `.env`
#### Key Modules and Customization
@ -124,11 +118,9 @@ 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.default` or `.env.example`.
When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example`.
If you use the default override-only workflow, review `.env.default` and add only the values you need to override to `.env`.
If you maintain a full `.env` file copied from `.env.example`, an optional environment variables synchronization tool is provided.
To help keep your existing `.env` file up to date **without losing your custom values**, an optional environment variables synchronization tool is provided.
> This tool performs a **one-way synchronization** from `.env.example` to `.env`.
> Existing values in `.env` are never overwritten automatically.
@ -151,9 +143,9 @@ Before synchronization, the current `.env` file is saved to the `env-backup/` di
**When to use**
- After upgrading Dify to a newer version with a full `.env` file
- 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 copied from `.env.example`
- When managing a large or heavily customized `.env` file
**Usage**

View File

@ -1,334 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_ENV_FILE=".env.default"
USER_ENV_FILE=".env"
log() {
printf '%s\n' "$*" >&2
}
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
detect_compose() {
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
return
fi
if command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
return
fi
die "Docker Compose is not available. Install Docker Compose, then run this command again."
}
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
}
ensure_env_files() {
[[ -f "$DEFAULT_ENV_FILE" ]] || die "$DEFAULT_ENV_FILE is missing."
if [[ -f "$USER_ENV_FILE" ]]; then
return
fi
: >"$USER_ENV_FILE"
if [[ ! -t 0 ]]; then
log "Created $USER_ENV_FILE for local overrides."
return
fi
printf 'Created %s for local overrides.\n' "$USER_ENV_FILE"
printf 'Do you need a custom deployment now? (Most users can press Enter to skip.) [y/N] '
read -r answer
case "${answer:-}" in
y | Y | yes | YES | Yes)
cat <<'EOF'
Edit .env with the settings you want to override, using .env.example as the full reference.
Run ./dify-compose up -d again when you are ready.
EOF
exit 0
;;
esac
}
user_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 }
' "$USER_ENV_FILE"
}
set_user_env_value() {
local key="$1"
local value="$2"
local temp_file
temp_file="$(mktemp "${TMPDIR:-/tmp}/dify-env.XXXXXX")"
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
}
}
' "$USER_ENV_FILE" >"$temp_file"
mv "$temp_file" "$USER_ENV_FILE"
}
ensure_secret_key() {
local current_secret_key
local secret_key
current_secret_key="$(user_env_value SECRET_KEY)"
if [[ -n "$current_secret_key" ]]; then
return
fi
secret_key="$(generate_secret_key)" || die "Unable to generate SECRET_KEY. Install openssl or configure SECRET_KEY in .env."
set_user_env_value SECRET_KEY "$secret_key"
log "Generated SECRET_KEY in $USER_ENV_FILE."
}
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 }
' "$DEFAULT_ENV_FILE" "$USER_ENV_FILE"
}
user_overrides() {
local key="$1"
grep -Eq "^[[:space:]]*${key}[[:space:]]*=" "$USER_ENV_FILE"
}
write_merged_env() {
awk '
function trim(s) {
sub(/^[[:space:]]+/, "", s)
sub(/[[:space:]]+$/, "", s)
return s
}
/^[[:space:]]*#/ || !/=/{ next }
{
key = $0
sub(/=.*/, "", key)
key = trim(key)
if (key == "") {
next
}
value = substr($0, index($0, "=") + 1)
value = trim(value)
if (!(key in seen)) {
order[++count] = key
seen[key] = 1
}
values[key] = value
}
END {
for (i = 1; i <= count; i++) {
key = order[i]
print key "=" values[key]
}
}
' "$DEFAULT_ENV_FILE" "$USER_ENV_FILE" >"$MERGED_ENV_FILE"
}
set_merged_env_value() {
local key="$1"
local value="$2"
local temp_file
temp_file="$(mktemp "${TMPDIR:-/tmp}/dify-compose-env.XXXXXX")"
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
}
}
' "$MERGED_ENV_FILE" >"$temp_file"
mv "$temp_file" "$MERGED_ENV_FILE"
}
set_if_not_overridden() {
local key="$1"
local value="$2"
if user_overrides "$key"; then
return
fi
set_merged_env_value "$key" "$value"
}
metadata_db_host() {
case "$1" in
mysql) printf 'db_mysql' ;;
postgresql | '') printf 'db_postgres' ;;
*) printf '%s' "$(env_value DB_HOST)" ;;
esac
}
metadata_db_port() {
case "$1" in
mysql) printf '3306' ;;
postgresql | '') printf '5432' ;;
*) printf '%s' "$(env_value DB_PORT)" ;;
esac
}
metadata_db_user() {
case "$1" in
mysql) printf 'root' ;;
postgresql | '') printf 'postgres' ;;
*) printf '%s' "$(env_value DB_USERNAME)" ;;
esac
}
build_merged_env() {
MERGED_ENV_FILE="$(mktemp "${TMPDIR:-/tmp}/dify-compose.XXXXXX")"
trap 'rm -f "$MERGED_ENV_FILE"' EXIT
write_merged_env
local db_type
local redis_host
local redis_port
local redis_username
local redis_password
local redis_auth
local code_execution_api_key
local weaviate_api_key
db_type="$(env_value DB_TYPE)"
set_if_not_overridden DB_HOST "$(metadata_db_host "$db_type")"
set_if_not_overridden DB_PORT "$(metadata_db_port "$db_type")"
set_if_not_overridden DB_USERNAME "$(metadata_db_user "$db_type")"
if ! user_overrides CELERY_BROKER_URL; then
redis_host="$(env_value REDIS_HOST)"
redis_port="$(env_value REDIS_PORT)"
redis_username="$(env_value REDIS_USERNAME)"
redis_password="$(env_value REDIS_PASSWORD)"
redis_auth=""
if [[ -n "$redis_username" && -n "$redis_password" ]]; then
redis_auth="${redis_username}:${redis_password}@"
elif [[ -n "$redis_password" ]]; then
redis_auth=":${redis_password}@"
elif [[ -n "$redis_username" ]]; then
redis_auth="${redis_username}@"
fi
set_merged_env_value CELERY_BROKER_URL "redis://${redis_auth}${redis_host:-redis}:${redis_port:-6379}/1"
fi
if ! user_overrides SANDBOX_API_KEY; then
code_execution_api_key="$(env_value CODE_EXECUTION_API_KEY)"
set_if_not_overridden SANDBOX_API_KEY "${code_execution_api_key:-dify-sandbox}"
fi
if ! user_overrides WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS; then
weaviate_api_key="$(env_value WEAVIATE_API_KEY)"
set_if_not_overridden WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS \
"${weaviate_api_key:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih}"
fi
}
main() {
detect_compose
ensure_env_files
ensure_secret_key
build_merged_env
if [[ "$#" -eq 0 ]]; then
set -- up -d
fi
"${COMPOSE_CMD[@]}" --env-file "$MERGED_ENV_FILE" "$@"
}
main "$@"

View File

@ -1,317 +0,0 @@
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
$DefaultEnvFile = ".env.default"
$UserEnvFile = ".env"
$MergedEnvFile = $null
$Utf8NoBom = New-Object System.Text.UTF8Encoding -ArgumentList $false
function Write-Info {
param([string]$Message)
[Console]::Error.WriteLine($Message)
}
function Fail {
param([string]$Message)
[Console]::Error.WriteLine("Error: $Message")
exit 1
}
function Test-CommandSuccess {
param([string[]]$Command)
try {
$Executable = $Command[0]
$CommandArgs = @()
if ($Command.Length -gt 1) {
$CommandArgs = @($Command[1..($Command.Length - 1)])
}
& $Executable @CommandArgs *> $null
return $LASTEXITCODE -eq 0
}
catch {
return $false
}
}
function Get-ComposeCommand {
if (Test-CommandSuccess @("docker", "compose", "version")) {
return @("docker", "compose")
}
if ((Get-Command "docker-compose" -ErrorAction SilentlyContinue) -and (Test-CommandSuccess @("docker-compose", "version"))) {
return @("docker-compose")
}
Fail "Docker Compose is not available. Install Docker Compose, then run this command again."
}
function New-SecretKey {
$Bytes = New-Object byte[] 42
$Generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$Generator.GetBytes($Bytes)
}
finally {
$Generator.Dispose()
}
return [Convert]::ToBase64String($Bytes)
}
function Ensure-EnvFiles {
if (-not (Test-Path $DefaultEnvFile -PathType Leaf)) {
Fail "$DefaultEnvFile is missing."
}
if (Test-Path $UserEnvFile -PathType Leaf) {
return
}
New-Item -ItemType File -Path $UserEnvFile | Out-Null
if ([Console]::IsInputRedirected) {
Write-Info "Created $UserEnvFile for local overrides."
return
}
Write-Info "Created $UserEnvFile for local overrides."
$Answer = Read-Host "Do you need a custom deployment now? (Most users can press Enter to skip.) [y/N]"
if ($Answer -match "^(y|yes)$") {
Write-Output "Edit .env with the settings you want to override, using .env.example as the full reference."
Write-Output "Run .\dify-compose.ps1 up -d again when you are ready."
exit 0
}
}
function Read-EnvFile {
param([string]$Path)
$Values = [ordered]@{}
if (-not (Test-Path $Path -PathType Leaf)) {
return $Values
}
foreach ($Line in Get-Content -Path $Path) {
if ($Line -match "^\s*#" -or $Line -notmatch "=") {
continue
}
$SeparatorIndex = $Line.IndexOf("=")
$Key = $Line.Substring(0, $SeparatorIndex).Trim()
$Value = $Line.Substring($SeparatorIndex + 1).Trim()
if (($Value.StartsWith('"') -and $Value.EndsWith('"')) -or ($Value.StartsWith("'") -and $Value.EndsWith("'"))) {
$Value = $Value.Substring(1, $Value.Length - 2)
}
if ($Key.Length -gt 0) {
$Values[$Key] = $Value
}
}
return $Values
}
function Set-UserEnvValue {
param(
[string]$Key,
[string]$Value
)
$Path = [string](Resolve-Path $UserEnvFile)
$Lines = [System.IO.File]::ReadAllLines($Path, [System.Text.Encoding]::UTF8)
$Output = New-Object System.Collections.Generic.List[string]
$Replaced = $false
foreach ($Line in $Lines) {
if ($Line -match "^\s*#" -or $Line -notmatch "=") {
$Output.Add($Line)
continue
}
$SeparatorIndex = $Line.IndexOf("=")
$CurrentKey = $Line.Substring(0, $SeparatorIndex).Trim()
if ($CurrentKey -eq $Key) {
if (-not $Replaced) {
$Output.Add("$Key=$Value")
$Replaced = $true
}
continue
}
$Output.Add($Line)
}
if (-not $Replaced) {
$Output.Add("$Key=$Value")
}
[System.IO.File]::WriteAllLines($Path, $Output, $Utf8NoBom)
}
function Ensure-SecretKey {
$Values = Read-EnvFile $UserEnvFile
if ($Values.Contains("SECRET_KEY") -and $Values["SECRET_KEY"]) {
return
}
Set-UserEnvValue "SECRET_KEY" (New-SecretKey)
Write-Info "Generated SECRET_KEY in $UserEnvFile."
}
function Merge-EnvValues {
$Values = [ordered]@{}
foreach ($Entry in (Read-EnvFile $DefaultEnvFile).GetEnumerator()) {
$Values[$Entry.Key] = $Entry.Value
}
foreach ($Entry in (Read-EnvFile $UserEnvFile).GetEnumerator()) {
$Values[$Entry.Key] = $Entry.Value
}
return $Values
}
function User-Overrides {
param([string]$Key)
if (-not (Test-Path $UserEnvFile -PathType Leaf)) {
return $false
}
return [bool](Select-String -Path $UserEnvFile -Pattern "^\s*$([regex]::Escape($Key))\s*=" -Quiet)
}
function Metadata-DbHost {
param([string]$DbType, $Values)
switch ($DbType) {
"mysql" { return "db_mysql" }
"postgresql" { return "db_postgres" }
"" { return "db_postgres" }
default { return $Values["DB_HOST"] }
}
}
function Metadata-DbPort {
param([string]$DbType, $Values)
switch ($DbType) {
"mysql" { return "3306" }
"postgresql" { return "5432" }
"" { return "5432" }
default { return $Values["DB_PORT"] }
}
}
function Metadata-DbUser {
param([string]$DbType, $Values)
switch ($DbType) {
"mysql" { return "root" }
"postgresql" { return "postgres" }
"" { return "postgres" }
default { return $Values["DB_USERNAME"] }
}
}
function Write-MergedEnv {
param($Values)
$Output = New-Object System.Collections.Generic.List[string]
foreach ($Entry in $Values.GetEnumerator()) {
$Output.Add("$($Entry.Key)=$($Entry.Value)")
}
[System.IO.File]::WriteAllLines($MergedEnvFile, $Output, $Utf8NoBom)
}
function Build-MergedEnv {
$Values = Merge-EnvValues
$script:MergedEnvFile = [System.IO.Path]::GetTempFileName()
$DbType = if ($Values.Contains("DB_TYPE")) { $Values["DB_TYPE"] } else { "postgresql" }
if (-not (User-Overrides "DB_HOST")) {
$Values["DB_HOST"] = Metadata-DbHost $DbType $Values
}
if (-not (User-Overrides "DB_PORT")) {
$Values["DB_PORT"] = Metadata-DbPort $DbType $Values
}
if (-not (User-Overrides "DB_USERNAME")) {
$Values["DB_USERNAME"] = Metadata-DbUser $DbType $Values
}
if (-not (User-Overrides "CELERY_BROKER_URL")) {
$RedisHost = if ($Values.Contains("REDIS_HOST") -and $Values["REDIS_HOST"]) { $Values["REDIS_HOST"] } else { "redis" }
$RedisPort = if ($Values.Contains("REDIS_PORT") -and $Values["REDIS_PORT"]) { $Values["REDIS_PORT"] } else { "6379" }
$RedisUsername = if ($Values.Contains("REDIS_USERNAME")) { $Values["REDIS_USERNAME"] } else { "" }
$RedisPassword = if ($Values.Contains("REDIS_PASSWORD")) { $Values["REDIS_PASSWORD"] } else { "" }
$RedisAuth = ""
if ($RedisUsername -and $RedisPassword) {
$RedisAuth = "${RedisUsername}:${RedisPassword}@"
}
elseif ($RedisPassword) {
$RedisAuth = ":${RedisPassword}@"
}
elseif ($RedisUsername) {
$RedisAuth = "${RedisUsername}@"
}
$Values["CELERY_BROKER_URL"] = "redis://$RedisAuth${RedisHost}:${RedisPort}/1"
}
if (-not (User-Overrides "SANDBOX_API_KEY")) {
$CodeExecutionApiKey = if ($Values.Contains("CODE_EXECUTION_API_KEY") -and $Values["CODE_EXECUTION_API_KEY"]) { $Values["CODE_EXECUTION_API_KEY"] } else { "dify-sandbox" }
$Values["SANDBOX_API_KEY"] = $CodeExecutionApiKey
}
if (-not (User-Overrides "WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS")) {
$WeaviateApiKey = if ($Values.Contains("WEAVIATE_API_KEY") -and $Values["WEAVIATE_API_KEY"]) { $Values["WEAVIATE_API_KEY"] } else { "WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih" }
$Values["WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS"] = $WeaviateApiKey
}
Write-MergedEnv $Values
}
$ComposeCommand = Get-ComposeCommand
try {
Ensure-EnvFiles
Ensure-SecretKey
Build-MergedEnv
$ComposeArgs = @($args)
if ($ComposeArgs.Count -eq 0) {
$ComposeArgs = @("up", "-d")
}
$ComposeCommandArgs = @()
if ($ComposeCommand.Length -gt 1) {
$ComposeCommandArgs = @($ComposeCommand[1..($ComposeCommand.Length - 1)])
}
$ComposeExecutable = $ComposeCommand[0]
& $ComposeExecutable @ComposeCommandArgs --env-file $MergedEnvFile @ComposeArgs
exit $LASTEXITCODE
}
finally {
if ($MergedEnvFile -and (Test-Path $MergedEnvFile -PathType Leaf)) {
Remove-Item -Force $MergedEnvFile
}
}

View File

@ -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:-10}
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-}
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10}
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10}
@ -228,7 +228,7 @@ services:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
MYSQL_DATABASE: ${DB_DATABASE:-dify}
command: >
--max_connections=1000
--max_connections=${MYSQL_MAX_CONNECTIONS:-1000}
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
--innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M}
--innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2}
@ -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"]

View File

@ -51,7 +51,7 @@ services:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
MYSQL_DATABASE: ${DB_DATABASE:-dify}
command: >
--max_connections=1000
--max_connections=${MYSQL_MAX_CONNECTIONS:-1000}
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
--innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M}
--innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2}

View File

@ -70,6 +70,7 @@ 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}
@ -361,7 +362,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_SCHEMA: ${VIKINGDB_SCHEMA:-http}
VIKINGDB_SCHEME: ${VIKINGDB_SCHEME:-http}
VIKINGDB_CONNECTION_TIMEOUT: ${VIKINGDB_CONNECTION_TIMEOUT:-30}
VIKINGDB_SOCKET_TIMEOUT: ${VIKINGDB_SOCKET_TIMEOUT:-30}
LINDORM_URL: ${LINDORM_URL:-http://localhost:30070}
@ -423,8 +424,6 @@ 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}
@ -441,10 +440,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:-}
MAIL_TYPE: ${MAIL_TYPE:-resend}
MAIL_DEFAULT_SEND_FROM: ${MAIL_DEFAULT_SEND_FROM:-}
RESEND_API_URL: ${RESEND_API_URL:-https://api.resend.com}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_API_KEY: ${RESEND_API_KEY:-your-resend-api-key}
SMTP_SERVER: ${SMTP_SERVER:-}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USERNAME: ${SMTP_USERNAME:-}
@ -586,8 +585,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:-}
CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-}
CERTBOT_EMAIL: ${CERTBOT_EMAIL:-your_email@example.com}
CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-your_domain.com}
CERTBOT_OPTIONS: ${CERTBOT_OPTIONS:-}
SSRF_HTTP_PORT: ${SSRF_HTTP_PORT:-3128}
SSRF_COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
@ -633,7 +632,6 @@ 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}
@ -894,8 +892,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:-10}
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-}
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10}
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10}
@ -952,7 +950,7 @@ services:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
MYSQL_DATABASE: ${DB_DATABASE:-dify}
command: >
--max_connections=1000
--max_connections=${MYSQL_MAX_CONNECTIONS:-1000}
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
--innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M}
--innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2}
@ -1126,8 +1124,8 @@ services:
- ./certbot/update-cert.template.txt:/update-cert.template.txt
- ./certbot/docker-entrypoint.sh:/docker-entrypoint.sh
environment:
- CERTBOT_EMAIL=${CERTBOT_EMAIL:-}
- CERTBOT_DOMAIN=${CERTBOT_DOMAIN:-}
- CERTBOT_EMAIL=${CERTBOT_EMAIL}
- CERTBOT_DOMAIN=${CERTBOT_DOMAIN}
- CERTBOT_OPTIONS=${CERTBOT_OPTIONS:-}
entrypoint: ["/docker-entrypoint.sh"]
command: ["tail", "-f", "/dev/null"]

View File

@ -202,6 +202,11 @@
"count": 1
}
},
"web/app/components/app/annotation/add-annotation-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/annotation/batch-add-annotation-modal/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -230,6 +235,11 @@
"count": 1
}
},
"web/app/components/app/annotation/edit-annotation-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/annotation/header-opts/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -252,6 +262,9 @@
"erasable-syntax-only/enums": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 5
},
@ -269,11 +282,6 @@
"count": 4
}
},
"web/app/components/app/app-publisher/index.tsx": {
"ts/no-explicit-any": {
"count": 5
}
},
"web/app/components/app/app-publisher/version-info-modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -344,6 +352,9 @@
}
},
"web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/exhaustive-deps": {
"count": 1
},
@ -401,6 +412,16 @@
"count": 2
}
},
"web/app/components/app/configuration/configuration-view.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/card-item/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -531,6 +552,9 @@
}
},
"web/app/components/app/log/list.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 6
},
@ -580,6 +604,9 @@
}
},
"web/app/components/app/workflow-log/list.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 2
}
@ -904,6 +931,11 @@
"count": 1
}
},
"web/app/components/base/drawer-plus/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/emoji-picker/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -1029,6 +1061,11 @@
"count": 3
}
},
"web/app/components/base/float-right-container/index.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/base/form/components/base/base-form.tsx": {
"ts/no-explicit-any": {
"count": 6
@ -1233,7 +1270,7 @@
},
"web/app/components/base/icons/src/vender/line/development/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
"count": 1
}
},
"web/app/components/base/icons/src/vender/line/editor/index.ts": {
@ -2144,14 +2181,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/batch-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -2162,11 +2191,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/components/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 3
}
},
"web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@ -2231,14 +2255,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/segment-add/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": {
"ts/no-explicit-any": {
"count": 6
@ -2280,6 +2296,9 @@
}
},
"web/app/components/datasets/hit-testing/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/unsupported-syntax": {
"count": 1
}
@ -2319,7 +2338,7 @@
},
"web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": {
"no-restricted-imports": {
"count": 2
"count": 3
}
},
"web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": {
@ -2813,10 +2832,18 @@
}
},
"web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 7
}
},
"web/app/components/plugins/plugin-detail-panel/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/model-list.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2838,6 +2865,9 @@
}
},
"web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -2896,6 +2926,9 @@
}
},
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 5
}
@ -2933,16 +2966,6 @@
"count": 1
}
},
"web/app/components/plugins/readme-panel/index.tsx": {
"react/unsupported-syntax": {
"count": 1
}
},
"web/app/components/plugins/readme-panel/store.ts": {
"erasable-syntax-only/enums": {
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -3170,7 +3193,7 @@
},
"web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": {
"no-restricted-imports": {
"count": 1
"count": 2
}
},
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
@ -3179,6 +3202,9 @@
}
},
"web/app/components/tools/edit-custom-collection-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 4
},
@ -3187,6 +3213,9 @@
}
},
"web/app/components/tools/edit-custom-collection-modal/test-api.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -3196,6 +3225,11 @@
"count": 1
}
},
"web/app/components/tools/mcp/detail/provider-detail.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/mcp/mcp-server-modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -3224,12 +3258,20 @@
"count": 1
}
},
"web/app/components/tools/provider/detail.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/provider/empty.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/tools/setting/build-in/config-credentials.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@ -4061,6 +4103,11 @@
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": {
"ts/no-explicit-any": {
"count": 1

View File

@ -28,6 +28,7 @@ Always import from a **subpath export** — there is no barrel:
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import '@langgenius/dify-ui/styles.css' // once, in the app root
```
@ -36,12 +37,12 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
| Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
Utilities:
@ -65,7 +66,7 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s
## Overlay & portal contract
All overlay primitives (`dialog`, `alert-dialog`, `autocomplete`, `combobox`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually.
Overlay primitives render their floating surfaces inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Convenience content components such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portal internally; primitives with explicit portal anatomy such as `Drawer` expose the matching `DrawerPortal` part so consumers can compose the full Base UI structure.
### Root isolation requirement
@ -83,19 +84,19 @@ Equivalent: any root element with `isolation: isolate` in CSS. Without it, overl
Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites.
| Layer | z-index | Where |
| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
| Layer | z-index | Where |
| ------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` / `base/drawer` / `base/drawer-plus` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`.
### Rules
- Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated.
- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal.
- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal.
- When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites.
## Development

View File

@ -37,6 +37,10 @@
"types": "./src/dialog/index.tsx",
"import": "./src/dialog/index.tsx"
},
"./drawer": {
"types": "./src/drawer/index.tsx",
"import": "./src/drawer/index.tsx"
},
"./dropdown-menu": {
"types": "./src/dropdown-menu/index.tsx",
"import": "./src/dropdown-menu/index.tsx"

View File

@ -0,0 +1,61 @@
import { render } from 'vitest-browser-react'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
DrawerViewport,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Drawer wrapper', () => {
describe('User Interactions', () => {
it('should open a portalled drawer and close it with the default close button', async () => {
const screen = await render(
<Drawer>
<DrawerTrigger>Open settings</DrawerTrigger>
<DrawerPortal>
<DrawerBackdrop data-testid="drawer-backdrop" />
<DrawerViewport>
<DrawerPopup>
<DrawerTitle>Settings</DrawerTitle>
<DrawerDescription>Configure the current workspace.</DrawerDescription>
<DrawerContent>
<p>Workspace controls</p>
<DrawerCloseButton />
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>,
)
expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Open settings' }).element()).click()
await vi.waitFor(() => {
expect(document.body.querySelector('[role="dialog"]')).toBeInTheDocument()
})
const dialog = asHTMLElement(document.body.querySelector('[role="dialog"]')!)
expect(document.body).toContainElement(dialog)
expect(screen.container).not.toContainElement(dialog)
await expect.element(dialog).toHaveTextContent('Workspace controls')
await expect.element(screen.getByText('Configure the current workspace.')).toBeInTheDocument()
await expect.element(screen.getByTestId('drawer-backdrop')).toHaveClass('z-1002')
asHTMLElement(screen.getByRole('button', { name: 'Close drawer' }).element()).click()
await vi.waitFor(() => {
expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument()
})
})
})
})

View File

@ -0,0 +1,116 @@
'use client'
import type { ReactNode } from 'react'
import { Drawer as BaseDrawer } from '@base-ui/react/drawer'
import { cn } from '../cn'
export const Drawer = BaseDrawer.Root
export const DrawerProvider = BaseDrawer.Provider
export const DrawerIndent = BaseDrawer.Indent
export const DrawerIndentBackground = BaseDrawer.IndentBackground
export const DrawerTrigger = BaseDrawer.Trigger
export const DrawerSwipeArea = BaseDrawer.SwipeArea
export const DrawerPortal = BaseDrawer.Portal
export const DrawerTitle = BaseDrawer.Title
export const DrawerDescription = BaseDrawer.Description
export const DrawerClose = BaseDrawer.Close
export const createDrawerHandle = BaseDrawer.createHandle
export type DrawerRootProps<Payload = unknown> = BaseDrawer.Root.Props<Payload>
export type DrawerRootActions = BaseDrawer.Root.Actions
export type DrawerRootChangeEventDetails = BaseDrawer.Root.ChangeEventDetails
export type DrawerRootChangeEventReason = BaseDrawer.Root.ChangeEventReason
export type DrawerRootSnapPoint = BaseDrawer.Root.SnapPoint
export type DrawerRootSnapPointChangeEventDetails = BaseDrawer.Root.SnapPointChangeEventDetails
export type DrawerRootSnapPointChangeEventReason = BaseDrawer.Root.SnapPointChangeEventReason
export type DrawerTriggerProps<Payload = unknown> = BaseDrawer.Trigger.Props<Payload>
export function DrawerBackdrop({
className,
...props
}: BaseDrawer.Backdrop.Props) {
return (
<BaseDrawer.Backdrop
className={cn(
'fixed inset-0 z-1002 bg-background-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))]',
'transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 motion-reduce:transition-none',
className,
)}
{...props}
/>
)
}
export function DrawerViewport({
className,
...props
}: BaseDrawer.Viewport.Props) {
return (
<BaseDrawer.Viewport
className={cn('fixed inset-0 z-1002 touch-none overflow-hidden overscroll-contain outline-hidden', className)}
{...props}
/>
)
}
export function DrawerPopup({
className,
...props
}: BaseDrawer.Popup.Props) {
return (
<BaseDrawer.Popup
className={cn(
'fixed z-1002 flex min-h-0 flex-col overflow-hidden border-[0.5px] border-components-panel-border bg-components-panel-bg text-text-primary shadow-xl outline-hidden touch-none',
'transition-[transform,opacity,box-shadow] duration-200 data-swiping:select-none data-swiping:duration-0 motion-reduce:transition-none',
'data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:h-dvh data-[swipe-direction=right]:w-120 data-[swipe-direction=right]:max-w-[calc(100vw-2rem)] data-[swipe-direction=right]:rounded-l-2xl data-[swipe-direction=right]:border-r-0 data-[swipe-direction=right]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]',
'data-starting-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))] data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(100%+2px))]',
'data-[swipe-direction=left]:inset-y-0 data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:h-dvh data-[swipe-direction=left]:w-120 data-[swipe-direction=left]:max-w-[calc(100vw-2rem)] data-[swipe-direction=left]:rounded-r-2xl data-[swipe-direction=left]:border-l-0 data-[swipe-direction=left]:transform-[translateX(var(--drawer-swipe-movement-x,0px))]',
'data-starting-style:data-[swipe-direction=left]:transform-[translateX(calc(-100%-2px))] data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(-100%-2px))]',
'data-[swipe-direction=down]:inset-x-0 data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:max-h-[calc(100dvh-2rem)] data-[swipe-direction=down]:w-full data-[swipe-direction=down]:rounded-t-2xl data-[swipe-direction=down]:border-b-0 data-[swipe-direction=down]:transform-[translateY(calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)))]',
'data-starting-style:data-[swipe-direction=down]:transform-[translateY(calc(100%+2px))] data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(100%+2px))]',
'data-[swipe-direction=up]:inset-x-0 data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:max-h-[calc(100dvh-2rem)] data-[swipe-direction=up]:w-full data-[swipe-direction=up]:rounded-b-2xl data-[swipe-direction=up]:border-t-0 data-[swipe-direction=up]:transform-[translateY(var(--drawer-swipe-movement-y,0px))]',
'data-starting-style:data-[swipe-direction=up]:transform-[translateY(calc(-100%-2px))] data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(-100%-2px))]',
className,
)}
{...props}
/>
)
}
export function DrawerContent({
className,
...props
}: BaseDrawer.Content.Props) {
return (
<BaseDrawer.Content
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain p-6 pb-[calc(1.5rem+env(safe-area-inset-bottom,0))]', className)}
{...props}
/>
)
}
type DrawerCloseButtonProps = Omit<BaseDrawer.Close.Props, 'children'> & {
children?: ReactNode
}
export function DrawerCloseButton({
className,
children,
type = 'button',
'aria-label': ariaLabel = 'Close drawer',
...props
}: DrawerCloseButtonProps) {
return (
<BaseDrawer.Close
type={type}
aria-label={ariaLabel}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
{children ?? <span aria-hidden="true" className="i-ri-close-line h-4 w-4" />}
</BaseDrawer.Close>
)
}

View File

@ -205,7 +205,7 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
}))
vi.mock('@/app/components/tools/workflow-tool', () => ({
default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
<div data-testid="workflow-tool-modal">
<button data-testid="wf-modal-hide" onClick={onHide}>Hide</button>
<button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>Save</button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +1,40 @@
import type { FormEvent } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types'
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '@/app/components/app/overview/app-card-utils'
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { RiStoreLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useKeyPress } from 'ahooks'
import {
memo,
use,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections'
import {
buildWorkflowLaunchUrl,
createWorkflowLaunchInitialValues,
isWorkflowLaunchInputSupported,
} from '@/app/components/app/overview/app-card-utils'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { WorkflowContext } from '@/app/components/workflow/context'
import { appDefaultIconBackground } from '@/config'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
@ -57,8 +69,8 @@ export type AppPublisherProps = {
debugWithMultipleModel?: boolean
multipleModelConfigs?: ModelAndParameter[]
/** modelAndParameter is passed when debugWithMultipleModel is true */
onPublish?: (params?: any) => Promise<any> | any
onRestore?: () => Promise<any> | any
onPublish?: AppPublisherPublishHandler
onRestore?: AppPublisherRestoreHandler
onToggle?: (state: boolean) => void
crossAxisOffset?: number
toolPublished?: boolean
@ -74,6 +86,12 @@ export type AppPublisherProps = {
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
type AppPublisherPublishHandler
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
| ((params?: unknown) => Promise<unknown> | unknown)
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown
const AppPublisher = ({
disabled = false,
publishDisabled = false,
@ -100,11 +118,15 @@ const AppPublisher = ({
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false)
const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('')
const [workflowLaunchValues, setWorkflowLaunchValues] = useState<Record<string, WorkflowLaunchInputValue>>({})
const [publishingToMarketplace, setPublishingToMarketplace] = useState(false)
const workflowStore = useContext(WorkflowContext)
const workflowStore = use(WorkflowContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -113,6 +135,22 @@ const AppPublisher = ({
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
const hiddenLaunchVariables = useMemo<WorkflowHiddenStartVariable[]>(
() => (inputs ?? []).filter(input => input.hide === true),
[inputs],
)
const supportedWorkflowLaunchVariables = useMemo(
() => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported),
[hiddenLaunchVariables],
)
const unsupportedWorkflowLaunchVariables = useMemo(
() => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)),
[hiddenLaunchVariables],
)
const initialWorkflowLaunchValues = useMemo(
() => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables),
[supportedWorkflowLaunchVariables],
)
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
@ -222,6 +260,31 @@ const AppPublisher = ({
}
}, [appDetail, setAppDetail])
const handleOpenWorkflowLaunchDialog = useCallback((targetUrl: string) => {
setWorkflowLaunchValues(initialWorkflowLaunchValues)
setWorkflowLaunchTargetUrl(targetUrl)
setWorkflowLaunchDialogOpen(true)
}, [initialWorkflowLaunchValues])
const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => {
setWorkflowLaunchValues(prev => ({
...prev,
[variable]: value,
}))
}, [])
const handleWorkflowLaunchConfirm = useCallback(async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const targetUrl = await buildWorkflowLaunchUrl({
accessibleUrl: workflowLaunchTargetUrl,
variables: supportedWorkflowLaunchVariables,
values: workflowLaunchValues,
})
window.open(targetUrl, '_blank')
setWorkflowLaunchDialogOpen(false)
}, [supportedWorkflowLaunchVariables, workflowLaunchTargetUrl, workflowLaunchValues])
const handlePublishToMarketplace = useCallback(async () => {
if (!appDetail?.id || publishingToMarketplace)
return
@ -273,6 +336,31 @@ const AppPublisher = ({
const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable
? t('common.workflowAsToolDisabledHint', { ns: 'workflow' })
: undefined
const workflowToolVisible = appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode
const workflowToolPublished = !!toolPublished
const closeWorkflowToolDrawer = useCallback(() => setWorkflowToolDrawerOpen(false), [])
const workflowToolIcon = useMemo(() => ({
content: (appDetail?.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
background: (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
}), [appDetail?.icon, appDetail?.icon_background, appDetail?.icon_type])
const workflowTool = useConfigureButton({
enabled: workflowToolVisible,
published: workflowToolPublished,
detailNeedUpdate: workflowToolPublished && published,
workflowAppId: appDetail?.id ?? '',
icon: workflowToolIcon,
name: appDetail?.name ?? '',
description: appDetail?.description ?? '',
inputs,
outputs,
handlePublish,
onRefreshData,
onConfigured: closeWorkflowToolDrawer,
})
const openWorkflowToolDrawer = useCallback(() => {
handleOpenChange(false)
setWorkflowToolDrawerOpen(true)
}, [handleOpenChange])
const upgradeHighlightStyle = useMemo(() => ({
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
WebkitBackgroundClip: 'text',
@ -343,23 +431,27 @@ const AppPublisher = ({
handleOpenChange(false)
handleOpenInExplore()
}}
handleOpenRunConfig={handleOpenWorkflowLaunchDialog}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)}
showRunConfig={hiddenLaunchVariables.length > 0}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable}
workflowToolIsLoading={workflowTool.isLoading}
workflowToolOutdated={workflowTool.outdated}
workflowToolIsCurrentWorkspaceManager={workflowTool.isCurrentWorkspaceManager}
workflowToolMessage={workflowToolMessage}
onConfigureWorkflowTool={openWorkflowToolDrawer}
/>
{systemFeatures.enable_creators_platform && (
<div className="border-t border-divider-subtle p-4">
<SuggestedAction
icon={<RiStoreLine className="h-4 w-4" />}
icon={<span className="i-ri-store-line h-4 w-4" />}
disabled={!publishedAt || publishingToMarketplace}
onClick={handlePublishToMarketplace}
>
@ -377,9 +469,29 @@ const AppPublisher = ({
onClose={() => setEmbeddingModalOpen(false)}
appBaseUrl={appBaseURL}
accessToken={accessToken}
hiddenInputs={hiddenLaunchVariables}
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
<WorkflowLaunchDialog
t={t}
open={workflowLaunchDialogOpen}
hiddenVariables={supportedWorkflowLaunchVariables}
unsupportedVariables={unsupportedWorkflowLaunchVariables}
values={workflowLaunchValues}
onOpenChange={setWorkflowLaunchDialogOpen}
onValueChange={handleWorkflowLaunchValueChange}
onSubmit={handleWorkflowLaunchConfirm}
/>
</Popover>
{workflowToolDrawerOpen && (
<WorkflowToolDrawer
isAdd={!workflowToolPublished}
payload={workflowTool.payload}
onHide={closeWorkflowToolDrawer}
onCreate={workflowTool.handleCreate}
onSave={workflowTool.handleUpdate}
/>
)}
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -54,22 +54,22 @@ const Operation: FC<Props> = ({
onOpenChange={setOpen}
>
<DropdownMenuTrigger
render={<div />}
render={(
<ActionButton
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
state={
isActive
? ActionButtonState.Active
: open
? ActionButtonState.Hover
: ActionButtonState.Default
}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</ActionButton>
)}
onClick={e => e.stopPropagation()}
>
<ActionButton
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
state={
isActive
? ActionButtonState.Active
: open
? ActionButtonState.Hover
: ActionButtonState.Default
}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
</ActionButton>
</DropdownMenuTrigger>
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}

View File

@ -182,11 +182,13 @@ describe('useChatLayout', () => {
act(() => {
capturedResizeCallbacks[0]?.([makeResizeEntry(80, 400)], {} as ResizeObserver)
flushAnimationFrames()
})
expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px')
act(() => {
capturedResizeCallbacks[1]?.([makeResizeEntry(50, 560)], {} as ResizeObserver)
flushAnimationFrames()
})
expect(screen.getByTestId('chat-footer').style.width).toBe('560px')

View File

@ -12,6 +12,11 @@ type UseChatLayoutOptions = {
sidebarCollapseState?: boolean
}
const setStyleValue = (element: HTMLElement, property: 'paddingBottom' | 'width', value: string) => {
if (element.style[property] !== value)
element.style[property] = value
}
export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutOptions) => {
const [width, setWidth] = useState(0)
const chatContainerRef = useRef<HTMLDivElement>(null)
@ -21,6 +26,9 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
const userScrolledRef = useRef(false)
const isAutoScrollingRef = useRef(false)
const prevFirstMessageIdRef = useRef<string | undefined>(undefined)
const resizeObserverFrameRef = useRef<number | null>(null)
const pendingFooterBlockSizeRef = useRef<number | null>(null)
const pendingContainerInlineSizeRef = useRef<number | null>(null)
const handleScrollToBottom = useCallback(() => {
if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) {
@ -34,16 +42,39 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
}, [chatList.length])
const handleWindowResize = useCallback(() => {
if (chatContainerRef.current)
setWidth(document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8)
if (chatContainerRef.current) {
const nextWidth = document.body.clientWidth - (chatContainerRef.current.clientWidth + 16) - 8
setWidth(currentWidth => currentWidth === nextWidth ? currentWidth : nextWidth)
}
if (chatContainerRef.current && chatFooterRef.current)
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
setStyleValue(chatFooterRef.current, 'width', `${chatContainerRef.current.clientWidth}px`)
if (chatContainerInnerRef.current && chatFooterInnerRef.current)
chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px`
setStyleValue(chatFooterInnerRef.current, 'width', `${chatContainerInnerRef.current.clientWidth}px`)
}, [])
const scheduleResizeObserverUpdate = useCallback(() => {
if (resizeObserverFrameRef.current !== null)
return
resizeObserverFrameRef.current = requestAnimationFrame(() => {
resizeObserverFrameRef.current = null
const footerBlockSize = pendingFooterBlockSizeRef.current
pendingFooterBlockSizeRef.current = null
if (footerBlockSize !== null && chatContainerRef.current) {
setStyleValue(chatContainerRef.current, 'paddingBottom', `${footerBlockSize}px`)
handleScrollToBottom()
}
const containerInlineSize = pendingContainerInlineSizeRef.current
pendingContainerInlineSizeRef.current = null
if (containerInlineSize !== null && chatFooterRef.current)
setStyleValue(chatFooterRef.current, 'width', `${containerInlineSize}px`)
})
}, [handleScrollToBottom])
useEffect(() => {
handleScrollToBottom()
const animationFrame = requestAnimationFrame(handleWindowResize)
@ -77,26 +108,31 @@ export const useChatLayout = ({ chatList, sidebarCollapseState }: UseChatLayoutO
const resizeContainerObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { blockSize } = entry.borderBoxSize[0]!
chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
handleScrollToBottom()
pendingFooterBlockSizeRef.current = blockSize
}
scheduleResizeObserverUpdate()
})
resizeContainerObserver.observe(chatFooterRef.current)
const resizeFooterObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { inlineSize } = entry.borderBoxSize[0]!
chatFooterRef.current!.style.width = `${inlineSize}px`
pendingContainerInlineSizeRef.current = inlineSize
}
scheduleResizeObserverUpdate()
})
resizeFooterObserver.observe(chatContainerRef.current)
return () => {
if (resizeObserverFrameRef.current !== null) {
cancelAnimationFrame(resizeObserverFrameRef.current)
resizeObserverFrameRef.current = null
}
resizeContainerObserver.disconnect()
resizeFooterObserver.disconnect()
}
}
}, [handleScrollToBottom])
}, [scheduleResizeObserverUpdate])
useEffect(() => {
const setUserScrolled = () => {

View File

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

View File

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

View File

@ -120,18 +120,12 @@ vi.mock('../document-title', () => ({
}))
vi.mock('../segment-add', () => ({
default: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => (
SegmentAdd: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => (
<div data-testid="segment-add" data-embedding={embedding}>
<button data-testid="new-segment-btn" onClick={showNewSegmentModal}>New Segment</button>
<button data-testid="batch-btn" onClick={showBatchModal}>Batch Import</button>
</div>
),
ProcessStatus: {
WAITING: 'waiting',
PROCESSING: 'processing',
ERROR: 'error',
COMPLETED: 'completed',
},
}))
vi.mock('../../components/operations', () => ({

View File

@ -2,12 +2,15 @@
import type { FC } from 'react'
import type { ChunkingMode, FileItem } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import CSVDownloader from './csv-downloader'
import CSVUploader from './csv-uploader'
@ -18,8 +21,9 @@ type IBatchModalProps = {
onConfirm: (file: FileItem) => void
}
const BatchModal: FC<IBatchModalProps> = ({
isShow,
type BatchModalContentProps = Omit<IBatchModalProps, 'isShow'>
const BatchModalContent: FC<BatchModalContentProps> = ({
docForm,
onCancel,
onConfirm,
@ -35,17 +39,13 @@ const BatchModal: FC<IBatchModalProps> = ({
onConfirm(currentCSV)
}
useEffect(() => {
if (!isShow)
setCurrentCSV(undefined)
}, [isShow])
return (
<Modal isShow={isShow} onClose={noop} className="max-w-[520px]! rounded-xl! px-8 py-6">
<div className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary">{t('list.batchModal.title', { ns: 'datasetDocuments' })}</div>
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onCancel}>
<RiCloseLine className="h-4 w-4 text-text-secondary" />
</div>
<DialogContent className="w-[520px]! overflow-hidden! rounded-xl! border-0! px-8 py-6">
<DialogTitle className="relative pb-1 text-xl leading-[30px] font-medium text-text-primary">{t('list.batchModal.title', { ns: 'datasetDocuments' })}</DialogTitle>
<DialogCloseButton
className="top-4 right-4"
aria-label={t('list.batchModal.cancel', { ns: 'datasetDocuments' })}
/>
<CSVUploader
file={currentCSV}
updateFile={handleFile}
@ -61,7 +61,33 @@ const BatchModal: FC<IBatchModalProps> = ({
{t('list.batchModal.run', { ns: 'datasetDocuments' })}
</Button>
</div>
</Modal>
</DialogContent>
)
}
const BatchModal: FC<IBatchModalProps> = ({
isShow,
docForm,
onCancel,
onConfirm,
}) => {
return (
<Dialog
open={isShow}
onOpenChange={open => !open && onCancel()}
disablePointerDismissal
>
{isShow
? (
<BatchModalContent
docForm={docForm}
onCancel={onCancel}
onConfirm={onConfirm}
/>
)
: null}
</Dialog>
)
}
export default React.memo(BatchModal)

View File

@ -137,9 +137,8 @@ vi.mock('../hooks/use-child-segment-data', () => ({
},
}))
// Mock child components to simplify testing
vi.mock('../components', () => ({
MenuBar: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
vi.mock('../components/menu-bar', () => ({
default: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: {
totalText: string
onInputChange: (value: string) => void
inputValue: string
@ -167,7 +166,13 @@ vi.mock('../components', () => ({
)}
</div>
),
}))
vi.mock('../components/drawer-group', () => ({
DrawerGroup: () => <div data-testid="drawer-group" />,
}))
vi.mock('../components/segment-list-content', () => ({
FullDocModeContent: () => <div data-testid="full-doc-mode-content" />,
GeneralModeContent: () => <div data-testid="general-mode-content" />,
}))
@ -563,7 +568,7 @@ describe('Edge Cases', () => {
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()
})
it('should handle ProcessStatus.COMPLETED importStatus', () => {
it('should handle completed importStatus', () => {
render(<Completed {...defaultProps} importStatus="completed" />, { wrapper: createWrapper() })
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()

View File

@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode } from '@/models/datasets'
import SegmentDetail from '../segment-detail'
import { SegmentDetail } from '../segment-detail'
// Mock dataset detail context
let mockIndexingTechnique = IndexingType.QUALIFIED
@ -167,7 +167,6 @@ describe('SegmentDetail', () => {
onCancel: vi.fn(),
isEditMode: false,
docForm: ChunkingMode.text,
onModalStateChange: vi.fn(),
}
describe('Rendering', () => {
@ -352,35 +351,12 @@ describe('SegmentDetail', () => {
expect(screen.getByTestId('regeneration-modal'))!.toBeInTheDocument()
})
it('should call onModalStateChange when regeneration modal opens', () => {
const mockOnModalStateChange = vi.fn()
render(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onModalStateChange={mockOnModalStateChange}
/>,
)
fireEvent.click(screen.getByTestId('regenerate-btn'))
expect(mockOnModalStateChange).toHaveBeenCalledWith(true)
})
it('should close modal when cancel is clicked', () => {
const mockOnModalStateChange = vi.fn()
render(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onModalStateChange={mockOnModalStateChange}
/>,
)
render(<SegmentDetail {...defaultProps} isEditMode={true} />)
fireEvent.click(screen.getByTestId('regenerate-btn'))
fireEvent.click(screen.getByTestId('cancel-regeneration'))
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument()
})
})
@ -504,22 +480,18 @@ describe('SegmentDetail', () => {
it('should close modal and edit drawer when close after regeneration is clicked', () => {
const mockOnCancel = vi.fn()
const mockOnModalStateChange = vi.fn()
render(
<SegmentDetail
{...defaultProps}
isEditMode={true}
onCancel={mockOnCancel}
onModalStateChange={mockOnModalStateChange}
/>,
)
// Open regeneration modal
fireEvent.click(screen.getByTestId('regenerate-btn'))
fireEvent.click(screen.getByTestId('close-regeneration'))
expect(mockOnModalStateChange).toHaveBeenCalledWith(false)
expect(mockOnCancel).toHaveBeenCalled()
})
})

View File

@ -1,27 +1,16 @@
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Drawer from '../drawer'
import { CompletedDrawer } from '../drawer'
let capturedKeyPressCallback: ((e: KeyboardEvent) => void) | undefined
(
globalThis as typeof globalThis & {
BASE_UI_ANIMATIONS_DISABLED: boolean
}
).BASE_UI_ANIMATIONS_DISABLED = true
// Mock useKeyPress: required because tests capture the registered callback
// and invoke it directly to verify ESC key handling behavior.
vi.mock('ahooks', () => ({
useKeyPress: vi.fn((_key: string, cb: (e: KeyboardEvent) => void) => {
capturedKeyPressCallback = cb
}),
}))
vi.mock('../..', () => ({
useSegmentListContext: (selector: (state: {
currSegment: { showModal: boolean }
currChildChunk: { showModal: boolean }
}) => unknown) =>
selector({
currSegment: { showModal: false },
currChildChunk: { showModal: false },
}),
}))
const getOverlay = () =>
Array.from(document.querySelectorAll<HTMLElement>('[class]'))
.find(element => element.className.includes('bg-background-overlay'))
describe('Drawer', () => {
const defaultProps = {
@ -31,103 +20,109 @@ describe('Drawer', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedKeyPressCallback = undefined
})
describe('Rendering', () => {
it('should return null when open is false', () => {
const { container } = render(
<Drawer open={false} onClose={vi.fn()}>
<CompletedDrawer open={false} onClose={vi.fn()}>
<span>Content</span>
</Drawer>,
</CompletedDrawer>,
)
expect(container.innerHTML).toBe('')
expect(screen.queryByText('Content')).not.toBeInTheDocument()
})
it('should render children in portal when open is true', () => {
it('should render children in the drawer portal when open is true', () => {
render(
<Drawer {...defaultProps}>
<CompletedDrawer {...defaultProps}>
<span>Drawer content</span>
</Drawer>,
</CompletedDrawer>,
)
expect(screen.getByText('Drawer content')).toBeInTheDocument()
})
it('should render dialog with role="dialog"', () => {
render(
<Drawer {...defaultProps}>
<span>Content</span>
</Drawer>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
// Overlay visibility
describe('Overlay', () => {
it('should show overlay when showOverlay is true', () => {
describe('Variant', () => {
it('should render a panel drawer without overlay by default', () => {
render(
<Drawer {...defaultProps} showOverlay={true}>
<CompletedDrawer {...defaultProps}>
<span>Content</span>
</Drawer>,
)
const overlay = document.querySelector('[aria-hidden="true"]')
expect(overlay).toBeInTheDocument()
})
it('should hide overlay when showOverlay is false', () => {
render(
<Drawer {...defaultProps} showOverlay={false}>
<span>Content</span>
</Drawer>,
)
const overlay = document.querySelector('[aria-hidden="true"]')
expect(overlay).not.toBeInTheDocument()
})
})
// aria-modal attribute
describe('aria-modal', () => {
it('should set aria-modal="true" when modal is true', () => {
render(
<Drawer {...defaultProps} modal={true}>
<span>Content</span>
</Drawer>,
)
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
})
it('should set aria-modal="false" when modal is false', () => {
render(
<Drawer {...defaultProps} modal={false}>
<span>Content</span>
</Drawer>,
</CompletedDrawer>,
)
expect(getOverlay()).toBeUndefined()
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false')
})
})
// ESC key handling
describe('ESC Key', () => {
it('should call onClose when ESC is pressed and drawer is open', () => {
const onClose = vi.fn()
it('should render a modal drawer with overlay', () => {
render(
<Drawer open={true} onClose={onClose}>
<CompletedDrawer {...defaultProps} modal>
<span>Content</span>
</Drawer>,
</CompletedDrawer>,
)
expect(capturedKeyPressCallback).toBeDefined()
const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent
capturedKeyPressCallback!(fakeEvent)
expect(getOverlay()).toBeInTheDocument()
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
})
})
describe('Dismissal', () => {
it('should call onClose when Escape is pressed', async () => {
const onClose = vi.fn()
render(
<CompletedDrawer open={true} onClose={onClose}>
<span>Content</span>
</CompletedDrawer>,
)
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1)
})
})
it('should keep a panel drawer open when the underlying page is clicked', () => {
const onClose = vi.fn()
render(
<>
<button type="button">Outside</button>
<CompletedDrawer open={true} onClose={onClose}>
<span>Content</span>
</CompletedDrawer>
</>,
)
fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' }))
expect(onClose).not.toHaveBeenCalled()
})
it('should keep a panel drawer open when the pointer down starts inside content', () => {
const onClose = vi.fn()
render(
<CompletedDrawer open={true} onClose={onClose}>
<button type="button">Inside</button>
</CompletedDrawer>,
)
fireEvent.pointerDown(screen.getByRole('button', { name: 'Inside' }))
expect(onClose).not.toHaveBeenCalled()
})
it('should close a modal drawer when the overlay is clicked', () => {
const onClose = vi.fn()
render(
<CompletedDrawer open={true} onClose={onClose} modal>
<span>Content</span>
</CompletedDrawer>,
)
fireEvent.click(getOverlay()!)
expect(onClose).toHaveBeenCalledTimes(1)
})

View File

@ -1,11 +1,11 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import FullScreenDrawer from '../full-screen-drawer'
import { DocumentDetailDrawer } from '../full-screen-drawer'
// Mock the Drawer component since it has high complexity
vi.mock('../drawer', () => ({
default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => {
CompletedDrawer: ({ children, open, panelClassName, panelContentClassName, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, modal: boolean }) => {
if (!open)
return null
return (
@ -13,8 +13,6 @@ vi.mock('../drawer', () => ({
data-testid="drawer-mock"
data-panel-class={panelClassName}
data-panel-content-class={panelContentClassName}
data-show-overlay={showOverlay}
data-need-check-chunks={needCheckChunks}
data-modal={modal}
>
{children}
@ -23,7 +21,7 @@ vi.mock('../drawer', () => ({
},
}))
describe('FullScreenDrawer', () => {
describe('DocumentDetailDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
@ -31,9 +29,9 @@ describe('FullScreenDrawer', () => {
describe('Rendering', () => {
it('should render without crashing when open', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
@ -41,9 +39,9 @@ describe('FullScreenDrawer', () => {
it('should not render when closed', () => {
render(
<FullScreenDrawer isOpen={false} fullScreen={false}>
<DocumentDetailDrawer open={false} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
@ -51,9 +49,9 @@ describe('FullScreenDrawer', () => {
it('should render children content', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Test Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.getByText('Test Content')).toBeInTheDocument()
@ -63,86 +61,46 @@ describe('FullScreenDrawer', () => {
describe('Props', () => {
it('should pass fullScreen=true to Drawer with full width class', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<DocumentDetailDrawer open={true} fullScreen={true}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-panel-class')).toContain('w-full')
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-full')
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-full')
})
it('should pass fullScreen=false to Drawer with fixed width class', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]')
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=right]:w-[568px]')
expect(drawer.getAttribute('data-panel-class')).toContain('data-[swipe-direction=left]:w-[568px]')
})
it('should pass showOverlay prop with default true', () => {
it('should render as non-modal by default', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-show-overlay')).toBe('true')
})
it('should pass showOverlay=false when specified', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false} showOverlay={false}>
<div>Content</div>
</FullScreenDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-show-overlay')).toBe('false')
})
it('should pass needCheckChunks prop with default false', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-need-check-chunks')).toBe('false')
})
it('should pass needCheckChunks=true when specified', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false} needCheckChunks={true}>
<div>Content</div>
</FullScreenDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-need-check-chunks')).toBe('true')
})
it('should pass modal prop with default false', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-modal')).toBe('false')
})
it('should pass modal=true when specified', () => {
it('should pass modal when specified', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false} modal={true}>
<DocumentDetailDrawer open={true} fullScreen={false} modal>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
@ -154,9 +112,9 @@ describe('FullScreenDrawer', () => {
describe('Styling', () => {
it('should apply panel content classes for non-fullScreen mode', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
@ -167,9 +125,9 @@ describe('FullScreenDrawer', () => {
it('should apply panel content classes without border for fullScreen mode', () => {
render(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<DocumentDetailDrawer open={true} fullScreen={true}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
const drawer = screen.getByTestId('drawer-mock')
@ -184,24 +142,24 @@ describe('FullScreenDrawer', () => {
// Arrange & Act & Assert - should not throw
expect(() => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
}).not.toThrow()
})
it('should maintain structure when rerendered', () => {
const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
rerender(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<DocumentDetailDrawer open={true} fullScreen={true}>
<div>Updated Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.getByText('Updated Content')).toBeInTheDocument()
@ -209,16 +167,16 @@ describe('FullScreenDrawer', () => {
it('should handle toggle between open and closed states', () => {
const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<DocumentDetailDrawer open={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
rerender(
<FullScreenDrawer isOpen={false} fullScreen={false}>
<DocumentDetailDrawer open={false} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
</DocumentDetailDrawer>,
)
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()

View File

@ -1,143 +1,92 @@
import type { ComponentProps, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useKeyPress } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useSegmentListContext } from '..'
import {
Drawer,
DrawerBackdrop,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
type DrawerProps = {
type DrawerSide = 'right' | 'left' | 'bottom' | 'top'
type DrawerSwipeDirection = 'right' | 'left' | 'down' | 'up'
type DrawerOpenChange = NonNullable<ComponentProps<typeof Drawer>['onOpenChange']>
type CompletedDrawerProps = {
open: boolean
onClose: () => void
side?: 'right' | 'left' | 'bottom' | 'top'
showOverlay?: boolean
modal?: boolean // click outside event can pass through if modal is false
closeOnOutsideClick?: boolean
side?: DrawerSide
panelClassName?: string
panelContentClassName?: string
needCheckChunks?: boolean
modal?: boolean
children: ReactNode
}
const SIDE_POSITION_CLASS = {
right: 'right-0',
left: 'left-0',
bottom: 'bottom-0',
top: 'top-0',
} as const
function containsTarget(selector: string, target: Node | null): boolean {
const elements = document.querySelectorAll(selector)
return Array.from(elements).some(el => el?.contains(target))
const SIDE_TO_SWIPE_DIRECTION: Record<DrawerSide, DrawerSwipeDirection> = {
right: 'right',
left: 'left',
bottom: 'down',
top: 'up',
}
function shouldReopenChunkDetail(
isClickOnChunk: boolean,
isClickOnChildChunk: boolean,
segmentModalOpen: boolean,
childChunkModalOpen: boolean,
): boolean {
if (segmentModalOpen && isClickOnChildChunk)
return true
if (childChunkModalOpen && isClickOnChunk && !isClickOnChildChunk)
return true
return !isClickOnChunk && !isClickOnChildChunk
}
const DRAWER_POPUP_CLASS_NAME = [
'pointer-events-auto overflow-visible border-0 bg-transparent shadow-none',
'data-[swipe-direction=right]:h-screen data-[swipe-direction=right]:max-w-none data-[swipe-direction=right]:rounded-none data-[swipe-direction=right]:border-0',
'data-[swipe-direction=left]:h-screen data-[swipe-direction=left]:max-w-none data-[swipe-direction=left]:rounded-none data-[swipe-direction=left]:border-0',
'data-[swipe-direction=down]:max-h-none data-[swipe-direction=down]:rounded-none data-[swipe-direction=down]:border-0',
'data-[swipe-direction=up]:max-h-none data-[swipe-direction=up]:rounded-none data-[swipe-direction=up]:border-0',
].join(' ')
const Drawer = ({
export function CompletedDrawer({
open,
onClose,
side = 'right',
showOverlay = true,
modal = false,
needCheckChunks = false,
children,
panelClassName,
panelContentClassName,
}: React.PropsWithChildren<DrawerProps>) => {
const panelContentRef = useRef<HTMLDivElement>(null)
const currSegment = useSegmentListContext(s => s.currSegment)
const currChildChunk = useSegmentListContext(s => s.currChildChunk)
useKeyPress('esc', (e) => {
if (!open)
modal = false,
}: CompletedDrawerProps) {
const handleOpenChange: DrawerOpenChange = (nextOpen, eventDetails) => {
if (nextOpen)
return
e.preventDefault()
if (eventDetails.reason === 'focus-out' || eventDetails.reason === 'outside-press')
return
onClose()
}, { exactMatch: true, useCapture: true })
const shouldCloseDrawer = useCallback((target: Node | null) => {
const panelContent = panelContentRef.current
if (!panelContent || !target)
return false
if (panelContent.contains(target))
return false
if (containsTarget('.image-previewer', target))
return false
if (!needCheckChunks)
return true
const isClickOnChunk = containsTarget('.chunk-card', target)
const isClickOnChildChunk = containsTarget('.child-chunk', target)
return shouldReopenChunkDetail(isClickOnChunk, isClickOnChildChunk, currSegment.showModal, currChildChunk.showModal)
}, [currSegment.showModal, currChildChunk.showModal, needCheckChunks])
const onDownCapture = useCallback((e: PointerEvent) => {
if (!open || modal)
return
const panelContent = panelContentRef.current
if (!panelContent)
return
const target = e.target as Node | null
if (shouldCloseDrawer(target))
queueMicrotask(onClose)
}, [shouldCloseDrawer, onClose, open, modal])
useEffect(() => {
window.addEventListener('pointerdown', onDownCapture, { capture: true })
return () =>
window.removeEventListener('pointerdown', onDownCapture, { capture: true })
}, [onDownCapture])
const isHorizontal = side === 'left' || side === 'right'
const overlayPointerEvents = modal && open ? 'pointer-events-auto' : 'pointer-events-none'
const content = (
<div className="pointer-events-none fixed inset-0 z-9999">
{showOverlay && (
<div
onClick={modal ? onClose : undefined}
aria-hidden="true"
className={cn(
'fixed inset-0 bg-black/30 opacity-0 transition-opacity duration-200 ease-in',
open && 'opacity-100',
overlayPointerEvents,
)}
/>
)}
<div
role="dialog"
aria-modal={modal ? 'true' : 'false'}
className={cn(
'pointer-events-auto fixed flex flex-col',
SIDE_POSITION_CLASS[side],
isHorizontal ? 'h-screen' : 'w-screen',
panelClassName,
)}
>
<div ref={panelContentRef} className={cn('flex grow flex-col', panelContentClassName)}>
{children}
</div>
</div>
</div>
)
}
if (!open)
return null
return createPortal(content, document.body)
return (
<Drawer
open={open}
modal={modal}
swipeDirection={SIDE_TO_SWIPE_DIRECTION[side]}
disablePointerDismissal
onOpenChange={handleOpenChange}
>
<DrawerPortal>
{modal && (
<DrawerBackdrop
onClick={onClose}
/>
)}
<DrawerViewport className="pointer-events-none">
<DrawerPopup
aria-modal={modal ? 'true' : 'false'}
className={cn(DRAWER_POPUP_CLASS_NAME, panelClassName)}
>
<DrawerContent
className={cn('flex grow flex-col overflow-visible p-0 pb-0', panelContentClassName)}
>
{children}
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}
export default Drawer

View File

@ -1,46 +1,39 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import Drawer from './drawer'
import { CompletedDrawer } from './drawer'
type IFullScreenDrawerProps = {
isOpen: boolean
type DocumentDetailDrawerProps = {
open: boolean
onClose?: () => void
fullScreen: boolean
showOverlay?: boolean
needCheckChunks?: boolean
modal?: boolean
children: ReactNode
}
const FullScreenDrawer = ({
isOpen,
export function DocumentDetailDrawer({
open,
onClose = noop,
fullScreen,
children,
showOverlay = true,
needCheckChunks = false,
modal = false,
}: React.PropsWithChildren<IFullScreenDrawerProps>) => {
}: DocumentDetailDrawerProps) {
return (
<Drawer
open={isOpen}
<CompletedDrawer
open={open}
onClose={onClose}
panelClassName={cn(
fullScreen
? 'w-full'
: 'w-[568px] pt-16 pr-2 pb-2',
? 'w-full data-[swipe-direction=left]:w-full data-[swipe-direction=right]:w-full'
: 'w-[568px] pt-16 pr-2 pb-2 data-[swipe-direction=left]:w-[568px] data-[swipe-direction=right]:w-[568px]',
)}
panelContentClassName={cn(
'bg-components-panel-bg',
!fullScreen && 'rounded-xl border-[0.5px] border-components-panel-border',
)}
showOverlay={showOverlay}
needCheckChunks={needCheckChunks}
modal={modal}
>
{children}
</Drawer>
</CompletedDrawer>
)
}
export default FullScreenDrawer

View File

@ -2,16 +2,16 @@ import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import DrawerGroup from '../drawer-group'
import { DrawerGroup } from '../drawer-group'
vi.mock('../../common/full-screen-drawer', () => ({
default: ({ isOpen, children }: { isOpen: boolean, children: React.ReactNode }) => (
isOpen ? <div data-testid="full-screen-drawer">{children}</div> : null
DocumentDetailDrawer: ({ open, children, modal = false }: { open: boolean, children: React.ReactNode, modal?: boolean }) => (
open ? <div data-testid="document-detail-drawer" data-modal={modal}>{children}</div> : null
),
}))
vi.mock('../../segment-detail', () => ({
default: () => <div data-testid="segment-detail" />,
SegmentDetail: () => <div data-testid="segment-detail" />,
}))
vi.mock('../../child-segment-detail', () => ({
@ -31,8 +31,6 @@ describe('DrawerGroup', () => {
currSegment: { segInfo: undefined, showModal: false, isEditMode: false },
onCloseSegmentDetail: vi.fn(),
onUpdateSegment: vi.fn(),
isRegenerationModalOpen: false,
setIsRegenerationModalOpen: vi.fn(),
showNewSegmentModal: false,
onCloseNewSegmentModal: vi.fn(),
onSaveNewSegment: vi.fn(),
@ -55,7 +53,7 @@ describe('DrawerGroup', () => {
it('should render nothing when all modals are closed', () => {
const { container } = render(<DrawerGroup {...defaultProps} />)
expect(container.querySelector('[data-testid="full-screen-drawer"]')).toBeNull()
expect(container.querySelector('[data-testid="document-detail-drawer"]')).toBeNull()
})
it('should render segment detail when segment modal is open', () => {
@ -66,6 +64,7 @@ describe('DrawerGroup', () => {
/>,
)
expect(screen.getByTestId('segment-detail')).toBeInTheDocument()
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false')
})
it('should render new segment modal when showNewSegmentModal is true', () => {
@ -73,6 +72,7 @@ describe('DrawerGroup', () => {
<DrawerGroup {...defaultProps} showNewSegmentModal={true} />,
)
expect(screen.getByTestId('new-segment')).toBeInTheDocument()
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true')
})
it('should render child segment detail when child chunk modal is open', () => {
@ -83,6 +83,7 @@ describe('DrawerGroup', () => {
/>,
)
expect(screen.getByTestId('child-segment-detail')).toBeInTheDocument()
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'false')
})
it('should render new child segment modal when showNewChildSegmentModal is true', () => {
@ -90,6 +91,7 @@ describe('DrawerGroup', () => {
<DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />,
)
expect(screen.getByTestId('new-child-segment')).toBeInTheDocument()
expect(screen.getByTestId('document-detail-drawer')).toHaveAttribute('data-modal', 'true')
})
it('should render multiple drawers simultaneously', () => {

View File

@ -1,15 +1,13 @@
'use client'
import type { FC } from 'react'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, ChunkingMode, SegmentDetailModel } from '@/models/datasets'
import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
import ChildSegmentDetail from '../child-segment-detail'
import FullScreenDrawer from '../common/full-screen-drawer'
import { DocumentDetailDrawer } from '../common/full-screen-drawer'
import NewChildSegment from '../new-child-segment'
import SegmentDetail from '../segment-detail'
import { SegmentDetail } from '../segment-detail'
type DrawerGroupProps = {
// Segment detail drawer
currSegment: {
segInfo?: SegmentDetailModel
showModal: boolean
@ -25,14 +23,10 @@ type DrawerGroupProps = {
summary?: string,
needRegenerate?: boolean,
) => Promise<void>
isRegenerationModalOpen: boolean
setIsRegenerationModalOpen: (open: boolean) => void
// New segment drawer
showNewSegmentModal: boolean
onCloseNewSegmentModal: () => void
onSaveNewSegment: () => void
viewNewlyAddedChunk: () => void
// Child segment detail drawer
currChildChunk: {
childChunkInfo?: ChildChunkDetail
showModal: boolean
@ -40,52 +34,39 @@ type DrawerGroupProps = {
currChunkId: string
onCloseChildSegmentDetail: () => void
onUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void>
// New child segment drawer
showNewChildSegmentModal: boolean
onCloseNewChildChunkModal: () => void
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
viewNewlyAddedChildChunk: () => void
// Common props
fullScreen: boolean
docForm: ChunkingMode
}
const DrawerGroup: FC<DrawerGroupProps> = ({
// Segment detail drawer
export function DrawerGroup({
currSegment,
onCloseSegmentDetail,
onUpdateSegment,
isRegenerationModalOpen,
setIsRegenerationModalOpen,
// New segment drawer
showNewSegmentModal,
onCloseNewSegmentModal,
onSaveNewSegment,
viewNewlyAddedChunk,
// Child segment detail drawer
currChildChunk,
currChunkId,
onCloseChildSegmentDetail,
onUpdateChildChunk,
// New child segment drawer
showNewChildSegmentModal,
onCloseNewChildChunkModal,
onSaveNewChildChunk,
viewNewlyAddedChildChunk,
// Common props
fullScreen,
docForm,
}) => {
}: DrawerGroupProps) {
return (
<>
{/* Edit or view segment detail */}
<FullScreenDrawer
isOpen={currSegment.showModal}
<DocumentDetailDrawer
open={currSegment.showModal}
fullScreen={fullScreen}
onClose={onCloseSegmentDetail}
showOverlay={false}
needCheckChunks
modal={isRegenerationModalOpen}
>
<SegmentDetail
key={currSegment.segInfo?.id}
@ -94,13 +75,11 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
isEditMode={currSegment.isEditMode}
onUpdate={onUpdateSegment}
onCancel={onCloseSegmentDetail}
onModalStateChange={setIsRegenerationModalOpen}
/>
</FullScreenDrawer>
</DocumentDetailDrawer>
{/* Create New Segment */}
<FullScreenDrawer
isOpen={showNewSegmentModal}
<DocumentDetailDrawer
open={showNewSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewSegmentModal}
modal
@ -111,15 +90,12 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onSave={onSaveNewSegment}
viewNewlyAddedChunk={viewNewlyAddedChunk}
/>
</FullScreenDrawer>
</DocumentDetailDrawer>
{/* Edit or view child segment detail */}
<FullScreenDrawer
isOpen={currChildChunk.showModal}
<DocumentDetailDrawer
open={currChildChunk.showModal}
fullScreen={fullScreen}
onClose={onCloseChildSegmentDetail}
showOverlay={false}
needCheckChunks
>
<ChildSegmentDetail
key={currChildChunk.childChunkInfo?.id}
@ -129,11 +105,10 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onUpdate={onUpdateChildChunk}
onCancel={onCloseChildSegmentDetail}
/>
</FullScreenDrawer>
</DocumentDetailDrawer>
{/* Create New Child Segment */}
<FullScreenDrawer
isOpen={showNewChildSegmentModal}
<DocumentDetailDrawer
open={showNewChildSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewChildChunkModal}
modal
@ -144,9 +119,7 @@ const DrawerGroup: FC<DrawerGroupProps> = ({
onSave={onSaveNewChildChunk}
viewNewlyAddedChildChunk={viewNewlyAddedChildChunk}
/>
</FullScreenDrawer>
</DocumentDetailDrawer>
</>
)
}
export default DrawerGroup

View File

@ -1,3 +0,0 @@
export { default as DrawerGroup } from './drawer-group'
export { default as MenuBar } from './menu-bar'
export { FullDocModeContent, GeneralModeContent } from './segment-list-content'

View File

@ -1,7 +1,9 @@
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useModalState } from '../use-modal-state'
import * as modalStateHooks from '../use-modal-state'
const renderDatasetModalState = modalStateHooks.useModalState
describe('useModalState', () => {
const onNewSegmentModalChange = vi.fn()
@ -10,22 +12,21 @@ describe('useModalState', () => {
vi.clearAllMocks()
})
const renderUseModalState = () =>
renderHook(() => useModalState({ onNewSegmentModalChange }))
const renderModalState = () =>
renderHook(() => renderDatasetModalState({ onNewSegmentModalChange }))
it('should initialize with all modals closed', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
expect(result.current.currSegment.showModal).toBe(false)
expect(result.current.currChildChunk.showModal).toBe(false)
expect(result.current.showNewChildSegmentModal).toBe(false)
expect(result.current.isRegenerationModalOpen).toBe(false)
expect(result.current.fullScreen).toBe(false)
expect(result.current.isCollapsed).toBe(true)
})
it('should open segment detail on card click', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
const detail = { id: 'seg-1', content: 'test' } as unknown as SegmentDetailModel
act(() => {
@ -37,8 +38,25 @@ describe('useModalState', () => {
expect(result.current.currSegment.isEditMode).toBe(true)
})
it('should close child detail when opening segment detail', () => {
const { result } = renderModalState()
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel
act(() => {
result.current.onClickSlice(childDetail)
})
act(() => {
result.current.onClickCard(segmentDetail)
})
expect(result.current.currSegment.showModal).toBe(true)
expect(result.current.currSegment.segInfo).toBe(segmentDetail)
expect(result.current.currChildChunk.showModal).toBe(false)
})
it('should close segment detail and reset fullscreen', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
result.current.onClickCard({ id: 'seg-1' } as unknown as SegmentDetailModel)
@ -55,7 +73,7 @@ describe('useModalState', () => {
})
it('should open child segment detail on slice click', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
act(() => {
@ -67,8 +85,25 @@ describe('useModalState', () => {
expect(result.current.currChunkId).toBe('seg-1')
})
it('should close segment detail when opening child detail', () => {
const { result } = renderModalState()
const segmentDetail = { id: 'seg-1' } as unknown as SegmentDetailModel
const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail
act(() => {
result.current.onClickCard(segmentDetail)
})
act(() => {
result.current.onClickSlice(childDetail)
})
expect(result.current.currSegment.showModal).toBe(false)
expect(result.current.currChildChunk.showModal).toBe(true)
expect(result.current.currChildChunk.childChunkInfo).toBe(childDetail)
})
it('should close child segment detail', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
result.current.onClickSlice({ id: 'c1', segment_id: 's1' } as unknown as ChildChunkDetail)
@ -81,7 +116,7 @@ describe('useModalState', () => {
})
it('should handle new child chunk modal', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
result.current.handleAddNewChildChunk('parent-chunk-1')
@ -98,7 +133,7 @@ describe('useModalState', () => {
})
it('should close new segment modal and notify parent', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
result.current.onCloseNewSegmentModal()
@ -108,7 +143,7 @@ describe('useModalState', () => {
})
it('should toggle full screen', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
result.current.toggleFullScreen()
@ -122,7 +157,7 @@ describe('useModalState', () => {
})
it('should toggle collapsed', () => {
const { result } = renderUseModalState()
const { result } = renderModalState()
act(() => {
result.current.toggleCollapsed()
@ -134,13 +169,4 @@ describe('useModalState', () => {
})
expect(result.current.isCollapsed).toBe(true)
})
it('should set regeneration modal state', () => {
const { result } = renderUseModalState()
act(() => {
result.current.setIsRegenerationModalOpen(true)
})
expect(result.current.isRegenerationModalOpen).toBe(true)
})
})

View File

@ -1,11 +1,12 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets'
import type { SegmentImportStatus } from '@/types/dataset'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets'
import { ProcessStatus } from '../../../segment-add'
import { segmentImportStatus } from '@/types/dataset'
import { useSegmentListData } from '../use-segment-list-data'
// Type for mutation callbacks
@ -176,7 +177,7 @@ const defaultOptions = {
searchValue: '',
selectedStatus: 'all' as boolean | 'all',
selectedSegmentIds: [] as string[],
importStatus: undefined as ProcessStatus | string | undefined,
importStatus: undefined as SegmentImportStatus | undefined,
currentPage: 1,
limit: 10,
onCloseSegmentDetail: vi.fn(),
@ -689,7 +690,7 @@ describe('useSegmentListData', () => {
renderHook(() => useSegmentListData({
...defaultOptions,
importStatus: ProcessStatus.COMPLETED,
importStatus: segmentImportStatus.completed,
clearSelection,
}), {
wrapper: createWrapper(),

View File

@ -13,29 +13,20 @@ type CurrChildChunkType = {
}
type UseModalStateReturn = {
// Segment detail modal
currSegment: CurrSegmentType
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
onCloseSegmentDetail: () => void
// Child segment detail modal
currChildChunk: CurrChildChunkType
currChunkId: string
onClickSlice: (detail: ChildChunkDetail) => void
onCloseChildSegmentDetail: () => void
// New segment modal
onCloseNewSegmentModal: () => void
// New child segment modal
showNewChildSegmentModal: boolean
handleAddNewChildChunk: (parentChunkId: string) => void
onCloseNewChildChunkModal: () => void
// Regeneration modal
isRegenerationModalOpen: boolean
setIsRegenerationModalOpen: (open: boolean) => void
// Full screen
fullScreen: boolean
toggleFullScreen: () => void
setFullScreen: (fullScreen: boolean) => void
// Collapsed state
isCollapsed: boolean
toggleCollapsed: () => void
}
@ -47,25 +38,15 @@ type UseModalStateOptions = {
export const useModalState = (options: UseModalStateOptions): UseModalStateReturn => {
const { onNewSegmentModalChange } = options
// Segment detail modal state
const [currSegment, setCurrSegment] = useState<CurrSegmentType>({ showModal: false })
// Child segment detail modal state
const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ showModal: false })
const [currChunkId, setCurrChunkId] = useState('')
// New child segment modal state
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
// Regeneration modal state
const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
// Display state
const [fullScreen, setFullScreen] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(true)
// Segment detail handlers
const onClickCard = useCallback((detail: SegmentDetailModel, isEditMode = false) => {
setCurrChildChunk({ showModal: false })
setCurrSegment({ segInfo: detail, showModal: true, isEditMode })
}, [])
@ -74,8 +55,8 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
setFullScreen(false)
}, [])
// Child segment detail handlers
const onClickSlice = useCallback((detail: ChildChunkDetail) => {
setCurrSegment({ showModal: false })
setCurrChildChunk({ childChunkInfo: detail, showModal: true })
setCurrChunkId(detail.segment_id)
}, [])
@ -85,13 +66,11 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
setFullScreen(false)
}, [])
// New segment modal handlers
const onCloseNewSegmentModal = useCallback(() => {
onNewSegmentModalChange(false)
setFullScreen(false)
}, [onNewSegmentModalChange])
// New child segment modal handlers
const handleAddNewChildChunk = useCallback((parentChunkId: string) => {
setShowNewChildSegmentModal(true)
setCurrChunkId(parentChunkId)
@ -102,7 +81,6 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
setFullScreen(false)
}, [])
// Display handlers - handles both direct calls and click events
const toggleFullScreen = useCallback(() => {
setFullScreen(prev => !prev)
}, [])
@ -112,29 +90,20 @@ export const useModalState = (options: UseModalStateOptions): UseModalStateRetur
}, [])
return {
// Segment detail modal
currSegment,
onClickCard,
onCloseSegmentDetail,
// Child segment detail modal
currChildChunk,
currChunkId,
onClickSlice,
onCloseChildSegmentDetail,
// New segment modal
onCloseNewSegmentModal,
// New child segment modal
showNewChildSegmentModal,
handleAddNewChildChunk,
onCloseNewChildChunkModal,
// Regeneration modal
isRegenerationModalOpen,
setIsRegenerationModalOpen,
// Full screen
fullScreen,
toggleFullScreen,
setFullScreen,
// Collapsed state
isCollapsed,
toggleCollapsed,
}

View File

@ -1,5 +1,6 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets'
import type { SegmentImportStatus } from '@/types/dataset'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef } from 'react'
@ -9,16 +10,16 @@ import { ChunkingMode } from '@/models/datasets'
import { usePathname } from '@/next/navigation'
import { useChunkListAllKey, useChunkListDisabledKey, useChunkListEnabledKey, useDeleteSegment, useDisableSegment, useEnableSegment, useSegmentList, useSegmentListKey, useUpdateSegment } from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { segmentImportStatus } from '@/types/dataset'
import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../../context'
import { ProcessStatus } from '../../segment-add'
const DEFAULT_LIMIT = 10
type UseSegmentListDataOptions = {
searchValue: string
selectedStatus: boolean | 'all'
selectedSegmentIds: string[]
importStatus: ProcessStatus | string | undefined
importStatus: SegmentImportStatus | undefined
currentPage: number
limit: number
onCloseSegmentDetail: () => void
@ -92,7 +93,7 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme
}, [pathname])
// Reset list on import completion
useEffect(() => {
if (importStatus === ProcessStatus.COMPLETED) {
if (importStatus === segmentImportStatus.completed) {
clearSelection()
invalidSegmentList()
}

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import type { ProcessStatus } from '../segment-add'
import type { SegmentListContextValue } from './segment-list-context'
import type { SegmentImportStatus } from '@/types/dataset'
import { useCallback, useMemo, useState } from 'react'
import Divider from '@/app/components/base/divider'
import Pagination from '@/app/components/base/pagination'
@ -13,7 +13,9 @@ import {
import { useInvalid } from '@/service/use-base'
import { useDocumentContext } from '../context'
import BatchAction from './common/batch-action'
import { DrawerGroup, FullDocModeContent, GeneralModeContent, MenuBar } from './components'
import { DrawerGroup } from './components/drawer-group'
import MenuBar from './components/menu-bar'
import { FullDocModeContent, GeneralModeContent } from './components/segment-list-content'
import {
useChildSegmentData,
useModalState,
@ -32,7 +34,7 @@ type ICompletedProps = {
embeddingAvailable: boolean
showNewSegmentModal: boolean
onNewSegmentModalChange: (state: boolean) => void
importStatus: ProcessStatus | string | undefined
importStatus: SegmentImportStatus | undefined
archived?: boolean
}
@ -225,8 +227,6 @@ const Completed: FC<ICompletedProps> = ({
currSegment={modalState.currSegment}
onCloseSegmentDetail={modalState.onCloseSegmentDetail}
onUpdateSegment={segmentListDataHook.handleUpdateSegment}
isRegenerationModalOpen={modalState.isRegenerationModalOpen}
setIsRegenerationModalOpen={modalState.setIsRegenerationModalOpen}
showNewSegmentModal={showNewSegmentModal}
onCloseNewSegmentModal={modalState.onCloseNewSegmentModal}
onSaveNewSegment={segmentListDataHook.resetList}

View File

@ -1,4 +1,3 @@
import type { FC } from 'react'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { SegmentDetailModel } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
@ -7,7 +6,6 @@ import {
RiCollapseDiagonalLine,
RiExpandDiagonalLine,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { v4 as uuid4 } from 'uuid'
@ -42,20 +40,15 @@ type ISegmentDetailProps = {
onCancel: () => void
isEditMode?: boolean
docForm: ChunkingMode
onModalStateChange?: (isOpen: boolean) => void
}
/**
* Show all the contents of the segment
*/
const SegmentDetail: FC<ISegmentDetailProps> = ({
export function SegmentDetail({
segInfo,
onUpdate,
onCancel,
isEditMode,
docForm,
onModalStateChange,
}) => {
}: ISegmentDetailProps) {
const { t } = useTranslation()
const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '')
const [answer, setAnswer] = useState(segInfo?.answer || '')
@ -99,19 +92,16 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
const handleRegeneration = useCallback(() => {
setShowRegenerationModal(true)
onModalStateChange?.(true)
}, [onModalStateChange])
}, [])
const onCancelRegeneration = useCallback(() => {
setShowRegenerationModal(false)
onModalStateChange?.(false)
}, [onModalStateChange])
}, [])
const onCloseAfterRegeneration = useCallback(() => {
setShowRegenerationModal(false)
onModalStateChange?.(false)
onCancel() // Close the edit drawer
}, [onCancel, onModalStateChange])
onCancel()
}, [onCancel])
const onConfirmRegeneration = useCallback(() => {
onUpdate(segInfo?.id || '', question, answer, keywords, attachments, summary, true)
@ -241,5 +231,3 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
</div>
)
}
export default React.memo(SegmentDetail)

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { DataSourceInfo, DocumentDisplayStatus, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
import type { SegmentImportStatus } from '@/types/dataset'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
@ -17,6 +18,7 @@ import { useRouter, useSearchParams } from '@/next/navigation'
import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document'
import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { segmentImportStatus } from '@/types/dataset'
import Operations from '../components/operations'
import StatusItem from '../status-item'
import BatchModal from './batch-modal'
@ -24,7 +26,7 @@ import Completed from './completed'
import { DocumentContext } from './context'
import { DocumentTitle } from './document-title'
import Embedding from './embedding'
import SegmentAdd, { ProcessStatus } from './segment-add'
import { SegmentAdd } from './segment-add'
import style from './style.module.css'
type DocumentDetailProps = {
@ -53,20 +55,20 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
const [showMetadata, setShowMetadata] = useState(!isMobile)
const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false)
const [batchModalVisible, setBatchModalVisible] = useState(false)
const [importStatus, setImportStatus] = useState<ProcessStatus | string>()
const [importStatus, setImportStatus] = useState<SegmentImportStatus>()
const showNewSegmentModal = () => setNewSegmentModalVisible(true)
const showBatchModal = () => setBatchModalVisible(true)
const hideBatchModal = () => setBatchModalVisible(false)
const resetProcessStatus = () => setImportStatus('')
const resetImportStatus = () => setImportStatus(undefined)
const { mutateAsync: checkSegmentBatchImportProgress } = useCheckSegmentBatchImportProgress()
const checkProcess = async (jobID: string) => {
await checkSegmentBatchImportProgress({ jobID }, {
onSuccess: (res) => {
setImportStatus(res.job_status)
if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING)
if (res.job_status === segmentImportStatus.waiting || res.job_status === segmentImportStatus.processing)
setTimeout(() => checkProcess(res.job_id), 2500)
if (res.job_status === ProcessStatus.ERROR)
if (res.job_status === segmentImportStatus.error)
toast.error(`${t('list.batchModal.runError', { ns: 'datasetDocuments' })}`)
},
onError: (e) => {
@ -222,7 +224,7 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
<>
<SegmentAdd
importStatus={importStatus}
clearProcessStatus={resetProcessStatus}
clearImportStatus={resetImportStatus}
showNewSegmentModal={showNewSegmentModal}
showBatchModal={showBatchModal}
embedding={embedding}

View File

@ -1,8 +1,10 @@
import type { SegmentImportStatus } from '@/types/dataset'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Plan } from '@/app/components/billing/type'
import { segmentImportStatus } from '@/types/dataset'
import SegmentAdd, { ProcessStatus } from '../index'
import { SegmentAdd } from '../index'
// Mock provider context
let mockPlan = { type: Plan.professional }
@ -22,8 +24,8 @@ describe('SegmentAdd', () => {
})
const defaultProps = {
importStatus: undefined as ProcessStatus | string | undefined,
clearProcessStatus: vi.fn(),
importStatus: undefined as SegmentImportStatus | undefined,
clearImportStatus: vi.fn(),
showNewSegmentModal: vi.fn(),
showBatchModal: vi.fn(),
embedding: false,
@ -52,33 +54,33 @@ describe('SegmentAdd', () => {
// Import Status displays
describe('Import Status Display', () => {
it('should show processing indicator when status is WAITING', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />)
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />)
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
})
it('should show processing indicator when status is PROCESSING', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument()
})
it('should show completed status with ok button', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.COMPLETED} />)
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.completed} />)
expect(screen.getByText(/list\.batchModal\.completed/i)).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
})
it('should show error status with ok button', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.ERROR} />)
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.error} />)
expect(screen.getByText(/list\.batchModal\.error/i)).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument()
})
it('should not show add button when importStatus is set', () => {
render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument()
})
@ -94,34 +96,34 @@ describe('SegmentAdd', () => {
expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1)
})
it('should call clearProcessStatus when ok is clicked on completed status', () => {
const mockClearProcessStatus = vi.fn()
it('should call clearImportStatus when ok is clicked on completed status', () => {
const mockClearImportStatus = vi.fn()
render(
<SegmentAdd
{...defaultProps}
importStatus={ProcessStatus.COMPLETED}
clearProcessStatus={mockClearProcessStatus}
importStatus={segmentImportStatus.completed}
clearImportStatus={mockClearImportStatus}
/>,
)
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
expect(mockClearImportStatus).toHaveBeenCalledTimes(1)
})
it('should call clearProcessStatus when ok is clicked on error status', () => {
const mockClearProcessStatus = vi.fn()
it('should call clearImportStatus when ok is clicked on error status', () => {
const mockClearImportStatus = vi.fn()
render(
<SegmentAdd
{...defaultProps}
importStatus={ProcessStatus.ERROR}
clearProcessStatus={mockClearProcessStatus}
importStatus={segmentImportStatus.error}
clearImportStatus={mockClearImportStatus}
/>,
)
fireEvent.click(screen.getByText(/list\.batchModal\.ok/i))
expect(mockClearProcessStatus).toHaveBeenCalledTimes(1)
expect(mockClearImportStatus).toHaveBeenCalledTimes(1)
})
it('should render batch add option in dropdown', async () => {
@ -215,14 +217,14 @@ describe('SegmentAdd', () => {
// Progress bar width tests
describe('Progress Bar', () => {
it('should show 3/12 width progress bar for WAITING status', () => {
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />)
const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.waiting} />)
const progressBar = container.querySelector('.w-3\\/12')
expect(progressBar).toBeInTheDocument()
})
it('should show 2/3 width progress bar for PROCESSING status', () => {
const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />)
const { container } = render(<SegmentAdd {...defaultProps} importStatus={segmentImportStatus.processing} />)
const progressBar = container.querySelector('.w-2\\/3')
expect(progressBar).toBeInTheDocument()
@ -230,15 +232,6 @@ describe('SegmentAdd', () => {
})
describe('Edge Cases', () => {
it('should handle unknown importStatus string', () => {
// Arrange & Act - pass unknown status
const { container } = render(<SegmentAdd {...defaultProps} importStatus="unknown" />)
// Assert - empty fragment is rendered for unknown status (container exists but has no visible content)
expect(container).toBeInTheDocument()
expect(container.textContent).toBe('')
})
it('should maintain structure when rerendered', () => {
const { rerender } = render(<SegmentAdd {...defaultProps} />)

View File

@ -1,5 +1,5 @@
'use client'
import type { FC } from 'react'
import type { SegmentImportStatus } from '@/types/dataset'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@ -7,95 +7,92 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
import { useProviderContext } from '@/context/provider-context'
import { segmentImportStatus } from '@/types/dataset'
type ISegmentAddProps = {
importStatus: ProcessStatus | string | undefined
clearProcessStatus: () => void
type SegmentAddProps = {
importStatus: SegmentImportStatus | undefined
clearImportStatus: () => void
showNewSegmentModal: () => void
showBatchModal: () => void
embedding: boolean
}
export enum ProcessStatus {
WAITING = 'waiting',
PROCESSING = 'processing',
COMPLETED = 'completed',
ERROR = 'error',
}
const SegmentAdd: FC<ISegmentAddProps> = ({
export function SegmentAdd({
importStatus,
clearProcessStatus,
clearImportStatus,
showNewSegmentModal,
showBatchModal,
embedding,
}) => {
}: SegmentAddProps) {
const { t } = useTranslation()
const [isShowPlanUpgradeModal, {
setTrue: showPlanUpgradeModal,
setFalse: hidePlanUpgradeModal,
}] = useBoolean(false)
const { plan, enableBilling } = useProviderContext()
const { type } = plan
const canAdd = enableBilling ? type !== Plan.sandbox : true
const [isBatchMenuOpen, setIsBatchMenuOpen] = useState(false)
const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false)
const batchMenuAnchorRef = useRef<HTMLDivElement>(null)
const { plan, enableBilling } = useProviderContext()
const canAddChunks = !enableBilling || plan.type !== Plan.sandbox
const withNeedUpgradeCheck = useCallback((fn: () => void) => {
return () => {
if (!canAdd) {
showPlanUpgradeModal()
return
}
fn()
const textColor = embedding
? 'text-components-button-secondary-accent-text-disabled'
: 'text-components-button-secondary-accent-text'
const handleAddClick = () => {
if (!canAddChunks) {
setIsPlanUpgradeModalOpen(true)
return
}
}, [canAdd, showPlanUpgradeModal])
const textColor = useMemo(() => {
return embedding
? 'text-components-button-secondary-accent-text-disabled'
: 'text-components-button-secondary-accent-text'
}, [embedding])
showNewSegmentModal()
}
const handleBatchAddClick = () => {
setIsBatchMenuOpen(false)
if (!canAddChunks) {
setIsPlanUpgradeModalOpen(true)
return
}
showBatchModal()
}
if (importStatus) {
return (
<>
{(importStatus === ProcessStatus.WAITING || importStatus === ProcessStatus.PROCESSING) && (
{(importStatus === segmentImportStatus.waiting || importStatus === segmentImportStatus.processing) && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-progress-bar-border
bg-components-progress-bar-border px-2.5 py-2 text-components-button-secondary-accent-text
shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]"
>
<div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === ProcessStatus.WAITING ? 'w-3/12' : 'w-2/3')} />
<div className={cn('absolute top-0 left-0 z-0 h-full border-r-[1.5px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress', importStatus === segmentImportStatus.waiting ? 'w-3/12' : 'w-2/3')} />
<span aria-hidden className="mr-1 i-ri-loader-2-line h-4 w-4 animate-spin" />
<span className="z-10 pr-0.5 system-sm-medium">{t('list.batchModal.processing', { ns: 'datasetDocuments' })}</span>
</div>
)}
{importStatus === ProcessStatus.COMPLETED && (
{importStatus === segmentImportStatus.completed && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-success">
<span aria-hidden className="mr-1 i-custom-vender-solid-general-check-circle h-4 w-4" />
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.completed', { ns: 'datasetDocuments' })}</span>
</div>
<div className="m-1 inline-flex items-center">
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearProcessStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
</div>
<div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-success-bg opacity-40" />
</div>
)}
{importStatus === ProcessStatus.ERROR && (
{importStatus === segmentImportStatus.error && (
<div className="relative mr-2 inline-flex items-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]">
<div className="inline-flex items-center border-r border-r-divider-subtle px-2.5 py-2 text-text-destructive">
<span aria-hidden className="mr-1 i-ri-error-warning-fill h-4 w-4" />
<span className="pr-0.5 system-sm-medium">{t('list.batchModal.error', { ns: 'datasetDocuments' })}</span>
</div>
<div className="m-1 inline-flex items-center">
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearProcessStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
<span className="cursor-pointer rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text hover:bg-components-button-ghost-bg-hover" onClick={clearImportStatus}>{t('list.batchModal.ok', { ns: 'datasetDocuments' })}</span>
</div>
<div className="absolute top-0 left-0 -z-10 h-full w-full bg-dataset-chunk-process-error-bg opacity-40" />
</div>
@ -116,7 +113,7 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
type="button"
className={`inline-flex items-center rounded-l-lg border-r border-r-divider-subtle px-2.5 py-2
hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`}
onClick={withNeedUpgradeCheck(showNewSegmentModal)}
onClick={handleAddClick}
disabled={embedding}
>
<span aria-hidden className={cn('i-ri-add-line h-4 w-4', textColor)} />
@ -142,25 +139,20 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
placement="bottom-start"
sideOffset={4}
positionerProps={{ anchor: batchMenuAnchorRef }}
popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-0 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]"
popupClassName="w-[var(--anchor-width)]"
>
<div className="w-full p-1">
<DropdownMenuItem
className="h-auto w-full px-2 py-1.5 system-md-regular"
onClick={() => {
setIsBatchMenuOpen(false)
withNeedUpgradeCheck(showBatchModal)()
}}
>
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
</DropdownMenuItem>
</div>
<DropdownMenuItem
className="system-md-regular"
onClick={handleBatchAddClick}
>
{t('list.action.batchAdd', { ns: 'datasetDocuments' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isShowPlanUpgradeModal && (
{isPlanUpgradeModalOpen && (
<PlanUpgradeModal
show
onClose={hidePlanUpgradeModal}
onClose={() => setIsPlanUpgradeModalOpen(false)}
title={t('upgrade.addChunks.title', { ns: 'billing' })!}
description={t('upgrade.addChunks.description', { ns: 'billing' })!}
/>
@ -169,4 +161,3 @@ const SegmentAdd: FC<ISegmentAddProps> = ({
)
}
export default React.memo(SegmentAdd)

View File

@ -55,10 +55,6 @@ vi.mock('../../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
vi.mock('../../../readme-panel/store', () => ({
ReadmeShowType: { modal: 'modal' },
}))
vi.mock('@/app/components/base/encrypted-bottom', () => ({
EncryptedBottom: () => <div data-testid="encrypted-bottom" />,
}))

View File

@ -41,10 +41,6 @@ vi.mock('../../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
vi.mock('../../../readme-panel/store', () => ({
ReadmeShowType: { modal: 'modal' },
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', () => {
const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => {
mockAuthFormProps = props

View File

@ -19,7 +19,6 @@ import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Loading from '@/app/components/base/loading'
import { ReadmeEntrance } from '../../readme-panel/entrance'
import { ReadmeShowType } from '../../readme-panel/store'
import {
useAddPluginCredentialHook,
useGetPluginCredentialSchemaHook,
@ -159,7 +158,7 @@ const ApiKeyModal = ({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
)}
{
isLoading && (

View File

@ -19,7 +19,6 @@ import {
import { useTranslation } from 'react-i18next'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import { ReadmeEntrance } from '../../readme-panel/entrance'
import { ReadmeShowType } from '../../readme-panel/store'
import {
useDeletePluginOAuthCustomClientHook,
useInvalidPluginOAuthClientSchemaHook,
@ -157,7 +156,7 @@ const OAuthClientSettings = ({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0">
{pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginPayload.detail} presentation="dialog" />
)}
<AuthForm
formFromProps={form}

View File

@ -14,7 +14,6 @@ import { FormTypeEnum } from '@/app/components/base/form/types'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
@ -318,7 +317,7 @@ export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props)
</div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)}
<MultiSteps currentStep={currentStep} onStepClick={handleBack} />

View File

@ -12,7 +12,6 @@ import { BaseForm } from '@/app/components/base/form/components/base'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
@ -159,7 +158,7 @@ export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props)
</div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)}
<BaseForm
formSchemas={formSchemas}

View File

@ -12,7 +12,6 @@ import { BaseForm } from '@/app/components/base/form/components/base'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
@ -173,7 +172,7 @@ export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) =
</div>
<div data-testid="modal-content" className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />
)}
<BaseForm
formSchemas={formSchemas}

View File

@ -1,31 +1,11 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@langgenius/dify-ui/cn', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))
const mockSetCurrentPluginDetail = vi.fn()
vi.mock('../store', () => ({
ReadmeShowType: { drawer: 'drawer', side: 'side', modal: 'modal' },
useReadmePanelStore: () => ({
setCurrentPluginDetail: mockSetCurrentPluginDetail,
}),
}))
vi.mock('../constants', () => ({
BUILTIN_TOOLS_ARRAY: ['google_search', 'bing_search'],
}))
import { beforeEach, describe, expect, it } from 'vitest'
import { ReadmeEntrance } from '../entrance'
import { useReadmePanelStore } from '../store'
describe('ReadmeEntrance', () => {
let ReadmeEntrance: (typeof import('../entrance'))['ReadmeEntrance']
beforeEach(async () => {
vi.clearAllMocks()
const mod = await import('../entrance')
ReadmeEntrance = mod.ReadmeEntrance
beforeEach(() => {
useReadmePanelStore.setState({ currentPanel: undefined })
})
it('should render readme button for non-builtin plugin with unique identifier', () => {
@ -35,18 +15,31 @@ describe('ReadmeEntrance', () => {
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should call setCurrentPluginDetail on button click', () => {
it('should open drawer presentation by default', () => {
const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never
render(<ReadmeEntrance pluginDetail={pluginDetail} />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(mockSetCurrentPluginDetail).toHaveBeenCalledWith(pluginDetail, 'drawer')
expect(useReadmePanelStore.getState().currentPanel).toEqual({
detail: pluginDetail,
presentation: 'drawer',
triggerId: button.id,
})
})
it('should open dialog presentation when requested', () => {
const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never
render(<ReadmeEntrance pluginDetail={pluginDetail} presentation="dialog" />)
fireEvent.click(screen.getByRole('button'))
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
})
it('should return null for builtin tools', () => {
const pluginDetail = { id: 'google_search', name: 'Google Search', plugin_unique_identifier: 'org/google' } as never
const pluginDetail = { id: 'code', name: 'Code', plugin_unique_identifier: 'org/code' } as never
const { container } = render(<ReadmeEntrance pluginDetail={pluginDetail} />)
expect(container.innerHTML).toBe('')

View File

@ -1,29 +1,29 @@
import type { ReactElement } from 'react'
import type { PluginDetail } from '../../types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum, PluginSource } from '../../types'
import { ReadmeEntrance } from '../entrance'
import ReadmePanel from '../index'
import { ReadmeShowType, useReadmePanelStore } from '../store'
import { useReadmePanelStore } from '../store'
// ================================
// Mock external dependencies only
// ================================
(
globalThis as typeof globalThis & {
BASE_UI_ANIMATIONS_DISABLED: boolean
}
).BASE_UI_ANIMATIONS_DISABLED = true
// Mock usePluginReadme hook
const mockUsePluginReadme = vi.fn()
vi.mock('@/service/use-plugins', () => ({
usePluginReadme: (params: { plugin_unique_identifier: string, language?: string }) => mockUsePluginReadme(params),
}))
// Mock useLanguage hook
let mockLanguage = 'en-US'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => mockLanguage,
}))
// Mock DetailHeader component (complex component with many dependencies)
vi.mock('../../plugin-detail-panel/detail-header', () => ({
default: ({ detail, isReadmeView }: { detail: PluginDetail, isReadmeView: boolean }) => (
<div data-testid="detail-header" data-is-readme-view={isReadmeView}>
@ -32,10 +32,6 @@ vi.mock('../../plugin-detail-panel/detail-header', () => ({
),
}))
// ================================
// Test Data Factories
// ================================
const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-plugin-id',
created_at: '2024-01-01T00:00:00Z',
@ -93,10 +89,6 @@ const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDe
...overrides,
})
// ================================
// Test Utilities
// ================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
@ -105,7 +97,7 @@ const createQueryClient = () => new QueryClient({
},
})
const renderWithQueryClient = (ui: React.ReactElement) => {
const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
@ -114,15 +106,23 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
)
}
// Constants (BUILTIN_TOOLS_ARRAY) tests moved to constants.spec.ts
// Store (useReadmePanelStore) tests moved to store.spec.ts
// Entrance (ReadmeEntrance) tests moved to entrance.spec.tsx
const openReadmePanel = (
detail = createMockPluginDetail(),
presentation: 'drawer' | 'dialog' = 'drawer',
) => {
useReadmePanelStore.getState().openReadmePanel({
detail,
presentation,
triggerId: 'readme-trigger',
})
return detail
}
// ================================
// ReadmePanel Component Tests
// ================================
describe('ReadmePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en-US'
useReadmePanelStore.setState({ currentPanel: undefined })
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
@ -130,487 +130,114 @@ describe('ReadmePanel', () => {
})
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should return null when no plugin detail is set', () => {
const { container } = renderWithQueryClient(<ReadmePanel />)
it('should return null when no readme panel is open', () => {
const { container } = renderWithQueryClient(<ReadmePanel />)
expect(container.firstChild).toBeNull()
expect(container.firstChild).toBeNull()
})
it('should render drawer presentation with plugin header content', () => {
openReadmePanel()
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true')
expect(screen.getByRole('dialog')).toHaveClass('data-[swipe-direction=left]:w-150')
})
it('should render dialog presentation when requested', () => {
openReadmePanel(createMockPluginDetail(), 'dialog')
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByRole('dialog')).toHaveClass('max-w-200')
})
it('should close the active panel when close button is clicked', () => {
openReadmePanel()
renderWithQueryClient(<ReadmePanel />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(useReadmePanelStore.getState().currentPanel).toBeUndefined()
})
it('should render loading, error, empty, and readme states from the readme query', () => {
openReadmePanel()
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: true,
error: null,
})
const { rerender } = renderWithQueryClient(<ReadmePanel />)
expect(screen.getByRole('status')).toBeInTheDocument()
it('should render portal content when plugin detail is set', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
error: new Error('Failed to fetch'),
})
rerender(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument()
it('should render DetailHeader component', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByTestId('detail-header')).toBeInTheDocument()
expect(screen.getByTestId('detail-header')).toHaveAttribute('data-is-readme-view', 'true')
mockUsePluginReadme.mockReturnValue({
data: { readme: '' },
isLoading: false,
error: null,
})
rerender(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
it('should render close button', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Test Readme Content' },
isLoading: false,
error: null,
})
rerender(<ReadmePanel />)
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
})
renderWithQueryClient(<ReadmePanel />)
it('should call usePluginReadme with the plugin identifier and selected language', () => {
openReadmePanel(createMockPluginDetail({
plugin_unique_identifier: 'custom-plugin@2.0.0',
}))
// ActionButton wraps the close icon
expect(screen.getByRole('button')).toBeInTheDocument()
renderWithQueryClient(<ReadmePanel />)
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'custom-plugin@2.0.0',
language: 'en-US',
})
})
// ================================
// Loading State Tests
// ================================
describe('Loading State', () => {
it('should show loading indicator when isLoading is true', () => {
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: true,
error: null,
})
it('should pass undefined language for zh-Hans locale', () => {
mockLanguage = 'zh-Hans'
openReadmePanel(createMockPluginDetail({
plugin_unique_identifier: 'zh-plugin@1.0.0',
}))
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
renderWithQueryClient(<ReadmePanel />)
// Loading component should be rendered with role="status"
expect(screen.getByRole('status')).toBeInTheDocument()
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'zh-plugin@1.0.0',
language: undefined,
})
})
// ================================
// Error State Tests
// ================================
describe('Error State', () => {
it('should show error message when error occurs', () => {
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
error: new Error('Failed to fetch'),
})
it('should open correctly from ReadmeEntrance through the global host', () => {
const detail = createMockPluginDetail()
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(
<>
<ReadmeEntrance pluginDetail={detail} />
<ReadmePanel />
</>,
)
renderWithQueryClient(<ReadmePanel />)
fireEvent.click(screen.getByRole('button', { name: /plugin\.readmeInfo\.needHelpCheckReadme/ }))
expect(screen.getByText('plugin.readmeInfo.failedToFetch')).toBeInTheDocument()
})
})
// ================================
// No Readme Available State Tests
// ================================
describe('No Readme Available', () => {
it('should show no readme message when readme is empty', () => {
mockUsePluginReadme.mockReturnValue({
data: { readme: '' },
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
})
it('should show no readme message when data is null', () => {
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByText('plugin.readmeInfo.noReadmeAvailable')).toBeInTheDocument()
})
})
// ================================
// Markdown Content Tests
// ================================
describe('Markdown Content', () => {
it('should render markdown container when readme is available', () => {
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Test Readme Content' },
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Markdown component container should be rendered
// Note: The Markdown component uses dynamic import, so content may load asynchronously
const markdownContainer = document.querySelector('.markdown-body')
expect(markdownContainer).toBeInTheDocument()
})
it('should not show error or no-readme message when readme is available', () => {
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Test Readme Content' },
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Should not show error or no-readme message
expect(screen.queryByText('plugin.readmeInfo.failedToFetch')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.readmeInfo.noReadmeAvailable')).not.toBeInTheDocument()
})
})
// ================================
// Portal Rendering Tests (Drawer Mode)
// ================================
describe('Portal Rendering - Drawer Mode', () => {
it('should render drawer styled container in drawer mode', () => {
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Test' },
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Drawer mode has specific max-width
const drawerContainer = document.querySelector('.max-w-\\[600px\\]')
expect(drawerContainer).toBeInTheDocument()
})
it('should have correct drawer positioning classes', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Check for drawer-specific classes
const backdrop = document.querySelector('.justify-start')
expect(backdrop).toBeInTheDocument()
})
})
// ================================
// Portal Rendering Tests (Modal Mode)
// ================================
describe('Portal Rendering - Modal Mode', () => {
it('should render modal styled container in modal mode', () => {
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Test' },
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
renderWithQueryClient(<ReadmePanel />)
// Modal mode has different max-width
const modalContainer = document.querySelector('.max-w-\\[800px\\]')
expect(modalContainer).toBeInTheDocument()
})
it('should have correct modal positioning classes', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
renderWithQueryClient(<ReadmePanel />)
// Check for modal-specific classes
const backdrop = document.querySelector('.items-center.justify-center')
expect(backdrop).toBeInTheDocument()
})
})
// ================================
// User Interactions / Event Handlers
// ================================
describe('User Interactions', () => {
it('should close panel when close button is clicked', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
fireEvent.click(screen.getByRole('button'))
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeUndefined()
})
it('should close panel when backdrop is clicked', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Click on the backdrop (outer div)
const backdrop = document.querySelector('.fixed.inset-0')
fireEvent.click(backdrop!)
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeUndefined()
})
it('should not close panel when content area is clicked', async () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// Click on the content container (should stop propagation)
const contentContainer = document.querySelector('.pointer-events-auto')
fireEvent.click(contentContainer!)
await waitFor(() => {
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeDefined()
})
})
it('should not close panel when content area is clicked in modal mode', async () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
renderWithQueryClient(<ReadmePanel />)
// Click on the content container in modal mode (should stop propagation)
const contentContainer = document.querySelector('.pointer-events-auto')
fireEvent.click(contentContainer!)
await waitFor(() => {
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeDefined()
})
})
})
// ================================
// API Call Tests
// ================================
describe('API Calls', () => {
it('should call usePluginReadme with correct parameters', () => {
const mockDetail = createMockPluginDetail({
plugin_unique_identifier: 'custom-plugin@2.0.0',
})
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'custom-plugin@2.0.0',
language: 'en-US',
})
})
it('should pass undefined language for zh-Hans locale', () => {
// Set language to zh-Hans
mockLanguage = 'zh-Hans'
const mockDetail = createMockPluginDetail({
plugin_unique_identifier: 'zh-plugin@1.0.0',
})
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
// The component should pass undefined for language when zh-Hans
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'zh-plugin@1.0.0',
language: undefined,
})
// Reset language
mockLanguage = 'en-US'
})
it('should handle empty plugin_unique_identifier', () => {
mockUsePluginReadme.mockReturnValue({
data: null,
isLoading: false,
error: null,
})
const mockDetail = createMockPluginDetail({
plugin_unique_identifier: '',
})
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: '',
language: 'en-US',
})
})
})
// ================================
// Edge Cases
// ================================
describe('Edge Cases', () => {
it('should handle detail with missing declaration', () => {
const mockDetail = createMockPluginDetail()
// Simulate missing fields
delete (mockDetail as Partial<PluginDetail>).declaration
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
// This should not throw
expect(() => setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)).not.toThrow()
})
it('should handle rapid open/close operations', async () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
// Rapidly toggle the panel
act(() => {
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
setCurrentPluginDetail()
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
})
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
})
it('should handle switching between drawer and modal modes', () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
// Start with drawer
act(() => {
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
})
let state = useReadmePanelStore.getState()
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.drawer)
// Switch to modal
act(() => {
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
})
state = useReadmePanelStore.getState()
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
})
it('should handle undefined detail gracefully', () => {
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
// Set to undefined explicitly
act(() => {
setCurrentPluginDetail(undefined, ReadmeShowType.drawer)
})
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeUndefined()
})
})
// ================================
// Integration Tests
// ================================
describe('Integration', () => {
it('should work correctly when opened from ReadmeEntrance', () => {
const mockDetail = createMockPluginDetail()
mockUsePluginReadme.mockReturnValue({
data: { readme: '# Integration Test' },
isLoading: false,
error: null,
})
// Render both components
const { rerender } = renderWithQueryClient(
<>
<ReadmeEntrance pluginDetail={mockDetail} />
<ReadmePanel />
</>,
)
// Initially panel should not show content
expect(screen.queryByTestId('detail-header')).not.toBeInTheDocument()
// Click the entrance button
fireEvent.click(screen.getByRole('button'))
// Re-render to pick up store changes
rerender(
<QueryClientProvider client={createQueryClient()}>
<ReadmeEntrance pluginDetail={mockDetail} />
<ReadmePanel />
</QueryClientProvider>,
)
// Panel should now show content
expect(screen.getByTestId('detail-header')).toBeInTheDocument()
// Markdown content renders in a container (dynamic import may not render content synchronously)
expect(document.querySelector('.markdown-body')).toBeInTheDocument()
})
it('should display correct plugin information in header', () => {
const mockDetail = createMockPluginDetail({
name: 'my-awesome-plugin',
})
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
renderWithQueryClient(<ReadmePanel />)
expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument()
})
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})

View File

@ -1,54 +1,52 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { beforeEach, describe, expect, it } from 'vitest'
import { ReadmeShowType, useReadmePanelStore } from '../store'
import { useReadmePanelStore } from '../store'
describe('readme-panel/store', () => {
beforeEach(() => {
useReadmePanelStore.setState({ currentPluginDetail: undefined })
useReadmePanelStore.setState({ currentPanel: undefined })
})
it('initializes with undefined currentPluginDetail', () => {
it('initializes without an active panel', () => {
const state = useReadmePanelStore.getState()
expect(state.currentPluginDetail).toBeUndefined()
expect(state.currentPanel).toBeUndefined()
})
it('sets current plugin detail with drawer showType by default', () => {
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail)
it('opens drawer presentation by default', () => {
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
useReadmePanelStore.getState().openReadmePanel({ detail, triggerId: 'readme-trigger' })
const state = useReadmePanelStore.getState()
expect(state.currentPluginDetail).toEqual({
detail: mockDetail,
showType: ReadmeShowType.drawer,
expect(useReadmePanelStore.getState().currentPanel).toEqual({
detail,
presentation: 'drawer',
triggerId: 'readme-trigger',
})
})
it('sets current plugin detail with modal showType', () => {
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
it('opens dialog presentation when requested', () => {
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
useReadmePanelStore.getState().openReadmePanel({ detail, presentation: 'dialog' })
const state = useReadmePanelStore.getState()
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
})
it('clears current plugin detail when called with undefined', () => {
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail)
expect(useReadmePanelStore.getState().currentPluginDetail).toBeDefined()
it('closes the active panel', () => {
const detail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
useReadmePanelStore.getState().openReadmePanel({ detail })
expect(useReadmePanelStore.getState().currentPanel).toBeDefined()
useReadmePanelStore.getState().setCurrentPluginDetail(undefined)
expect(useReadmePanelStore.getState().currentPluginDetail).toBeUndefined()
useReadmePanelStore.getState().closeReadmePanel()
expect(useReadmePanelStore.getState().currentPanel).toBeUndefined()
})
it('replaces previous detail with new one', () => {
it('replaces the active panel with the latest request', () => {
const detail1 = { id: 'plugin-1', plugin_unique_identifier: 'uid-1' } as PluginDetail
const detail2 = { id: 'plugin-2', plugin_unique_identifier: 'uid-2' } as PluginDetail
useReadmePanelStore.getState().setCurrentPluginDetail(detail1)
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-1')
useReadmePanelStore.getState().openReadmePanel({ detail: detail1 })
useReadmePanelStore.getState().openReadmePanel({ detail: detail2, presentation: 'dialog' })
useReadmePanelStore.getState().setCurrentPluginDetail(detail2, ReadmeShowType.modal)
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-2')
expect(useReadmePanelStore.getState().currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
expect(useReadmePanelStore.getState().currentPanel?.detail.id).toBe('plugin-2')
expect(useReadmePanelStore.getState().currentPanel?.presentation).toBe('dialog')
})
})

View File

@ -0,0 +1,81 @@
'use client'
import type { ReactNode } from 'react'
import type { PluginDetail } from '../types'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { Markdown } from '@/app/components/base/markdown'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { usePluginReadme } from '@/service/use-plugins'
import DetailHeader from '../plugin-detail-panel/detail-header'
type ReadmePanelContentProps = {
detail: PluginDetail
title: ReactNode
closeButton: ReactNode
}
export function ReadmePanelContent({
detail,
title,
closeButton,
}: ReadmePanelContentProps) {
const { t } = useTranslation()
const language = useLanguage()
const pluginUniqueIdentifier = detail.plugin_unique_identifier || ''
const { data: readmeData, isLoading, error } = usePluginReadme({
plugin_unique_identifier: pluginUniqueIdentifier,
language: language === 'zh-Hans' ? undefined : language,
})
let readmeContent: ReactNode
if (isLoading) {
readmeContent = (
<div className="flex h-40 items-center justify-center">
<Loading type="area" />
</div>
)
}
else if (error) {
readmeContent = (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.failedToFetch', { ns: 'plugin' })}</p>
</div>
)
}
else if (readmeData?.readme) {
readmeContent = (
<Markdown
content={readmeData.readme}
pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }}
/>
)
}
else {
readmeContent = (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.noReadmeAvailable', { ns: 'plugin' })}</p>
</div>
)
}
return (
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden">
<div className="shrink-0 rounded-t-xl bg-background-body px-4 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
<span aria-hidden="true" className="i-ri-book-read-line h-3 w-3 shrink-0 text-text-tertiary" />
{title}
</div>
{closeButton}
</div>
<DetailHeader detail={detail} isReadmeView={true} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
{readmeContent}
</div>
</div>
)
}

View File

@ -0,0 +1,52 @@
'use client'
import type { PluginDetail } from '../types'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { useTranslation } from 'react-i18next'
import { ReadmePanelContent } from './content'
type ReadmeDialogProps = {
detail: PluginDetail
open: boolean
onOpenChange: (open: boolean) => void
triggerId?: string
}
export function ReadmeDialog({
detail,
open,
onOpenChange,
triggerId,
}: ReadmeDialogProps) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
triggerId={triggerId}
>
<DialogContent className="h-[calc(100dvh-16px)] w-full max-w-200 overflow-hidden p-0">
<ReadmePanelContent
detail={detail}
title={(
<DialogTitle className="truncate text-xs font-medium text-text-tertiary uppercase">
{t('readmeInfo.title', { ns: 'plugin' })}
</DialogTitle>
)}
closeButton={(
<DialogCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="static h-8 w-8 rounded-lg"
/>
)}
/>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,62 @@
'use client'
import type { PluginDetail } from '../types'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { useTranslation } from 'react-i18next'
import { ReadmePanelContent } from './content'
type ReadmeDrawerProps = {
detail: PluginDetail
open: boolean
onOpenChange: (open: boolean) => void
triggerId?: string
}
export function ReadmeDrawer({
detail,
open,
onOpenChange,
triggerId,
}: ReadmeDrawerProps) {
const { t } = useTranslation()
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
triggerId={triggerId}
modal
swipeDirection="left"
>
<DrawerPortal>
<DrawerBackdrop className="bg-transparent" />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=left]:top-16 data-[swipe-direction=left]:bottom-2 data-[swipe-direction=left]:left-2 data-[swipe-direction=left]:h-auto data-[swipe-direction=left]:w-150 data-[swipe-direction=left]:max-w-[calc(100vw-1rem)] data-[swipe-direction=left]:rounded-2xl data-[swipe-direction=left]:border-l-[0.5px]">
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0">
<ReadmePanelContent
detail={detail}
title={(
<DrawerTitle className="truncate text-xs font-medium text-text-tertiary uppercase">
{t('readmeInfo.title', { ns: 'plugin' })}
</DrawerTitle>
)}
closeButton={(
<DrawerCloseButton aria-label={t('operation.close', { ns: 'common' })} />
)}
/>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}

View File

@ -1,34 +1,40 @@
import type { PluginDetail } from '../types'
import type { ReadmePanelPresentation } from './store'
import { cn } from '@langgenius/dify-ui/cn'
import { RiBookReadLine } from '@remixicon/react'
import * as React from 'react'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import { BUILTIN_TOOLS_ARRAY } from './constants'
import { ReadmeShowType, useReadmePanelStore } from './store'
import { useReadmePanelStore } from './store'
export const ReadmeEntrance = ({
pluginDetail,
showType = ReadmeShowType.drawer,
presentation = 'drawer',
className,
showShortTip = false,
}: {
pluginDetail: PluginDetail
showType?: ReadmeShowType
presentation?: ReadmePanelPresentation
className?: string
showShortTip?: boolean
}) => {
const { t } = useTranslation()
const { setCurrentPluginDetail } = useReadmePanelStore()
const triggerId = useId()
const openReadmePanel = useReadmePanelStore(s => s.openReadmePanel)
const handleReadmeClick = () => {
if (pluginDetail)
setCurrentPluginDetail(pluginDetail, showType)
if (pluginDetail) {
openReadmePanel({
detail: pluginDetail,
presentation,
triggerId,
})
}
}
if (!pluginDetail || !pluginDetail?.plugin_unique_identifier || BUILTIN_TOOLS_ARRAY.includes(pluginDetail.id))
return null
return (
<div className={cn('flex flex-col items-start justify-center gap-2 pt-0 pb-4', showType === ReadmeShowType.drawer && 'px-4', className)}>
<div className={cn('flex flex-col items-start justify-center gap-2 pt-0 pb-4', presentation === 'drawer' && 'px-4', className)}>
{!showShortTip && (
<div className="relative h-1 w-8 shrink-0">
<div className="h-px w-full bg-divider-regular"></div>
@ -36,11 +42,13 @@ export const ReadmeEntrance = ({
)}
<button
id={triggerId}
type="button"
onClick={handleReadmeClick}
className="flex w-full items-center justify-start gap-1 text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only"
className="flex w-full items-center justify-start gap-1 rounded-sm text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden"
>
<div className="relative flex h-3 w-3 items-center justify-center overflow-hidden">
<RiBookReadLine className="h-3 w-3" />
<span aria-hidden="true" className="i-ri-book-read-line h-3 w-3" />
</div>
<span className="text-xs leading-4 font-normal">
{!showShortTip ? t('readmeInfo.needHelpCheckReadme', { ns: 'plugin' }) : t('readmeInfo.title', { ns: 'plugin' })}

View File

@ -1,124 +1,38 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiBookReadLine, RiCloseLine } from '@remixicon/react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Loading from '@/app/components/base/loading'
import { Markdown } from '@/app/components/base/markdown'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { usePluginReadme } from '@/service/use-plugins'
import DetailHeader from '../plugin-detail-panel/detail-header'
import { ReadmeShowType, useReadmePanelStore } from './store'
const ReadmePanel: FC = () => {
const { currentPluginDetail, setCurrentPluginDetail } = useReadmePanelStore()
const { detail, showType } = currentPluginDetail || {}
const { t } = useTranslation()
const language = useLanguage()
import { ReadmeDialog } from './dialog'
import { ReadmeDrawer } from './drawer'
import { useReadmePanelStore } from './store'
const pluginUniqueIdentifier = detail?.plugin_unique_identifier || ''
export default function ReadmePanel() {
const currentPanel = useReadmePanelStore(s => s.currentPanel)
const closeReadmePanel = useReadmePanelStore(s => s.closeReadmePanel)
const { data: readmeData, isLoading, error } = usePluginReadme(
{ plugin_unique_identifier: pluginUniqueIdentifier, language: language === 'zh-Hans' ? undefined : language },
)
const onClose = () => {
setCurrentPluginDetail()
}
if (!detail)
if (!currentPanel)
return null
const children = (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="rounded-t-xl bg-background-body px-4 py-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-1">
<RiBookReadLine className="h-3 w-3 text-text-tertiary" />
<span className="text-xs font-medium text-text-tertiary uppercase">
{t('readmeInfo.title', { ns: 'plugin' })}
</span>
</div>
<ActionButton onClick={onClose}>
<RiCloseLine className="h-4 w-4" />
</ActionButton>
</div>
<DetailHeader detail={detail} isReadmeView={true} />
</div>
const onOpenChange = (open: boolean) => {
if (!open)
closeReadmePanel()
}
<div className="flex-1 overflow-y-auto px-4 py-3">
{(() => {
if (isLoading) {
return (
<div className="flex h-40 items-center justify-center">
<Loading type="area" />
</div>
)
}
if (currentPanel.presentation === 'dialog') {
return (
<ReadmeDialog
detail={currentPanel.detail}
open
onOpenChange={onOpenChange}
triggerId={currentPanel.triggerId}
/>
)
}
if (error) {
return (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.failedToFetch', { ns: 'plugin' })}</p>
</div>
)
}
if (readmeData?.readme) {
return (
<Markdown
content={readmeData.readme}
pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }}
/>
)
}
return (
<div className="py-8 text-center text-text-tertiary">
<p>{t('readmeInfo.noReadmeAvailable', { ns: 'plugin' })}</p>
</div>
)
})()}
</div>
</div>
)
const portalContent = showType === ReadmeShowType.drawer
? (
<div className="fixed inset-0 z-1002 flex justify-start" onClick={onClose}>
<div
className={cn(
'pointer-events-auto mt-16 mr-2 mb-2 ml-2 w-[600px] max-w-[600px] justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl',
)}
onClick={(event) => {
event.stopPropagation()
}}
>
{children}
</div>
</div>
)
: (
<div className="fixed inset-0 z-1002 flex items-center justify-center p-2" onClick={onClose}>
<div
className={cn(
'pointer-events-auto relative h-[calc(100vh-16px)] w-full max-w-[800px] rounded-2xl bg-components-panel-bg p-0 shadow-xl',
)}
onClick={(event) => {
event.stopPropagation()
}}
>
{children}
</div>
</div>
)
return createPortal(
portalContent,
document.body,
return (
<ReadmeDrawer
detail={currentPanel.detail}
open
onOpenChange={onOpenChange}
triggerId={currentPanel.triggerId}
/>
)
}
export default ReadmePanel

View File

@ -1,27 +1,34 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { create } from 'zustand'
export enum ReadmeShowType {
drawer = 'drawer',
modal = 'modal',
export type ReadmePanelPresentation = 'drawer' | 'dialog'
type ReadmePanelState = {
detail: PluginDetail
presentation: ReadmePanelPresentation
triggerId?: string
}
type OpenReadmePanelPayload = {
detail: PluginDetail
presentation?: ReadmePanelPresentation
triggerId?: string
}
type Shape = {
currentPluginDetail?: {
detail: PluginDetail
showType: ReadmeShowType
}
setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => void
currentPanel?: ReadmePanelState
openReadmePanel: (payload: OpenReadmePanelPayload) => void
closeReadmePanel: () => void
}
export const useReadmePanelStore = create<Shape>(set => ({
currentPluginDetail: undefined,
setCurrentPluginDetail: (detail?: PluginDetail, showType?: ReadmeShowType) => set({
currentPluginDetail: !detail
? undefined
: {
detail,
showType: showType ?? ReadmeShowType.drawer,
},
currentPanel: undefined,
openReadmePanel: ({ detail, presentation = 'drawer', triggerId }) => set({
currentPanel: {
detail,
presentation,
triggerId,
},
}),
closeReadmePanel: () => set({ currentPanel: undefined }),
}))

View File

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

View File

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

View File

@ -68,13 +68,13 @@ const MenuDropdown: FC<Props> = ({
onOpenChange={setOpen}
>
<DropdownMenuTrigger
render={<div />}
render={(
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
<span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" />
</ActionButton>
)}
aria-label={t('operation.more', { ns: 'common' })}
>
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
<span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" />
</ActionButton>
</DropdownMenuTrigger>
/>
<DropdownMenuContent
placement={placement || 'bottom-end'}
sideOffset={4}

View File

@ -34,12 +34,28 @@ vi.mock('@langgenius/dify-ui/popover', async () => {
)
}
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const PopoverTrigger = ({
children,
className,
render,
}: {
children?: React.ReactNode
className?: string
render?: React.ReactNode
}) => {
const { open, setOpen } = React.useContext(PopoverContext)
if (render) {
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
<button type="button" className={className} onClick={() => setOpen(!open)}>
{children}
</button>
)
}
@ -119,6 +135,12 @@ describe('LabelSelector', () => {
expect(screen.getByText('tools.createTool.toolInput.labelPlaceholder')).toBeInTheDocument()
})
it('should render the trigger as a native button', () => {
render(<LabelSelector value={[]} onChange={mockOnChange} />)
expect(screen.getByRole('button', { name: 'tools.createTool.toolInput.labelPlaceholder' })).toHaveAttribute('type', 'button')
})
it('should display selected labels as comma-separated list', () => {
render(<LabelSelector value={['agent', 'rag']} onChange={mockOnChange} />)

View File

@ -6,7 +6,6 @@ import {
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useMemo, useState } from 'react'
@ -60,22 +59,19 @@ const LabelSelector: FC<LabelSelectorProps> = ({
<Popover open={open} onOpenChange={setOpen}>
<div className="relative">
<PopoverTrigger
render={(
<div className={cn(
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 hover:bg-components-input-bg-hover',
open && '!hover:bg-components-input-bg-hover hover:bg-components-input-bg-hover',
)}
>
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && 'text-text-quaternary!')}>
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
{!!value.length && selectedLabels}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<RiArrowDownSLine className="h-4 w-4" />
</div>
</div>
className={cn(
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 text-left hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-hover hover:bg-components-input-bg-hover',
)}
/>
>
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-4.5 text-text-secondary', !value.length && 'text-text-quaternary!')}>
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
{!!value.length && selectedLabels}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<span className="i-ri-arrow-down-s-line h-4 w-4" />
</div>
</PopoverTrigger>
<PopoverContent
placement="bottom-start"
sideOffset={4}

View File

@ -133,8 +133,8 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
}))
vi.mock('@/app/components/tools/workflow-tool', () => ({
default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
<div data-testid="workflow-tool-modal">
WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
<div data-testid="workflow-tool-drawer">
<button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}>Save</button>
<button data-testid="wf-remove" onClick={onRemove}>Remove</button>
<button data-testid="wf-close" onClick={onHide}>Close</button>
@ -581,7 +581,7 @@ describe('ProviderDetail', () => {
})
})
it('saves workflow tool via workflow modal', async () => {
it('saves workflow tool via workflow drawer', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
@ -593,7 +593,7 @@ describe('ProviderDetail', () => {
expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument()
expect(screen.getByTestId('workflow-tool-drawer'))!.toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('wf-save'))
})
@ -627,7 +627,7 @@ describe('ProviderDetail', () => {
})
})
describe('Modal Close Actions', () => {
describe('Overlay Close Actions', () => {
it('closes ConfigCredential when cancel is clicked', async () => {
render(
<ProviderDetail
@ -665,7 +665,7 @@ describe('ProviderDetail', () => {
expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument()
})
it('closes WorkflowToolModal via onHide', async () => {
it('closes WorkflowToolDrawer via onHide', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
@ -677,9 +677,9 @@ describe('ProviderDetail', () => {
expect(screen.getByText('tools.createTool.editAction'))!.toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('workflow-tool-modal'))!.toBeInTheDocument()
expect(screen.getByTestId('workflow-tool-drawer'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('wf-close'))
expect(screen.queryByTestId('workflow-tool-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('workflow-tool-drawer')).not.toBeInTheDocument()
})
})

View File

@ -1,6 +1,6 @@
'use client'
import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types'
import type { WorkflowToolModalPayload } from '@/app/components/tools/workflow-tool'
import type { WorkflowToolDrawerPayload } from '@/app/components/tools/workflow-tool'
import {
AlertDialog,
AlertDialogActions,
@ -31,7 +31,7 @@ import OrgInfo from '@/app/components/plugins/card/base/org-info'
import Title from '@/app/components/plugins/card/base/title'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import WorkflowToolModal from '@/app/components/tools/workflow-tool'
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
import { useAppContext } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
@ -140,7 +140,7 @@ const ProviderDetail = ({
setIsShowEditCustomCollectionModal(false)
}
// workflow provider
const [isShowEditWorkflowToolModal, setIsShowEditWorkflowToolModal] = useState(false)
const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
const getWorkflowToolProvider = useCallback(async () => {
setIsDetailLoading(true)
const res = await fetchWorkflowToolDetail(collection.id)
@ -164,7 +164,7 @@ const ProviderDetail = ({
await deleteWorkflowTool(collection.id)
onRefreshData()
toast.success(t('api.actionSuccess', { ns: 'common' }))
setIsShowEditWorkflowToolModal(false)
setWorkflowToolDrawerOpen(false)
}
const updateWorkflowToolProvider = async (data: WorkflowToolProviderRequest & Partial<{
workflow_app_id: string
@ -175,7 +175,7 @@ const ProviderDetail = ({
onRefreshData()
getWorkflowToolProvider()
toast.success(t('api.actionSuccess', { ns: 'common' }))
setIsShowEditWorkflowToolModal(false)
setWorkflowToolDrawerOpen(false)
}
const onClickCustomToolDelete = () => {
setDeleteAction('customTool')
@ -287,7 +287,7 @@ const ProviderDetail = ({
</Button>
<Button
className={cn('my-3 w-[183px] shrink-0')}
onClick={() => setIsShowEditWorkflowToolModal(true)}
onClick={() => setWorkflowToolDrawerOpen(true)}
disabled={!isCurrentWorkspaceManager}
>
<div className="system-sm-medium text-text-secondary">{t('createTool.editAction', { ns: 'tools' })}</div>
@ -401,10 +401,10 @@ const ProviderDetail = ({
onRemove={onClickCustomToolDelete}
/>
)}
{isShowEditWorkflowToolModal && (
<WorkflowToolModal
payload={customCollection as unknown as WorkflowToolModalPayload}
onHide={() => setIsShowEditWorkflowToolModal(false)}
{workflowToolDrawerOpen && (
<WorkflowToolDrawer
payload={customCollection as unknown as WorkflowToolDrawerPayload}
onHide={() => setWorkflowToolDrawerOpen(false)}
onRemove={onClickWorkflowToolDelete}
onSave={updateWorkflowToolProvider}
/>

View File

@ -1,22 +1,9 @@
import type { WorkflowToolModalPayload } from '../index'
import type { ReactNode } from 'react'
import type { WorkflowToolDrawerPayload } from '../index'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WorkflowToolAsModal from '../index'
vi.mock('@/app/components/base/drawer-plus', () => ({
default: ({ isShow, onHide, title, body }: { isShow: boolean, onHide: () => void, title: string, body: React.ReactNode }) => (
isShow
? (
<div data-testid="drawer" role="dialog">
<span>{title}</span>
<button data-testid="drawer-close" onClick={onHide}>Close</button>
{body}
</div>
)
: null
),
}))
import { WorkflowToolDrawer } from '../index'
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
@ -46,8 +33,8 @@ vi.mock('@/app/components/base/tooltip', () => ({
children,
popupContent,
}: {
children?: React.ReactNode
popupContent?: React.ReactNode
children?: ReactNode
popupContent?: ReactNode
}) => (
<div>
{children}
@ -86,7 +73,7 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
const createPayload = (overrides: Partial<WorkflowToolModalPayload> = {}): WorkflowToolModalPayload => ({
const createPayload = (overrides: Partial<WorkflowToolDrawerPayload> = {}): WorkflowToolDrawerPayload => ({
icon: { content: '🔧', background: '#ffffff' },
label: 'My Tool',
name: 'my_tool',
@ -105,7 +92,7 @@ const createPayload = (overrides: Partial<WorkflowToolModalPayload> = {}): Workf
...overrides,
})
describe('WorkflowToolAsModal', () => {
describe('WorkflowToolDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
@ -115,7 +102,7 @@ describe('WorkflowToolAsModal', () => {
const onCreate = vi.fn()
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
isAdd
payload={createPayload()}
onHide={vi.fn()}
@ -144,7 +131,7 @@ describe('WorkflowToolAsModal', () => {
const onCreate = vi.fn()
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
isAdd
payload={createPayload({ name: 'bad-name' })}
onHide={vi.fn()}
@ -165,7 +152,7 @@ describe('WorkflowToolAsModal', () => {
const onSave = vi.fn()
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
payload={createPayload()}
onHide={vi.fn()}
onSave={onSave}
@ -187,7 +174,7 @@ describe('WorkflowToolAsModal', () => {
it('should show duplicate reserved output warnings', () => {
render(
<WorkflowToolAsModal
<WorkflowToolDrawer
isAdd
payload={createPayload()}
onHide={vi.fn()}

View File

@ -1,70 +1,33 @@
'use client'
import type { Emoji } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Indicator from '@/app/components/header/indicator'
import WorkflowToolModal from '@/app/components/tools/workflow-tool'
import { useRouter } from '@/next/navigation'
import Divider from '../../base/divider'
import { useConfigureButton } from './hooks/use-configure-button'
type Props = {
disabled: boolean
published: boolean
detailNeedUpdate: boolean
workflowAppId: string
icon: Emoji
name: string
description: string
inputs?: InputVar[]
outputs?: Variable[]
handlePublish: (params?: PublishWorkflowParams) => Promise<void>
onRefreshData?: () => void
isLoading: boolean
outdated: boolean
isCurrentWorkspaceManager: boolean
onConfigure: () => void
disabledReason?: string
}
const WorkflowToolConfigureButton = ({
disabled,
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
isLoading,
outdated,
isCurrentWorkspaceManager,
onConfigure,
disabledReason,
}: Props) => {
const { t } = useTranslation()
const {
showModal,
isLoading,
outdated,
payload,
isCurrentWorkspaceManager,
openModal,
closeModal,
handleCreate,
handleUpdate,
navigateToTools,
} = useConfigureButton({
published,
detailNeedUpdate,
workflowAppId,
icon,
name,
description,
inputs,
outputs,
handlePublish,
onRefreshData,
})
const router = useRouter()
return (
<>
@ -80,9 +43,12 @@ const WorkflowToolConfigureButton = ({
? (
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
onClick={() => !disabled && !published && openModal()}
onClick={() => {
if (!disabled && !published)
onConfigure()
}}
>
<RiHammerLine className={cn('relative h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
<span className={cn('relative i-ri-hammer-line h-4 w-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className={cn('shrink grow basis-0 truncate system-sm-medium text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')}
@ -100,7 +66,7 @@ const WorkflowToolConfigureButton = ({
<div
className="flex items-center justify-start gap-2 p-2 pl-2.5"
>
<RiHammerLine className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-hammer-line h-4 w-4 text-text-tertiary" />
<div
title={t('common.workflowAsTool', { ns: 'workflow' }) || ''}
className="shrink grow basis-0 truncate system-sm-medium text-text-tertiary"
@ -120,7 +86,7 @@ const WorkflowToolConfigureButton = ({
<Button
size="small"
className="w-[140px]"
onClick={openModal}
onClick={onConfigure}
disabled={!isCurrentWorkspaceManager || disabled}
>
{t('common.configure', { ns: 'workflow' })}
@ -129,11 +95,11 @@ const WorkflowToolConfigureButton = ({
<Button
size="small"
className="w-[140px]"
onClick={navigateToTools}
onClick={() => router.push('/tools?category=workflow')}
disabled={disabled}
>
{t('common.manageInTools', { ns: 'workflow' })}
<RiArrowRightUpLine className="ml-1 h-4 w-4" />
<span className="ml-1 i-ri-arrow-right-up-line h-4 w-4" />
</Button>
</div>
{outdated && (
@ -146,15 +112,6 @@ const WorkflowToolConfigureButton = ({
</div>
)}
{published && isLoading && <div className="pt-2"><Loading type="app" /></div>}
{showModal && (
<WorkflowToolModal
isAdd={!published}
payload={payload}
onHide={closeModal}
onCreate={handleCreate}
onSave={handleUpdate}
/>
)}
</>
)
}

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