Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73a1dc293f | ||
|
|
26c00ffd85 | ||
|
|
33db927327 | ||
|
|
d226a5bdd3 | ||
|
|
d65a6b4810 | ||
|
|
44a91e344c | ||
|
|
2c5a9ec1a0 | ||
|
|
b59d8d3446 | ||
|
|
87bbcc4e3b | ||
|
|
0fec9af6a6 | ||
|
|
5e5113e08e | ||
|
|
73f9a9e7d6 | ||
|
|
91008c0329 | ||
|
|
48d23cd744 | ||
|
|
49e12984c3 | ||
|
|
0b60bf6ef0 | ||
|
|
3b24d8d2d1 | ||
|
|
051ba99cd2 | ||
|
|
dc83e8aa09 | ||
|
|
77f8f2babb | ||
|
|
77d6c108e7 | ||
|
|
c2a5962023 | ||
|
|
d583b1b835 | ||
|
|
da00de6688 | ||
|
|
a633387e9b | ||
|
|
34c46561e9 | ||
|
|
a25e3b66c1 | ||
|
|
45b0751826 | ||
|
|
ea58415f88 | ||
|
|
73ff36cb0e | ||
|
|
6afdde1bc4 | ||
|
|
c294006ecf | ||
|
|
53c0dde3d5 | ||
|
|
a1759fccb8 |
@@ -237,6 +237,10 @@ scripts/stress-test/reports/
|
||||
.playwright-mcp/
|
||||
.serena/
|
||||
|
||||
# vitest browser mode attachments (failure screenshots, traces, etc.)
|
||||
.vitest-attachments/
|
||||
**/__screenshots__/
|
||||
|
||||
# settings
|
||||
*.local.json
|
||||
*.local.md
|
||||
|
||||
@@ -84,6 +84,9 @@ class WorkflowEventsApi(WebApiResource):
|
||||
|
||||
def _generate_stream_events():
|
||||
if include_state_snapshot:
|
||||
# TODO(wylswz): events between shapshot and live tail may be lost.
|
||||
# TODO(wylswz): previous message chunks are not replayed. In order to support replay, we need
|
||||
# to figure out a way to deduplicate events between snapshot and stream.
|
||||
return generator.convert_to_event_stream(
|
||||
build_workflow_event_stream(
|
||||
app_mode=app_mode,
|
||||
|
||||
@@ -311,7 +311,7 @@ class MessageBasedAppGenerator(BaseAppGenerator):
|
||||
cls,
|
||||
app_mode: AppMode,
|
||||
workflow_run_id: str,
|
||||
idle_timeout=300,
|
||||
idle_timeout: float = 300,
|
||||
on_subscribe: Callable[[], None] | None = None,
|
||||
) -> Generator[Mapping | str, None, None]:
|
||||
topic = cls.get_response_topic(app_mode, workflow_run_id)
|
||||
|
||||
@@ -23,7 +23,7 @@ class MessageGenerator:
|
||||
cls,
|
||||
app_mode: AppMode,
|
||||
workflow_run_id: str,
|
||||
idle_timeout=300,
|
||||
idle_timeout: float = 300,
|
||||
ping_interval: float = 10.0,
|
||||
on_subscribe: Callable[[], None] | None = None,
|
||||
) -> Generator[Mapping | str, None, None]:
|
||||
|
||||
@@ -27,6 +27,9 @@ def stream_topic_events(
|
||||
terminal_values = _normalize_terminal_events(terminal_events)
|
||||
last_msg_time = time.time()
|
||||
last_ping_time = last_msg_time
|
||||
# The application layer intentionally does not use broadcast-channel replay;
|
||||
# callers that need historical events should compose them from persisted state
|
||||
# (see ``build_workflow_event_stream``) and then tail the live stream.
|
||||
with topic.subscribe() as sub:
|
||||
# on_subscribe fires only after the Redis subscription is active.
|
||||
# This is used to gate task start and reduce pub/sub race for the first event.
|
||||
|
||||
@@ -410,7 +410,7 @@ class WorkflowBasedAppRunner:
|
||||
elif isinstance(event, GraphRunFailedEvent):
|
||||
self._publish_event(QueueWorkflowFailedEvent(error=event.error, exceptions_count=event.exceptions_count))
|
||||
elif isinstance(event, GraphRunAbortedEvent):
|
||||
self._publish_event(QueueWorkflowFailedEvent(error=event.reason or "Unknown error", exceptions_count=0))
|
||||
self._publish_event(QueueWorkflowPartialSuccessEvent(outputs={}, exceptions_count=0))
|
||||
elif isinstance(event, GraphRunPausedEvent):
|
||||
runtime_state = workflow_entry.graph_engine.graph_runtime_state
|
||||
paused_nodes = runtime_state.get_paused_nodes()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Generator # Changed from Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
@@ -32,7 +32,7 @@ def get_current_file_access_scope() -> FileAccessScope | None:
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_file_access_scope(scope: FileAccessScope) -> Iterator[None]:
|
||||
def bind_file_access_scope(scope: FileAccessScope) -> Generator[None, None, None]: # Changed from Iterator[None]
|
||||
token = _current_file_access_scope.set(scope)
|
||||
try:
|
||||
yield
|
||||
|
||||
@@ -70,12 +70,32 @@ class ProviderManager:
|
||||
Request-bound managers may carry caller identity in that runtime, and the
|
||||
resulting ``ProviderConfiguration`` objects must reuse it for downstream
|
||||
model-type and schema lookups.
|
||||
|
||||
Configuration assembly is cached per manager instance so call chains that
|
||||
share one request-scoped manager can reuse the same provider graph instead
|
||||
of rebuilding it for every lookup. Call ``clear_configurations_cache()``
|
||||
when a long-lived manager needs to observe writes performed within the same
|
||||
instance scope.
|
||||
"""
|
||||
|
||||
decoding_rsa_key: Any | None
|
||||
decoding_cipher_rsa: Any | None
|
||||
_model_runtime: ModelRuntime
|
||||
_configurations_cache: dict[str, ProviderConfigurations]
|
||||
|
||||
def __init__(self, model_runtime: ModelRuntime):
|
||||
self.decoding_rsa_key = None
|
||||
self.decoding_cipher_rsa = None
|
||||
self._model_runtime = model_runtime
|
||||
self._configurations_cache = {}
|
||||
|
||||
def clear_configurations_cache(self, tenant_id: str | None = None) -> None:
|
||||
"""Drop assembled provider configurations cached on this manager instance."""
|
||||
if tenant_id is None:
|
||||
self._configurations_cache.clear()
|
||||
return
|
||||
|
||||
self._configurations_cache.pop(tenant_id, None)
|
||||
|
||||
def get_configurations(self, tenant_id: str) -> ProviderConfigurations:
|
||||
"""
|
||||
@@ -114,6 +134,10 @@ class ProviderManager:
|
||||
:param tenant_id:
|
||||
:return:
|
||||
"""
|
||||
cached_configurations = self._configurations_cache.get(tenant_id)
|
||||
if cached_configurations is not None:
|
||||
return cached_configurations
|
||||
|
||||
# Get all provider records of the workspace
|
||||
provider_name_to_provider_records_dict = self._get_all_providers(tenant_id)
|
||||
|
||||
@@ -273,6 +297,8 @@ class ProviderManager:
|
||||
|
||||
provider_configurations[str(provider_id_entity)] = provider_configuration
|
||||
|
||||
self._configurations_cache[tenant_id] = provider_configurations
|
||||
|
||||
# Return the encapsulated object
|
||||
return provider_configurations
|
||||
|
||||
|
||||
@@ -139,8 +139,10 @@ class Jieba(BaseKeyword):
|
||||
"__data__": {"index_id": self.dataset.id, "summary": None, "table": keyword_table},
|
||||
}
|
||||
dataset_keyword_table = self.dataset.dataset_keyword_table
|
||||
keyword_data_source_type = dataset_keyword_table.data_source_type
|
||||
keyword_data_source_type = dataset_keyword_table.data_source_type if dataset_keyword_table else "file"
|
||||
if keyword_data_source_type == "database":
|
||||
if dataset_keyword_table is None:
|
||||
return
|
||||
dataset_keyword_table.keyword_table = dumps_with_sets(keyword_table_dict)
|
||||
db.session.commit()
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from operator import itemgetter
|
||||
from typing import cast
|
||||
|
||||
@@ -80,12 +81,14 @@ class JiebaKeywordTableHandler:
|
||||
|
||||
def extract_tags(self, sentence: str, top_k: int | None = 20, **kwargs):
|
||||
# Basic frequency-based keyword extraction as a fallback when TF-IDF is unavailable.
|
||||
top_k = kwargs.pop("topK", top_k)
|
||||
top_k = cast(int | None, kwargs.pop("topK", top_k))
|
||||
if top_k is None:
|
||||
top_k = 20
|
||||
cut = getattr(jieba, "cut", None)
|
||||
if self._lcut:
|
||||
tokens = self._lcut(sentence)
|
||||
elif callable(cut):
|
||||
tokens = list(cut(sentence))
|
||||
tokens = list(cast(Callable[[str], list[str]], cut)(sentence))
|
||||
else:
|
||||
tokens = re.findall(r"\w+", sentence)
|
||||
|
||||
@@ -108,7 +111,7 @@ class JiebaKeywordTableHandler:
|
||||
sentence=text,
|
||||
topK=max_keywords_per_chunk,
|
||||
)
|
||||
# jieba.analyse.extract_tags returns list[Any] when withFlag is False by default.
|
||||
# jieba.analyse.extract_tags returns an untyped list when withFlag is False by default.
|
||||
keywords = cast(list[str], keywords)
|
||||
|
||||
return set(self._expand_tokens_with_subtokens(set(keywords)))
|
||||
|
||||
@@ -158,7 +158,7 @@ class RetrievalService:
|
||||
)
|
||||
|
||||
if futures:
|
||||
for future in concurrent.futures.as_completed(futures, timeout=3600):
|
||||
for _ in concurrent.futures.as_completed(futures, timeout=3600):
|
||||
if exceptions:
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
|
||||
@@ -94,6 +94,7 @@ class ExtractProcessor:
|
||||
cls, extract_setting: ExtractSetting, is_automatic: bool = False, file_path: str | None = None
|
||||
) -> list[Document]:
|
||||
if extract_setting.datasource_type == DatasourceType.FILE:
|
||||
upload_file = extract_setting.upload_file
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
upload_file = extract_setting.upload_file
|
||||
if not file_path:
|
||||
@@ -104,6 +105,7 @@ class ExtractProcessor:
|
||||
storage.download(upload_file.key, file_path)
|
||||
input_file = Path(file_path)
|
||||
file_extension = input_file.suffix.lower()
|
||||
assert upload_file is not None, "upload_file is required"
|
||||
etl_type = dify_config.ETL_TYPE
|
||||
extractor: BaseExtractor | None = None
|
||||
if etl_type == "Unstructured":
|
||||
|
||||
@@ -28,7 +28,7 @@ class FunctionCallMultiDatasetRouter:
|
||||
SystemPromptMessage(content="You are a helpful AI assistant."),
|
||||
UserPromptMessage(content=query),
|
||||
]
|
||||
result: LLMResult = model_instance.invoke_llm(
|
||||
result: LLMResult = model_instance.invoke_llm( # pyright: ignore[reportCallIssue, reportArgumentType]
|
||||
prompt_messages=prompt_messages,
|
||||
tools=dataset_tools,
|
||||
stream=False,
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import re
|
||||
from collections.abc import Collection
|
||||
from collections.abc import Set as AbstractSet
|
||||
from typing import Any, Literal
|
||||
|
||||
from core.model_manager import ModelInstance
|
||||
@@ -21,8 +21,8 @@ class EnhanceRecursiveCharacterTextSplitter(RecursiveCharacterTextSplitter):
|
||||
def from_encoder[T: EnhanceRecursiveCharacterTextSplitter](
|
||||
cls: type[T],
|
||||
embedding_model_instance: ModelInstance | None,
|
||||
allowed_special: Literal["all"] | set[str] = set(),
|
||||
disallowed_special: Literal["all"] | Collection[str] = "all",
|
||||
allowed_special: Literal["all"] | AbstractSet[str] = frozenset(),
|
||||
disallowed_special: Literal["all"] | AbstractSet[str] = "all",
|
||||
**kwargs: Any,
|
||||
) -> T:
|
||||
def _token_encoder(texts: list[str]) -> list[int]:
|
||||
@@ -40,6 +40,7 @@ class EnhanceRecursiveCharacterTextSplitter(RecursiveCharacterTextSplitter):
|
||||
|
||||
return [len(text) for text in texts]
|
||||
|
||||
_ = _token_encoder # kept for future token-length wiring
|
||||
return cls(length_function=_character_encoder, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import copy
|
||||
import logging
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Collection, Iterable, Sequence, Set
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -187,8 +188,8 @@ class TokenTextSplitter(TextSplitter):
|
||||
self,
|
||||
encoding_name: str = "gpt2",
|
||||
model_name: str | None = None,
|
||||
allowed_special: Literal["all"] | Set[str] = set(),
|
||||
disallowed_special: Literal["all"] | Collection[str] = "all",
|
||||
allowed_special: Literal["all"] | AbstractSet[str] = frozenset(),
|
||||
disallowed_special: Literal["all"] | AbstractSet[str] = "all",
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Create a new TextSplitter."""
|
||||
@@ -207,8 +208,8 @@ class TokenTextSplitter(TextSplitter):
|
||||
else:
|
||||
enc = tiktoken.get_encoding(encoding_name)
|
||||
self._tokenizer = enc
|
||||
self._allowed_special = allowed_special
|
||||
self._disallowed_special = disallowed_special
|
||||
self._allowed_special: Literal["all"] | AbstractSet[str] = allowed_special
|
||||
self._disallowed_special: Literal["all"] | AbstractSet[str] = disallowed_special
|
||||
|
||||
def split_text(self, text: str) -> list[str]:
|
||||
def _encode(_text: str) -> list[int]:
|
||||
|
||||
@@ -105,7 +105,7 @@ class Article:
|
||||
|
||||
|
||||
def extract_using_readabilipy(html: str):
|
||||
json_article: dict[str, Any] = simple_json_from_html_string(html, use_readability=True)
|
||||
json_article: dict[str, Any] = simple_json_from_html_string(html, use_readability=False)
|
||||
article = Article(
|
||||
title=json_article.get("title") or "",
|
||||
author=json_article.get("byline") or "",
|
||||
|
||||
@@ -58,6 +58,7 @@ class MessageListItem(ResponseModel):
|
||||
message_files: list[MessageFile]
|
||||
status: str
|
||||
error: str | None = None
|
||||
workflow_run_id: str | None = None
|
||||
extra_contents: list[ExecutionExtraContentDomainModel]
|
||||
|
||||
@field_validator("inputs", mode="before")
|
||||
|
||||
@@ -76,7 +76,6 @@ class _StreamsSubscription(Subscription):
|
||||
# reading and writing the _listener / `_closed` attribute.
|
||||
self._lock = threading.Lock()
|
||||
self._closed: bool = False
|
||||
# self._closed = threading.Event()
|
||||
self._listener: threading.Thread | None = None
|
||||
|
||||
def _listen(self) -> None:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import contextvars
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Generator # Changed from Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||
def preserve_flask_contexts(
|
||||
flask_app: Flask,
|
||||
context_vars: contextvars.Context,
|
||||
) -> Iterator[None]:
|
||||
) -> Generator[None, None, None]: # Changed from Iterator[None]
|
||||
"""
|
||||
A context manager that handles:
|
||||
1. flask-login's UserProxy copy
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Generator # Added Generator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -75,7 +75,7 @@ class AnalyticdbVectorBySql:
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _get_cursor(self) -> Iterator[Any]:
|
||||
def _get_cursor(self) -> Generator[Any, None, None]: # Changed from Iterator[Any]
|
||||
assert self.pool is not None, "Connection pool is not initialized"
|
||||
conn = self.pool.getconn()
|
||||
cur = conn.cursor()
|
||||
|
||||
+6
-6
@@ -114,10 +114,10 @@ override-dependencies = [
|
||||
dev = [
|
||||
"coverage>=7.13.4",
|
||||
"dotenv-linter>=0.7.0",
|
||||
"faker>=20.1.0",
|
||||
"faker>=40.15.0",
|
||||
"lxml-stubs>=0.5.1",
|
||||
"basedpyright>=1.39.0",
|
||||
"ruff>=0.15.10",
|
||||
"basedpyright>=1.39.3",
|
||||
"ruff>=0.15.11",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-benchmark>=5.2.3",
|
||||
"pytest-cov>=7.1.0",
|
||||
@@ -157,14 +157,14 @@ dev = [
|
||||
"types-tensorflow>=2.18.0.20260408",
|
||||
"types-tqdm>=4.67.3.20260408",
|
||||
"types-ujson>=5.10.0",
|
||||
"boto3-stubs>=1.42.88",
|
||||
"boto3-stubs>=1.42.92",
|
||||
"types-jmespath>=1.1.0.20260408",
|
||||
"hypothesis>=6.151.12",
|
||||
"hypothesis>=6.152.1",
|
||||
"types_pyOpenSSL>=24.1.0",
|
||||
"types_cffi>=2.0.0.20260408",
|
||||
"types_setuptools>=82.0.0.20260408",
|
||||
"pandas-stubs>=3.0.0",
|
||||
"scipy-stubs>=1.15.3.0",
|
||||
"scipy-stubs>=1.17.1.4",
|
||||
"types-python-http-client>=3.3.7.20260408",
|
||||
"import-linter>=2.3",
|
||||
"types-redis>=4.6.0.20241004",
|
||||
|
||||
@@ -37,10 +37,13 @@ class AppTaskService:
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
# Legacy mechanism: Set stop flag in Redis
|
||||
AppQueueManager.set_stop_flag(task_id, invoke_from, user_id)
|
||||
|
||||
# New mechanism: Send stop command via GraphEngine for workflow-based apps
|
||||
# This ensures proper workflow status recording in the persistence layer
|
||||
if app_mode in (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW):
|
||||
# Let the event handler process the Graphon abort event instead of
|
||||
# stopping the queue listener immediately. Otherwise, events may be
|
||||
# lost and the workflow run can remain stuck in the running state.
|
||||
GraphEngineManager(redis_client).send_stop_command(task_id)
|
||||
else:
|
||||
# Legacy mechanism: Set stop flag in Redis
|
||||
AppQueueManager.set_stop_flag(task_id, invoke_from, user_id)
|
||||
|
||||
@@ -2,7 +2,7 @@ import base64
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Iterator, Sequence
|
||||
from collections.abc import Generator, Sequence # Changed Iterator to Generator
|
||||
from contextlib import contextmanager, suppress
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Literal
|
||||
@@ -324,7 +324,7 @@ class FileService:
|
||||
def build_upload_files_zip_tempfile(
|
||||
*,
|
||||
upload_files: Sequence[UploadFile],
|
||||
) -> Iterator[str]:
|
||||
) -> Generator[str, None, None]: # Changed from Iterator[str]
|
||||
"""
|
||||
Build a ZIP from `UploadFile`s and yield a tempfile path.
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.rag.datasource.retrieval_service import RetrievalService
|
||||
from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService
|
||||
from core.rag.index_processor.constant.query_type import QueryType
|
||||
from core.rag.models.document import Document
|
||||
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
|
||||
@@ -36,6 +36,10 @@ default_retrieval_model = {
|
||||
}
|
||||
|
||||
|
||||
class HitTestingRetrievalModelDict(DefaultRetrievalModelDict, total=False):
|
||||
metadata_filtering_conditions: dict[str, Any]
|
||||
|
||||
|
||||
class HitTestingService:
|
||||
@classmethod
|
||||
def retrieve(
|
||||
@@ -51,17 +55,18 @@ class HitTestingService:
|
||||
start = time.perf_counter()
|
||||
|
||||
# get retrieval model , if the model is not setting , using default
|
||||
if not retrieval_model:
|
||||
retrieval_model = dataset.retrieval_model or default_retrieval_model
|
||||
assert isinstance(retrieval_model, dict)
|
||||
resolved_retrieval_model = cast(
|
||||
HitTestingRetrievalModelDict,
|
||||
retrieval_model or dataset.retrieval_model or default_retrieval_model,
|
||||
)
|
||||
document_ids_filter = None
|
||||
metadata_filtering_conditions = retrieval_model.get("metadata_filtering_conditions", {})
|
||||
if metadata_filtering_conditions and query:
|
||||
metadata_filtering_conditions_raw = resolved_retrieval_model.get("metadata_filtering_conditions", {})
|
||||
if metadata_filtering_conditions_raw and query:
|
||||
dataset_retrieval = DatasetRetrieval()
|
||||
|
||||
from core.rag.entities import MetadataFilteringCondition
|
||||
|
||||
metadata_filtering_conditions = MetadataFilteringCondition.model_validate(metadata_filtering_conditions)
|
||||
metadata_filtering_conditions = MetadataFilteringCondition.model_validate(metadata_filtering_conditions_raw)
|
||||
|
||||
metadata_filter_document_ids, metadata_condition = dataset_retrieval.get_metadata_filter_condition(
|
||||
dataset_ids=[dataset.id],
|
||||
@@ -78,19 +83,21 @@ class HitTestingService:
|
||||
if metadata_condition and not document_ids_filter:
|
||||
return cls.compact_retrieve_response(query, [])
|
||||
all_documents = RetrievalService.retrieve(
|
||||
retrieval_method=RetrievalMethod(retrieval_model.get("search_method", RetrievalMethod.SEMANTIC_SEARCH)),
|
||||
retrieval_method=RetrievalMethod(
|
||||
resolved_retrieval_model.get("search_method", RetrievalMethod.SEMANTIC_SEARCH)
|
||||
),
|
||||
dataset_id=dataset.id,
|
||||
query=query,
|
||||
attachment_ids=attachment_ids,
|
||||
top_k=retrieval_model.get("top_k", 4),
|
||||
score_threshold=retrieval_model.get("score_threshold", 0.0)
|
||||
if retrieval_model["score_threshold_enabled"]
|
||||
top_k=resolved_retrieval_model.get("top_k", 4),
|
||||
score_threshold=resolved_retrieval_model.get("score_threshold", 0.0)
|
||||
if resolved_retrieval_model["score_threshold_enabled"]
|
||||
else 0.0,
|
||||
reranking_model=retrieval_model.get("reranking_model", None)
|
||||
if retrieval_model["reranking_enable"]
|
||||
reranking_model=resolved_retrieval_model.get("reranking_model", None)
|
||||
if resolved_retrieval_model["reranking_enable"]
|
||||
else None,
|
||||
reranking_mode=retrieval_model.get("reranking_mode") or "reranking_model",
|
||||
weights=retrieval_model.get("weights", None),
|
||||
reranking_mode=resolved_retrieval_model.get("reranking_mode") or "reranking_model",
|
||||
weights=resolved_retrieval_model.get("weights", None),
|
||||
document_ids_filter=document_ids_filter,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class PluginAutoUpgradeService:
|
||||
exclude_plugins: list[str],
|
||||
include_plugins: list[str],
|
||||
) -> bool:
|
||||
with session_factory.create_session() as session:
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
exist_strategy = session.scalar(
|
||||
select(TenantPluginAutoUpgradeStrategy)
|
||||
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||
@@ -50,7 +50,7 @@ class PluginAutoUpgradeService:
|
||||
|
||||
@staticmethod
|
||||
def exclude_plugin(tenant_id: str, plugin_id: str) -> bool:
|
||||
with session_factory.create_session() as session:
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
exist_strategy = session.scalar(
|
||||
select(TenantPluginAutoUpgradeStrategy)
|
||||
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||
|
||||
@@ -62,6 +62,19 @@ def build_workflow_event_stream(
|
||||
idle_timeout: float = 300,
|
||||
ping_interval: float = 10.0,
|
||||
) -> Generator[Mapping[str, Any] | str, None, None]:
|
||||
"""Yield a stream of workflow events composed of a DB-derived snapshot followed by live tail.
|
||||
|
||||
The stream is assembled in two phases that are kept **structurally disjoint** so no
|
||||
per-event deduplication is required:
|
||||
|
||||
1. Snapshot phase: events rebuilt from persistent state via ``_build_snapshot_events``
|
||||
(``workflow_started``, optional ``message_replace``, ``node_started``/``node_finished``
|
||||
for each persisted execution, and an optional terminal ``workflow_paused``). This
|
||||
represents the history that already happened from the client's point of view.
|
||||
2. Tail phase: events delivered by the broadcast subscription **from the moment of
|
||||
subscription onward only**. Anything that was published before the subscription was
|
||||
established is ignored here, since message id is not tracked in current implementation.
|
||||
"""
|
||||
topic = MessageGenerator.get_response_topic(app_mode, workflow_run.id)
|
||||
workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
|
||||
node_execution_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(session_maker)
|
||||
|
||||
@@ -50,6 +50,7 @@ def make_message():
|
||||
msg.user_feedback = MagicMock(rating=None)
|
||||
msg.status = "normal"
|
||||
msg.error = None
|
||||
msg.workflow_run_id = "22222222-2222-2222-2222-222222222222"
|
||||
return msg
|
||||
|
||||
|
||||
@@ -84,6 +85,8 @@ class TestMessageListApi:
|
||||
assert result["limit"] == 20
|
||||
assert result["has_more"] is False
|
||||
assert len(result["data"]) == 2
|
||||
assert result["data"][0]["workflow_run_id"] == "22222222-2222-2222-2222-222222222222"
|
||||
assert result["data"][1]["workflow_run_id"] == "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
def test_get_not_chat_app(self):
|
||||
api = module.MessageListApi()
|
||||
|
||||
@@ -372,6 +372,78 @@ def test_get_configurations_binds_manager_runtime_to_provider_configuration(
|
||||
provider_configuration.bind_model_runtime.assert_called_once_with(manager._model_runtime)
|
||||
|
||||
|
||||
def test_get_configurations_reuses_cached_result_for_same_tenant(mocker: MockerFixture, mock_provider_entity):
|
||||
manager = _build_provider_manager(mocker)
|
||||
provider_configuration = Mock()
|
||||
provider_factory = Mock()
|
||||
provider_factory.get_providers.return_value = [mock_provider_entity]
|
||||
custom_configuration = SimpleNamespace(provider=None, models=[])
|
||||
system_configuration = SimpleNamespace(enabled=False, quota_configurations=[], current_quota_type=None)
|
||||
|
||||
with (
|
||||
patch.object(manager, "_get_all_providers", return_value={"openai": []}) as mock_get_all_providers,
|
||||
patch.object(manager, "_init_trial_provider_records", return_value={"openai": []}),
|
||||
patch.object(manager, "_get_all_provider_models", return_value={"openai": []}),
|
||||
patch.object(manager, "_get_all_preferred_model_providers", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory) as mock_factory_cls,
|
||||
patch(
|
||||
"core.provider_manager.ProviderConfiguration",
|
||||
return_value=provider_configuration,
|
||||
) as mock_provider_configuration,
|
||||
):
|
||||
first = manager.get_configurations("tenant-id")
|
||||
second = manager.get_configurations("tenant-id")
|
||||
|
||||
assert first is second
|
||||
mock_get_all_providers.assert_called_once_with("tenant-id")
|
||||
mock_factory_cls.assert_called_once_with(model_runtime=manager._model_runtime)
|
||||
mock_provider_configuration.assert_called_once()
|
||||
provider_configuration.bind_model_runtime.assert_called_once_with(manager._model_runtime)
|
||||
|
||||
|
||||
def test_clear_configurations_cache_rebuilds_requested_tenant(mocker: MockerFixture, mock_provider_entity):
|
||||
manager = _build_provider_manager(mocker)
|
||||
provider_factory = Mock()
|
||||
provider_factory.get_providers.return_value = [mock_provider_entity]
|
||||
custom_configuration = SimpleNamespace(provider=None, models=[])
|
||||
system_configuration = SimpleNamespace(enabled=False, quota_configurations=[], current_quota_type=None)
|
||||
provider_configuration_first = Mock()
|
||||
provider_configuration_second = Mock()
|
||||
|
||||
with (
|
||||
patch.object(manager, "_get_all_providers", return_value={"openai": []}) as mock_get_all_providers,
|
||||
patch.object(manager, "_init_trial_provider_records", return_value={"openai": []}),
|
||||
patch.object(manager, "_get_all_provider_models", return_value={"openai": []}),
|
||||
patch.object(manager, "_get_all_preferred_model_providers", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
patch("core.provider_manager.ModelProviderFactory", return_value=provider_factory),
|
||||
patch(
|
||||
"core.provider_manager.ProviderConfiguration",
|
||||
side_effect=[provider_configuration_first, provider_configuration_second],
|
||||
) as mock_provider_configuration,
|
||||
):
|
||||
first = manager.get_configurations("tenant-id")
|
||||
manager.clear_configurations_cache("tenant-id")
|
||||
second = manager.get_configurations("tenant-id")
|
||||
|
||||
assert first is not second
|
||||
assert mock_get_all_providers.call_count == 2
|
||||
assert mock_provider_configuration.call_count == 2
|
||||
provider_configuration_first.bind_model_runtime.assert_called_once_with(manager._model_runtime)
|
||||
provider_configuration_second.bind_model_runtime.assert_called_once_with(manager._model_runtime)
|
||||
|
||||
|
||||
def test_get_provider_model_bundle_returns_selected_model_type_instance(mocker: MockerFixture):
|
||||
manager = _build_provider_manager(mocker)
|
||||
provider_configuration = Mock()
|
||||
|
||||
Generated
+40
-40
@@ -469,14 +469,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "basedpyright"
|
||||
version = "1.39.0"
|
||||
version = "1.39.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodejs-wheel-binaries" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/f4/4a77cc1ffb3dab7391642cde30163961d8ee973e9e6b6740c7d15aa3d3ba/basedpyright-1.39.0.tar.gz", hash = "sha256:6666f51c378c7ac45877c4c1c7041ee0b5b83d755ebc82f898f47b6fafe0cc4f", size = 25357403, upload-time = "2026-04-01T12:27:41.92Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/47/08145d1bcc3083ed20059bdecbde404bd767f91b91e2764ec01cffec9f4b/basedpyright-1.39.0-py3-none-any.whl", hash = "sha256:91b8ad50bc85ee4a985b928f9368c35c99eee5a56c44e99b2442fa12ecc3d670", size = 12353868, upload-time = "2026-04-01T12:27:38.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -618,15 +618,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3-stubs"
|
||||
version = "1.42.88"
|
||||
version = "1.42.92"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore-stubs" },
|
||||
{ name = "types-s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c7/d4dfbb4757cd72fd350ba666902ec3ac19e04d6be639e96cdad4543d4726/boto3_stubs-1.42.88.tar.gz", hash = "sha256:85215fb4938a94d1cf83cd8632f46ae7728b5ec88187d83468f393bbe64236d6", size = 102495, upload-time = "2026-04-10T19:55:57.526Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/b4/7f472d64a89f6aa6b8e8eeadc876667b7e4edfb526c6118efe2b2c98ba17/boto3_stubs-1.42.92.tar.gz", hash = "sha256:4bc934069c5e8c7b3cdd2442569dae14e8272fe207d445bd38aa578b8463638f", size = 102696, upload-time = "2026-04-20T19:55:19.858Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/6f/3befd72080aedbb4ad26b353a6e364645668664930ce49668fd0bab8f2b5/boto3_stubs-1.42.88-py3-none-any.whl", hash = "sha256:9e74350715ca8ccd63fc250f8eca9fa3161b3d1704339554344d72e4e21c5ed1", size = 70603, upload-time = "2026-04-10T19:55:49.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/ce/2fe2c6456f8dc0b8bb8d80e05e154c7975ec058991bedf54f3aeed634b79/boto3_stubs-1.42.92-py3-none-any.whl", hash = "sha256:b3994e60f0133b2dd3d9a88ceaeef48fa6367d9a9429426e919575768a1ad9c6", size = 70666, upload-time = "2026-04-20T19:55:16.398Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1616,13 +1616,13 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "basedpyright", specifier = ">=1.39.0" },
|
||||
{ name = "boto3-stubs", specifier = ">=1.42.88" },
|
||||
{ name = "basedpyright", specifier = ">=1.39.3" },
|
||||
{ name = "boto3-stubs", specifier = ">=1.42.92" },
|
||||
{ name = "celery-types", specifier = ">=0.23.0" },
|
||||
{ name = "coverage", specifier = ">=7.13.4" },
|
||||
{ name = "dotenv-linter", specifier = ">=0.7.0" },
|
||||
{ name = "faker", specifier = ">=20.1.0" },
|
||||
{ name = "hypothesis", specifier = ">=6.151.12" },
|
||||
{ name = "faker", specifier = ">=40.15.0" },
|
||||
{ name = "hypothesis", specifier = ">=6.152.1" },
|
||||
{ name = "import-linter", specifier = ">=2.3" },
|
||||
{ name = "lxml-stubs", specifier = ">=0.5.1" },
|
||||
{ name = "mypy", specifier = ">=1.20.1" },
|
||||
@@ -1635,8 +1635,8 @@ dev = [
|
||||
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.8.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.10" },
|
||||
{ name = "scipy-stubs", specifier = ">=1.15.3.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.11" },
|
||||
{ name = "scipy-stubs", specifier = ">=1.17.1.4" },
|
||||
{ name = "testcontainers", specifier = ">=4.14.2" },
|
||||
{ name = "types-aiofiles", specifier = ">=25.1.0" },
|
||||
{ name = "types-beautifulsoup4", specifier = ">=4.12.0" },
|
||||
@@ -2351,14 +2351,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "faker"
|
||||
version = "40.13.0"
|
||||
version = "40.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/95/4822ffe94723553789aef783104f4f18fc20d7c4c68e1bbd633e11d09758/faker-40.13.0.tar.gz", hash = "sha256:a0751c84c3abac17327d7bb4c98e8afe70ebf7821e01dd7d0b15cd8856415525", size = 1962043, upload-time = "2026-04-06T16:44:55.68Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7f/13/6741787bd91c4109c7bed047d68273965cd52ce8a5f773c471b949334b6d/faker-40.15.0.tar.gz", hash = "sha256:20f3a6ec8c266b74d4c554e34118b21c3c2056c0b4a519d15c8decb3a4e6e795", size = 1967447, upload-time = "2026-04-17T20:05:27.555Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/8a/708103325edff16a0b0e004de0d37db8ba216a32713948c64d71f6d4a4c2/faker-40.13.0-py3-none-any.whl", hash = "sha256:c1298fd0d819b3688fb5fd358c4ba8f56c7c8c740b411fd3dbd8e30bf2c05019", size = 1994597, upload-time = "2026-04-06T16:44:53.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/a7/a600f8f30d4505e89166de51dd121bd540ab8e560e8cf0901de00a81de8c/faker-40.15.0-py3-none-any.whl", hash = "sha256:71ab3c3370da9d2205ab74ffb0fd51273063ad562b3a3bb69d0026a20923e318", size = 2004447, upload-time = "2026-04-17T20:05:25.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3317,14 +3317,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hypothesis"
|
||||
version = "6.151.12"
|
||||
version = "6.152.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/ab/67ca321d1ab96fd3828b12142f1c258e2d4a668a025d06cd50ab3409787f/hypothesis-6.151.12.tar.gz", hash = "sha256:be485f503979af4c3dfa19e3fc2b967d0458e7f8c4e28128d7e215e0a55102e0", size = 463900, upload-time = "2026-04-08T19:40:06.205Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/b1/c32bcddb9aab9e3abc700f1f56faf14e7655c64a16ca47701a57362276ea/hypothesis-6.152.1.tar.gz", hash = "sha256:4f4ed934eee295dd84ee97592477d23e8dc03e9f12ae0ee30a4e7c9ef3fca3b0", size = 465029, upload-time = "2026-04-14T22:29:24.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/5a/6cecf134b631050a1f8605096adbe812483b60790d951470989d39b56860/hypothesis-6.151.12-py3-none-any.whl", hash = "sha256:37d4f3a768365c30571b11dfd7a6857a12173d933010b2c4ab65619f1b5952c5", size = 529656, upload-time = "2026-04-08T19:40:03.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/83/860fb3075e00b0fc19a22a2301bc3c96f00437558c3911bdd0a3573a4a53/hypothesis-6.152.1-py3-none-any.whl", hash = "sha256:40a3619d9e0cb97b018857c7986f75cf5de2e5ec0fa8a0b172d00747758f749e", size = 530752, upload-time = "2026-04-14T22:29:20.893Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5887,27 +5887,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.10"
|
||||
version = "0.15.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5946,14 +5946,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "scipy-stubs"
|
||||
version = "1.17.1.3"
|
||||
version = "1.17.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "optype", extra = ["numpy"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/59/59c6cc3f9970154b9ed6b1aff42a0185cdd60cef54adc0404b9e77972221/scipy_stubs-1.17.1.3.tar.gz", hash = "sha256:5eb87a8d23d726706259b012ebe76a4a96a9ae9e141fc59bf55fc8eac2ed9e0f", size = 392185, upload-time = "2026-03-22T22:11:58.34Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/75/d944a11fca64aa84fbb4bfcf613b758319c6103cb30a304a0e9727009d62/scipy_stubs-1.17.1.4.tar.gz", hash = "sha256:cae00c5207aa62ceb4bcadea202d9fbbf002e958f9e4de981720436b8d5c1802", size = 396980, upload-time = "2026-04-13T11:46:54.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/d4/94304532c0a75a55526119043dd44a9bd1541a21e14483cbb54261c527d2/scipy_stubs-1.17.1.3-py3-none-any.whl", hash = "sha256:7b91d3f05aa47da06fbca14eb6c5bb4c28994e9245fd250cc847e375bab31297", size = 597933, upload-time = "2026-03-22T22:11:56.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/f8/334aa5a7a482ea89cb14d92f6a4d9ffa1e193e733144d4d14c7ffcb33583/scipy_stubs-1.17.1.4-py3-none-any.whl", hash = "sha256:e6e5c390fb864745bc3d5f591de81f5cb4f84403857d4f660acb5b6339956f5b", size = 604752, upload-time = "2026-04-13T11:46:53.135Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -273,7 +273,7 @@ SQLALCHEMY_POOL_TIMEOUT=30
|
||||
# Default is 100
|
||||
#
|
||||
# Reference: https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS
|
||||
POSTGRES_MAX_CONNECTIONS=100
|
||||
POSTGRES_MAX_CONNECTIONS=200
|
||||
|
||||
# Sets the amount of shared memory used for postgres's shared buffers.
|
||||
# Default is 128MB
|
||||
|
||||
@@ -70,7 +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}
|
||||
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-100}
|
||||
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-200}
|
||||
POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-128MB}
|
||||
POSTGRES_WORK_MEM: ${POSTGRES_WORK_MEM:-4MB}
|
||||
POSTGRES_MAINTENANCE_WORK_MEM: ${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}
|
||||
|
||||
+1
-336
@@ -129,11 +129,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -493,11 +488,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/dataset-config/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -1001,7 +991,7 @@
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 17
|
||||
"count": 20
|
||||
}
|
||||
},
|
||||
"web/app/components/base/chat/chat/index.tsx": {
|
||||
@@ -1086,21 +1076,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/date-and-time-picker/date-picker/index.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/base/date-and-time-picker/hooks.ts": {
|
||||
"react/no-unnecessary-use-prefix": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/date-and-time-picker/time-picker/index.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/date-and-time-picker/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
@@ -1195,11 +1175,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -1223,11 +1198,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
@@ -1878,11 +1848,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/portal-to-follow-elem/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 1
|
||||
@@ -1906,11 +1871,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/context-block/component.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/context-block/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 3
|
||||
@@ -1940,11 +1900,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/history-block/component.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/history-block/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 3
|
||||
@@ -1971,11 +1926,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/plugins/last-run-block/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 3
|
||||
@@ -2263,21 +2213,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/billing/usage-info/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/common/document-picker/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/common/document-picker/preview-document-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/common/image-previewer/index.tsx": {
|
||||
"no-irregular-whitespace": {
|
||||
"count": 1
|
||||
@@ -2894,14 +2829,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/settings/permission-selector/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/no-missing-key": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/settings/summary-index-setting.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -3069,21 +2996,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/api-based-extension-page/selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/data-source-page-new/card.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/data-source-page-new/configure.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/data-source-page-new/hooks/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 2
|
||||
@@ -3138,14 +3055,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/declarations.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 11
|
||||
@@ -3167,19 +3076,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -3411,11 +3307,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/marketplace/search-box/tags-filter.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -3447,14 +3338,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorized/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorized/item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -3510,16 +3393,6 @@
|
||||
"count": 8
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -3668,11 +3541,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -3713,11 +3581,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
@@ -3756,16 +3619,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-page/filter-management/category-filter.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-page/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -3801,11 +3654,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
@@ -3918,11 +3766,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -4067,11 +3910,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/labels/selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/create-card.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -4211,15 +4049,7 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/blocks.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/featured-tools.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
},
|
||||
@@ -4228,9 +4058,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/featured-triggers.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
},
|
||||
@@ -4248,11 +4075,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/main.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/market-place-plugin/action.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@@ -4268,26 +4090,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/start-blocks.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/tabs.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/tool-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/tool/action-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -4299,9 +4106,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -4378,19 +4182,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/header/view-history.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/header/view-workflow-history.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/hooks-store/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 2
|
||||
@@ -4489,14 +4280,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/agent-strategy.tsx": {
|
||||
"ts/no-empty-object-type": {
|
||||
"count": 1
|
||||
@@ -4723,22 +4506,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/variable/var-type-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -4918,11 +4685,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/code/dependency-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/code/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@@ -5053,11 +4815,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -5078,16 +4835,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -5138,11 +4885,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/if-else/components/condition-add.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-input.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -5158,11 +4900,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -5266,16 +5003,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.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
|
||||
@@ -5291,11 +5018,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -5306,16 +5028,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-retrieval/default.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -5414,17 +5126,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -5496,11 +5197,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/loop/components/condition-add.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -5516,11 +5212,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/loop/components/condition-number-input.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -5871,24 +5562,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/note-node/note-editor/toolbar/command.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/note-node/note-editor/utils.ts": {
|
||||
"regexp/no-useless-quantifier": {
|
||||
"count": 1
|
||||
@@ -6012,11 +5690,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/panel/version-history-panel/filter/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -6315,14 +5988,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/education-apply/search-input.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/education-apply/verify-state-modal.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
||||
@@ -11,6 +11,27 @@ Shared design tokens, the `cn()` utility, a Tailwind CSS preset, and headless pr
|
||||
- Props pattern: `Omit<BaseXxx.Root.Props, 'className' | ...> & VariantProps<typeof xxxVariants> & { /* custom */ }`.
|
||||
- When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath.
|
||||
|
||||
## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover
|
||||
|
||||
Pick by the **trigger's purpose** and **a11y reach**, not visual richness.
|
||||
|
||||
| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? |
|
||||
| ------------- | --------------------- | -------------------------- | ------------------------- | ------------------------ |
|
||||
| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) |
|
||||
| `PreviewCard` | hover / focus | has a primary click target | supplementary preview | ❌ (via click target) |
|
||||
| `Popover` | click / tap (+ hover) | **to open the popup** | anything, incl. long text | ✅ |
|
||||
|
||||
Base UI decision rule ([docs]):
|
||||
|
||||
> _"If the trigger's purpose is to open the popup itself, it's a popover.
|
||||
> If the trigger's purpose is unrelated to opening the popup, it's a tooltip."_
|
||||
|
||||
Apply this first, then narrow:
|
||||
|
||||
- `Tooltip` — ephemeral visual label. Trigger must already carry its own `aria-label` / visible text; tooltip mirrors it for sighted mouse/keyboard users. No interactive UI, no multi-line prose. Not dwell-able.
|
||||
- `PreviewCard` — hover-revealed rich supplementary preview anchored to a trigger whose click goes somewhere (link, selectable row, jumpable chip). **Hard contract:** the popup MUST NOT contain information or actions unreachable from the trigger's click destination — touch and SR users can't open it. If the info is unique to the popup, switch to `Popover` (click or `openOnHover`) or move it to the click destination. Do not hand-roll "hover to open" on top of `Popover` to evade this split.
|
||||
- `Popover` — any popup with its own interactions, or any "infotip" (`?` / `(i)` glyph whose sole purpose is to reveal help text). Pass `openOnHover` on `PopoverTrigger` for the infotip case — unlike `Tooltip` / `PreviewCard`, this stays accessible to touch and SR users because the popover still opens on tap and focus.
|
||||
|
||||
## Border Radius: Figma Token → Tailwind Class Mapping
|
||||
|
||||
The Figma design system uses `--radius/*` tokens whose scale is **offset by one step** from Tailwind CSS v4 defaults. When translating Figma specs to code, always use this mapping — never use `radius-*` as a CSS class, and never extend `borderRadius` in the preset.
|
||||
@@ -34,3 +55,5 @@ The Figma design system uses `--radius/*` tokens whose scale is **offset by one
|
||||
- **Do not** use `radius-*` as CSS class names. The old `@utility radius-*` definitions have been removed.
|
||||
- When the Figma MCP returns `rounded-[var(--radius/sm, 6px)]`, convert it to the standard Tailwind class from the table above (e.g. `rounded-md`).
|
||||
- For values without a standard Tailwind equivalent (10px, 20px, 28px), use arbitrary values like `rounded-[10px]`.
|
||||
|
||||
[docs]: https://base-ui.com/react/components/tooltip#infotips
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
"types": "./src/popover/index.tsx",
|
||||
"import": "./src/popover/index.tsx"
|
||||
},
|
||||
"./preview-card": {
|
||||
"types": "./src/preview-card/index.tsx",
|
||||
"import": "./src/preview-card/index.tsx"
|
||||
},
|
||||
"./scroll-area": {
|
||||
"types": "./src/scroll-area/index.tsx",
|
||||
"import": "./src/scroll-area/index.tsx"
|
||||
|
||||
@@ -20,7 +20,7 @@ const meta = {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.',
|
||||
component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.\n\nPass `openOnHover` on `PopoverTrigger` when the popup should also reveal on hover (see the **Infotip** story). Unlike `Tooltip` and `PreviewCard`, hover on `Popover` still falls back to tap/focus, so touch and screen-reader users can reach the content.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -101,6 +101,48 @@ export const WithActions: Story = {
|
||||
),
|
||||
}
|
||||
|
||||
export const Infotip: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: [
|
||||
'The **infotip** pattern from [Base UI](https://base-ui.com/react/components/tooltip#infotips): an info glyph (`?`, `(i)`) whose sole purpose is to reveal explanatory text. Use `Popover` with `openOnHover` on the trigger — never `Tooltip`.',
|
||||
'',
|
||||
'Why not `Tooltip`? Tooltips are disabled on touch devices and not announced to screen readers; descriptive help text hidden in them is unreachable for those users. Why not `PreviewCard`? PreviewCard\'s a11y contract requires the trigger to already own a primary click destination, but an info glyph has no other purpose.',
|
||||
'',
|
||||
'Base UI rule of thumb: *"If the trigger\'s purpose is to open the popup itself, it\'s a popover. If the trigger\'s purpose is unrelated to opening the popup, it\'s a tooltip."*',
|
||||
'',
|
||||
'Hover, tap, or focus the `?` icon to open. In the Dify app, reach for `@/app/components/base/infotip` (`<Infotip aria-label={...}>{helpText}</Infotip>`) which wraps this pattern with consistent delays (300/200), typography, and `aria-label` plumbing.',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<span>Usage priority</span>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
aria-label="Set which resource to use first when running models."
|
||||
render={(
|
||||
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
popupClassName="max-w-[300px] px-3 py-2 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
Set which resource to use first when running models. The Trial quota will be used after the paid quota is exhausted.
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const PLACEMENTS: Placement[] = [
|
||||
'top-start',
|
||||
'top',
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
PreviewCard,
|
||||
PreviewCardContent,
|
||||
PreviewCardTrigger,
|
||||
} from '..'
|
||||
|
||||
const renderWithSafeViewport = (ui: import('react').ReactNode) => render(
|
||||
<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>
|
||||
{ui}
|
||||
</div>,
|
||||
)
|
||||
|
||||
describe('PreviewCardContent', () => {
|
||||
describe('Placement', () => {
|
||||
it('should use bottom placement and default offsets when placement props are not provided', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={<button type="button" aria-label="preview trigger">Open</button>}
|
||||
/>
|
||||
<PreviewCardContent
|
||||
positionerProps={{ 'role': 'group', 'aria-label': 'default positioner' }}
|
||||
popupProps={{ 'role': 'dialog', 'aria-label': 'default popup' }}
|
||||
>
|
||||
<span>Default content</span>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-side', 'bottom')
|
||||
await expect.element(screen.getByRole('group', { name: 'default positioner' })).toHaveAttribute('data-align', 'center')
|
||||
await expect.element(screen.getByRole('dialog', { name: 'default popup' })).toHaveTextContent('Default content')
|
||||
})
|
||||
|
||||
it('should apply parsed custom placement and custom offsets when placement props are provided', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={<button type="button" aria-label="preview trigger">Open</button>}
|
||||
/>
|
||||
<PreviewCardContent
|
||||
placement="top-end"
|
||||
sideOffset={14}
|
||||
alignOffset={6}
|
||||
positionerProps={{ 'role': 'group', 'aria-label': 'custom positioner' }}
|
||||
popupProps={{ 'role': 'dialog', 'aria-label': 'custom popup' }}
|
||||
>
|
||||
<span>Custom placement content</span>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-side', 'top')
|
||||
await expect.element(screen.getByRole('group', { name: 'custom positioner' })).toHaveAttribute('data-align', 'end')
|
||||
await expect.element(screen.getByRole('dialog', { name: 'custom popup' })).toHaveTextContent('Custom placement content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Passthrough props', () => {
|
||||
it('should forward positionerProps and popupProps when passthrough props are provided', async () => {
|
||||
const onPopupClick = vi.fn()
|
||||
|
||||
const screen = await render(
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={<button type="button" aria-label="preview trigger">Open</button>}
|
||||
/>
|
||||
<PreviewCardContent
|
||||
positionerProps={{
|
||||
'role': 'group',
|
||||
'aria-label': 'preview positioner',
|
||||
'id': 'preview-positioner-id',
|
||||
}}
|
||||
popupProps={{
|
||||
'id': 'preview-popup-id',
|
||||
'role': 'dialog',
|
||||
'aria-label': 'preview content',
|
||||
'onClick': onPopupClick,
|
||||
}}
|
||||
>
|
||||
<span>Preview body</span>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
const popup = screen.getByRole('dialog', { name: 'preview content' })
|
||||
await popup.click()
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'preview positioner' })).toHaveAttribute('id', 'preview-positioner-id')
|
||||
await expect.element(popup).toHaveAttribute('id', 'preview-popup-id')
|
||||
expect(onPopupClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Trigger click behavior', () => {
|
||||
it('should forward the trigger click to the consumer handler so the primary action runs', async () => {
|
||||
const onPrimaryClick = vi.fn()
|
||||
|
||||
const screen = await renderWithSafeViewport(
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label="preview trigger"
|
||||
onClick={onPrimaryClick}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PreviewCardContent
|
||||
popupProps={{ 'role': 'dialog', 'aria-label': 'preview content' }}
|
||||
>
|
||||
<span>Preview body</span>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'preview trigger' })
|
||||
await trigger.click()
|
||||
|
||||
expect(onPrimaryClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { Placement } from '.'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
createPreviewCardHandle,
|
||||
PreviewCard,
|
||||
PreviewCardContent,
|
||||
PreviewCardTrigger,
|
||||
} from '.'
|
||||
|
||||
const rowButtonClassName
|
||||
= 'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover'
|
||||
|
||||
const triggerButtonClassName
|
||||
= 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
|
||||
|
||||
const inlineLinkClassName
|
||||
= 'text-text-accent underline decoration-text-accent/60 decoration-1 underline-offset-2 outline-hidden hover:decoration-text-accent focus-visible:rounded-xs focus-visible:no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-text-accent data-[popup-open]:decoration-text-accent'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/PreviewCard',
|
||||
component: PreviewCard,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Hover- and focus-activated rich preview for triggers whose primary click has its own destination (following a link, selecting a row, jumping to a definition). Built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Never place information or actions in the popup that are not also reachable from the trigger\'s primary click destination. If that is unavoidable, add a separate click affordance (Popover) or move the unique content onto the destination.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PreviewCard>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// --- Canonical: inline link preview ---------------------------------------
|
||||
// Mirrors Base UI's own PreviewCard docs demo: an inline `<a href>` in a
|
||||
// paragraph, hovering reveals a rich preview (image + summary) of the link's
|
||||
// destination. The Wikipedia URL and Unsplash image are the exact assets used
|
||||
// in base-ui.com's public docs so the story renders a real preview.
|
||||
// https://base-ui.com/react/components/preview-card
|
||||
const typographyPreview = createPreviewCardHandle()
|
||||
|
||||
export const LinkPreview: Story = {
|
||||
name: 'Link preview (canonical)',
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'The prototypical PreviewCard use case: an inline hyperlink with a rich hover preview of the destination. Uses a detached trigger + `createPreviewCardHandle()` so the trigger can sit inline in prose while the popup content is defined elsewhere. The trigger renders a real `<a href>` — click still follows the link; the preview is strictly supplementary.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="max-w-md p-6 text-sm leading-6 text-text-secondary">
|
||||
<p>
|
||||
The principles of good
|
||||
{' '}
|
||||
<PreviewCardTrigger
|
||||
handle={typographyPreview}
|
||||
href="https://en.wikipedia.org/wiki/Typography"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={inlineLinkClassName}
|
||||
>
|
||||
typography
|
||||
</PreviewCardTrigger>
|
||||
{' '}
|
||||
remain in the digital age.
|
||||
</p>
|
||||
|
||||
<PreviewCard handle={typographyPreview}>
|
||||
<PreviewCardContent popupClassName="w-[240px] p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<img
|
||||
width="224"
|
||||
height="150"
|
||||
className="block max-w-none rounded-md"
|
||||
src="https://images.unsplash.com/photo-1619615391095-dfa29e1672ef?q=80&w=448&h=300"
|
||||
alt="Station Hofplein signage in Rotterdam, Netherlands"
|
||||
/>
|
||||
<p className="m-0 text-xs leading-5 text-text-secondary">
|
||||
<strong className="text-text-primary">Typography</strong>
|
||||
{' '}
|
||||
is the art and science of arranging type to make written language legible, readable, and visually appealing.
|
||||
</p>
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Supplementary: Story = {
|
||||
name: 'Supplementary preview on a button trigger',
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Application-level adaptation of the same semantic: the trigger is a `<button>` that owns a primary action (selecting a model row) rather than an `<a>`. The preview still only shows supplementary info reachable from the selection destination, so the a11y contract holds.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger
|
||||
render={(
|
||||
<button type="button" className={rowButtonClassName}>
|
||||
<span className="i-ri-sparkling-fill h-4 w-4 text-text-accent" />
|
||||
<span>gpt-4o</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PreviewCardContent
|
||||
placement="right"
|
||||
popupClassName="w-[220px] p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm font-medium text-text-primary">gpt-4o</div>
|
||||
<div className="text-xs text-text-tertiary">
|
||||
Multimodal flagship model. Vision, audio and 128k context.
|
||||
</div>
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
),
|
||||
}
|
||||
|
||||
const PLACEMENTS: Placement[] = [
|
||||
'top-start',
|
||||
'top',
|
||||
'top-end',
|
||||
'right-start',
|
||||
'right',
|
||||
'right-end',
|
||||
'bottom-start',
|
||||
'bottom',
|
||||
'bottom-end',
|
||||
'left-start',
|
||||
'left',
|
||||
'left-end',
|
||||
]
|
||||
|
||||
const PlacementsDemo = () => {
|
||||
const [placement, setPlacement] = useState<Placement>('bottom')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 p-20">
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
{PLACEMENTS.map(value => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setPlacement(value)}
|
||||
className={`rounded-md border border-divider-subtle px-2 py-1 text-text-secondary ${
|
||||
placement === value ? 'bg-state-base-hover' : 'bg-components-button-secondary-bg'
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PreviewCard open>
|
||||
<PreviewCardTrigger
|
||||
render={<button type="button" className={triggerButtonClassName}>Hover me</button>}
|
||||
/>
|
||||
<PreviewCardContent placement={placement} popupClassName="w-56 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm font-semibold text-text-primary">
|
||||
placement="
|
||||
{placement}
|
||||
"
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
Preview positions itself relative to the trigger.
|
||||
</div>
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Placements: Story = {
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
render: () => <PlacementsDemo />,
|
||||
}
|
||||
|
||||
const CustomDelayDemo = () => (
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger
|
||||
delay={100}
|
||||
closeDelay={100}
|
||||
render={<button type="button" className={triggerButtonClassName}>Snappy trigger</button>}
|
||||
/>
|
||||
<PreviewCardContent popupClassName="w-64 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm font-semibold text-text-primary">Fast hover</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
Base UI defaults (600ms / 300ms) are tuned for link previews. Override per trigger for denser UIs.
|
||||
</div>
|
||||
</div>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
)
|
||||
|
||||
export const CustomDelays: Story = {
|
||||
render: () => <CustomDelayDemo />,
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Placement } from '../placement'
|
||||
import { PreviewCard as BasePreviewCard } from '@base-ui/react/preview-card'
|
||||
import { cn } from '../cn'
|
||||
import { parsePlacement } from '../placement'
|
||||
|
||||
export type { Placement }
|
||||
|
||||
/**
|
||||
* PreviewCard is a hover/focus-triggered rich preview intended to supplement a
|
||||
* trigger whose primary action is its own click destination (e.g. a link, a
|
||||
* selectable row, a chip that jumps to a definition).
|
||||
*
|
||||
* A11y contract — match Base UI's guidance:
|
||||
* - The popup MUST NOT contain information or actions that are not also
|
||||
* reachable from the trigger's primary click destination. Touch and screen
|
||||
* reader users cannot open the card and must be able to get the same
|
||||
* information/actions without it.
|
||||
* - If content is unique to the popup, either (a) add a separate click-triggered
|
||||
* affordance (Popover) next to the trigger, or (b) move the unique content
|
||||
* onto the click destination.
|
||||
*/
|
||||
export const PreviewCard = BasePreviewCard.Root
|
||||
export const PreviewCardTrigger = BasePreviewCard.Trigger
|
||||
export const createPreviewCardHandle = BasePreviewCard.createHandle
|
||||
|
||||
type PreviewCardContentProps = {
|
||||
children: ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: Omit<
|
||||
BasePreviewCard.Positioner.Props,
|
||||
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
|
||||
>
|
||||
popupProps?: Omit<
|
||||
BasePreviewCard.Popup.Props,
|
||||
'children' | 'className'
|
||||
>
|
||||
}
|
||||
|
||||
export function PreviewCardContent({
|
||||
children,
|
||||
placement = 'bottom',
|
||||
sideOffset = 8,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: PreviewCardContentProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
return (
|
||||
<BasePreviewCard.Portal>
|
||||
<BasePreviewCard.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn('z-1002 outline-hidden', className)}
|
||||
{...positionerProps}
|
||||
>
|
||||
<BasePreviewCard.Popup
|
||||
className={cn(
|
||||
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
|
||||
'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
|
||||
popupClassName,
|
||||
)}
|
||||
{...popupProps}
|
||||
>
|
||||
{children}
|
||||
</BasePreviewCard.Popup>
|
||||
</BasePreviewCard.Positioner>
|
||||
</BasePreviewCard.Portal>
|
||||
)
|
||||
}
|
||||
@@ -46,20 +46,7 @@ describe('TooltipContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variant and popup props', () => {
|
||||
it('should render popup content when variant is plain', async () => {
|
||||
const screen = await render(
|
||||
<Tooltip open>
|
||||
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
|
||||
<TooltipContent variant="plain" role="tooltip" aria-label="plain tooltip">
|
||||
Plain tooltip body
|
||||
</TooltipContent>
|
||||
</Tooltip>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('tooltip', { name: 'plain tooltip' })).toHaveTextContent('Plain tooltip body')
|
||||
})
|
||||
|
||||
describe('Popup props', () => {
|
||||
it('should forward popup props and handlers when popup props are provided', async () => {
|
||||
const onMouseEnter = vi.fn()
|
||||
|
||||
@@ -83,7 +70,11 @@ describe('TooltipContent', () => {
|
||||
|
||||
await expect.element(popup).toHaveAttribute('id', 'tooltip-popup-id')
|
||||
await expect.element(popup).toHaveAttribute('data-track-id', 'tooltip-track')
|
||||
expect(onMouseEnter).toHaveBeenCalledTimes(1)
|
||||
// Intent of the assertion is "handler is wired up". The exact call count
|
||||
// depends on vitest-browser's pointer simulation and Base UI's internal
|
||||
// pointer tracking (both of which may fire more than one enter event for
|
||||
// a single `.hover()` action), so assert presence, not count.
|
||||
expect(onMouseEnter).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply className to the popup and positionerClassName to the positioner', async () => {
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '.'
|
||||
|
||||
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
|
||||
const iconButtonClassName = 'inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-subtle bg-components-button-secondary-bg text-text-secondary shadow-xs hover:bg-state-base-hover'
|
||||
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Tooltip',
|
||||
@@ -25,7 +25,7 @@ const meta = {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement, offsets, and two style variants.',
|
||||
component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement and offsets.\n\n**Usage contract** (mirrors the [Base UI tooltip guidelines](https://base-ui.com/react/components/tooltip#alternatives-to-tooltips)):\n\n- Tooltips are **supplementary visual labels** for sighted mouse and keyboard users. They are disabled on touch devices and are not announced to screen readers.\n- The trigger **must carry its own `aria-label` or visible text** that matches the tooltip — the tooltip does not replace labeling.\n- Keep content short and non-interactive (an icon-button label, a keyboard shortcut, one-word clarification).\n- **Do not** place descriptions, prose, links, or interactive controls inside a tooltip — touch and screen-reader users cannot reach them.\n- For hover-triggered rich previews that users move their cursor onto, use `PreviewCard` (dwell-able, structured content).\n- For an info icon that explains a concept (an "infotip"), or for any hover popup that needs interactive content or to reach touch/assistive-tech users, use `Popover` with `openOnHover` on the trigger.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -35,47 +35,58 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
Hover me
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Tooltips describe interactive elements without a click.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
const ICON_ACTIONS = [
|
||||
{ icon: 'i-ri-pencil-line', label: 'Edit' },
|
||||
{ icon: 'i-ri-file-copy-line', label: 'Duplicate' },
|
||||
{ icon: 'i-ri-archive-line', label: 'Archive' },
|
||||
{ icon: 'i-ri-delete-bin-line', label: 'Delete' },
|
||||
] as const
|
||||
|
||||
export const Plain: Story = {
|
||||
export const IconButton: Story = {
|
||||
name: 'Icon button (canonical)',
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Use `variant="plain"` to render the popup without default chrome (background, padding, typography). Apply your own styling via `className` on `TooltipContent`.',
|
||||
story: 'The canonical tooltip use case: an icon-only button surfaces its accessible label as a tooltip for sighted mouse and keyboard users. The trigger already carries `aria-label` — the tooltip mirrors that label visually; it does **not** replace it.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
{ICON_ACTIONS.map(({ icon, label }) => (
|
||||
<Tooltip key={label}>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label={label} className={iconButtonClassName}>
|
||||
<span aria-hidden className={`${icon} h-4 w-4`} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const KeyboardShortcut: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A short, supplementary hint that surfaces a keyboard shortcut next to a visible button label. The trigger is fully self-describing ("Save"); the tooltip only adds non-essential extra clarity for mouse/keyboard users.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
Preview details
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
variant="plain"
|
||||
className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-lg"
|
||||
>
|
||||
<div className="flex w-64 flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-text-primary">Dataset preview</span>
|
||||
<span className="text-xs text-text-secondary">
|
||||
32 documents • Last indexed 2 minutes ago
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
render={(
|
||||
<button type="button" className={triggerButtonClassName}>
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>⌘S</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
@@ -116,14 +127,10 @@ const PlacementsDemo = () => {
|
||||
</div>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
Anchor
|
||||
</TooltipTrigger>
|
||||
render={<button type="button" aria-label="Placement anchor" className={iconButtonClassName}><span aria-hidden className="i-ri-pushpin-line h-4 w-4" /></button>}
|
||||
/>
|
||||
<TooltipContent placement={placement}>
|
||||
placement="
|
||||
{placement}
|
||||
"
|
||||
{`placement="${placement}"`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -133,113 +140,45 @@ const PlacementsDemo = () => {
|
||||
export const Placements: Story = {
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Placement reference. `placement` accepts the 12 standard side/align combinations; Base UI flips automatically if the tooltip would overflow the viewport.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => <PlacementsDemo />,
|
||||
}
|
||||
|
||||
export const OnIconButtons: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Tooltips are essential for icon-only buttons. The trigger is the button; the tooltip provides the accessible label and hover hint.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label="Edit" className={iconButtonClassName}>
|
||||
<span aria-hidden className="i-ri-pencil-line h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label="Duplicate" className={iconButtonClassName}>
|
||||
<span aria-hidden className="i-ri-file-copy-line h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>Duplicate</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label="Archive" className={iconButtonClassName}>
|
||||
<span aria-hidden className="i-ri-archive-line h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>Archive</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label="Delete" className={iconButtonClassName}>
|
||||
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
What are tokens?
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Tokens are the basic units a model reads. English text averages ~4 characters per token; non-Latin scripts often use more tokens per character. Both input and output count toward your quota.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
const DELAY_PRESETS: Array<{ label: string, delay: number }> = [
|
||||
{ label: 'Instant (0ms)', delay: 0 },
|
||||
{ label: 'Fast (150ms)', delay: 150 },
|
||||
{ label: 'Default (600ms)', delay: 600 },
|
||||
{ label: 'Instant', delay: 0 },
|
||||
{ label: 'Fast', delay: 150 },
|
||||
{ label: 'Default', delay: 600 },
|
||||
]
|
||||
|
||||
const DelayDemo = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{DELAY_PRESETS.map(({ label, delay }) => (
|
||||
<TooltipProvider key={delay} delay={delay}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<button type="button" className={triggerButtonClassName} />}
|
||||
>
|
||||
{label}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Appeared after
|
||||
{delay}
|
||||
ms hover delay.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const DelayDemo = () => (
|
||||
<div className="flex items-center gap-3">
|
||||
{DELAY_PRESETS.map(({ label, delay }) => (
|
||||
<TooltipProvider key={delay} delay={delay}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" aria-label={`${label} (${delay}ms)`} className={iconButtonClassName}>
|
||||
<span aria-hidden className="i-ri-timer-line h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>{`${label} (${delay}ms)`}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const WithDelay: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown.',
|
||||
story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown. The Dify app root sets `delay={300} closeDelay={200}` — override locally only when the surrounding UX demands it.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,7 +8,28 @@ import { parsePlacement } from '../placement'
|
||||
|
||||
export type { Placement }
|
||||
|
||||
type TooltipContentVariant = 'default' | 'plain'
|
||||
/**
|
||||
* Tooltip is an **ephemeral hint** tied to a trigger (typically an icon button,
|
||||
* badge, or short label). It follows Base UI's Tooltip semantics:
|
||||
*
|
||||
* - Opens on pointer hover or keyboard focus on the trigger.
|
||||
* - Closes as soon as the pointer leaves the trigger — the popup itself is
|
||||
* **not dwell-able**; users cannot move their cursor onto the tooltip.
|
||||
* - Must contain only short, non-interactive text. No links, buttons, form
|
||||
* controls, or structured panels.
|
||||
*
|
||||
* If you need any of the following, use `PreviewCard` instead (hover-triggered
|
||||
* rich preview that users can move their cursor onto):
|
||||
*
|
||||
* - Multi-line or structured content (icon + title + metadata)
|
||||
* - Content the user needs to "stop and read" for more than ~1 second
|
||||
* - Content wider than ~300px
|
||||
*
|
||||
* If you need interactive affordances (buttons, links, forms) use `Popover`.
|
||||
*/
|
||||
export const TooltipProvider = BaseTooltip.Provider
|
||||
export const Tooltip = BaseTooltip.Root
|
||||
export const TooltipTrigger = BaseTooltip.Trigger
|
||||
|
||||
type TooltipContentProps = {
|
||||
children: ReactNode
|
||||
@@ -17,7 +38,6 @@ type TooltipContentProps = {
|
||||
alignOffset?: number
|
||||
positionerClassName?: string
|
||||
className?: string
|
||||
variant?: TooltipContentVariant
|
||||
} & Omit<BaseTooltip.Popup.Props, 'children' | 'className'>
|
||||
|
||||
export function TooltipContent({
|
||||
@@ -27,7 +47,6 @@ export function TooltipContent({
|
||||
alignOffset = 0,
|
||||
positionerClassName,
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: TooltipContentProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
@@ -43,7 +62,7 @@ export function TooltipContent({
|
||||
>
|
||||
<BaseTooltip.Popup
|
||||
className={cn(
|
||||
variant === 'default' && 'max-w-[300px] rounded-md bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
|
||||
'max-w-[300px] rounded-md bg-components-panel-bg px-3 py-2 text-left system-xs-regular wrap-break-word text-text-tertiary shadow-lg',
|
||||
'origin-(--transform-origin) transition-opacity data-ending-style:opacity-0 data-instant:transition-none data-starting-style:opacity-0 motion-reduce:transition-none',
|
||||
className,
|
||||
)}
|
||||
@@ -55,7 +74,3 @@ export function TooltipContent({
|
||||
</BaseTooltip.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export const TooltipProvider = BaseTooltip.Provider
|
||||
export const Tooltip = BaseTooltip.Root
|
||||
export const TooltipTrigger = BaseTooltip.Trigger
|
||||
|
||||
Generated
+207
-203
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -42,15 +42,15 @@ overrides:
|
||||
svgo@>=3.0.0 <3.3.3: 3.3.3
|
||||
tar@<=7.5.10: 7.5.11
|
||||
undici@>=7.0.0 <7.24.0: 7.24.0
|
||||
vite: npm:@voidzero-dev/[email protected]8
|
||||
vitest: npm:@voidzero-dev/[email protected]8
|
||||
vite: npm:@voidzero-dev/[email protected]9
|
||||
vitest: npm:@voidzero-dev/[email protected]9
|
||||
yaml@>=2.0.0 <2.8.3: 2.8.3
|
||||
yauzl@<3.2.1: 3.2.1
|
||||
catalog:
|
||||
'@amplitude/analytics-browser': 2.39.0
|
||||
'@amplitude/plugin-session-replay-browser': 1.27.7
|
||||
'@antfu/eslint-config': 8.2.0
|
||||
'@base-ui/react': 1.4.0
|
||||
'@base-ui/react': 1.4.1
|
||||
'@chromatic-com/storybook': 5.1.2
|
||||
'@cucumber/cucumber': 12.8.0
|
||||
'@egoist/tailwindcss-icons': 1.9.2
|
||||
@@ -222,10 +222,10 @@ catalog:
|
||||
use-context-selector: 2.0.0
|
||||
uuid: 13.0.0
|
||||
vinext: 0.0.41
|
||||
vite: npm:@voidzero-dev/[email protected]8
|
||||
vite: npm:@voidzero-dev/[email protected]9
|
||||
vite-plugin-inspect: 12.0.0-beta.1
|
||||
vite-plus: 0.1.18
|
||||
vitest: npm:@voidzero-dev/[email protected]8
|
||||
vite-plus: 0.1.19
|
||||
vitest: npm:@voidzero-dev/[email protected]9
|
||||
vitest-browser-react: 2.2.0
|
||||
vitest-canvas-mock: 1.1.4
|
||||
zod: 4.3.6
|
||||
|
||||
@@ -39,7 +39,7 @@ const jsonResponse = (
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ const textResponse = (body: string, init: ResponseInit = {}): Response =>
|
||||
new Response(body, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const jsonResponse = (body: unknown, init: ResponseInit = {}): Response =>
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
pack: {
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
platform: "node",
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '../base-ui-popover'
|
||||
|
||||
type PopoverHarnessProps = {
|
||||
useRenderElement?: boolean
|
||||
preventDefaultOnTrigger?: boolean
|
||||
}
|
||||
|
||||
const PopoverHarness = ({
|
||||
useRenderElement = false,
|
||||
preventDefaultOnTrigger = false,
|
||||
}: PopoverHarnessProps) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="outside-area">outside</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={useRenderElement
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="custom-trigger"
|
||||
onClick={(event) => {
|
||||
if (preventDefaultOnTrigger)
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
toggle
|
||||
</button>
|
||||
)
|
||||
: undefined}
|
||||
>
|
||||
fallback trigger
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="custom-content"
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
alignOffset={8}
|
||||
positionerProps={{ 'data-positioner': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>}
|
||||
popupProps={{ 'data-popup': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>}
|
||||
>
|
||||
<div>popover body</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div data-testid="open-state">{open ? 'open' : 'closed'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('base-ui-popover mock', () => {
|
||||
it('should toggle popover content from the fallback trigger and expose content props', () => {
|
||||
render(<PopoverHarness />)
|
||||
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-placement', 'bottom-start')
|
||||
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-side-offset', '4')
|
||||
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-align-offset', '8')
|
||||
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-positioner', 'true')
|
||||
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-popup', 'true')
|
||||
expect(screen.getByTestId('popover-content')).toHaveClass('custom-content')
|
||||
})
|
||||
|
||||
it('should keep the popover open on inside clicks and close it on outside clicks or escape', () => {
|
||||
render(<PopoverHarness useRenderElement />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-trigger'))
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId('popover-content'))
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-trigger'))
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId('outside-area'))
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||
})
|
||||
|
||||
it('should preserve rendered trigger props and respect preventDefault', () => {
|
||||
render(<PopoverHarness useRenderElement preventDefaultOnTrigger />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-trigger'))
|
||||
|
||||
expect(screen.getByTestId('custom-trigger')).toHaveAttribute('data-popover-trigger', 'true')
|
||||
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the popover closed when the fallback trigger click is prevented', () => {
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
render(
|
||||
<div>
|
||||
<Popover open={false} onOpenChange={vi.fn()}>
|
||||
<PopoverTrigger onClick={handleClick}>
|
||||
fallback trigger
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div>popover body</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
onOpenChange: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
type PopoverProps = {
|
||||
children?: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
type PopoverTriggerProps = React.HTMLAttributes<HTMLElement> & {
|
||||
children?: ReactNode
|
||||
nativeButton?: boolean
|
||||
render?: React.ReactElement
|
||||
}
|
||||
|
||||
type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
children?: ReactNode
|
||||
placement?: string
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
positionerProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
popupProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}
|
||||
|
||||
export const Popover = ({
|
||||
children,
|
||||
open = false,
|
||||
onOpenChange,
|
||||
}: PopoverProps) => {
|
||||
React.useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target as Element | null
|
||||
if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]'))
|
||||
return
|
||||
|
||||
onOpenChange?.(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape')
|
||||
onOpenChange?.(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleMouseDown)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [open, onOpenChange])
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{
|
||||
open,
|
||||
onOpenChange: onOpenChange ?? (() => {}),
|
||||
}}
|
||||
>
|
||||
<div data-testid="popover" data-open={String(open)}>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const PopoverTrigger = ({
|
||||
children,
|
||||
render,
|
||||
nativeButton: _nativeButton,
|
||||
onClick,
|
||||
...props
|
||||
}: PopoverTriggerProps) => {
|
||||
const { open, onOpenChange } = React.useContext(PopoverContext)
|
||||
const node = render ?? children
|
||||
|
||||
if (React.isValidElement(node)) {
|
||||
const triggerElement = node as React.ReactElement<Record<string, unknown>>
|
||||
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string }
|
||||
|
||||
return React.cloneElement(triggerElement, {
|
||||
...props,
|
||||
...childProps,
|
||||
'data-testid': childProps['data-testid'] ?? 'popover-trigger',
|
||||
'data-popover-trigger': 'true',
|
||||
'onClick': (event: React.MouseEvent<HTMLElement>) => {
|
||||
childProps.onClick?.(event)
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented)
|
||||
return
|
||||
onOpenChange(!open)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="popover-trigger"
|
||||
data-popover-trigger="true"
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented)
|
||||
return
|
||||
onOpenChange(!open)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{node}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PopoverContent = ({
|
||||
children,
|
||||
className,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
...props
|
||||
}: PopoverContentProps) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="popover-content"
|
||||
data-popover-content="true"
|
||||
data-placement={placement}
|
||||
data-side-offset={sideOffset}
|
||||
data-align-offset={alignOffset}
|
||||
className={className}
|
||||
{...positionerProps}
|
||||
{...popupProps}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PopoverClose = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
export const PopoverTitle = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
export const PopoverDescription = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
+22
-27
@@ -3,13 +3,13 @@ import type { FC } from 'react'
|
||||
import type { PopupProps } from './config-popup'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import ConfigPopup from './config-popup'
|
||||
|
||||
type Props = {
|
||||
@@ -25,36 +25,31 @@ const ConfigBtn: FC<Props> = ({
|
||||
children,
|
||||
...popupProps
|
||||
}) => {
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (popupProps.readOnly && !hasConfigured)
|
||||
return null
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={12}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div className={cn('select-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className={cn('select-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={12}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<ConfigPopup {...popupProps} />
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigBtn)
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ vi.mock('../form-fields', () => ({
|
||||
>
|
||||
invalid-name-change
|
||||
</button>
|
||||
<button data-testid="valid-json-change" onClick={() => props.onJSONSchemaChange('{\n \"foo\": \"bar\"\n}')}>valid-json-change</button>
|
||||
<button data-testid="valid-json-change" onClick={() => props.onJSONSchemaChange('{\n "foo": "bar"\n}')}>valid-json-change</button>
|
||||
<button data-testid="empty-json-change" onClick={() => props.onJSONSchemaChange(' ')}>empty-json-change</button>
|
||||
<button data-testid="invalid-json-change" onClick={() => props.onJSONSchemaChange('{invalid-json}')}>invalid-json-change</button>
|
||||
<button data-testid="type-change" onClick={() => props.onTypeChange({ value: InputVarType.singleFile })}>type-change</button>
|
||||
|
||||
+73
-11
@@ -10,6 +10,72 @@ vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => '/test',
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => {
|
||||
const React = await import('react')
|
||||
const PopoverContext = React.createContext({
|
||||
open: false,
|
||||
setOpen: (_open: boolean) => {},
|
||||
})
|
||||
|
||||
const Popover = ({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? !!controlledOpen : uncontrolledOpen
|
||||
const setOpen = (nextOpen: boolean) => {
|
||||
if (!isControlled)
|
||||
setUncontrolledOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
|
||||
const { open, setOpen } = React.useContext(PopoverContext)
|
||||
return (
|
||||
<div
|
||||
data-testid="popover-trigger"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{render}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="popover-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
}
|
||||
})
|
||||
|
||||
type PortalToFollowElemProps = {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
@@ -209,20 +275,17 @@ describe('ContextVar', () => {
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
const varPickerTrigger = triggers[triggers.length - 1]
|
||||
const varPickerTrigger = screen.getByTestId('popover-trigger')
|
||||
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Select a different option
|
||||
const options = screen.getAllByText('var2')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
await user.click(options[0]!)
|
||||
await user.click(screen.getByText('var2'))
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('var2')
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle dropdown when clicking the trigger button', async () => {
|
||||
@@ -233,16 +296,15 @@ describe('ContextVar', () => {
|
||||
// Act
|
||||
render(<ContextVar {...props} />)
|
||||
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
const varPickerTrigger = triggers[triggers.length - 1]
|
||||
const varPickerTrigger = screen.getByTestId('popover-trigger')
|
||||
|
||||
// Open dropdown
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Close dropdown
|
||||
await user.click(varPickerTrigger!)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+31
-11
@@ -18,18 +18,21 @@ type PortalToFollowElemProps = {
|
||||
type PortalToFollowElemTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode, asChild?: boolean }
|
||||
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
const PortalContext = React.createContext({ open: false })
|
||||
vi.mock('@langgenius/dify-ui/popover', () => {
|
||||
const PortalContext = React.createContext({
|
||||
open: false,
|
||||
onOpenChange: undefined as ((open: boolean) => void) | undefined,
|
||||
})
|
||||
|
||||
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
|
||||
const Popover = ({ children, open, onOpenChange }: PortalToFollowElemProps) => {
|
||||
return (
|
||||
<PortalContext.Provider value={{ open: !!open }}>
|
||||
<PortalContext.Provider value={{ open: !!open, onOpenChange }}>
|
||||
<div data-testid="portal">{children}</div>
|
||||
</PortalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
|
||||
const PopoverContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
|
||||
const { open } = React.useContext(PortalContext)
|
||||
if (!open)
|
||||
return null
|
||||
@@ -40,24 +43,41 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
)
|
||||
}
|
||||
|
||||
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
|
||||
const PopoverTrigger = ({ children, asChild, render, ...props }: PortalToFollowElemTriggerProps & { render?: React.ReactNode }) => {
|
||||
const { open, onOpenChange } = React.useContext(PortalContext)
|
||||
const content = render ?? children
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
props.onClick?.(e)
|
||||
if (!props.onClick)
|
||||
onOpenChange?.(!open)
|
||||
}
|
||||
|
||||
if (React.isValidElement(content)) {
|
||||
return React.cloneElement(content, {
|
||||
...props,
|
||||
'onClick': handleClick,
|
||||
'data-testid': 'portal-trigger',
|
||||
} as React.HTMLAttributes<HTMLElement>)
|
||||
}
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children, {
|
||||
...props,
|
||||
'onClick': handleClick,
|
||||
'data-testid': 'portal-trigger',
|
||||
} as React.HTMLAttributes<HTMLElement>)
|
||||
}
|
||||
return (
|
||||
<div data-testid="portal-trigger" {...props}>
|
||||
{children}
|
||||
<div data-testid="portal-trigger" {...props} onClick={handleClick}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@ import type { FC } from 'react'
|
||||
import type { IInputTypeIconProps } from '@/app/components/app/configuration/config-var/input-type-icon'
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import IconTypeIcon from '@/app/components/app/configuration/config-var/input-type-icon'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type Option = { name: string, value: string, type: string }
|
||||
export type Props = {
|
||||
@@ -33,6 +33,7 @@ const VarItem: FC<{ item: Option }> = ({ item }) => (
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const VarPicker: FC<Props> = ({
|
||||
triggerClassName,
|
||||
className,
|
||||
@@ -45,47 +46,51 @@ const VarPicker: FC<Props> = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
const currItem = options.find(item => item.value === value)
|
||||
const notSetVar = !currItem
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 8,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className={cn(triggerClassName)} onClick={() => setOpen(v => !v)}>
|
||||
<div className={cn(
|
||||
className,
|
||||
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
|
||||
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
|
||||
`
|
||||
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
|
||||
font-medium shadow-xs
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{value
|
||||
? (
|
||||
<VarItem item={currItem as Option} />
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
{notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn(triggerClassName)}>
|
||||
<div className={cn(
|
||||
className,
|
||||
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
|
||||
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
|
||||
`
|
||||
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
|
||||
font-medium shadow-xs
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{currItem
|
||||
? (
|
||||
<VarItem item={currItem} />
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
{notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={8}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
positionerProps={{ style: { zIndex: 1000 } }}
|
||||
>
|
||||
{options.length > 0
|
||||
? (
|
||||
<div className="max-h-[50vh] w-[240px] overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
|
||||
{options.map(({ name, value, type }, index) => (
|
||||
{options.map(({ name, value, type }) => (
|
||||
<div
|
||||
key={index}
|
||||
key={value}
|
||||
className="flex cursor-pointer rounded-lg px-3 py-1 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onChange(value)
|
||||
@@ -103,9 +108,9 @@ const VarPicker: FC<Props> = ({
|
||||
<div className="text-xs leading-normal text-text-tertiary">{t('feature.dataSet.queryVariable.noVarTip', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(VarPicker)
|
||||
|
||||
@@ -125,6 +125,7 @@ const defaultChatHookReturn: Partial<ChatHookReturn> = {
|
||||
handleSend: vi.fn(),
|
||||
handleStop: vi.fn(),
|
||||
handleSwitchSibling: vi.fn(),
|
||||
handleReconnect: vi.fn(),
|
||||
isResponding: false,
|
||||
suggestedQuestions: [],
|
||||
}
|
||||
@@ -605,6 +606,95 @@ describe('ChatWrapper', () => {
|
||||
expect(handleSwitchSibling).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reconnect to a recent running workflow on mount', () => {
|
||||
const handleReconnect = vi.fn()
|
||||
vi.mocked(useChat).mockReturnValue({
|
||||
...defaultChatHookReturn,
|
||||
chatList: [],
|
||||
handleReconnect,
|
||||
} as unknown as ChatHookReturn)
|
||||
|
||||
vi.mocked(useChatWithHistoryContext).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
appPrevChatTree: [{
|
||||
id: 'running-answer',
|
||||
isAnswer: true,
|
||||
content: 'partial content',
|
||||
workflow_run_id: 'run-active',
|
||||
created_at: Math.floor(Date.now() / 1000) - 30,
|
||||
children: [],
|
||||
} as unknown as ChatItemInTree],
|
||||
})
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(handleReconnect).toHaveBeenCalledWith(
|
||||
'running-answer',
|
||||
'run-active',
|
||||
expect.objectContaining({ isPublicAPI: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not reconnect to an old workflow beyond the retention window', () => {
|
||||
const handleReconnect = vi.fn()
|
||||
vi.mocked(useChat).mockReturnValue({
|
||||
...defaultChatHookReturn,
|
||||
chatList: [],
|
||||
handleReconnect,
|
||||
} as unknown as ChatHookReturn)
|
||||
|
||||
vi.mocked(useChatWithHistoryContext).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
appPrevChatTree: [{
|
||||
id: 'old-answer',
|
||||
isAnswer: true,
|
||||
content: 'old content',
|
||||
workflow_run_id: 'run-old',
|
||||
created_at: Math.floor(Date.now() / 1000) - 700,
|
||||
children: [],
|
||||
} as unknown as ChatItemInTree],
|
||||
})
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(handleReconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prefer paused workflow over running workflow for reconnection', () => {
|
||||
const handleSwitchSibling = vi.fn()
|
||||
const handleReconnect = vi.fn()
|
||||
vi.mocked(useChat).mockReturnValue({
|
||||
...defaultChatHookReturn,
|
||||
chatList: [],
|
||||
handleSwitchSibling,
|
||||
handleReconnect,
|
||||
} as unknown as ChatHookReturn)
|
||||
|
||||
vi.mocked(useChatWithHistoryContext).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
appPrevChatTree: [
|
||||
{
|
||||
id: 'running-answer',
|
||||
isAnswer: true,
|
||||
content: '',
|
||||
workflow_run_id: 'run-active',
|
||||
created_at: Math.floor(Date.now() / 1000) - 10,
|
||||
children: [],
|
||||
} as unknown as ChatItemInTree,
|
||||
{
|
||||
id: 'paused-answer',
|
||||
isAnswer: true,
|
||||
content: '',
|
||||
workflow_run_id: 'run-paused',
|
||||
humanInputFormDataList: [{ node_id: 'n-1' }],
|
||||
children: [],
|
||||
} as unknown as ChatItemInTree,
|
||||
],
|
||||
})
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(handleSwitchSibling).toHaveBeenCalledWith('paused-answer', expect.any(Object))
|
||||
expect(handleReconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call stopChatMessageResponding when handleStop is triggered', () => {
|
||||
const handleStop = vi.fn()
|
||||
vi.mocked(useChat).mockReturnValue({
|
||||
|
||||
@@ -1836,6 +1836,82 @@ describe('useChatWithHistory', () => {
|
||||
expect(messageWithFiles?.children?.[0]?.message_files).toHaveLength(1)
|
||||
expect(messageWithFiles?.children?.[0]?.agent_thoughts?.[0]?.message_files).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should pass through workflow_run_id from item and created_at', async () => {
|
||||
const listData = createConversationData({
|
||||
data: [createConversationItem({ id: 'conversation-1' })],
|
||||
})
|
||||
mockFetchConversations.mockResolvedValue(listData)
|
||||
mockFetchChatList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'msg-running',
|
||||
query: 'Running query',
|
||||
answer: 'Running answer',
|
||||
message_files: [],
|
||||
feedback: null,
|
||||
retriever_resources: [],
|
||||
agent_thoughts: null,
|
||||
parent_message_id: null,
|
||||
inputs: {},
|
||||
status: 'normal',
|
||||
workflow_run_id: 'wf-direct-id',
|
||||
created_at: 1700000000,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result } = await renderWithClient(() => useChatWithHistory())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
|
||||
expect(answerNode?.workflow_run_id).toBe('wf-direct-id')
|
||||
expect(answerNode?.created_at).toBe(1700000000)
|
||||
})
|
||||
|
||||
it('should prefer item.workflow_run_id over extra_contents workflow_run_id', async () => {
|
||||
const listData = createConversationData({
|
||||
data: [createConversationItem({ id: 'conversation-1' })],
|
||||
})
|
||||
mockFetchConversations.mockResolvedValue(listData)
|
||||
mockFetchChatList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'msg-both',
|
||||
query: 'Both query',
|
||||
answer: 'Both answer',
|
||||
message_files: [],
|
||||
feedback: null,
|
||||
retriever_resources: [],
|
||||
agent_thoughts: null,
|
||||
parent_message_id: null,
|
||||
inputs: {},
|
||||
status: 'paused',
|
||||
workflow_run_id: 'wf-item-level',
|
||||
extra_contents: [
|
||||
{
|
||||
type: 'human_input',
|
||||
submitted: false,
|
||||
form_definition: { fields: [] },
|
||||
workflow_run_id: 'wf-extra-level',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result } = await renderWithClient(() => useChatWithHistory())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
|
||||
expect(answerNode?.workflow_run_id).toBe('wf-item-level')
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: newConversation merge replaces existing conversation item when id already exists.
|
||||
|
||||
@@ -79,6 +79,7 @@ const ChatWrapper = () => {
|
||||
handleSend,
|
||||
handleStop,
|
||||
handleSwitchSibling,
|
||||
handleReconnect,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
@@ -140,37 +141,47 @@ const ChatWrapper = () => {
|
||||
setIsResponding(respondingState)
|
||||
}, [respondingState, setIsResponding])
|
||||
|
||||
// Resume paused workflows when chat history is loaded
|
||||
// Resume paused workflows or reconnect to running workflows when chat history is loaded
|
||||
useEffect(() => {
|
||||
if (!appPrevChatTree || appPrevChatTree.length === 0)
|
||||
return
|
||||
|
||||
// Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
|
||||
let lastPausedNode: ChatItemInTree | undefined
|
||||
const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
|
||||
nodes.forEach((node) => {
|
||||
// DFS: recurse to children first
|
||||
if (node.children && node.children.length > 0)
|
||||
findLastPausedWorkflow(node.children)
|
||||
const STREAM_RETENTION_SECONDS = 600
|
||||
|
||||
// Track the last node with humanInputFormDataList
|
||||
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
|
||||
lastPausedNode = node
|
||||
let lastPausedNode: ChatItemInTree | undefined
|
||||
let lastRunningNode: ChatItemInTree | undefined
|
||||
const findReconnectableWorkflow = (nodes: ChatItemInTree[]) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.isAnswer && node.workflow_run_id) {
|
||||
if (node.humanInputFormDataList && node.humanInputFormDataList.length > 0) {
|
||||
lastPausedNode = node
|
||||
}
|
||||
else if (
|
||||
node.created_at
|
||||
&& (Date.now() / 1000 - node.created_at) < STREAM_RETENTION_SECONDS
|
||||
) {
|
||||
lastRunningNode = node
|
||||
}
|
||||
}
|
||||
|
||||
if (node.children && node.children.length > 0)
|
||||
findReconnectableWorkflow(node.children)
|
||||
})
|
||||
}
|
||||
|
||||
findLastPausedWorkflow(appPrevChatTree)
|
||||
findReconnectableWorkflow(appPrevChatTree)
|
||||
|
||||
const callbacks = {
|
||||
onGetSuggestedQuestions: (responseItemId: string) => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
}
|
||||
|
||||
// Only resume the last paused workflow
|
||||
if (lastPausedNode) {
|
||||
handleSwitchSibling(
|
||||
lastPausedNode.id,
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
handleSwitchSibling(lastPausedNode.id, callbacks)
|
||||
}
|
||||
else if (lastRunningNode) {
|
||||
handleReconnect(lastRunningNode.id, lastRunningNode.workflow_run_id!, callbacks)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -35,12 +35,12 @@ function getFormattedChatList(messages: any[]) {
|
||||
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
|
||||
const humanInputFormDataList: HumanInputFormData[] = []
|
||||
const humanInputFilledFormDataList: HumanInputFilledFormData[] = []
|
||||
let workflowRunId = ''
|
||||
let workflowRunIdFromExtra = ''
|
||||
if (item.status === 'paused') {
|
||||
item.extra_contents?.forEach((content: ExtraContent) => {
|
||||
if (content.type === 'human_input' && !content.submitted) {
|
||||
humanInputFormDataList.push(content.form_definition)
|
||||
workflowRunId = content.workflow_run_id
|
||||
workflowRunIdFromExtra = content.workflow_run_id
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -62,7 +62,8 @@ function getFormattedChatList(messages: any[]) {
|
||||
parentMessageId: `question-${item.id}`,
|
||||
humanInputFormDataList,
|
||||
humanInputFilledFormDataList,
|
||||
workflow_run_id: workflowRunId,
|
||||
workflow_run_id: item.workflow_run_id || workflowRunIdFromExtra,
|
||||
created_at: item.created_at,
|
||||
})
|
||||
})
|
||||
return newChatList
|
||||
@@ -437,7 +438,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
if (conversationId === currentConversationId)
|
||||
handleNewConversation()
|
||||
handleUpdateConversationList()
|
||||
}, [isInstalledApp, appId, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting])
|
||||
}, [conversationDeleting, currentConversationId, handleNewConversation, handleUpdateConversationList, appSourceType, appId, t])
|
||||
const [conversationRenaming, setConversationRenaming] = useState(false)
|
||||
const handleRenameConversation = useCallback(async (conversationId: string, newName: string, { onSuccess }: Callback) => {
|
||||
if (conversationRenaming)
|
||||
@@ -463,7 +464,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
finally {
|
||||
setConversationRenaming(false)
|
||||
}
|
||||
}, [isInstalledApp, appId, t, conversationRenaming, originConversationList])
|
||||
}, [conversationRenaming, t, appSourceType, appId, originConversationList])
|
||||
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
|
||||
setNewConversationId(newConversationId)
|
||||
handleConversationIdInfoChange(newConversationId)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiChatSettingsLine,
|
||||
} from '@remixicon/react'
|
||||
@@ -6,30 +7,29 @@ import { useTranslation } from 'react-i18next'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
const ViewFormDropdown = () => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs">
|
||||
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
|
||||
<Message3Fill className="h-6 w-6 shrink-0" />
|
||||
@@ -39,8 +39,8 @@ const ViewFormDropdown = () => {
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1264,6 +1264,102 @@ describe('useChat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleReconnect', () => {
|
||||
it('should call sseGet with include_state_snapshot and rebuild from snapshot events', () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const prevChatTree = [{
|
||||
id: 'q-1',
|
||||
content: 'query',
|
||||
isAnswer: false,
|
||||
children: [{
|
||||
id: 'm-reconnect',
|
||||
content: 'stale partial content',
|
||||
isAnswer: true,
|
||||
siblingIndex: 0,
|
||||
workflowProcess: { status: 'running', tracing: [{ node_id: 'old-node' }] },
|
||||
}],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[]))
|
||||
|
||||
act(() => {
|
||||
result.current.handleReconnect('m-reconnect', 'wr-reconnect', { isPublicAPI: true })
|
||||
})
|
||||
|
||||
expect(sseGet).toHaveBeenCalledWith(
|
||||
'/workflow/wr-reconnect/events?include_state_snapshot=true',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
)
|
||||
|
||||
// Content is not reset until onWorkflowStarted fires
|
||||
const beforeStart = result.current.chatList[1]
|
||||
expect(beforeStart.content).toBe('stale partial content')
|
||||
|
||||
act(() => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-reconnect', task_id: 't-1' })
|
||||
})
|
||||
|
||||
// After onWorkflowStarted, content is reset and workflowProcess is fresh
|
||||
const afterStart = result.current.chatList[1]
|
||||
expect(afterStart.content).toBe('')
|
||||
expect(afterStart.workflowProcess).toEqual({ status: WorkflowRunningStatus.Running, tracing: [] })
|
||||
|
||||
act(() => {
|
||||
callbacks.onMessageReplace({ answer: 'full snapshot text' })
|
||||
callbacks.onNodeStarted({ data: { node_id: 'n-1', id: 'n-1', title: 'Node 1' } })
|
||||
callbacks.onNodeFinished({ data: { node_id: 'n-1', id: 'n-1', title: 'Node 1', status: 'succeeded' } })
|
||||
callbacks.onWorkflowFinished({ data: { status: 'succeeded' } })
|
||||
callbacks.onCompleted()
|
||||
})
|
||||
|
||||
const lastResponse = result.current.chatList[1]
|
||||
expect(lastResponse.content).toBe('full snapshot text')
|
||||
expect(lastResponse.workflowProcess?.status).toBe('succeeded')
|
||||
expect(lastResponse.workflowProcess?.tracing).toHaveLength(1)
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
})
|
||||
|
||||
it('should abort previous stream when reconnecting again', () => {
|
||||
const callbacksList: HookCallbacks[] = []
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
callbacksList.push(options as HookCallbacks)
|
||||
})
|
||||
|
||||
const prevChatTree = [{
|
||||
id: 'q-1',
|
||||
content: 'query',
|
||||
isAnswer: false,
|
||||
children: [{
|
||||
id: 'm-rc',
|
||||
content: 'partial',
|
||||
isAnswer: true,
|
||||
siblingIndex: 0,
|
||||
}],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[]))
|
||||
const previousAbort = createAbortControllerMock()
|
||||
|
||||
act(() => {
|
||||
result.current.handleReconnect('m-rc', 'wr-1', { isPublicAPI: true })
|
||||
})
|
||||
act(() => {
|
||||
callbacksList[0].getAbortController(previousAbort)
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleReconnect('m-rc', 'wr-2', { isPublicAPI: true })
|
||||
})
|
||||
|
||||
expect(previousAbort.abort).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAudioPlayerManager branch cases', () => {
|
||||
it('should handle ttsUrl generation for appId with installed apps', async () => {
|
||||
vi.mocked(usePathname).mockReturnValue('/explore/installed/app')
|
||||
@@ -1444,6 +1540,89 @@ describe('useChat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('onWorkflowStarted re-enables responding in handleSend', () => {
|
||||
it('should set isResponding back to true when onWorkflowStarted fires after stop', () => {
|
||||
let callbacks: HookCallbacks
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const stopChat = vi.fn()
|
||||
const { result } = renderHook(() => useChat(undefined, undefined, undefined, stopChat))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'workflow restart' }, {})
|
||||
})
|
||||
expect(result.current.isResponding).toBe(true)
|
||||
|
||||
act(() => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
|
||||
callbacks.onData('part', true, { messageId: 'm-1', conversationId: 'c-1', taskId: 't-1' })
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleStop()
|
||||
})
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
|
||||
act(() => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-2', task_id: 't-2' })
|
||||
})
|
||||
expect(result.current.isResponding).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('abortInflightRequests on unmount', () => {
|
||||
it('should abort all in-flight requests when the hook unmounts', () => {
|
||||
let callbacks: HookCallbacks
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const workflowAbort = createAbortControllerMock()
|
||||
const conversationAbort = createAbortControllerMock()
|
||||
const suggestedAbort = createAbortControllerMock()
|
||||
|
||||
const config = { suggested_questions_after_answer: { enabled: true } }
|
||||
const onGetConversationMessages = vi.fn().mockImplementation(async (_id: string, setAbort: (ac: AbortController) => void) => {
|
||||
setAbort(conversationAbort)
|
||||
return {
|
||||
data: [{
|
||||
id: 'm-1',
|
||||
answer: 'a',
|
||||
message: [{ role: 'assistant', text: 'a' }],
|
||||
created_at: Date.now(),
|
||||
answer_tokens: 1,
|
||||
message_tokens: 1,
|
||||
provider_response_latency: 0.1,
|
||||
inputs: {},
|
||||
query: 'q',
|
||||
}],
|
||||
}
|
||||
})
|
||||
const onGetSuggestedQuestions = vi.fn().mockImplementation(async (_id: string, setAbort: (ac: AbortController) => void) => {
|
||||
setAbort(suggestedAbort)
|
||||
return { data: [] }
|
||||
})
|
||||
|
||||
const { result, unmount } = renderHook(() => useChat(config as ChatConfig))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'unmount' }, {
|
||||
onGetConversationMessages,
|
||||
onGetSuggestedQuestions,
|
||||
})
|
||||
})
|
||||
act(() => {
|
||||
callbacks.getAbortController(workflowAbort)
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
expect(workflowAbort.abort).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('annotations and siblings', () => {
|
||||
const prevChatTree = [{
|
||||
id: 'q-1',
|
||||
|
||||
@@ -143,6 +143,67 @@ describe('Answer Component', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContentSwitch visibility while responding', () => {
|
||||
it('should hide ContentSwitch when responding in non-human-inputs layout', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
responding={true}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
siblingCount: 3,
|
||||
siblingIndex: 1,
|
||||
prevSibling: 'msg-0',
|
||||
nextSibling: 'msg-2',
|
||||
} as unknown as ChatItem}
|
||||
switchSibling={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Previous' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide ContentSwitch when responding in human-inputs layout with content', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
responding={true}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
content: 'partial response',
|
||||
siblingCount: 3,
|
||||
siblingIndex: 1,
|
||||
prevSibling: 'msg-0',
|
||||
nextSibling: 'msg-2',
|
||||
humanInputFormDataList: [{ id: 'form1' }],
|
||||
} as unknown as ChatItem}
|
||||
switchSibling={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Previous' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show ContentSwitch when not responding with siblings', () => {
|
||||
render(
|
||||
<Answer
|
||||
{...defaultProps}
|
||||
responding={false}
|
||||
item={{
|
||||
...defaultProps.item,
|
||||
siblingCount: 3,
|
||||
siblingIndex: 1,
|
||||
prevSibling: 'msg-0',
|
||||
nextSibling: 'msg-2',
|
||||
} as unknown as ChatItem}
|
||||
switchSibling={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Previous' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should handle switch sibling', () => {
|
||||
const mockSwitchSibling = vi.fn()
|
||||
|
||||
@@ -303,6 +303,7 @@ const Answer: FC<AnswerProps> = ({
|
||||
{
|
||||
typeof item.siblingCount === 'number'
|
||||
&& item.siblingCount > 1
|
||||
&& !responding
|
||||
&& (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
@@ -408,7 +409,9 @@ const Answer: FC<AnswerProps> = ({
|
||||
}
|
||||
{
|
||||
typeof item.siblingCount === 'number'
|
||||
&& item.siblingCount > 1 && (
|
||||
&& item.siblingCount > 1
|
||||
&& !responding
|
||||
&& (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useDocumentDownload } from '@/service/knowledge/use-document'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import Popup from '../popup'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
vi.mock('@/service/knowledge/use-document', () => ({
|
||||
useDocumentDownload: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
import type { Resources } from './index'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FileIcon from '@/app/components/base/file-icon'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Link from '@/next/link'
|
||||
import { useDocumentDownload } from '@/service/knowledge/use-document'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
@@ -47,22 +43,25 @@ const Popup: FC<PopupProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
offset={{
|
||||
mainAxis: 8,
|
||||
crossAxis: -2,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top-start"
|
||||
sideOffset={8}
|
||||
alignOffset={-2}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="flex h-[18px] items-center">
|
||||
@@ -156,8 +155,8 @@ const Popup: FC<PopupProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,19 @@ export const useChat = (
|
||||
cb?.()
|
||||
}, [handleStop])
|
||||
|
||||
const abortInflightRequests = useCallback(() => {
|
||||
conversationMessagesAbortControllerRef.current?.abort()
|
||||
suggestedQuestionsAbortControllerRef.current?.abort()
|
||||
workflowEventsAbortControllerRef.current?.abort()
|
||||
}, [])
|
||||
|
||||
// Abort all in-flight fetch/SSE requests when the consumer unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortInflightRequests()
|
||||
}
|
||||
}, [abortInflightRequests])
|
||||
|
||||
const createAudioPlayerManager = useCallback(() => {
|
||||
let ttsUrl = ''
|
||||
let ttsIsPublic = false
|
||||
@@ -244,7 +257,8 @@ export const useChat = (
|
||||
}: SendCallback,
|
||||
) => {
|
||||
const getOrCreatePlayer = createAudioPlayerManager()
|
||||
// Re-subscribe to workflow events for the specific message
|
||||
// Re-subscribe to workflow events for the specific message.
|
||||
// so all retained events are replayed on reconnection (e.g. page refresh).
|
||||
const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true`
|
||||
|
||||
const otherOptions: IOtherOptions = {
|
||||
@@ -583,6 +597,320 @@ export const useChat = (
|
||||
)
|
||||
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer])
|
||||
|
||||
const handleReconnect = useCallback((
|
||||
messageId: string,
|
||||
workflowRunId: string,
|
||||
{
|
||||
onGetSuggestedQuestions,
|
||||
onConversationComplete,
|
||||
isPublicAPI,
|
||||
}: SendCallback,
|
||||
) => {
|
||||
const getOrCreatePlayer = createAudioPlayerManager()
|
||||
const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true`
|
||||
|
||||
const otherOptions: IOtherOptions = {
|
||||
isPublicAPI,
|
||||
getAbortController: (abortController) => {
|
||||
workflowEventsAbortControllerRef.current = abortController
|
||||
},
|
||||
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId: msgId, taskId }: IOnDataMoreInfo) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
const isAgentMode = responseItem.agent_thoughts && responseItem.agent_thoughts.length > 0
|
||||
if (!isAgentMode) {
|
||||
responseItem.content = responseItem.content + message
|
||||
}
|
||||
else {
|
||||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
|
||||
if (lastThought)
|
||||
lastThought.thought = lastThought.thought + message
|
||||
}
|
||||
if (msgId)
|
||||
responseItem.id = msgId
|
||||
})
|
||||
|
||||
if (isFirstMessage && newConversationId)
|
||||
conversationIdRef.current = newConversationId
|
||||
|
||||
if (taskId)
|
||||
taskIdRef.current = taskId
|
||||
},
|
||||
async onCompleted(hasError?: boolean) {
|
||||
handleResponding(false)
|
||||
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
messageId,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
catch {
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
}
|
||||
},
|
||||
onFile(file) {
|
||||
const fileType = (file as { type?: string }).type || 'image'
|
||||
const baseFile = ('transferMethod' in file) ? (file as Partial<FileEntity>) : null
|
||||
const convertedFile: FileEntity = {
|
||||
id: baseFile?.id || (file as { id: string }).id,
|
||||
type: baseFile?.type || (fileType === 'image' ? 'image/png' : fileType === 'video' ? 'video/mp4' : fileType === 'audio' ? 'audio/mpeg' : 'application/octet-stream'),
|
||||
transferMethod: (baseFile?.transferMethod as FileEntity['transferMethod']) || (fileType === 'image' ? 'remote_url' : 'local_file'),
|
||||
uploadedId: baseFile?.uploadedId || (file as { id: string }).id,
|
||||
supportFileType: baseFile?.supportFileType || (fileType === 'image' ? 'image' : fileType === 'video' ? 'video' : fileType === 'audio' ? 'audio' : 'document'),
|
||||
progress: baseFile?.progress ?? 100,
|
||||
name: baseFile?.name || `generated_${fileType}.${fileType === 'image' ? 'png' : fileType === 'video' ? 'mp4' : fileType === 'audio' ? 'mp3' : 'bin'}`,
|
||||
url: baseFile?.url || (file as { url?: string }).url,
|
||||
size: baseFile?.size ?? 0,
|
||||
}
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
|
||||
if (lastThought) {
|
||||
responseItem.agent_thoughts!.at(-1)!.message_files = [...(lastThought as any).message_files, convertedFile]
|
||||
}
|
||||
else {
|
||||
const currentFiles = (responseItem.message_files as FileEntity[] | undefined) ?? []
|
||||
responseItem.message_files = [...currentFiles, convertedFile]
|
||||
}
|
||||
})
|
||||
},
|
||||
onThought(thought) {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (thought.message_id)
|
||||
responseItem.id = thought.message_id
|
||||
if (thought.conversation_id)
|
||||
responseItem.conversationId = thought.conversation_id
|
||||
if (!responseItem.agent_thoughts)
|
||||
responseItem.agent_thoughts = []
|
||||
if (responseItem.agent_thoughts.length === 0) {
|
||||
responseItem.agent_thoughts.push(thought)
|
||||
}
|
||||
else {
|
||||
const lastThought = responseItem.agent_thoughts.at(-1)
|
||||
if (lastThought?.id === thought.id) {
|
||||
thought.thought = lastThought.thought
|
||||
thought.message_files = lastThought.message_files
|
||||
responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1] = thought
|
||||
}
|
||||
else {
|
||||
responseItem.agent_thoughts.push(thought)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onMessageEnd: (messageEnd) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (messageEnd.metadata?.annotation_reply) {
|
||||
responseItem.annotation = ({
|
||||
id: messageEnd.metadata.annotation_reply.id,
|
||||
authorName: messageEnd.metadata.annotation_reply.account.name,
|
||||
})
|
||||
return
|
||||
}
|
||||
responseItem.citation = messageEnd.metadata?.retriever_resources || []
|
||||
const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
|
||||
responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
|
||||
})
|
||||
},
|
||||
onMessageReplace: (messageReplace) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
responseItem.content = messageReplace.answer
|
||||
})
|
||||
},
|
||||
onError() {
|
||||
handleResponding(false)
|
||||
},
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
|
||||
handleResponding(true)
|
||||
hasStopRespondedRef.current = false
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
taskIdRef.current = task_id
|
||||
responseItem.content = ''
|
||||
responseItem.workflow_run_id = workflow_run_id
|
||||
responseItem.workflowProcess = {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
tracing: [],
|
||||
}
|
||||
})
|
||||
},
|
||||
onWorkflowFinished: ({ data: workflowFinishedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (responseItem.workflowProcess)
|
||||
responseItem.workflowProcess.status = workflowFinishedData.status as WorkflowRunningStatus
|
||||
})
|
||||
},
|
||||
onIterationStart: ({ data: iterationStartedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (!responseItem.workflowProcess)
|
||||
return
|
||||
if (!responseItem.workflowProcess.tracing)
|
||||
responseItem.workflowProcess.tracing = []
|
||||
responseItem.workflowProcess.tracing.push({
|
||||
...iterationStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
})
|
||||
},
|
||||
onIterationFinish: ({ data: iterationFinishedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
const tracing = responseItem.workflowProcess.tracing
|
||||
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
|
||||
if (iterationIndex > -1) {
|
||||
tracing[iterationIndex] = {
|
||||
...tracing[iterationIndex],
|
||||
...iterationFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onNodeStarted: ({ data: nodeStartedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (!responseItem.workflowProcess)
|
||||
return
|
||||
if (!responseItem.workflowProcess.tracing)
|
||||
responseItem.workflowProcess.tracing = []
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
|
||||
if (currentIndex > -1) {
|
||||
responseItem.workflowProcess.tracing[currentIndex] = {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (nodeStartedData.iteration_id)
|
||||
return
|
||||
responseItem.workflowProcess.tracing.push({
|
||||
...nodeStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
onNodeFinished: ({ data: nodeFinishedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
if (nodeFinishedData.iteration_id)
|
||||
return
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => {
|
||||
if (!item.execution_metadata?.parallel_id)
|
||||
return item.id === nodeFinishedData.id
|
||||
return item.id === nodeFinishedData.id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata?.parallel_id)
|
||||
})
|
||||
if (currentIndex > -1)
|
||||
responseItem.workflowProcess.tracing[currentIndex] = nodeFinishedData as any
|
||||
})
|
||||
},
|
||||
onTTSChunk: (msgId: string, audio: string) => {
|
||||
if (!audio || audio === '')
|
||||
return
|
||||
const audioPlayer = getOrCreatePlayer()
|
||||
if (audioPlayer) {
|
||||
audioPlayer.playAudioWithAudio(audio, true)
|
||||
AudioPlayerManager.getInstance().resetMsgId(msgId)
|
||||
}
|
||||
},
|
||||
onTTSEnd: (_msgId: string, audio: string) => {
|
||||
const audioPlayer = getOrCreatePlayer()
|
||||
if (audioPlayer)
|
||||
audioPlayer.playAudioWithAudio(audio, false)
|
||||
},
|
||||
onLoopStart: ({ data: loopStartedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (!responseItem.workflowProcess)
|
||||
return
|
||||
if (!responseItem.workflowProcess.tracing)
|
||||
responseItem.workflowProcess.tracing = []
|
||||
responseItem.workflowProcess.tracing.push({
|
||||
...loopStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
})
|
||||
},
|
||||
onLoopFinish: ({ data: loopFinishedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
const tracing = responseItem.workflowProcess.tracing
|
||||
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
|
||||
if (loopIndex > -1) {
|
||||
tracing[loopIndex] = {
|
||||
...tracing[loopIndex],
|
||||
...loopFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onHumanInputRequired: ({ data: humanInputRequiredData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (!responseItem.humanInputFormDataList) {
|
||||
responseItem.humanInputFormDataList = [humanInputRequiredData]
|
||||
}
|
||||
else {
|
||||
const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputRequiredData.node_id)
|
||||
if (currentFormIndex > -1)
|
||||
responseItem.humanInputFormDataList[currentFormIndex] = humanInputRequiredData
|
||||
else
|
||||
responseItem.humanInputFormDataList.push(humanInputRequiredData)
|
||||
}
|
||||
if (responseItem.workflowProcess?.tracing) {
|
||||
const currentTracingIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === humanInputRequiredData.node_id)
|
||||
if (currentTracingIndex > -1)
|
||||
responseItem.workflowProcess.tracing[currentTracingIndex].status = NodeRunningStatus.Paused
|
||||
}
|
||||
})
|
||||
},
|
||||
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (responseItem.humanInputFormDataList?.length) {
|
||||
const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
|
||||
if (currentFormIndex > -1)
|
||||
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
|
||||
}
|
||||
if (!responseItem.humanInputFilledFormDataList)
|
||||
responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
|
||||
else
|
||||
responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
|
||||
})
|
||||
},
|
||||
onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (responseItem.humanInputFormDataList?.length) {
|
||||
const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFormTimeoutData.node_id)
|
||||
responseItem.humanInputFormDataList[currentFormIndex].expiration_time = humanInputFormTimeoutData.expiration_time
|
||||
}
|
||||
})
|
||||
},
|
||||
onWorkflowPaused: ({ data: workflowPausedData }) => {
|
||||
const resumeUrl = `/workflow/${workflowPausedData.workflow_run_id}/events`
|
||||
pausedStateRef.current = true
|
||||
sseGet(resumeUrl, {}, otherOptions)
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
if (workflowEventsAbortControllerRef.current)
|
||||
workflowEventsAbortControllerRef.current.abort()
|
||||
|
||||
sseGet(url, {}, otherOptions)
|
||||
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer])
|
||||
|
||||
const updateCurrentQAOnTree = useCallback(({
|
||||
parentId,
|
||||
responseItem,
|
||||
@@ -917,6 +1245,8 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id, conversation_id, message_id }) => {
|
||||
handleResponding(true)
|
||||
hasStopRespondedRef.current = false
|
||||
// If there are no streaming messages, we still need to set the conversation_id to avoid create a new conversation when regeneration in chat-flow.
|
||||
if (conversation_id) {
|
||||
conversationIdRef.current = conversation_id
|
||||
@@ -1275,6 +1605,7 @@ export const useChat = (
|
||||
setIsResponding,
|
||||
handleSend,
|
||||
handleResume,
|
||||
handleReconnect,
|
||||
handleSwitchSibling,
|
||||
suggestedQuestions,
|
||||
handleRestart,
|
||||
|
||||
@@ -111,6 +111,7 @@ export type IChatItem = {
|
||||
agent_thoughts?: ThoughtItem[]
|
||||
message_files?: FileEntity[]
|
||||
workflow_run_id?: string
|
||||
created_at?: number
|
||||
// for agent log
|
||||
conversationId?: string
|
||||
input?: any
|
||||
|
||||
@@ -177,6 +177,7 @@ const createUseChatReturn = (overrides: Partial<UseChatReturn> = {}): UseChatRet
|
||||
setTargetMessageId: vi.fn() as UseChatReturn['setTargetMessageId'],
|
||||
handleSend: vi.fn(),
|
||||
handleResume: vi.fn(),
|
||||
handleReconnect: vi.fn(),
|
||||
setIsResponding: vi.fn() as UseChatReturn['setIsResponding'],
|
||||
handleStop: vi.fn(),
|
||||
handleSwitchSibling: vi.fn(),
|
||||
@@ -541,6 +542,52 @@ describe('EmbeddedChatbot chat-wrapper', () => {
|
||||
expect(handleSwitchSibling).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reconnect to a recent running workflow on mount', () => {
|
||||
const handleReconnect = vi.fn()
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
handleReconnect,
|
||||
}))
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
appPrevChatList: [
|
||||
{
|
||||
id: 'running-node',
|
||||
isAnswer: true,
|
||||
content: 'partial',
|
||||
workflow_run_id: 'run-active',
|
||||
created_at: Math.floor(Date.now() / 1000) - 30,
|
||||
children: [],
|
||||
} as unknown as ChatItemInTree,
|
||||
],
|
||||
}))
|
||||
render(<ChatWrapper />)
|
||||
expect(handleReconnect).toHaveBeenCalledWith(
|
||||
'running-node',
|
||||
'run-active',
|
||||
expect.objectContaining({ isPublicAPI: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not reconnect to an old workflow beyond the retention window', () => {
|
||||
const handleReconnect = vi.fn()
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
handleReconnect,
|
||||
}))
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
appPrevChatList: [
|
||||
{
|
||||
id: 'old-node',
|
||||
isAnswer: true,
|
||||
content: 'done',
|
||||
workflow_run_id: 'run-old',
|
||||
created_at: Math.floor(Date.now() / 1000) - 700,
|
||||
children: [],
|
||||
} as unknown as ChatItemInTree,
|
||||
],
|
||||
}))
|
||||
render(<ChatWrapper />)
|
||||
expect(handleReconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle conversation completion and suggested questions in chat actions', async () => {
|
||||
const handleSend = vi.fn()
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
|
||||
@@ -834,6 +834,34 @@ describe('useEmbeddedChatbot', () => {
|
||||
const question = chatList.find((m: unknown) => (m as Record<string, unknown>).id === 'question-msg-no-files')
|
||||
expect(question).toBeDefined()
|
||||
})
|
||||
|
||||
it('should pass through workflow_run_id and created_at from message items', async () => {
|
||||
localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ 'app-1': { DEFAULT: 'conversation-1' } }))
|
||||
mockFetchConversations.mockResolvedValue(
|
||||
createConversationData({ data: [createConversationItem({ id: 'conversation-1' })] }),
|
||||
)
|
||||
mockFetchChatList.mockResolvedValue({
|
||||
data: [{
|
||||
id: 'msg-wf',
|
||||
query: 'Running workflow',
|
||||
answer: 'Partial',
|
||||
workflow_run_id: 'wf-embedded-1',
|
||||
created_at: 1700000000,
|
||||
}],
|
||||
})
|
||||
|
||||
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
|
||||
await waitFor(() => expect(result.current.appPrevChatList.length).toBeGreaterThan(0), { timeout: 3000 })
|
||||
|
||||
const questionNode = result.current.appPrevChatList.find(
|
||||
(m: unknown) => (m as Record<string, unknown>).id === 'question-msg-wf',
|
||||
) as Record<string, unknown> | undefined
|
||||
expect(questionNode).toBeDefined()
|
||||
const answerNode = (questionNode!.children as Record<string, unknown>[])?.[0]
|
||||
expect(answerNode).toBeDefined()
|
||||
expect(answerNode!.workflow_run_id).toBe('wf-embedded-1')
|
||||
expect(answerNode!.created_at).toBe(1700000000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentConversationItem from pinned list', () => {
|
||||
|
||||
@@ -85,6 +85,7 @@ const ChatWrapper = () => {
|
||||
handleSend,
|
||||
handleStop,
|
||||
handleSwitchSibling,
|
||||
handleReconnect,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
@@ -142,37 +143,47 @@ const ChatWrapper = () => {
|
||||
setIsResponding(respondingState)
|
||||
}, [respondingState, setIsResponding])
|
||||
|
||||
// Resume paused workflows when chat history is loaded
|
||||
// Resume paused workflows or reconnect to running workflows when chat history is loaded
|
||||
useEffect(() => {
|
||||
if (!appPrevChatList || appPrevChatList.length === 0)
|
||||
return
|
||||
|
||||
// Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
|
||||
let lastPausedNode: ChatItemInTree | undefined
|
||||
const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
|
||||
nodes.forEach((node) => {
|
||||
// DFS: recurse to children first
|
||||
if (node.children && node.children.length > 0)
|
||||
findLastPausedWorkflow(node.children)
|
||||
const STREAM_RETENTION_SECONDS = 600
|
||||
|
||||
// Track the last node with humanInputFormDataList
|
||||
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
|
||||
lastPausedNode = node
|
||||
let lastPausedNode: ChatItemInTree | undefined
|
||||
let lastRunningNode: ChatItemInTree | undefined
|
||||
const findReconnectableWorkflow = (nodes: ChatItemInTree[]) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.isAnswer && node.workflow_run_id) {
|
||||
if (node.humanInputFormDataList && node.humanInputFormDataList.length > 0) {
|
||||
lastPausedNode = node
|
||||
}
|
||||
else if (
|
||||
node.created_at
|
||||
&& (Date.now() / 1000 - node.created_at) < STREAM_RETENTION_SECONDS
|
||||
) {
|
||||
lastRunningNode = node
|
||||
}
|
||||
}
|
||||
|
||||
if (node.children && node.children.length > 0)
|
||||
findReconnectableWorkflow(node.children)
|
||||
})
|
||||
}
|
||||
|
||||
findLastPausedWorkflow(appPrevChatList)
|
||||
findReconnectableWorkflow(appPrevChatList)
|
||||
|
||||
const callbacks = {
|
||||
onGetSuggestedQuestions: (responseItemId: string) => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
}
|
||||
|
||||
// Only resume the last paused workflow
|
||||
if (lastPausedNode) {
|
||||
handleSwitchSibling(
|
||||
lastPausedNode.id,
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
handleSwitchSibling(lastPausedNode.id, callbacks)
|
||||
}
|
||||
else if (lastRunningNode) {
|
||||
handleReconnect(lastRunningNode.id, lastRunningNode.workflow_run_id!, callbacks)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ function getFormattedChatList(messages: any[]) {
|
||||
citation: item.retriever_resources,
|
||||
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))),
|
||||
parentMessageId: `question-${item.id}`,
|
||||
workflow_run_id: item.workflow_run_id,
|
||||
created_at: item.created_at,
|
||||
})
|
||||
})
|
||||
return newChatList
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type Props = {
|
||||
iconColor?: string
|
||||
@@ -17,25 +17,27 @@ const ViewFormDropdown = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
||||
data-testid="view-form-dropdown-trigger"
|
||||
>
|
||||
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-99">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
||||
data-testid="view-form-dropdown-trigger"
|
||||
>
|
||||
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div
|
||||
data-testid="view-form-dropdown-content"
|
||||
className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs"
|
||||
@@ -48,8 +50,8 @@ const ViewFormDropdown = ({
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,20 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import dayjs from '../../utils/dayjs'
|
||||
import DatePicker from '../index'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({ children, onClick, disabled, className }: {
|
||||
children?: React.ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) => (
|
||||
<button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock scrollIntoView
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
@@ -113,14 +127,13 @@ describe('DatePicker', () => {
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
|
||||
|
||||
// Simulate a mousedown event outside the container
|
||||
act(() => {
|
||||
document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
})
|
||||
|
||||
// The picker should now be closed - input shows its value
|
||||
// The picker should now be closed - input shows its value
|
||||
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import type { DatePickerProps, Period } from '../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Calendar from '../calendar'
|
||||
import TimePickerHeader from '../time-picker/header'
|
||||
import TimePickerOptions from '../time-picker/options'
|
||||
@@ -35,15 +31,14 @@ const DatePicker = ({
|
||||
needTimePicker = true,
|
||||
renderTrigger,
|
||||
triggerWrapClassName,
|
||||
popupZIndexClassname = 'z-11',
|
||||
popupZIndexClassname,
|
||||
noConfirm,
|
||||
getIsDateDisabled,
|
||||
}: DatePickerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [view, setView] = useState(ViewType.date)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isInitial = useRef(true)
|
||||
const isInitialRef = useRef(true)
|
||||
|
||||
// Normalize the value to ensure that all subsequent uses are Day.js objects.
|
||||
const normalizedValue = useMemo(() => {
|
||||
@@ -62,46 +57,41 @@ const DatePicker = ({
|
||||
const [selectedYear, setSelectedYear] = useState(() => (inputValue || defaultValue).year())
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
setView(ViewType.date)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitial.current) {
|
||||
isInitial.current = false
|
||||
if (isInitialRef.current) {
|
||||
isInitialRef.current = false
|
||||
return
|
||||
}
|
||||
clearMonthMapCache()
|
||||
if (normalizedValue) {
|
||||
const newValue = getDateWithTimezone({ date: normalizedValue, timezone })
|
||||
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
|
||||
setCurrentDate(newValue)
|
||||
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value.
|
||||
setSelectedDate(newValue)
|
||||
onChange(newValue)
|
||||
}
|
||||
else {
|
||||
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
|
||||
setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone }))
|
||||
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value.
|
||||
setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
|
||||
}
|
||||
// eslint-disable-next-line react/exhaustive-deps -- this effect intentionally runs only when timezone changes.
|
||||
}, [timezone])
|
||||
|
||||
const handleClickTrigger = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setIsOpen(nextOpen)
|
||||
setView(ViewType.date)
|
||||
setIsOpen(true)
|
||||
if (normalizedValue) {
|
||||
if (nextOpen && normalizedValue) {
|
||||
setCurrentDate(normalizedValue)
|
||||
setSelectedDate(normalizedValue)
|
||||
}
|
||||
}, [normalizedValue])
|
||||
|
||||
const handleClickTrigger = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleOpenChange(!isOpen)
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
@@ -210,21 +200,21 @@ const DatePicker = ({
|
||||
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('defaultPlaceholder', { ns: 'time' }))
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
placement="bottom-end"
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger className={triggerWrapClassName}>
|
||||
{renderTrigger
|
||||
? (
|
||||
renderTrigger({
|
||||
value: normalizedValue,
|
||||
selectedDate,
|
||||
isOpen,
|
||||
handleClear,
|
||||
handleClickTrigger,
|
||||
}))
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
className={triggerWrapClassName}
|
||||
render={renderTrigger
|
||||
? renderTrigger({
|
||||
value: normalizedValue,
|
||||
selectedDate,
|
||||
isOpen,
|
||||
handleClear,
|
||||
handleClickTrigger,
|
||||
})
|
||||
: (
|
||||
<div
|
||||
className="group flex w-[252px] cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt"
|
||||
@@ -242,8 +232,13 @@ const DatePicker = ({
|
||||
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block hover:text-text-secondary')} onClick={handleClear} data-testid="date-picker-clear-button" />
|
||||
</div>
|
||||
)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={popupZIndexClassname}>
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={0}
|
||||
className={popupZIndexClassname}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5">
|
||||
{/* Header */}
|
||||
{view === ViewType.date
|
||||
@@ -319,8 +314,8 @@ const DatePicker = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,20 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import dayjs, { isDayjsObject } from '../../utils/dayjs'
|
||||
import TimePicker from '../index'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({ children, onClick, disabled, className }: {
|
||||
children?: React.ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) => (
|
||||
<button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock scrollIntoView since the test DOM runtime doesn't implement it
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
@@ -106,7 +120,7 @@ describe('TimePicker', () => {
|
||||
expect(input)!.toHaveValue('')
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(input)!.toHaveValue('')
|
||||
expect(input)!.toHaveValue('10:00 AM')
|
||||
})
|
||||
|
||||
it('should call onClear when clear is clicked while picker is closed', () => {
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import type { TimePickerProps } from '../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import TimezoneLabel from '@/app/components/base/timezone-label'
|
||||
import { Period } from '../types'
|
||||
import dayjs, {
|
||||
@@ -43,31 +39,20 @@ const TimePicker = ({
|
||||
}: TimePickerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isInitial = useRef(true)
|
||||
const isInitialRef = useRef(true)
|
||||
|
||||
// Initialize selectedTime
|
||||
const [selectedTime, setSelectedTime] = useState(() => {
|
||||
return toDayjs(value, { timezone })
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
/* v8 ignore next 2 -- outside-click closing is handled by PortalToFollowElem; this local ref guard is a defensive fallback. */
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node))
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Track previous values to avoid unnecessary updates
|
||||
const prevValueRef = useRef(value)
|
||||
const prevTimezoneRef = useRef(timezone)
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitial.current) {
|
||||
isInitial.current = false
|
||||
if (isInitialRef.current) {
|
||||
isInitialRef.current = false
|
||||
// Save initial values on first render
|
||||
prevValueRef.current = value
|
||||
prevTimezoneRef.current = timezone
|
||||
@@ -91,6 +76,7 @@ const TimePicker = ({
|
||||
if (!dayjsValue)
|
||||
return
|
||||
|
||||
// eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
|
||||
setSelectedTime(dayjsValue)
|
||||
|
||||
if (timezoneChanged && !valueChanged)
|
||||
@@ -98,6 +84,7 @@ const TimePicker = ({
|
||||
return
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
|
||||
setSelectedTime((prev) => {
|
||||
if (!isDayjsObject(prev))
|
||||
return undefined
|
||||
@@ -105,24 +92,30 @@ const TimePicker = ({
|
||||
})
|
||||
}, [timezone, value, onChange])
|
||||
|
||||
const handleClickTrigger = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
const syncSelectedTimeFromValue = useCallback(() => {
|
||||
if (!value)
|
||||
return
|
||||
}
|
||||
setIsOpen(true)
|
||||
|
||||
if (value) {
|
||||
const dayjsValue = toDayjs(value, { timezone })
|
||||
const needsUpdate = dayjsValue && (
|
||||
!selectedTime
|
||||
|| !isDayjsObject(selectedTime)
|
||||
|| !dayjsValue.isSame(selectedTime, 'minute')
|
||||
)
|
||||
if (needsUpdate)
|
||||
setSelectedTime(dayjsValue)
|
||||
}
|
||||
const dayjsValue = toDayjs(value, { timezone })
|
||||
const needsUpdate = dayjsValue && (
|
||||
!selectedTime
|
||||
|| !isDayjsObject(selectedTime)
|
||||
|| !dayjsValue.isSame(selectedTime, 'minute')
|
||||
)
|
||||
if (needsUpdate)
|
||||
setSelectedTime(dayjsValue)
|
||||
}, [selectedTime, timezone, value])
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setIsOpen(nextOpen)
|
||||
if (nextOpen)
|
||||
syncSelectedTimeFromValue()
|
||||
}, [syncSelectedTimeFromValue])
|
||||
|
||||
const handleClickTrigger = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleOpenChange(!isOpen)
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
@@ -132,7 +125,7 @@ const TimePicker = ({
|
||||
onClear()
|
||||
}
|
||||
|
||||
const handleTimeSelect = (hour: string, minute: string, period: Period) => {
|
||||
const handleTimeSelect = useCallback((hour: string, minute: string, period: Period) => {
|
||||
const periodAdjustedHour = to24Hour(hour, period)
|
||||
const nextMinute = Number.parseInt(minute, 10)
|
||||
setSelectedTime((prev) => {
|
||||
@@ -145,7 +138,7 @@ const TimePicker = ({
|
||||
.set('second', 0)
|
||||
.set('millisecond', 0)
|
||||
})
|
||||
}
|
||||
}, [timezone])
|
||||
|
||||
const getSafeTimeObject = useCallback(() => {
|
||||
if (isDayjsObject(selectedTime))
|
||||
@@ -156,17 +149,17 @@ const TimePicker = ({
|
||||
const handleSelectHour = useCallback((hour: string) => {
|
||||
const time = getSafeTimeObject()
|
||||
handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period)
|
||||
}, [getSafeTimeObject])
|
||||
}, [getSafeTimeObject, handleTimeSelect])
|
||||
|
||||
const handleSelectMinute = useCallback((minute: string) => {
|
||||
const time = getSafeTimeObject()
|
||||
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period)
|
||||
}, [getSafeTimeObject])
|
||||
}, [getSafeTimeObject, handleTimeSelect])
|
||||
|
||||
const handleSelectPeriod = useCallback((period: Period) => {
|
||||
const time = getSafeTimeObject()
|
||||
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period)
|
||||
}, [getSafeTimeObject])
|
||||
}, [getSafeTimeObject, handleTimeSelect])
|
||||
|
||||
const handleSelectCurrentTime = useCallback(() => {
|
||||
const newDate = getDateWithTimezone({ timezone })
|
||||
@@ -207,18 +200,19 @@ const TimePicker = ({
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
placement={placement}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger className={triggerFullWidth ? 'block! w-full' : undefined}>
|
||||
{renderTrigger
|
||||
? (renderTrigger({
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
className={triggerFullWidth ? 'block! w-full' : undefined}
|
||||
render={renderTrigger
|
||||
? renderTrigger({
|
||||
inputElem,
|
||||
onClick: handleClickTrigger,
|
||||
isOpen,
|
||||
}))
|
||||
})
|
||||
: (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -236,8 +230,13 @@ const TimePicker = ({
|
||||
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:inline-block hover:text-text-secondary')} role="button" aria-label={t('operation.clear', { ns: 'common' })} onClick={handleClear} />
|
||||
</div>
|
||||
)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={cn('z-50', popupClassName)}>
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={0}
|
||||
className={popupClassName}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5">
|
||||
{/* Header */}
|
||||
<Header title={title} />
|
||||
@@ -258,8 +257,8 @@ const TimePicker = ({
|
||||
/>
|
||||
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export type DatePickerProps = {
|
||||
onChange: (date: Dayjs | undefined) => void
|
||||
onClear: () => void
|
||||
triggerWrapClassName?: string
|
||||
renderTrigger?: (props: TriggerProps) => React.ReactNode
|
||||
renderTrigger?: (props: TriggerProps) => React.ReactElement
|
||||
minuteFilter?: (minutes: string[]) => string[]
|
||||
popupZIndexClassname?: string
|
||||
noConfirm?: boolean
|
||||
@@ -62,7 +62,7 @@ export type TimePickerProps = {
|
||||
placeholder?: string
|
||||
onChange: (date: Dayjs | undefined) => void
|
||||
onClear: () => void
|
||||
renderTrigger?: (props: TriggerParams) => React.ReactNode
|
||||
renderTrigger?: (props: TriggerParams) => React.ReactElement
|
||||
title?: string
|
||||
minuteFilter?: (minutes: string[]) => string[]
|
||||
popupClassName?: string
|
||||
|
||||
+2
-7
@@ -61,7 +61,7 @@ describe('FileUploadSettings (setting-modal)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onOpen with toggle function when trigger is clicked', () => {
|
||||
it('should call onOpen with true when trigger is clicked', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={false} onOpen={onOpen}>
|
||||
@@ -71,12 +71,7 @@ describe('FileUploadSettings (setting-modal)', () => {
|
||||
|
||||
fireEvent.click(screen.getByText('Upload Settings'))
|
||||
|
||||
expect(onOpen).toHaveBeenCalled()
|
||||
// The toggle function should flip the open state
|
||||
const toggleFn = onOpen.mock.calls[0]![0]
|
||||
expect(typeof toggleFn).toBe('function')
|
||||
expect(toggleFn(false)).toBe(true)
|
||||
expect(toggleFn(true)).toBe(false)
|
||||
expect(onOpen).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should not call onOpen when disabled', () => {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { memo } from 'react'
|
||||
import SettingContent from '@/app/components/base/features/new-feature-panel/file-upload/setting-content'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type FileUploadSettingsProps = {
|
||||
open: boolean
|
||||
onOpen: (state: any) => void
|
||||
onOpen: (state: boolean) => void
|
||||
onChange?: OnFeaturesChange
|
||||
disabled?: boolean
|
||||
children?: React.ReactNode
|
||||
@@ -25,18 +25,27 @@ const FileUploadSettings = ({
|
||||
imageUpload,
|
||||
}: FileUploadSettingsProps) => {
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={onOpen}
|
||||
placement="left"
|
||||
offset={{
|
||||
mainAxis: 32,
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
onOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className="flex" onClick={() => !disabled && onOpen((open: boolean) => !open)}>
|
||||
{children}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 50 }}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className="flex">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="left"
|
||||
sideOffset={32}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="max-h-[calc(100vh-20px)] w-[360px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
|
||||
<SettingContent
|
||||
imageUpload={imageUpload}
|
||||
@@ -47,8 +56,8 @@ const FileUploadSettings = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default memo(FileUploadSettings)
|
||||
|
||||
+34
-44
@@ -1,38 +1,17 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Features } from '../../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../../context'
|
||||
import VoiceSettings from '../voice-settings'
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({
|
||||
children,
|
||||
placement,
|
||||
offset,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
placement?: string
|
||||
offset?: { mainAxis?: number }
|
||||
}) => (
|
||||
<div
|
||||
data-testid="voice-settings-portal"
|
||||
data-placement={placement}
|
||||
data-main-axis={offset?.mainAxis}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<div data-testid="voice-settings-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@@ -46,6 +25,25 @@ vi.mock('@/service/use-apps', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/switch', () => ({
|
||||
Switch: ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
...props
|
||||
}: {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="switch"
|
||||
data-checked={String(checked)}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
@@ -58,7 +56,7 @@ const defaultFeatures: Features = {
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (ui: React.ReactNode) => {
|
||||
const renderWithProvider = (ui: ReactNode) => {
|
||||
return render(
|
||||
<FeaturesProvider features={defaultFeatures}>
|
||||
{ui}
|
||||
@@ -101,12 +99,7 @@ describe('VoiceSettings', () => {
|
||||
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
|
||||
expect(onOpen).toHaveBeenCalled()
|
||||
// The toggle function should flip the open state
|
||||
const toggleFn = onOpen.mock.calls[0]![0]
|
||||
expect(typeof toggleFn).toBe('function')
|
||||
expect(toggleFn(false)).toBe(true)
|
||||
expect(toggleFn(true)).toBe(false)
|
||||
expect(onOpen).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should not call onOpen when disabled and trigger is clicked', () => {
|
||||
@@ -137,16 +130,13 @@ describe('VoiceSettings', () => {
|
||||
|
||||
it('should use top placement and mainAxis 4 when placementLeft is false', () => {
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={false} onOpen={vi.fn()} placementLeft={false}>
|
||||
<VoiceSettings open={true} onOpen={vi.fn()} placementLeft={false}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
const portal = screen.getAllByTestId('voice-settings-portal')
|
||||
.find(item => item.hasAttribute('data-main-axis'))
|
||||
|
||||
expect(portal).toBeDefined()
|
||||
expect(portal)!.toHaveAttribute('data-placement', 'top')
|
||||
expect(portal)!.toHaveAttribute('data-main-axis', '4')
|
||||
const content = screen.getByTestId('popover-content')
|
||||
expect(content).toHaveAttribute('data-placement', 'top')
|
||||
expect(content).toHaveAttribute('data-side-offset', '4')
|
||||
})
|
||||
})
|
||||
|
||||
+26
-17
@@ -1,16 +1,16 @@
|
||||
'use client'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { memo } from 'react'
|
||||
import ParamConfigContent from '@/app/components/base/features/new-feature-panel/text-to-speech/param-config-content'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type VoiceSettingsProps = {
|
||||
open: boolean
|
||||
onOpen: (state: any) => void
|
||||
onOpen: (state: boolean) => void
|
||||
onChange?: OnFeaturesChange
|
||||
disabled?: boolean
|
||||
children?: React.ReactNode
|
||||
@@ -25,23 +25,32 @@ const VoiceSettings = ({
|
||||
placementLeft = true,
|
||||
}: VoiceSettingsProps) => {
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={onOpen}
|
||||
placement={placementLeft ? 'left' : 'top'}
|
||||
offset={{
|
||||
mainAxis: placementLeft ? 32 : 4,
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
onOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className="flex" onClick={() => !disabled && onOpen((open: boolean) => !open)}>
|
||||
{children}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 50 }}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className="flex">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placementLeft ? 'left' : 'top'}
|
||||
sideOffset={placementLeft ? 32 : 4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[360px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
|
||||
<ParamConfigContent onClose={() => onOpen(false)} onChange={onChange} />
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default memo(VoiceSettings)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiUploadCloud2Line } from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { FILE_URL_REGEX } from '../constants'
|
||||
import FileInput from '../file-input'
|
||||
import { useFile } from '../hooks'
|
||||
@@ -54,16 +54,16 @@ const FileFromLinkOrLocal = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="top"
|
||||
offset={4}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} asChild>
|
||||
{trigger(open)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1001">
|
||||
<PopoverTrigger render={trigger(open) as React.ReactElement} />
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg">
|
||||
{
|
||||
showFromLink && (
|
||||
@@ -126,8 +126,8 @@ const FileFromLinkOrLocal = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ImageLinkInput from './image-link-input'
|
||||
import Uploader from './uploader'
|
||||
@@ -63,29 +63,31 @@ const UploaderButton: FC<UploaderButtonProps> = ({
|
||||
|
||||
const closePopover = () => setOpen(false)
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top-start"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[260px] rounded-lg border-[0.5px] border-gray-200 bg-white p-2 shadow-lg">
|
||||
<ImageLinkInput onUpload={handleUpload} disabled={disabled} />
|
||||
{!!hasUploadFromLocal && (
|
||||
@@ -115,8 +117,8 @@ const UploaderButton: FC<UploaderButtonProps> = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
Fragment,
|
||||
useEffect,
|
||||
@@ -8,11 +13,6 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link03 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { useImageFiles } from './hooks'
|
||||
import ImageLinkInput from './image-link-input'
|
||||
@@ -35,35 +35,38 @@ const PasteImageLinkButton: FC<PasteImageLinkButtonProps> = ({
|
||||
onUpload(imageFile)
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
||||
<div className={`
|
||||
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
|
||||
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<Link03 className="mr-2 h-4 w-4" />
|
||||
{t('imageUploader.pasteImageLink', { ns: 'common' })}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div
|
||||
className={`
|
||||
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
|
||||
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<Link03 className="mr-2 h-4 w-4" />
|
||||
{t('imageUploader.pasteImageLink', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top-start"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-2 shadow-lg">
|
||||
<ImageLinkInput onUpload={handleUpload} />
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import type { Placement } from '@langgenius/dify-ui/popover'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
|
||||
/**
|
||||
* Infotip — a `?` icon that reveals a long-form explanation on hover / focus / tap.
|
||||
*
|
||||
* Implements the pattern Base UI calls an "infotip":
|
||||
* https://base-ui.com/react/components/tooltip#infotips
|
||||
*
|
||||
* > "Popups that open when hovering an info icon should use Popover with the
|
||||
* > `openOnHover` prop on the trigger instead of a tooltip. This way, touch
|
||||
* > users and screen reader users can access the content."
|
||||
*
|
||||
* Use whenever the trigger is an info glyph whose sole purpose is to open a
|
||||
* popup (help text, documentation-style explanation). Do NOT use `Tooltip` for
|
||||
* this — Tooltip is reserved for ephemeral, non-interactive visual labels that
|
||||
* are unreachable on touch devices and by screen readers.
|
||||
*
|
||||
* Base UI rule of thumb:
|
||||
*
|
||||
* > "If the trigger's purpose is to open the popup itself, it's a popover.
|
||||
* > If the trigger's purpose is unrelated to opening the popup, it's a tooltip."
|
||||
*
|
||||
* For hover-revealed supplementary previews of a link / row trigger that has
|
||||
* its own primary click destination, use `PreviewCard` instead.
|
||||
*/
|
||||
|
||||
type InfotipProps = {
|
||||
/** Popup content. Rich nodes are allowed. */
|
||||
'children': ReactNode
|
||||
/** Accessible name for the trigger. Required; should match the popup text. */
|
||||
'aria-label': string
|
||||
/** Placement of the popup relative to the trigger. Defaults to `top`. */
|
||||
'placement'?: Placement
|
||||
/** Extra classes on the outer trigger wrapper (layout / margin). */
|
||||
'className'?: string
|
||||
/** Extra classes on the `?` icon itself (size / color overrides). */
|
||||
'iconClassName'?: string
|
||||
/** Extra classes on the popup body (width / padding / whitespace overrides). */
|
||||
'popupClassName'?: string
|
||||
/** Hover open delay in ms. Defaults to 300 to match the app-wide Tooltip delay. */
|
||||
'delay'?: number
|
||||
/** Hover close delay in ms. Defaults to 200 to match the app-wide Tooltip delay. */
|
||||
'closeDelay'?: number
|
||||
}
|
||||
|
||||
export function Infotip({
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
placement = 'top',
|
||||
className,
|
||||
iconClassName,
|
||||
popupClassName,
|
||||
delay = 300,
|
||||
closeDelay = 200,
|
||||
}: InfotipProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={delay}
|
||||
closeDelay={closeDelay}
|
||||
aria-label={ariaLabel}
|
||||
render={(
|
||||
<span className={cn('inline-flex h-4 w-4 shrink-0 items-center justify-center', className)}>
|
||||
<span aria-hidden className={cn('i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary', iconClassName)} />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
popupClassName={cn('max-w-[300px] px-3 py-2 system-xs-regular text-text-tertiary', popupClassName)}
|
||||
>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ function buildDirectiveRehypePlugins(): PluggableList {
|
||||
])
|
||||
|
||||
const attributes: Record<string, AttributeDefinition[]> = {
|
||||
...(defaultSanitizeSchema.attributes ?? {}),
|
||||
...defaultSanitizeSchema.attributes,
|
||||
}
|
||||
|
||||
for (const [tagName, allowedAttributes] of Object.entries(DIRECTIVE_ALLOWED_TAGS))
|
||||
|
||||
@@ -86,7 +86,7 @@ function buildRehypePlugins(extraPlugins?: PluggableList): PluggableList {
|
||||
])
|
||||
|
||||
const mergedAttributes: Record<string, AttributeDefinition[]> = {
|
||||
...(defaultSanitizeSchema.attributes ?? {}),
|
||||
...defaultSanitizeSchema.attributes,
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(ALLOWED_TAGS)) {
|
||||
|
||||
@@ -148,14 +148,17 @@ export const PortalToFollowElemTrigger = (
|
||||
}: React.HTMLProps<HTMLElement> & { ref?: React.RefObject<HTMLElement | null>, asChild?: boolean },
|
||||
) => {
|
||||
const context = usePortalToFollowElemContext()
|
||||
const childrenRef = (children as any).props?.ref
|
||||
const childElement = React.isValidElement<{ ref?: React.Ref<HTMLElement | null> }>(children)
|
||||
? children
|
||||
: null
|
||||
const childrenRef = childElement?.props.ref
|
||||
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
|
||||
|
||||
// `asChild` allows the user to pass any element as the anchor
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const childProps = (children.props ?? {}) as Record<string, unknown>
|
||||
if (asChild && childElement) {
|
||||
const childProps = (childElement.props ?? {}) as Record<string, unknown>
|
||||
return React.cloneElement(
|
||||
children,
|
||||
childElement,
|
||||
context.getReferenceProps({
|
||||
ref,
|
||||
...props,
|
||||
|
||||
@@ -423,11 +423,11 @@ describe('prompt-editor/hooks', () => {
|
||||
maxLength: 5,
|
||||
}))
|
||||
|
||||
const match = result.current('prefix @..', {} as LexicalEditor)
|
||||
const match = result.current('prefix @ab', {} as LexicalEditor)
|
||||
expect(match).toEqual({
|
||||
leadOffset: 7,
|
||||
matchingString: '..',
|
||||
replaceableString: '@..',
|
||||
matchingString: 'ab',
|
||||
replaceableString: '@ab',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -437,7 +437,7 @@ describe('prompt-editor/hooks', () => {
|
||||
maxLength: 5,
|
||||
}))
|
||||
|
||||
expect(result.current('prefix @.', {} as LexicalEditor)).toBeNull()
|
||||
expect(result.current('prefix @a', {} as LexicalEditor)).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when matching text exceeds maxLength', () => {
|
||||
@@ -445,7 +445,7 @@ describe('prompt-editor/hooks', () => {
|
||||
minLength: 1,
|
||||
maxLength: 2,
|
||||
}))
|
||||
expect(result.current('prefix @...', {} as LexicalEditor)).toBeNull()
|
||||
expect(result.current('prefix @abc', {} as LexicalEditor)).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when text has no trigger character', () => {
|
||||
|
||||
@@ -154,17 +154,18 @@ type TriggerFn = (
|
||||
text: string,
|
||||
editor: LexicalEditor,
|
||||
) => MenuTextMatch | null
|
||||
const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
|
||||
const escapeForCharacterClass = (value: string) => value.replace(/[[\]\\^-]/g, '\\$&')
|
||||
export function useBasicTypeaheadTriggerMatch(
|
||||
trigger: string,
|
||||
{ minLength = 1, maxLength = 75 }: { minLength?: number, maxLength?: number },
|
||||
): TriggerFn {
|
||||
return useCallback(
|
||||
(text: string) => {
|
||||
const validChars = `[${PUNCTUATION}\\s]`
|
||||
const escapedTrigger = escapeForCharacterClass(trigger)
|
||||
const validChars = `[^${escapedTrigger}\\n\\r]`
|
||||
const TypeaheadTriggerRegex = new RegExp(
|
||||
'(.*)('
|
||||
+ `[${trigger}]`
|
||||
+ `[${escapedTrigger}]`
|
||||
+ `((?:${validChars}){0,${maxLength}})`
|
||||
+ ')$',
|
||||
)
|
||||
|
||||
+78
@@ -521,6 +521,84 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
await waitFor(() => expect(readEditorText(editor)).not.toContain('{'))
|
||||
})
|
||||
|
||||
it('filters workflow variables from slash input and matches child paths', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
const user = userEvent.setup()
|
||||
|
||||
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
|
||||
makeWorkflowVarNode('node-1', 'Node 1', [
|
||||
makeWorkflowNodeVar('payload', VarType.object, [makeWorkflowNodeVar('child_name', VarType.string)]),
|
||||
makeWorkflowNodeVar('other_value', VarType.string),
|
||||
]),
|
||||
])
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="/"
|
||||
contextBlock={makeContextBlock()}
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
const dispatchSpy = vi.spyOn(editor, 'dispatchCommand')
|
||||
|
||||
await setEditorText(editor, '/child', true)
|
||||
await flushNextTick()
|
||||
|
||||
expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('payload')).toBeInTheDocument()
|
||||
expect(screen.queryByText('other_value')).not.toBeInTheDocument()
|
||||
|
||||
const label = document.querySelector('[title="payload"]')
|
||||
expect(label).not.toBeNull()
|
||||
const row = (label as HTMLElement).parentElement?.parentElement
|
||||
expect(row).not.toBeNull()
|
||||
|
||||
await user.hover(row as HTMLElement)
|
||||
const childField = await screen.findByText('child_name')
|
||||
fireEvent.mouseDown(childField)
|
||||
await user.unhover(row as HTMLElement)
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'payload', 'child_name'])
|
||||
await waitFor(() => expect(readEditorText(editor)).not.toContain('/child'))
|
||||
})
|
||||
|
||||
it('filters workflow variables on the first character after slash and does not highlight context by default', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
|
||||
makeWorkflowVarNode('node-1', 'Node 1', [
|
||||
makeWorkflowNodeVar('child_value', VarType.string),
|
||||
makeWorkflowNodeVar('other_value', VarType.string),
|
||||
]),
|
||||
])
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="/"
|
||||
contextBlock={makeContextBlock()}
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '/c', true)
|
||||
await flushNextTick()
|
||||
|
||||
expect(await screen.findByText('child_value')).toBeInTheDocument()
|
||||
expect(screen.queryByText('other_value')).not.toBeInTheDocument()
|
||||
|
||||
const contextTitle = screen.getByText('common.promptEditor.context.item.title')
|
||||
expect(contextTitle.closest('[tabindex="-1"]')).not.toHaveClass('bg-state-base-hover!')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readEditorText(editor)).toContain('/c')
|
||||
})
|
||||
})
|
||||
|
||||
it('skips removing the trigger when selection is null (needRemove is null) and still dispatches', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MenuRenderFn } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import type { TextNode } from 'lexical'
|
||||
import type { LexicalEditor, TextNode } from 'lexical'
|
||||
import type {
|
||||
ContextBlockType,
|
||||
CurrentBlockType,
|
||||
@@ -89,10 +89,16 @@ const ComponentPicker = ({
|
||||
],
|
||||
})
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
|
||||
const triggerMatchRef = useRef<string | null>(null)
|
||||
const baseCheckForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
|
||||
minLength: 0,
|
||||
maxLength: 0,
|
||||
maxLength: 75,
|
||||
})
|
||||
const checkForTriggerMatch = useCallback((text: string, editor: LexicalEditor) => {
|
||||
const match = baseCheckForTriggerMatch(text, editor)
|
||||
triggerMatchRef.current = match?.matchingString ?? null
|
||||
return match
|
||||
}, [baseCheckForTriggerMatch])
|
||||
|
||||
const [queryString, setQueryString] = useState<string | null>(null)
|
||||
const [blurHidden, setBlurHidden] = useState(false)
|
||||
@@ -155,6 +161,7 @@ const ComponentPicker = ({
|
||||
currentBlock,
|
||||
errorMessageBlock,
|
||||
lastRunBlock,
|
||||
queryString || undefined,
|
||||
)
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
@@ -207,6 +214,8 @@ const ComponentPicker = ({
|
||||
anchorElementRef,
|
||||
{ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
|
||||
) => {
|
||||
const effectiveQueryString = triggerMatchRef.current ?? queryString
|
||||
|
||||
if (blurHidden)
|
||||
return null
|
||||
if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
|
||||
@@ -237,6 +246,8 @@ const ComponentPicker = ({
|
||||
workflowVariableBlock?.show && (
|
||||
<div className="p-1">
|
||||
<VarReferenceVars
|
||||
hideSearch={triggerString === '/'}
|
||||
searchText={triggerString === '/' ? (effectiveQueryString || '') : undefined}
|
||||
searchBoxClassName="mt-1"
|
||||
vars={workflowVariableOptions}
|
||||
onChange={(variables: string[]) => {
|
||||
@@ -270,8 +281,8 @@ const ComponentPicker = ({
|
||||
)
|
||||
}
|
||||
{option.renderMenuOption({
|
||||
queryString,
|
||||
isSelected: selectedIndex === index,
|
||||
queryString: effectiveQueryString,
|
||||
isSelected: workflowVariableBlock?.show ? false : selectedIndex === index,
|
||||
onSelect: () => {
|
||||
selectOptionAndCleanUp(option)
|
||||
},
|
||||
|
||||
+41
@@ -2,6 +2,9 @@ import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../../constants'
|
||||
import ContextBlockComponent from '../component'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
// Mock the hooks used by ContextBlockComponent
|
||||
const mockUseSelectOrDelete = vi.fn()
|
||||
const mockUseTrigger = vi.fn()
|
||||
@@ -223,6 +226,21 @@ describe('ContextBlockComponent', () => {
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should keep the popover closed when the trigger prevents the default click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { triggerSetOpen } = defaultSetup()
|
||||
render(
|
||||
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(triggerSetOpen).not.toHaveBeenCalled()
|
||||
expect(
|
||||
screen.queryByText('common.promptEditor.context.modal.add'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onAddContext when add button is clicked', async () => {
|
||||
defaultSetup({ open: true })
|
||||
const handleAddContext = vi.fn()
|
||||
@@ -345,6 +363,29 @@ describe('ContextBlockComponent', () => {
|
||||
// Original datasets still there
|
||||
expect(screen.getByText('Dataset A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore string events from the event emitter', () => {
|
||||
defaultSetup({ open: true })
|
||||
let subscriptionCallback: (v: Record<string, unknown> | string) => void = () => { }
|
||||
mockUseSubscription.mockImplementation((cb: (v: Record<string, unknown> | string) => void) => {
|
||||
subscriptionCallback = cb
|
||||
})
|
||||
|
||||
render(
|
||||
<ContextBlockComponent
|
||||
nodeKey="test-key"
|
||||
datasets={mockDatasets}
|
||||
onAddContext={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
subscriptionCallback('ignore-me')
|
||||
})
|
||||
|
||||
expect(screen.getByText('Dataset A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dataset B')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Dataset } from './index'
|
||||
import type { EventEmitterValue } from '@/context/event-emitter'
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../constants'
|
||||
import { useSelectOrDelete, useTrigger } from '../../hooks'
|
||||
@@ -32,9 +29,12 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const [localDatasets, setLocalDatasets] = useState<Dataset[]>(datasets)
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === UPDATE_DATASETS_EVENT_EMITTER)
|
||||
setLocalDatasets(v.payload)
|
||||
eventEmitter?.useSubscription((event?: EventEmitterValue) => {
|
||||
if (typeof event === 'string')
|
||||
return
|
||||
|
||||
if (event?.type === UPDATE_DATASETS_EVENT_EMITTER && Array.isArray(event.payload))
|
||||
setLocalDatasets(event.payload as Dataset[])
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -49,24 +49,31 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
|
||||
<span className="mr-1 i-custom-vender-solid-files-file-05 h-[14px] w-[14px]" data-testid="file-icon" />
|
||||
<div className="mr-1 text-xs font-medium">{t('promptEditor.context.item.title', { ns: 'common' })}</div>
|
||||
{!canNotAddContext && (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 3,
|
||||
alignmentAxis: -147,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger ref={triggerRef}>
|
||||
<div className={`
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
className={`
|
||||
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded text-[11px] font-semibold
|
||||
${open ? 'bg-[#6938EF] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
|
||||
`}>
|
||||
{localDatasets.length}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 100 }}>
|
||||
`}
|
||||
ref={triggerRef}
|
||||
onClick={e => e.preventDefault()}
|
||||
>
|
||||
{localDatasets.length}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={3}
|
||||
alignOffset={-147}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[360px] rounded-xl bg-white shadow-lg">
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
@@ -95,8 +102,8 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
|
||||
{t('promptEditor.context.modal.footer', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
+43
@@ -6,6 +6,8 @@ import { UPDATE_HISTORY_EVENT_EMITTER } from '../../../constants'
|
||||
import HistoryBlockComponent from '../component'
|
||||
import { DELETE_HISTORY_BLOCK_COMMAND } from '../index'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
type HistoryEventPayload = {
|
||||
type?: string
|
||||
payload?: RoleName
|
||||
@@ -109,6 +111,24 @@ describe('HistoryBlockComponent', () => {
|
||||
expect(screen.getByText('common.promptEditor.history.modal.assistant')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the popover closed when the trigger prevents the default click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setOpen = vi.fn() as unknown as Dispatch<SetStateAction<boolean>>
|
||||
mockUseTrigger.mockReturnValue(createTriggerHookReturn(false, setOpen))
|
||||
|
||||
render(
|
||||
<HistoryBlockComponent
|
||||
nodeKey="history-node-trigger"
|
||||
onEditRole={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(setOpen).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('common.promptEditor.history.modal.edit')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onEditRole when edit action is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEditRole = vi.fn()
|
||||
@@ -188,6 +208,29 @@ describe('HistoryBlockComponent', () => {
|
||||
expect(screen.getByText('kept-assistant')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore string events from the event emitter', () => {
|
||||
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
|
||||
|
||||
render(
|
||||
<HistoryBlockComponent
|
||||
nodeKey="history-node-6-string"
|
||||
roleName={createRoleName({
|
||||
user: 'kept-user',
|
||||
assistant: 'kept-assistant',
|
||||
})}
|
||||
onEditRole={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(subscribedHandler).not.toBeNull()
|
||||
act(() => {
|
||||
subscribedHandler?.('ignore-me' as unknown as HistoryEventPayload)
|
||||
})
|
||||
|
||||
expect(screen.getByText('kept-user')).toBeInTheDocument()
|
||||
expect(screen.getByText('kept-assistant')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render when event emitter is unavailable', () => {
|
||||
mockUseEventEmitterContextContext.mockReturnValue({
|
||||
eventEmitter: undefined,
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type { FC } from 'react'
|
||||
import type { RoleName } from './index'
|
||||
import type { EventEmitterValue } from '@/context/event-emitter'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiMoreFill,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { UPDATE_HISTORY_EVENT_EMITTER } from '../../constants'
|
||||
import { useSelectOrDelete, useTrigger } from '../../hooks'
|
||||
@@ -33,9 +30,12 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const [localRoleName, setLocalRoleName] = useState<RoleName>(roleName)
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === UPDATE_HISTORY_EVENT_EMITTER)
|
||||
setLocalRoleName(v.payload)
|
||||
eventEmitter?.useSubscription((event?: EventEmitterValue) => {
|
||||
if (typeof event === 'string')
|
||||
return
|
||||
|
||||
if (event?.type === UPDATE_HISTORY_EVENT_EMITTER && event.payload && typeof event.payload === 'object')
|
||||
setLocalRoleName(event.payload as RoleName)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -49,25 +49,31 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
|
||||
>
|
||||
<MessageClockCircle className="mr-1 h-[14px] w-[14px]" />
|
||||
<div className="mr-1 text-xs font-medium">{t('promptEditor.history.item.title', { ns: 'common' })}</div>
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
alignmentAxis: -148,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger ref={triggerRef}>
|
||||
<div className={`
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
className={`
|
||||
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded
|
||||
${open ? 'bg-[#DD2590] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
|
||||
`}
|
||||
>
|
||||
<RiMoreFill className="h-3 w-3" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 100 }}>
|
||||
ref={triggerRef}
|
||||
onClick={e => e.preventDefault()}
|
||||
>
|
||||
<RiMoreFill className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top-end"
|
||||
sideOffset={4}
|
||||
alignOffset={-148}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[360px] rounded-xl bg-white shadow-lg">
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">{t('promptEditor.history.modal.title', { ns: 'common' })}</div>
|
||||
@@ -87,8 +93,8 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
|
||||
{t('promptEditor.history.modal.edit', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+5
-6
@@ -302,8 +302,8 @@ describe('HITLInputVariableBlockComponent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip payload', () => {
|
||||
it('should call getVarType with rag selector and use rag node id mapping', () => {
|
||||
describe('Full-path preview payload', () => {
|
||||
it('should resolve the rag node via isRagVar offset and skip the full-path preview', () => {
|
||||
const getVarType = vi.fn(() => Type.number)
|
||||
const { container } = renderVariableBlock({
|
||||
variables: ['rag', 'node-rag', 'chunk'],
|
||||
@@ -314,10 +314,9 @@ describe('HITLInputVariableBlockComponent', () => {
|
||||
|
||||
expect(screen.getByText('chunk')).toBeInTheDocument()
|
||||
expect(hasErrorIcon(container)).toBe(false)
|
||||
expect(getVarType).toHaveBeenCalledWith({
|
||||
nodeId: 'rag',
|
||||
valueSelector: ['rag', 'node-rag', 'chunk'],
|
||||
})
|
||||
// Rag selectors always have `isShowAPart === false`, so the full-path
|
||||
// preview is not rendered and `getVarType` is not invoked.
|
||||
expect(getVarType).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use shortened display name for deep non-rag selectors', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { UpdateWorkflowNodesMapPayload } from '../workflow-variable-block'
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
isConversationVar,
|
||||
isENV,
|
||||
@@ -119,13 +119,13 @@ const HITLInputVariableBlockComponent = ({
|
||||
/>
|
||||
)
|
||||
|
||||
if (!node)
|
||||
if (!node || !isShowAPart)
|
||||
return Item
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
noDecoration
|
||||
popupContent={(
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger delay={300} closeDelay={200} render={<div>{Item}</div>} />
|
||||
<PreviewCardContent popupClassName="border-0 bg-transparent p-0 shadow-none">
|
||||
<VarFullPathPanel
|
||||
nodeName={node.title}
|
||||
path={variables.slice(1)}
|
||||
@@ -137,11 +137,8 @@ const HITLInputVariableBlockComponent = ({
|
||||
: Type.string}
|
||||
nodeType={node?.type}
|
||||
/>
|
||||
)}
|
||||
disabled={!isShowAPart}
|
||||
>
|
||||
<div>{Item}</div>
|
||||
</Tooltip>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
} from './index'
|
||||
import type { WorkflowNodesMap } from './node'
|
||||
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
@@ -151,13 +151,13 @@ const WorkflowVariableBlockComponent = ({
|
||||
/>
|
||||
)
|
||||
|
||||
if (!node)
|
||||
if (!node || !isShowAPart)
|
||||
return Item
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger disabled={!isShowAPart} render={<div>{Item}</div>} />
|
||||
<TooltipContent variant="plain">
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger delay={300} closeDelay={200} render={<div>{Item}</div>} />
|
||||
<PreviewCardContent popupClassName="border-0 bg-transparent p-0 shadow-none">
|
||||
<VarFullPathPanel
|
||||
nodeName={node.title}
|
||||
path={variables.slice(1)}
|
||||
@@ -169,8 +169,8 @@ const WorkflowVariableBlockComponent = ({
|
||||
: Type.string}
|
||||
nodeType={node?.type}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -309,7 +309,7 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-state]')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.cursor-default').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,8 @@ import { defaultPlan } from '../../config'
|
||||
import { Plan } from '../../type'
|
||||
import VectorSpaceInfo from '../vector-space-info'
|
||||
|
||||
const queryPlaceholder = () => document.body.querySelector('[aria-hidden="true"]')
|
||||
const queryPlaceholder = () =>
|
||||
document.body.querySelector('[aria-hidden="true"].bg-components-progress-bar-bg')
|
||||
|
||||
// Mock provider context with configurable plan
|
||||
let mockPlanType = Plan.sandbox
|
||||
|
||||
@@ -3,9 +3,10 @@ import type { MeterTone } from '@langgenius/dify-ui/meter'
|
||||
import type { ComponentType, FC, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { MeterIndicator, MeterRoot, MeterTrack } from '@langgenius/dify-ui/meter'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import { NUM_INFINITE } from '../config'
|
||||
|
||||
type Props = {
|
||||
@@ -159,11 +160,11 @@ const UsageInfo: FC<Props> = ({
|
||||
const wrapWithStorageTooltip = (children: ReactNode) => {
|
||||
if (storageMode && storageTooltip) {
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={<div className="w-[200px]">{storageTooltip}</div>}
|
||||
asChild={false}
|
||||
>
|
||||
<div className="cursor-default">{children}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className="cursor-default">{children}</div>} />
|
||||
<TooltipContent className="w-[200px] max-w-[200px]">
|
||||
{storageTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -178,13 +179,9 @@ const UsageInfo: FC<Props> = ({
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="system-xs-medium text-text-tertiary">{name}</div>
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[180px]">
|
||||
{tooltip}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Infotip aria-label={tooltip} popupClassName="w-[180px] max-w-[180px]">
|
||||
{tooltip}
|
||||
</Infotip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 system-md-semibold text-text-primary">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user