Compare commits

..

3 Commits

77 changed files with 995 additions and 2427 deletions

View File

@ -24,7 +24,6 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
- Preserve visible keyboard focus states on the final focusable element. Prefer styled `@langgenius/dify-ui/*` controls when available, because components such as `Button` and form/control primitives carry the standard Dify UI `focus-visible` styling. Do not assume every Dify UI export provides visual focus styles: headless anatomy parts and direct Base UI re-exports such as dialog/popover/tooltip/drawer triggers usually only provide behavior and semantics. When using native `button` / `a`, custom trigger `render` props, clickable rows, icon buttons, menu-like items, or direct trigger parts, verify the rendered focusable element has a visible focus state. If it does not, add the standard Dify UI focus style: `outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid`. Do not hide outlines without an equivalent visible `focus-visible` indicator. Component-specific focus styles should follow an existing styled primitive pattern or a concrete design constraint, not a new ad hoc style.
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README.
- Module README whitelist: `@/service/client`, `@/next/*`.
@ -54,7 +53,6 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props.
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
- `jotai-tanstack-query` query atoms do not support TanStack Query tracked properties. A component that reads `useAtomValue(queryAtom)` subscribes to the whole query result, even if it only accesses `data`, `isLoading`, or `isError`. Export field-specific derived atoms and have components read the exact fields they render; use `selectAtom(queryAtom, result => result.field)` for query-result fields so unchanged selections do not notify subscribers. Keep direct `useAtomValue(queryAtom)` only when the component or hook genuinely needs the full observer result.
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
- For scoped primitives that are always hydrated by `ScopeProvider`, prefer `atomWithLazy<T>(() => { throw new Error(...) })` when consumers should see a non-null type.

View File

@ -13,7 +13,7 @@ from services.account_service import RegisterService, TenantService
from .error import AlreadySetupError, NotInitValidateError
from .init_validate import get_init_validate_status
from .wraps import mark_setup_completed, only_edition_self_hosted
from .wraps import only_edition_self_hosted
class SetupRequestPayload(BaseModel):
@ -96,7 +96,6 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
language=payload.language,
session=db.session(),
)
mark_setup_completed()
return SetupResponse(result="success")

View File

@ -4,7 +4,7 @@ import os
import time
from collections.abc import Callable
from functools import wraps
from typing import Any, Concatenate, Protocol, cast, overload
from typing import Any, Concatenate, overload
from flask import abort, request
from pydantic import BaseModel, ValidationError
@ -46,60 +46,6 @@ ERROR_MSG_INVALID_ENCRYPTED_DATA = "Invalid encrypted data"
ERROR_MSG_INVALID_ENCRYPTED_CODE = "Invalid encrypted code"
class OnceTrueCallable[**P](Protocol):
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> bool: ...
def mark_success(self) -> None: ...
def reset_success(self) -> None: ...
def once_true[**P](func: Callable[P, bool]) -> OnceTrueCallable[P]:
"""Wrap a predicate so only a strict True result is memoized."""
has_success = False
def mark_success() -> None:
nonlocal has_success
has_success = True
def reset_success() -> None:
nonlocal has_success
has_success = False
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> bool:
nonlocal has_success
if has_success:
return True
result = func(*args, **kwargs)
if result is True:
has_success = True
return result
wrapper.mark_success = mark_success # type: ignore[attr-defined]
wrapper.reset_success = reset_success # type: ignore[attr-defined]
return cast(OnceTrueCallable[P], wrapper)
def mark_setup_completed() -> None:
"""Remember in this process that one-time self-hosted setup has completed."""
_is_setup_completed.mark_success()
@once_true
def _is_setup_completed() -> bool:
"""Check whether setup exists, caching only successful observations.
Use `once_true` instead of `@cache` because a pre-setup False result must not be memoized.
"""
return db.session.scalar(select(DifySetup).limit(1)) is not None
@overload
def account_initialization_required[T, **P, R](
view: Callable[Concatenate[T, P], R],
@ -300,9 +246,7 @@ def setup_required[T, **P, R](
@overload
def setup_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Require self-hosted bootstrap setup before serving protected routes."""
...
def setup_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: ...
def setup_required[R](view: Callable[..., R]) -> Callable[..., R]:
@ -311,7 +255,7 @@ def setup_required[R](view: Callable[..., R]) -> Callable[..., R]:
# The overloads keep Resource methods method-aware for pyrefly while
# preserving support for plain functions used in tests and utilities.
# check setup
if dify_config.EDITION == "SELF_HOSTED" and not _is_setup_completed():
if dify_config.EDITION == "SELF_HOSTED" and not db.session.scalar(select(DifySetup).limit(1)):
if os.environ.get("INIT_PASSWORD"):
raise NotInitValidateError()
raise NotSetupError()

View File

@ -92,7 +92,6 @@ class PluginService:
PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:"
PLUGIN_MODEL_PROVIDERS_GENERATION_REDIS_KEY_PREFIX = "plugin_model_providers_generation:tenant_id:"
PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX = "plugin_model_providers_refresh_lock:tenant_id:"
PLUGIN_MODEL_PROVIDERS_REMOTE_DEBUG_REDIS_KEY_PREFIX = "plugin_model_providers_remote_debug:tenant_id:"
PLUGIN_MODEL_PROVIDERS_LOCK_TTL = 30
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT = 2.0
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL = 0.05
@ -118,10 +117,6 @@ class PluginService:
def _get_plugin_model_providers_lock_key(cls, tenant_id: str, generation: int) -> str:
return f"{cls.PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX}{tenant_id}:generation:{generation}"
@classmethod
def _get_plugin_model_providers_remote_debug_cache_key(cls, tenant_id: str) -> str:
return f"{cls.PLUGIN_MODEL_PROVIDERS_REMOTE_DEBUG_REDIS_KEY_PREFIX}{tenant_id}"
@staticmethod
def _get_provider_short_name_alias(provider: PluginModelProviderEntity) -> str:
"""
@ -264,111 +259,6 @@ class PluginService:
except (RedisError, RuntimeError):
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
@classmethod
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[PluginEntity]) -> str | None:
remote_model_plugins = sorted(
f"{plugin.plugin_id}:{plugin.plugin_unique_identifier}"
for plugin in plugins
if plugin.source == PluginInstallationSource.Remote
)
if not remote_model_plugins:
return None
return "\n".join(remote_model_plugins)
@classmethod
def _load_cached_remote_model_plugin_marker(cls, tenant_id: str) -> str | None:
cache_key = cls._get_plugin_model_providers_remote_debug_cache_key(tenant_id)
try:
cached_marker = redis_client.get(cache_key)
except (RedisError, RuntimeError):
logger.warning("Failed to read remote debug model plugin marker for tenant %s.", tenant_id, exc_info=True)
return None
if cached_marker is None:
return None
if isinstance(cached_marker, bytes):
try:
return cached_marker.decode()
except UnicodeDecodeError:
logger.warning(
"Invalid remote debug model plugin marker for tenant %s; deleting cache marker.",
tenant_id,
exc_info=True,
)
try:
redis_client.delete(cache_key)
except (RedisError, RuntimeError):
logger.warning(
"Failed to delete invalid remote debug model plugin marker for tenant %s.",
tenant_id,
exc_info=True,
)
return None
if isinstance(cached_marker, str):
return cached_marker
logger.warning("Invalid remote debug model plugin marker for tenant %s; deleting cache marker.", tenant_id)
try:
redis_client.delete(cache_key)
except (RedisError, RuntimeError):
logger.warning(
"Failed to delete invalid remote debug model plugin marker for tenant %s.",
tenant_id,
exc_info=True,
)
return None
@classmethod
def _store_cached_remote_model_plugin_marker(cls, tenant_id: str, marker: str | None) -> None:
cache_key = cls._get_plugin_model_providers_remote_debug_cache_key(tenant_id)
try:
if marker is None:
redis_client.delete(cache_key)
else:
redis_client.setex(cache_key, dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL, marker)
except (RedisError, RuntimeError):
logger.warning("Failed to cache remote debug model plugin marker for tenant %s.", tenant_id, exc_info=True)
@classmethod
def _load_cached_plugin_model_provider_plugin_ids(cls, tenant_id: str) -> set[str] | None:
"""Return plugin ids represented by the current provider cache, or None when no usable cache exists."""
generation = cls._load_plugin_model_providers_generation(tenant_id)
cached_providers, _ = cls._load_cached_plugin_model_providers_for_generation(tenant_id, generation)
if cached_providers is None:
return None
plugin_ids: set[str] = set()
for provider in cached_providers:
last_slash = provider.provider.rfind("/")
if last_slash > 0:
plugin_ids.add(provider.provider[:last_slash])
return plugin_ids
@classmethod
def _should_invalidate_model_provider_cache_for_remote_model_plugins(
cls,
tenant_id: str,
plugins: Sequence[PluginEntity],
) -> bool:
remote_model_plugin_marker = cls._get_remote_model_plugin_cache_marker(plugins)
cached_remote_model_plugin_marker = cls._load_cached_remote_model_plugin_marker(tenant_id)
if remote_model_plugin_marker is None:
return cached_remote_model_plugin_marker is not None
if remote_model_plugin_marker != cached_remote_model_plugin_marker:
return True
remote_model_plugin_ids = {
plugin.plugin_id for plugin in plugins if plugin.source == PluginInstallationSource.Remote
}
cached_plugin_ids = cls._load_cached_plugin_model_provider_plugin_ids(tenant_id)
if cached_plugin_ids is None:
return False
return not remote_model_plugin_ids.issubset(cached_plugin_ids)
@classmethod
@contextmanager
def _plugin_model_providers_refresh_lock(
@ -681,21 +571,7 @@ class PluginService:
This keeps pagination usable before category is persisted on installation rows.
"""
manager = PluginInstaller()
plugins = manager.list_plugins_by_category(tenant_id, category, page, page_size)
if category == PluginCategory.Model:
should_invalidate_model_provider_cache = (
PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(
tenant_id,
plugins.list,
)
)
if should_invalidate_model_provider_cache:
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
remote_model_plugin_marker = PluginService._get_remote_model_plugin_cache_marker(plugins.list)
PluginService._store_cached_remote_model_plugin_marker(tenant_id, remote_model_plugin_marker)
return plugins
return manager.list_plugins_by_category(tenant_id, category, page, page_size)
@staticmethod
def _normalize_endpoint_count(value: object) -> int:

View File

@ -78,7 +78,9 @@ class SummaryIndex:
if segment is None:
return
try:
SummaryIndexService.generate_and_vectorize_summary(segment, dataset, summary_index_setting)
SummaryIndexService.generate_and_vectorize_summary(
segment, dataset, summary_index_setting, session=session
)
except Exception:
logger.exception(
"Failed to generate summary for segment %s",

View File

@ -416,9 +416,10 @@ class PluginMigration:
data = _tenant_plugin_adapter.validate_json(line)
tenant_id = data["tenant_id"]
plugin_ids = data["plugins"]
plugin_not_exist = [
plugin_id for plugin_id in plugin_ids if plugin_id not in package_identifier_by_plugin_id
]
plugin_not_exist: list[str] = []
for plugin_id in plugin_ids:
if plugin_id not in package_identifier_by_plugin_id:
plugin_not_exist.append(plugin_id)
if plugin_not_exist:
not_installed.append(

View File

@ -45,6 +45,108 @@ class DocumentSummaryStatusDetailDict(TypedDict):
class SummaryIndexService:
"""Service for generating and managing summary indexes."""
@staticmethod
def _get_summary_record(
session: Session,
segment_id: str,
dataset_id: str,
) -> DocumentSegmentSummary | None:
return session.scalar(
select(DocumentSegmentSummary)
.where(
DocumentSegmentSummary.chunk_id == segment_id,
DocumentSegmentSummary.dataset_id == dataset_id,
)
.limit(1)
)
@staticmethod
def _reenable_summary_record(summary_record: DocumentSegmentSummary) -> None:
if summary_record.enabled:
return
summary_record.enabled = True
summary_record.disabled_at = None
summary_record.disabled_by = None
@staticmethod
def _mark_summary_generation_started(
segment: DocumentSegment,
dataset: Dataset,
) -> DocumentSegmentSummary:
with session_factory.create_session() as session:
summary_record = SummaryIndexService._get_summary_record(session, segment.id, dataset.id)
if not summary_record:
logger.warning("Summary record not found for segment %s, creating one", segment.id)
summary_record = DocumentSegmentSummary(
dataset_id=dataset.id,
document_id=segment.document_id,
chunk_id=segment.id,
summary_content="",
status=SummaryStatus.GENERATING,
enabled=True,
)
else:
SummaryIndexService._reenable_summary_record(summary_record)
summary_record.status = SummaryStatus.GENERATING
summary_record.error = None
session.add(summary_record)
session.commit()
return summary_record
@staticmethod
def _save_summary_content(
segment: DocumentSegment,
dataset: Dataset,
summary_content: str,
*,
summary_record_id: str | None = None,
status: SummaryStatus = SummaryStatus.GENERATING,
) -> DocumentSegmentSummary:
with session_factory.create_session() as session:
summary_record = session.get(DocumentSegmentSummary, summary_record_id) if summary_record_id else None
if not summary_record:
summary_record = SummaryIndexService._get_summary_record(session, segment.id, dataset.id)
if not summary_record:
summary_record = DocumentSegmentSummary(
dataset_id=dataset.id,
document_id=segment.document_id,
chunk_id=segment.id,
summary_content=summary_content,
status=status,
enabled=True,
)
else:
summary_record.summary_content = summary_content
summary_record.status = status
summary_record.error = None
SummaryIndexService._reenable_summary_record(summary_record)
session.add(summary_record)
session.commit()
return summary_record
@staticmethod
def _enable_summary_record(
summary_record_id: str,
segment_id: str,
dataset_id: str,
) -> bool:
with session_factory.create_session() as session:
summary_record = session.get(DocumentSegmentSummary, summary_record_id)
if not summary_record:
summary_record = SummaryIndexService._get_summary_record(session, segment_id, dataset_id)
if not summary_record:
return False
SummaryIndexService._reenable_summary_record(summary_record)
session.add(summary_record)
session.commit()
return True
@staticmethod
def generate_summary_for_segment(
segment: DocumentSegment,
@ -98,6 +200,7 @@ class SummaryIndexService:
"""
Create or update a DocumentSegmentSummary record.
If a summary record already exists for this segment, it will be updated instead of creating a new one.
The write is committed before returning so follow-up vectorization can run without a dirty DB session.
Args:
segment: DocumentSegment to create summary for
@ -108,50 +211,19 @@ class SummaryIndexService:
Returns:
Created or updated DocumentSegmentSummary instance
"""
with session_factory.create_session() as session:
# Check if summary record already exists
existing_summary = session.scalar(
select(DocumentSegmentSummary)
.where(
DocumentSegmentSummary.chunk_id == segment.id,
DocumentSegmentSummary.dataset_id == dataset.id,
)
.limit(1)
)
if existing_summary:
# Update existing record
existing_summary.summary_content = summary_content
existing_summary.status = status
existing_summary.error = None # Clear any previous errors
# Re-enable if it was disabled
if not existing_summary.enabled:
existing_summary.enabled = True
existing_summary.disabled_at = None
existing_summary.disabled_by = None
session.add(existing_summary)
session.flush()
return existing_summary
else:
# Create new record (enabled by default)
summary_record = DocumentSegmentSummary(
dataset_id=dataset.id,
document_id=segment.document_id,
chunk_id=segment.id,
summary_content=summary_content,
status=status,
enabled=True, # Explicitly set enabled to True
)
session.add(summary_record)
session.flush()
return summary_record
return SummaryIndexService._save_summary_content(
segment=segment,
dataset=dataset,
summary_content=summary_content,
status=status,
)
@staticmethod
def vectorize_summary(
summary_record: DocumentSegmentSummary,
segment: DocumentSegment,
dataset: Dataset,
session: Session | None = None,
session: Session | scoped_session | None = None,
) -> None:
"""
Vectorize summary and store in vector database.
@ -161,7 +233,8 @@ class SummaryIndexService:
segment: Original DocumentSegment
dataset: Dataset containing the segment
session: Optional SQLAlchemy session. If provided, uses this session instead of creating a new one.
If not provided, creates a new session and commits automatically.
If not provided, creates a new session and commits automatically. Prefer the sessionless path for
service-owned workflows so external vector calls do not run while a caller has uncommitted writes.
"""
if dataset.indexing_technique != IndexTechniqueType.HIGH_QUALITY:
logger.warning(
@ -641,15 +714,19 @@ class SummaryIndexService:
segment: DocumentSegment,
dataset: Dataset,
summary_index_setting: SummaryIndexSettingDict,
session: Session | scoped_session | None = None,
) -> DocumentSegmentSummary:
"""
Generate summary for a segment and vectorize it.
Assumes summary record already exists (created by batch_create_summary_records).
Summary status/content writes are committed before the external LLM and vector operations. This keeps callers
from sharing a transaction with long-running network I/O while still preserving status visibility.
Args:
segment: DocumentSegment to generate summary for
dataset: Dataset containing the segment
summary_index_setting: Summary index configuration
session: Optional caller session kept for API compatibility with task/wrapper callsites. Summary writes
still use short service-owned transactions so external LLM/vector calls do not share dirty state.
Returns:
Created DocumentSegmentSummary instance
@ -657,105 +734,52 @@ class SummaryIndexService:
Raises:
ValueError: If summary generation fails
"""
with session_factory.create_session() as session:
try:
# Get or refresh summary record in this session
summary_record_in_session = session.scalar(
select(DocumentSegmentSummary)
.where(
DocumentSegmentSummary.chunk_id == segment.id,
DocumentSegmentSummary.dataset_id == dataset.id,
)
.limit(1)
summary_record = SummaryIndexService._mark_summary_generation_started(segment, dataset)
try:
# Generate summary (returns summary_content and llm_usage) outside any service-owned write transaction.
summary_content, llm_usage = SummaryIndexService.generate_summary_for_segment(
segment, dataset, summary_index_setting
)
summary_record = SummaryIndexService._save_summary_content(
segment=segment,
dataset=dataset,
summary_content=summary_content,
summary_record_id=summary_record.id,
status=SummaryStatus.GENERATING,
)
# Log LLM usage for summary generation
if llm_usage and llm_usage.total_tokens > 0:
logger.info(
"Summary generation for segment %s used %s tokens (prompt: %s, completion: %s)",
segment.id,
llm_usage.total_tokens,
llm_usage.prompt_tokens,
llm_usage.completion_tokens,
)
if not summary_record_in_session:
# If not found, create one
logger.warning("Summary record not found for segment %s, creating one", segment.id)
summary_record_in_session = DocumentSegmentSummary(
dataset_id=dataset.id,
document_id=segment.document_id,
chunk_id=segment.id,
summary_content="",
status=SummaryStatus.GENERATING,
enabled=True,
)
session.add(summary_record_in_session)
session.flush()
# Update status to "generating"
summary_record_in_session.status = SummaryStatus.GENERATING
summary_record_in_session.error = None
session.add(summary_record_in_session)
# Don't flush here - wait until after vectorization succeeds
# Generate summary (returns summary_content and llm_usage)
summary_content, llm_usage = SummaryIndexService.generate_summary_for_segment(
segment, dataset, summary_index_setting
)
# Update summary content
summary_record_in_session.summary_content = summary_content
session.add(summary_record_in_session)
# Flush to ensure summary_content is saved before vectorize_summary queries it
session.flush()
# Log LLM usage for summary generation
if llm_usage and llm_usage.total_tokens > 0:
logger.info(
"Summary generation for segment %s used %s tokens (prompt: %s, completion: %s)",
segment.id,
llm_usage.total_tokens,
llm_usage.prompt_tokens,
llm_usage.completion_tokens,
)
# Vectorize summary (will delete old vector if exists before creating new one)
# Pass the session-managed record to vectorize_summary
# vectorize_summary will update status to "completed" and tokens in its own session
# vectorize_summary will also ensure summary_content is preserved
try:
# Pass the session to vectorize_summary to avoid session isolation issues
SummaryIndexService.vectorize_summary(summary_record_in_session, segment, dataset, session=session)
# Refresh the object from database to get the updated status and tokens from vectorize_summary
session.refresh(summary_record_in_session)
# Commit the session
# (summary_record_in_session should have status="completed" and tokens from refresh)
session.commit()
logger.info("Successfully generated and vectorized summary for segment %s", segment.id)
return summary_record_in_session
except Exception as vectorize_error:
# If vectorization fails, update status to error in current session
logger.exception("Failed to vectorize summary for segment %s", segment.id)
summary_record_in_session.status = SummaryStatus.ERROR
summary_record_in_session.error = f"Vectorization failed: {str(vectorize_error)}"
session.add(summary_record_in_session)
session.commit()
raise
except Exception as e:
logger.exception("Failed to generate summary for segment %s", segment.id)
# Update summary record with error status
summary_record_in_session = session.scalar(
select(DocumentSegmentSummary)
.where(
DocumentSegmentSummary.chunk_id == segment.id,
DocumentSegmentSummary.dataset_id == dataset.id,
)
.limit(1)
)
if summary_record_in_session:
summary_record_in_session.status = SummaryStatus.ERROR
summary_record_in_session.error = str(e)
session.add(summary_record_in_session)
session.commit()
raise
SummaryIndexService.vectorize_summary(summary_record, segment, dataset)
logger.info("Successfully generated and vectorized summary for segment %s", segment.id)
return summary_record
except Exception as e:
logger.exception("Failed to generate summary for segment %s", segment.id)
SummaryIndexService.update_summary_record_error(
segment=segment,
dataset=dataset,
error=str(e),
)
summary_record.status = SummaryStatus.ERROR
summary_record.error = str(e)
raise
@staticmethod
def generate_summaries_for_document(
dataset: Dataset,
document: DatasetDocument,
summary_index_setting: SummaryIndexSettingDict,
session: Session | scoped_session | None = None,
segment_ids: list[str] | None = None,
only_parent_chunks: bool = False,
) -> list[DocumentSegmentSummary]:
@ -766,6 +790,8 @@ class SummaryIndexService:
dataset: Dataset containing the document
document: DatasetDocument to generate summaries for
summary_index_setting: Summary index configuration
session: Optional caller session used for segment lookup. Summary record writes are still committed in
short service-owned transactions.
segment_ids: Optional list of specific segment IDs to process
only_parent_chunks: If True, only process parent chunks (for parent-child mode)
@ -798,7 +824,7 @@ class SummaryIndexService:
only_parent_chunks,
)
with session_factory.create_session() as session:
def _load_segments(query_session: Session | scoped_session) -> list[DocumentSegment]:
# Query segments (only enabled segments)
stmt = select(DocumentSegment).where(
DocumentSegment.dataset_id == dataset.id,
@ -810,60 +836,67 @@ class SummaryIndexService:
if segment_ids:
stmt = stmt.where(DocumentSegment.id.in_(segment_ids))
segments = list(session.scalars(stmt).all())
return list(query_session.scalars(stmt).all())
if not segments:
logger.info("No segments found for document %s", document.id)
return []
if session is None:
with session_factory.create_session() as query_session:
segments = _load_segments(query_session)
else:
segments = _load_segments(session)
# Batch create summary records with "not_started" status before processing
# This ensures all records exist upfront, allowing status tracking
SummaryIndexService.batch_create_summary_records(
segments=segments,
dataset=dataset,
status=SummaryStatus.NOT_STARTED,
)
if not segments:
logger.info("No segments found for document %s", document.id)
return []
summary_records = []
# Batch create summary records with "not_started" status before processing
# This ensures all records exist upfront, allowing status tracking
SummaryIndexService.batch_create_summary_records(
segments=segments,
dataset=dataset,
status=SummaryStatus.NOT_STARTED,
)
for segment in segments:
# For parent-child mode, only process parent chunks
# In parent-child mode, all DocumentSegments are parent chunks,
# so we process all of them. Child chunks are stored in ChildChunk table
# and are not DocumentSegments, so they won't be in the segments list.
# This check is mainly for clarity and future-proofing.
if only_parent_chunks:
# In parent-child mode, all segments in the query are parent chunks
# Child chunks are not DocumentSegments, so they won't appear here
# We can process all segments
pass
summary_records = []
try:
summary_record = SummaryIndexService.generate_and_vectorize_summary(
segment, dataset, summary_index_setting
)
summary_records.append(summary_record)
except Exception as e:
logger.exception("Failed to generate summary for segment %s", segment.id)
# Update summary record with error status
SummaryIndexService.update_summary_record_error(
segment=segment,
dataset=dataset,
error=str(e),
)
# Continue with other segments
continue
for segment in segments:
# For parent-child mode, only process parent chunks
# In parent-child mode, all DocumentSegments are parent chunks,
# so we process all of them. Child chunks are stored in ChildChunk table
# and are not DocumentSegments, so they won't be in the segments list.
# This check is mainly for clarity and future-proofing.
if only_parent_chunks:
# In parent-child mode, all segments in the query are parent chunks
# Child chunks are not DocumentSegments, so they won't appear here
# We can process all segments
pass
logger.info(
"Completed summary generation for document %s: %s summaries generated and vectorized",
document.id,
len(summary_records),
)
return summary_records
try:
summary_record = SummaryIndexService.generate_and_vectorize_summary(
segment, dataset, summary_index_setting, session=session
)
summary_records.append(summary_record)
except Exception as e:
logger.exception("Failed to generate summary for segment %s", segment.id)
# Update summary record with error status
SummaryIndexService.update_summary_record_error(
segment=segment,
dataset=dataset,
error=str(e),
)
# Continue with other segments
continue
logger.info(
"Completed summary generation for document %s: %s summaries generated and vectorized",
document.id,
len(summary_records),
)
return summary_records
@staticmethod
def disable_summaries_for_segments(
dataset: Dataset,
session: Session | scoped_session | None = None,
segment_ids: list[str] | None = None,
disabled_by: str | None = None,
) -> None:
@ -873,12 +906,13 @@ class SummaryIndexService:
Args:
dataset: Dataset containing the segments
session: Optional caller session. When provided, it is used for the summary row query/update.
segment_ids: List of segment IDs to disable summaries for. If None, disable all.
disabled_by: User ID who disabled the summaries
"""
from libs.datetime_utils import naive_utc_now
with session_factory.create_session() as session:
def _disable_with_session(write_session: Session | scoped_session) -> None:
stmt = select(DocumentSegmentSummary).where(
DocumentSegmentSummary.dataset_id == dataset.id,
DocumentSegmentSummary.enabled.is_(True), # Only disable enabled summaries
@ -887,7 +921,7 @@ class SummaryIndexService:
if segment_ids:
stmt = stmt.where(DocumentSegmentSummary.chunk_id.in_(segment_ids))
summaries = session.scalars(stmt).all()
summaries = write_session.scalars(stmt).all()
if not summaries:
return
@ -915,14 +949,21 @@ class SummaryIndexService:
summary.enabled = False
summary.disabled_at = now
summary.disabled_by = disabled_by
session.add(summary)
write_session.add(summary)
session.commit()
write_session.commit()
logger.info("Disabled %s summary records for dataset %s", len(summaries), dataset.id)
if session is None:
with session_factory.create_session() as write_session:
_disable_with_session(write_session)
else:
_disable_with_session(session)
@staticmethod
def enable_summaries_for_segments(
dataset: Dataset,
session: Session | scoped_session | None = None,
segment_ids: list[str] | None = None,
) -> None:
"""
@ -935,13 +976,17 @@ class SummaryIndexService:
Args:
dataset: Dataset containing the segments
session: Optional caller session used for candidate lookup. Vectorization and final enable writes remain
short-scoped to avoid sharing dirty state with external vector calls.
segment_ids: List of segment IDs to enable summaries for. If None, enable all.
"""
# Only enable summary index for high_quality indexing technique
if dataset.indexing_technique != IndexTechniqueType.HIGH_QUALITY:
return
with session_factory.create_session() as session:
summary_segment_pairs: list[tuple[DocumentSegmentSummary, DocumentSegment]] = []
def _collect_candidates(query_session: Session | scoped_session) -> None:
stmt = select(DocumentSegmentSummary).where(
DocumentSegmentSummary.dataset_id == dataset.id,
DocumentSegmentSummary.enabled.is_(False), # Only enable disabled summaries
@ -950,7 +995,7 @@ class SummaryIndexService:
if segment_ids:
stmt = stmt.where(DocumentSegmentSummary.chunk_id.in_(segment_ids))
summaries = session.scalars(stmt).all()
summaries = query_session.scalars(stmt).all()
if not summaries:
return
@ -963,10 +1008,9 @@ class SummaryIndexService:
)
# Re-vectorize and re-add to vector database
enabled_count = 0
for summary in summaries:
# Get the original segment
segment = session.scalar(
segment = query_session.scalar(
select(DocumentSegment)
.where(
DocumentSegment.id == summary.chunk_id,
@ -983,27 +1027,27 @@ class SummaryIndexService:
if not summary.summary_content:
continue
try:
# Re-vectorize summary (this will update status and tokens in its own session)
# Pass the session to vectorize_summary to avoid session isolation issues
SummaryIndexService.vectorize_summary(summary, segment, dataset, session=session)
summary_segment_pairs.append((summary, segment))
# Refresh the object from database to get the updated status and tokens from vectorize_summary
session.refresh(summary)
if session is None:
with session_factory.create_session() as query_session:
_collect_candidates(query_session)
else:
_collect_candidates(session)
# Enable summary record
summary.enabled = True
summary.disabled_at = None
summary.disabled_by = None
session.add(summary)
enabled_count = 0
for summary, segment in summary_segment_pairs:
try:
# Re-vectorize outside the query session; vectorize_summary commits its own metadata update.
SummaryIndexService.vectorize_summary(summary, segment, dataset)
if SummaryIndexService._enable_summary_record(summary.id, segment.id, dataset.id):
enabled_count += 1
except Exception:
logger.exception("Failed to re-vectorize summary %s", summary.id)
# Keep it disabled if vectorization fails
continue
except Exception:
logger.exception("Failed to re-vectorize summary %s", summary.id)
# Keep it disabled if vectorization fails
continue
session.commit()
logger.info("Enabled %s summary records for dataset %s", enabled_count, dataset.id)
logger.info("Enabled %s summary records for dataset %s", enabled_count, dataset.id)
@staticmethod
def delete_summaries_for_segments(
@ -1072,67 +1116,15 @@ class SummaryIndexService:
if segment.document and segment.document.doc_form == "qa_model":
return None
with session_factory.create_session() as session:
try:
# Check if summary_content is empty (whitespace-only strings are considered empty)
if not summary_content or not summary_content.strip():
# If summary is empty, only delete existing summary vector and record
summary_record = session.scalar(
select(DocumentSegmentSummary)
.where(
DocumentSegmentSummary.chunk_id == segment.id,
DocumentSegmentSummary.dataset_id == dataset.id,
)
.limit(1)
)
if summary_record:
# Delete old vector if exists
old_summary_node_id = summary_record.summary_index_node_id
if old_summary_node_id:
try:
vector = Vector(dataset)
vector.delete_by_ids([old_summary_node_id])
except Exception as e:
logger.warning(
"Failed to delete old summary vector for segment %s: %s",
segment.id,
str(e),
)
# Delete summary record since summary is empty
session.delete(summary_record)
session.commit()
logger.info("Deleted summary for segment %s (empty content provided)", segment.id)
return None
else:
# No existing summary record, nothing to do
logger.info("No summary record found for segment %s, nothing to delete", segment.id)
return None
# Find existing summary record
summary_record = session.scalar(
select(DocumentSegmentSummary)
.where(
DocumentSegmentSummary.chunk_id == segment.id,
DocumentSegmentSummary.dataset_id == dataset.id,
)
.limit(1)
)
# Check if summary_content is empty (whitespace-only strings are considered empty)
if not summary_content or not summary_content.strip():
with session_factory.create_session() as session:
# If summary is empty, only delete existing summary vector and record
summary_record = SummaryIndexService._get_summary_record(session, segment.id, dataset.id)
if summary_record:
# Update existing summary
# Delete old vector before deleting the row; no summary writes are pending yet.
old_summary_node_id = summary_record.summary_index_node_id
# Update summary content
summary_record.summary_content = summary_content
summary_record.status = SummaryStatus.GENERATING
summary_record.error = None # Clear any previous errors
session.add(summary_record)
# Flush to ensure summary_content is saved before vectorize_summary queries it
session.flush()
# Delete old vector if exists (before vectorization)
if old_summary_node_id:
try:
vector = Vector(dataset)
@ -1144,77 +1136,38 @@ class SummaryIndexService:
str(e),
)
# Re-vectorize summary (this will update status to "completed" and tokens in its own session)
# vectorize_summary will also ensure summary_content is preserved
# Note: vectorize_summary may take time due to embedding API calls, but we need to complete it
# to ensure the summary is properly indexed
try:
# Pass the session to vectorize_summary to avoid session isolation issues
SummaryIndexService.vectorize_summary(summary_record, segment, dataset, session=session)
# Refresh the object from database to get the updated status and tokens from vectorize_summary
session.refresh(summary_record)
# Now commit the session (summary_record should have status="completed" and tokens from refresh)
session.commit()
logger.info("Successfully updated and re-vectorized summary for segment %s", segment.id)
return summary_record
except Exception as e:
# If vectorization fails, update status to error in current session
# Don't raise the exception - just log it and return the record with error status
# This allows the segment update to complete even if vectorization fails
summary_record.status = SummaryStatus.ERROR
summary_record.error = f"Vectorization failed: {str(e)}"
session.commit()
logger.exception("Failed to vectorize summary for segment %s", segment.id)
# Return the record with error status instead of raising
# The caller can check the status if needed
return summary_record
else:
# Create new summary record if doesn't exist
summary_record = SummaryIndexService.create_summary_record(
segment, dataset, summary_content, status=SummaryStatus.GENERATING
)
# Re-vectorize summary (this will update status to "completed" and tokens in its own session)
# Note: summary_record was created in a different session,
# so we need to merge it into current session
try:
# Merge the record into current session first (since it was created in a different session)
summary_record = session.merge(summary_record)
# Pass the session to vectorize_summary - it will update the merged record
SummaryIndexService.vectorize_summary(summary_record, segment, dataset, session=session)
# Refresh to get updated status and tokens from database
session.refresh(summary_record)
# Commit the session to persist the changes
session.commit()
logger.info("Successfully created and vectorized summary for segment %s", segment.id)
return summary_record
except Exception as e:
# If vectorization fails, update status to error in current session
# Merge the record into current session first
error_record = session.merge(summary_record)
error_record.status = SummaryStatus.ERROR
error_record.error = f"Vectorization failed: {str(e)}"
session.commit()
logger.exception("Failed to vectorize summary for segment %s", segment.id)
# Return the record with error status instead of raising
return error_record
except Exception as e:
logger.exception("Failed to update summary for segment %s", segment.id)
# Update summary record with error status if it exists
summary_record = session.scalar(
select(DocumentSegmentSummary)
.where(
DocumentSegmentSummary.chunk_id == segment.id,
DocumentSegmentSummary.dataset_id == dataset.id,
)
.limit(1)
)
if summary_record:
summary_record.status = SummaryStatus.ERROR
summary_record.error = str(e)
session.add(summary_record)
# Delete summary record since summary is empty
session.delete(summary_record)
session.commit()
raise
logger.info("Deleted summary for segment %s (empty content provided)", segment.id)
return None
# No existing summary record, nothing to do
logger.info("No summary record found for segment %s, nothing to delete", segment.id)
return None
summary_record = SummaryIndexService._save_summary_content(
segment=segment,
dataset=dataset,
summary_content=summary_content,
status=SummaryStatus.GENERATING,
)
try:
SummaryIndexService.vectorize_summary(summary_record, segment, dataset)
logger.info("Successfully updated and re-vectorized summary for segment %s", segment.id)
return summary_record
except Exception as e:
logger.exception("Failed to vectorize summary for segment %s", segment.id)
error = f"Vectorization failed: {str(e)}"
SummaryIndexService.update_summary_record_error(
segment=segment,
dataset=dataset,
error=error,
)
summary_record.status = SummaryStatus.ERROR
summary_record.error = error
return summary_record
@staticmethod
def get_segment_summary(segment_id: str, dataset_id: str) -> DocumentSegmentSummary | None:

View File

@ -126,6 +126,7 @@ def add_document_to_index_task(dataset_document_id: str):
try:
SummaryIndexService.enable_summaries_for_segments(
dataset=dataset,
session=session,
segment_ids=segment_ids_list,
)
except Exception as e:

View File

@ -69,6 +69,7 @@ def disable_segment_from_index_task(segment_id: str):
try:
SummaryIndexService.disable_summaries_for_segments(
dataset=dataset,
session=session,
segment_ids=[segment.id],
disabled_by=segment.disabled_by,
)

View File

@ -75,6 +75,7 @@ def disable_segments_from_index_task(segment_ids: list, dataset_id: str, documen
disabled_by = segments[0].disabled_by if segments else None
SummaryIndexService.disable_summaries_for_segments(
dataset=dataset,
session=session,
segment_ids=segment_ids_list,
disabled_by=disabled_by,
)

View File

@ -114,6 +114,7 @@ def enable_segment_to_index_task(segment_id: str):
try:
SummaryIndexService.enable_summaries_for_segments(
dataset=dataset,
session=session,
segment_ids=[segment.id],
)
except Exception as e:

View File

@ -113,6 +113,7 @@ def enable_segments_to_index_task(segment_ids: list, dataset_id: str, document_i
try:
SummaryIndexService.enable_summaries_for_segments(
dataset=dataset,
session=session,
segment_ids=segment_ids_list,
)
except Exception as e:

View File

@ -90,6 +90,7 @@ def generate_summary_index_task(dataset_id: str, document_id: str, segment_ids:
dataset=dataset,
document=document,
summary_index_setting=summary_index_setting,
session=session,
segment_ids=segment_ids,
only_parent_chunks=only_parent_chunks,
)

View File

@ -162,7 +162,7 @@ def regenerate_summary_index_task(
)
# Re-vectorize with new embedding model
SummaryIndexService.vectorize_summary(summary_record, segment, dataset)
SummaryIndexService.vectorize_summary(summary_record, segment, dataset, session=session)
session.commit()
total_segments_processed += 1
@ -259,7 +259,7 @@ def regenerate_summary_index_task(
# Regenerate both summary content and vectors (for summary_model change)
SummaryIndexService.generate_and_vectorize_summary(
segment, dataset, summary_index_setting
segment, dataset, summary_index_setting, session=session
)
session.commit()
total_segments_processed += 1

View File

@ -55,6 +55,7 @@ def remove_document_from_index_task(document_id: str):
try:
SummaryIndexService.disable_summaries_for_segments(
dataset=dataset,
session=session,
segment_ids=segment_ids_list,
disabled_by=document.disabled_by,
)

View File

@ -48,11 +48,9 @@ def test_console_setup_fastopenapi_post_success(app: Flask):
patch("controllers.console.setup.TenantService.get_tenant_count", return_value=0),
patch("controllers.console.setup.get_init_validate_status", return_value=True),
patch("controllers.console.setup.RegisterService.setup"),
patch("controllers.console.setup.mark_setup_completed") as mark_setup_completed,
):
client = app.test_client()
response = client.post("/console/api/setup", json=payload)
assert response.status_code == 201
assert response.get_json() == {"result": "success"}
mark_setup_completed.assert_called_once_with()

View File

@ -13,7 +13,6 @@ from controllers.console.workspace.error import AccountNotInitializedError
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
_is_setup_completed,
account_initialization_required,
cloud_edition_billing_enabled,
cloud_edition_billing_rate_limit_check,
@ -36,12 +35,6 @@ from models.account import AccountStatus, TenantAccountRole
from services.feature_service import LicenseStatus
@pytest.fixture(autouse=True)
def reset_setup_required_cache():
"""Keep setup_required's process cache isolated across unit tests."""
_is_setup_completed.reset_success()
class MockUser(UserMixin):
"""Simple User class for testing."""
@ -742,39 +735,6 @@ class TestSystemSetup:
# Assert
assert result == "admin_success"
@patch("controllers.console.wraps.db")
def test_should_cache_completed_setup(self, mock_db):
"""Test that completed setup skips repeated DB reads in this process"""
mock_db.session.scalar.return_value = MagicMock()
@setup_required
def admin_view():
return "admin_success"
with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"):
assert admin_view() == "admin_success"
assert admin_view() == "admin_success"
assert mock_db.session.scalar.call_count == 1
@patch("controllers.console.wraps.db")
@patch("controllers.console.wraps.os.environ.get")
def test_should_not_cache_missing_setup(self, mock_environ_get, mock_db):
"""Test that first-time bootstrap completion can be observed later in the same process"""
mock_db.session.scalar.side_effect = [None, MagicMock()]
mock_environ_get.return_value = None
@setup_required
def admin_view():
return "admin_success"
with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"):
with pytest.raises(NotSetupError):
admin_view()
assert admin_view() == "admin_success"
assert mock_db.session.scalar.call_count == 2
@patch("controllers.console.wraps.db")
@patch("controllers.console.wraps.os.environ.get")
def test_should_raise_not_init_validate_error_with_init_password(self, mock_environ_get, mock_db: MagicMock):

View File

@ -148,35 +148,5 @@ class TestHandlePluginInstanceInstall:
PluginMigration.install_plugins(str(extracted_plugins), str(output_file), workers=1)
assert json.loads(output_file.read_text())["not_installed"] == [
{
"tenant_id": "tenant1",
"plugin_not_exist": ["langgenius/missing"],
}
{"tenant_id": "tenant1", "plugin_not_exist": ["langgenius/missing"]}
]
mock_installer.install_from_identifiers.assert_called_once()
def test_install_plugins_skips_unresolved_plugins(self, tmp_path) -> None:
extracted_plugins = tmp_path / "plugins.jsonl"
output_file = tmp_path / "output.json"
extracted_plugins.write_text('{"tenant_id":"tenant1","plugins":["langgenius/missing"]}\n')
with (
patch(
f"{MIGRATION_MODULE}.PluginMigration.extract_unique_plugins",
return_value={
"plugins": {},
"plugin_not_exist": ["langgenius/missing"],
},
),
patch(f"{MIGRATION_MODULE}.PluginMigration.handle_plugin_instance_install", return_value={}),
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
):
mock_installer = MagicMock()
mock_installer.list_plugins.return_value = []
mock_installer_cls.return_value = mock_installer
PluginMigration.install_plugins(str(extracted_plugins), str(output_file), workers=1)
output = json.loads(output_file.read_text())
assert output["not_installed"] == [{"tenant_id": "tenant1", "plugin_not_exist": ["langgenius/missing"]}]
mock_installer.install_from_identifiers.assert_not_called()

View File

@ -8,7 +8,7 @@ import zstandard
from pydantic import TypeAdapter
from redis import RedisError
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
@ -71,16 +71,6 @@ def _build_install_task(*, task_id: str = "task-1", status: PluginInstallTaskSta
)
def _build_remote_model_plugin(
*, plugin_id: str = "langgenius/debug-model", plugin_unique_identifier: str = "langgenius/debug-model:1.0.0"
) -> SimpleNamespace:
return SimpleNamespace(
plugin_id=plugin_id,
plugin_unique_identifier=plugin_unique_identifier,
source=PluginInstallationSource.Remote,
)
def _provider_cache_key(tenant_id: str, generation: int | None = None) -> str:
if generation is None:
return f"plugin_model_providers:tenant_id:{tenant_id}"
@ -807,144 +797,6 @@ class TestPluginListEndpointCounts:
class TestPluginModelProviderCacheInvalidation:
def test_get_debugging_key_does_not_invalidate_model_provider_cache(self) -> None:
"""Reading a debug key does not mean a debug runtime has registered a model provider."""
with (
patch(f"{MODULE}.PluginDebuggingClient") as debugging_client_cls,
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
):
debugging_client_cls.return_value.get_debugging_key.return_value = "debug-key"
from core.plugin.plugin_service import PluginService
result = PluginService.get_debugging_key("tenant-1")
assert result == "debug-key"
debugging_client_cls.return_value.get_debugging_key.assert_called_once_with("tenant-1")
invalidate_cache.assert_not_called()
def test_list_model_category_invalidates_when_remote_model_plugin_is_missing_from_provider_cache(self) -> None:
"""Remote model plugins are daemon-registered, so category reads repair a stale provider cache."""
remote_plugin = _build_remote_model_plugin()
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
return_value=remote_plugin_marker,
),
patch(
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
return_value={"langgenius/openai"},
),
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
assert result is plugins
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
"tenant-1", PluginCategory.Model, 1, 100
)
invalidate_cache.assert_called_once_with("tenant-1")
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
def test_list_model_category_invalidates_when_remote_model_plugin_identity_changes(self) -> None:
"""A debug model plugin can share plugin_id with an installed plugin, so identity changes bust cache too."""
remote_plugin = _build_remote_model_plugin(
plugin_id="langgenius/openai",
plugin_unique_identifier="langgenius/openai:debug",
)
remote_plugin_marker = "langgenius/openai:langgenius/openai:debug"
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
return_value="langgenius/openai:langgenius/openai:1.0.0",
),
patch(
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
return_value={"langgenius/openai"},
) as load_cached_provider_plugin_ids,
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
assert result is plugins
invalidate_cache.assert_called_once_with("tenant-1")
load_cached_provider_plugin_ids.assert_not_called()
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
def test_list_model_category_keeps_provider_cache_when_remote_model_plugin_is_already_cached(self) -> None:
"""A connected remote model plugin should not force provider cache churn once represented."""
remote_plugin = _build_remote_model_plugin()
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
return_value=remote_plugin_marker,
),
patch(
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
return_value={"langgenius/debug-model"},
),
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
assert result is plugins
invalidate_cache.assert_not_called()
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
def test_list_model_category_invalidates_when_remote_model_plugin_disconnects(self) -> None:
"""The current model category result clears provider cache when the previous debug model disappears."""
installed_plugin = SimpleNamespace(
plugin_id="langgenius/openai",
plugin_unique_identifier="langgenius/openai:1.0.0",
source=PluginInstallationSource.Marketplace,
)
plugins = SimpleNamespace(list=[installed_plugin], has_more=True)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
patch(
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
return_value="langgenius/debug-model:langgenius/debug-model:1.0.0",
),
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
):
installer_cls.return_value.list_plugins_by_category.return_value = plugins
from core.plugin.plugin_service import PluginService
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
assert result is plugins
invalidate_cache.assert_called_once_with("tenant-1")
store_marker.assert_called_once_with("tenant-1", None)
def test_fetch_install_task_invalidates_model_provider_cache_when_finished(self) -> None:
"""Finished plugin install tasks invalidate tenant provider cache."""
task = _build_install_task(status=PluginInstallTaskStatus.Success)

View File

@ -633,19 +633,6 @@ def test_import_rag_pipeline_yaml_content_requires_content() -> None:
assert "yaml_content is required" in result.error
def test_import_rag_pipeline_rejects_oversized_yaml_content_before_parsing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.rag_pipeline.rag_pipeline_dsl_service.DSL_MAX_SIZE", 3)
service = RagPipelineDslService(session=Mock())
account = Mock(current_tenant_id="t1")
result = service.import_rag_pipeline(account=account, import_mode="yaml-content", yaml_content="你你")
assert result.status == ImportStatus.FAILED
assert result.error == "File size exceeds the limit of 10MB"
def test_import_rag_pipeline_yaml_content_requires_mapping() -> None:
service = RagPipelineDslService(session=Mock())
account = Mock(current_tenant_id="t1")

View File

@ -1,49 +1,50 @@
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from services.app_dsl_service import AppDslService
from services.entities.dsl_entities import ImportStatus
from services.app_dsl_service import AppDslService, ImportStatus
def test_import_app_rejects_oversized_yaml_content_before_parsing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 3)
service = AppDslService(session=Mock())
account = Mock(current_tenant_id="tenant-1")
def test_import_app_rejects_oversized_yaml_content_by_bytes(monkeypatch) -> None:
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 1)
service = AppDslService(session=SimpleNamespace())
result = service.import_app(account=account, import_mode="yaml-content", yaml_content="你你")
result = service.import_app(
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode="yaml-content",
yaml_content="é",
)
assert result.status == ImportStatus.FAILED
assert result.error == "File size exceeds the limit of 10MB"
assert "10MB" in result.error
def test_import_app_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch: pytest.MonkeyPatch) -> None:
def test_import_app_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch) -> None:
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 1)
response = Mock()
response.raise_for_status.return_value = None
response.content = b"\xff\xff"
monkeypatch.setattr("services.app_dsl_service.remote_fetcher.make_request", Mock(return_value=response))
service = AppDslService(session=Mock())
service = AppDslService(session=SimpleNamespace())
result = service.import_app(
account=Mock(current_tenant_id="tenant-1"),
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode="yaml-url",
yaml_url="https://example.com/app.yaml",
)
assert result.status == ImportStatus.FAILED
assert result.error == "File size exceeds the limit of 10MB"
assert "10MB" in result.error
def test_import_app_returns_decode_error_for_invalid_yaml_url_bytes(monkeypatch: pytest.MonkeyPatch) -> None:
def test_import_app_returns_decode_error_for_invalid_yaml_url_bytes(monkeypatch) -> None:
response = Mock()
response.raise_for_status.return_value = None
response.content = b"\xff"
monkeypatch.setattr("services.app_dsl_service.remote_fetcher.make_request", Mock(return_value=response))
service = AppDslService(session=Mock())
service = AppDslService(session=SimpleNamespace())
result = service.import_app(
account=Mock(current_tenant_id="tenant-1"),
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode="yaml-url",
yaml_url="https://example.com/app.yaml",
)

View File

@ -1,7 +1,7 @@
import json
import unittest
from datetime import UTC, datetime
from typing import Any
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock, Mock
import pytest
@ -11,7 +11,7 @@ from core.trigger.constants import TRIGGER_SCHEDULE_NODE_TYPE
from core.workflow.nodes.trigger_schedule.entities import VisualConfig
from core.workflow.nodes.trigger_schedule.exc import ScheduleConfigError
from libs.schedule_utils import calculate_next_run_at, convert_12h_to_24h
from models.workflow import Workflow, WorkflowType
from models.workflow import Workflow
from services.trigger.schedule_service import ScheduleService
@ -503,22 +503,7 @@ def session_mock() -> MagicMock:
def _workflow(**kwargs: Any) -> Workflow:
graph_dict = kwargs.pop("graph_dict", {})
workflow = Workflow.new(
tenant_id="tenant-1",
app_id="app-1",
type=WorkflowType.WORKFLOW,
version="draft",
graph=json.dumps(graph_dict),
features="{}",
created_by="account-1",
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
for key, value in kwargs.items():
setattr(workflow, key, value)
return workflow
return cast(Workflow, SimpleNamespace(**kwargs))
def test_to_schedule_config_should_build_from_cron_mode() -> None:

View File

@ -142,7 +142,7 @@ def test_create_summary_record_updates_existing_and_reenables(monkeypatch: pytes
assert existing.disabled_by is None
assert existing.error is None
session.add.assert_called_once_with(existing)
session.flush.assert_called_once()
session.commit.assert_called_once()
def test_create_summary_record_creates_new(monkeypatch: pytest.MonkeyPatch) -> None:
@ -158,7 +158,7 @@ def test_create_summary_record_creates_new(monkeypatch: pytest.MonkeyPatch) -> N
assert record.summary_content == "new"
assert record.enabled is True
session.add.assert_called_once()
session.flush.assert_called_once()
session.commit.assert_called_once()
def test_vectorize_summary_skips_non_high_quality(monkeypatch: pytest.MonkeyPatch) -> None:
@ -330,6 +330,7 @@ def test_generate_and_vectorize_summary_success(monkeypatch: pytest.MonkeyPatch)
session = MagicMock()
session.scalar.return_value = record
session.get.return_value = record
monkeypatch.setattr(
summary_module,
@ -339,12 +340,45 @@ def test_generate_and_vectorize_summary_success(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setattr(
SummaryIndexService, "generate_summary_for_segment", MagicMock(return_value=("sum", MagicMock(total_tokens=0)))
)
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(return_value=None))
vectorize_mock = MagicMock(return_value=None)
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", vectorize_mock)
out = SummaryIndexService.generate_and_vectorize_summary(segment, dataset, {"enable": True})
assert out is record
session.refresh.assert_called_once_with(record)
session.commit.assert_called()
vectorize_mock.assert_called_once_with(record, segment, dataset)
assert session.commit.call_count == 2
def test_generate_and_vectorize_summary_commits_before_external_work(monkeypatch: pytest.MonkeyPatch) -> None:
dataset = _dataset()
segment = _segment()
record = _summary_record(summary_content="")
events: list[str] = []
session = MagicMock()
session.scalar.return_value = record
session.get.return_value = record
session.commit.side_effect = lambda: events.append("commit")
monkeypatch.setattr(
summary_module,
"session_factory",
SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))),
)
def generate_summary(*args, **kwargs):
events.append("llm")
return "sum", MagicMock(total_tokens=0)
def vectorize_summary(*args, **kwargs):
events.append("vector")
monkeypatch.setattr(SummaryIndexService, "generate_summary_for_segment", generate_summary)
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", vectorize_summary)
SummaryIndexService.generate_and_vectorize_summary(segment, dataset, {"enable": True})
assert events == ["commit", "llm", "commit", "vector"]
def test_generate_and_vectorize_summary_vectorize_failure_sets_error(monkeypatch: pytest.MonkeyPatch) -> None:
@ -354,6 +388,7 @@ def test_generate_and_vectorize_summary_vectorize_failure_sets_error(monkeypatch
session = MagicMock()
session.scalar.return_value = record
session.get.return_value = record
monkeypatch.setattr(
summary_module,
@ -542,6 +577,7 @@ def test_update_summary_record_error_warns_when_missing(
session = MagicMock()
session.scalar.return_value = None
session.get.return_value = None
monkeypatch.setattr(
summary_module,
"session_factory",
@ -732,6 +768,7 @@ def test_enable_summaries_for_segments_revectorizes_and_enables(monkeypatch: pyt
session = MagicMock()
session.scalars.return_value.all.return_value = [summary]
session.scalar.return_value = segment
session.get.return_value = summary
monkeypatch.setattr(
summary_module,
@ -742,7 +779,7 @@ def test_enable_summaries_for_segments_revectorizes_and_enables(monkeypatch: pyt
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", vec_mock)
SummaryIndexService.enable_summaries_for_segments(dataset, segment_ids=[summary.chunk_id])
vec_mock.assert_called_once()
vec_mock.assert_called_once_with(summary, segment, dataset)
assert summary.enabled is True
session.commit.assert_called_once()
@ -795,7 +832,7 @@ def test_enable_summaries_for_segments_skips_segment_or_content_and_handles_vect
with caplog.at_level(logging.ERROR, logger="services.summary_index_service"):
SummaryIndexService.enable_summaries_for_segments(dataset)
assert any(r.levelno >= logging.ERROR for r in caplog.records)
session.commit.assert_called_once()
session.commit.assert_not_called()
def test_delete_summaries_for_segments_deletes_vectors_and_records(monkeypatch: pytest.MonkeyPatch) -> None:
@ -847,6 +884,8 @@ def test_update_summary_for_segment_empty_content_deletes_existing(monkeypatch:
session = MagicMock()
session.scalar.return_value = record
events: list[str] = []
session.commit.side_effect = lambda: events.append("commit")
vector_instance = MagicMock()
monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance))
@ -909,6 +948,8 @@ def test_update_summary_for_segment_updates_existing_and_vectorizes(monkeypatch:
session = MagicMock()
session.scalar.return_value = record
events: list[str] = []
session.commit.side_effect = lambda: events.append("commit")
vector_instance = MagicMock()
monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance))
@ -918,19 +959,22 @@ def test_update_summary_for_segment_updates_existing_and_vectorizes(monkeypatch:
SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))),
)
vectorize_mock = MagicMock()
def vectorize_summary(*args, **kwargs):
events.append("vector")
vectorize_mock = MagicMock(side_effect=vectorize_summary)
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", vectorize_mock)
out = SummaryIndexService.update_summary_for_segment(segment, dataset, "new summary")
assert out is record
vectorize_mock.assert_called_once()
session.refresh.assert_called_once_with(record)
session.commit.assert_called()
vectorize_mock.assert_called_once_with(record, segment, dataset)
session.refresh.assert_not_called()
session.commit.assert_called_once()
assert events == ["commit", "vector"]
def test_update_summary_for_segment_existing_vector_delete_warns(
def test_update_summary_for_segment_existing_vector_delete_is_left_to_vectorize_summary(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
dataset = _dataset()
segment = _segment()
@ -945,13 +989,13 @@ def test_update_summary_for_segment_existing_vector_delete_warns(
)
vector_instance = MagicMock()
vector_instance.delete_by_ids.side_effect = RuntimeError("boom")
monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance))
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(return_value=None))
vectorize_mock = MagicMock(return_value=None)
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", vectorize_mock)
with caplog.at_level(logging.WARNING, logger="services.summary_index_service"):
SummaryIndexService.update_summary_for_segment(segment, dataset, "new")
assert any(r.levelno >= logging.WARNING for r in caplog.records)
SummaryIndexService.update_summary_for_segment(segment, dataset, "new")
vector_instance.delete_by_ids.assert_not_called()
vectorize_mock.assert_called_once_with(record, segment, dataset)
def test_update_summary_for_segment_existing_vectorize_failure_returns_error_record(
@ -988,36 +1032,22 @@ def test_update_summary_for_segment_new_record_success(monkeypatch: pytest.Monke
SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))),
)
created = _summary_record(summary_content="new", node_id=None)
monkeypatch.setattr(SummaryIndexService, "create_summary_record", MagicMock(return_value=created))
session.merge.return_value = created
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(return_value=None))
out = SummaryIndexService.update_summary_for_segment(segment, dataset, "new")
assert out is created
session.refresh.assert_called()
session.commit.assert_called()
assert out is not None
assert out.summary_content == "new"
session.refresh.assert_not_called()
session.commit.assert_called_once()
def test_update_summary_for_segment_outer_exception_sets_error_and_reraises(monkeypatch: pytest.MonkeyPatch) -> None:
def test_update_summary_for_segment_save_failure_reraises(monkeypatch: pytest.MonkeyPatch) -> None:
dataset = _dataset()
segment = _segment()
record = _summary_record(summary_content="old", node_id="n1")
monkeypatch.setattr(SummaryIndexService, "_save_summary_content", MagicMock(side_effect=RuntimeError("save boom")))
session = MagicMock()
session.scalar.return_value = record
session.flush.side_effect = RuntimeError("flush boom")
monkeypatch.setattr(
summary_module,
"session_factory",
SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))),
)
with pytest.raises(RuntimeError, match="flush boom"):
with pytest.raises(RuntimeError, match="save boom"):
SummaryIndexService.update_summary_for_segment(segment, dataset, "new")
assert record.status == SummaryStatus.ERROR
assert record.error == "flush boom"
session.commit.assert_called()
def test_get_segment_summary_and_document_summaries(monkeypatch: pytest.MonkeyPatch) -> None:
@ -1100,14 +1130,11 @@ def test_update_summary_for_segment_creates_new_and_vectorize_fails_returns_erro
SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))),
)
created = _summary_record(summary_content="new", node_id=None)
monkeypatch.setattr(SummaryIndexService, "create_summary_record", MagicMock(return_value=created))
session.merge.return_value = created
vectorize_mock = MagicMock(side_effect=RuntimeError("boom"))
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", vectorize_mock)
out = SummaryIndexService.update_summary_for_segment(segment, dataset, "new")
assert out is not None
assert out.status == SummaryStatus.ERROR
assert "Vectorization failed" in (out.error or "")

View File

@ -7031,6 +7031,11 @@
"count": 1
}
},
"web/service/access-control/__tests__/use-permission-keys.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx": {
"no-restricted-imports": {
"count": 1
@ -7051,6 +7056,11 @@
"count": 1
}
},
"web/service/access-control/use-permission-keys.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/use-workspace-access-rules.ts": {
"no-restricted-imports": {
"count": 1
@ -7218,6 +7228,17 @@
"count": 1
}
},
"web/service/use-common.ts": {
"no-restricted-imports": {
"count": 1
},
"ts/no-empty-object-type": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/service/use-datasource.ts": {
"no-restricted-imports": {
"count": 1

View File

@ -1,9 +1,8 @@
import * as React from 'react'
import type { ReactNode } from 'react'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import MainNavLayout from '@/app/components/main-nav/layout'
import { NextRouteStateBridge } from '@/app/components/next-route-state'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
@ -18,35 +17,32 @@ export default async function Layout({
children,
detailSidebar,
}: {
children: React.ReactNode
detailSidebar: React.ReactNode
children: ReactNode
detailSidebar: ReactNode
}) {
return (
<React.Fragment>
<>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<NextRouteStateBridge>
<div className="flex h-full flex-col overflow-hidden">
<MaintenanceNotice />
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout detailSidebar={detailSidebar}>
{children}
</MainNavLayout>
<CommonLayoutGlobalMounts />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</div>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout detailSidebar={detailSidebar}>
{children}
</MainNavLayout>
<CommonLayoutGlobalMounts />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</NextRouteStateBridge>
</CommonLayoutHydrationBoundary>
<Zendesk />
</React.Fragment>
</>
)
}

View File

@ -1,10 +1,10 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { CommonLayoutHydrationBoundary } from '@/app/(commonLayout)/hydration-boundary'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@ -12,32 +12,30 @@ import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import Header from './header'
export default async function Layout({ children }: { children: React.ReactNode }) {
const Layout = async ({ children }: { children: ReactNode }) => {
return (
<React.Fragment>
<>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<div className="flex h-full flex-col overflow-hidden bg-background-body">
<MaintenanceNotice />
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
<div className="relative flex h-0 min-h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg">
{children}
</div>
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</div>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg">
{children}
</div>
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</CommonLayoutHydrationBoundary>
</React.Fragment>
</>
)
}
export default Layout

View File

@ -4,18 +4,10 @@ import { useLanguage } from '@/app/components/header/account-setting/model-provi
import { NOTICE_I18N } from '@/i18n-config/language'
import MaintenanceNotice from '../maintenance-notice'
const mockEnv = vi.hoisted(() => ({
NEXT_PUBLIC_MAINTENANCE_NOTICE: 'true',
}))
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
X: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
}))
vi.mock('@/env', () => ({
env: mockEnv,
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/hooks',
() => ({
@ -52,7 +44,6 @@ describe('MaintenanceNotice', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockEnv.NEXT_PUBLIC_MAINTENANCE_NOTICE = 'true'
vi.mocked(useLanguage).mockReturnValue('en_US')
setNoticeHref('#')
})
@ -80,14 +71,6 @@ describe('MaintenanceNotice', () => {
const { container } = render(<MaintenanceNotice />)
expect(container.firstChild).toBeNull()
})
it('should not render when the notice env flag is disabled', () => {
mockEnv.NEXT_PUBLIC_MAINTENANCE_NOTICE = ''
const { container } = render(<MaintenanceNotice />)
expect(container.firstChild).toBeNull()
})
})
describe('User Interactions', () => {

View File

@ -41,7 +41,6 @@ vi.mock('@/context/provider-context', () => ({
vi.mock('../hooks', () => ({
useDefaultModel: () => ({ data: null, isLoading: false }),
useLanguage: () => 'en_US',
}))
vi.mock('../provider-added-card', () => ({
@ -85,11 +84,9 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
}))
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
useCheckInstalled: () => ({
data: { plugins: [] },
}),
useInvalidateInstalledPluginList: () => vi.fn(),
useInvalidateCheckInstalled: () => vi.fn(),
usePluginAutoUpgradeSettings: () => ({
data: {
category: 'model',

View File

@ -1,9 +1,7 @@
import type { ReactNode } from 'react'
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
import { act, fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
@ -43,18 +41,10 @@ const { mockReferenceSetting, mockAutoUpgradeError } = vi.hoisted(() => ({
},
}))
const { mockProviderContextState, mockRefreshModelProviders } = vi.hoisted(() => ({
const { mockProviderContextState } = vi.hoisted(() => ({
mockProviderContextState: {
isLoadingModelProviders: false,
},
mockRefreshModelProviders: vi.fn(),
}))
const { mockInstalledModelPlugins, mockUseInstalledPluginList } = vi.hoisted(() => ({
mockInstalledModelPlugins: {
value: [] as PluginDetail[],
},
mockUseInstalledPluginList: vi.fn(),
}))
const mockQuotaConfig = {
@ -89,70 +79,6 @@ const saveUpdateSettings = () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
}
const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
version: '1.0.0',
author: 'langgenius',
icon: 'debug-model.png',
icon_dark: 'debug-model-dark.png',
name: 'debug-model',
category: PluginCategoryEnum.model,
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
description: { en_US: 'Debug model provider' } as unknown as PluginDeclaration['description'],
created_at: '2024-01-01',
resource: null,
plugins: null,
verified: false,
endpoint: null,
tool: undefined,
datasource: undefined,
model: {},
tags: [],
agent_strategy: null,
meta: {
version: '1.0.0',
},
trigger: {} as unknown as PluginDeclaration['trigger'],
...overrides,
})
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => {
const {
declaration: overrideDeclaration,
plugin_id: overridePluginId,
...restOverrides
} = overrides
const declaration = overrideDeclaration ?? createPluginDeclaration()
const pluginId = overridePluginId ?? 'langgenius/debug-model'
return {
id: 'plugin-installation-id',
created_at: '2024-01-01',
updated_at: '2024-01-01',
name: declaration.name,
plugin_id: pluginId,
plugin_unique_identifier: declaration.plugin_unique_identifier,
declaration,
installation_id: 'plugin-installation-id',
tenant_id: 'tenant-id',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: declaration.plugin_unique_identifier,
source: PluginSource.debugging,
meta: {
repo: '',
version: '1.0.0',
package: '',
},
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...restOverrides,
}
}
const mockProviders = [
{
provider: 'openai',
@ -180,7 +106,6 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: mockProviders,
isLoadingModelProviders: mockProviderContextState.isLoadingModelProviders,
refreshModelProviders: mockRefreshModelProviders,
}),
}))
@ -194,7 +119,6 @@ const mockDefaultModels: Record<string, { data: unknown, isLoading: boolean }> =
vi.mock('../hooks', () => ({
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
useLanguage: () => 'en_US',
}))
vi.mock('../install-from-marketplace', () => ({
@ -202,24 +126,7 @@ vi.mock('../install-from-marketplace', () => ({
}))
vi.mock('../provider-added-card', () => ({
default: ({
notConfigured,
provider,
pluginDetail,
}: {
notConfigured?: boolean
provider: { provider: string }
pluginDetail?: { plugin_id: string, source?: string }
}) => (
<div
data-testid="provider-card"
data-not-configured={String(!!notConfigured)}
data-plugin-id={pluginDetail?.plugin_id ?? ''}
data-plugin-source={pluginDetail?.source ?? ''}
>
{provider.provider}
</div>
),
default: ({ provider }: { provider: { provider: string } }) => <div data-testid="provider-card">{provider.provider}</div>,
}))
vi.mock('../provider-added-card/quota-panel', () => ({
@ -253,12 +160,12 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
}))
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: (...args: unknown[]) => {
mockUseInstalledPluginList(...args)
return {
data: { plugins: mockInstalledModelPlugins.value },
}
},
useInstalledPluginList: () => ({
data: { plugins: [] },
}),
useCheckInstalled: () => ({
data: { plugins: [] },
}),
usePluginAutoUpgradeSettings: () => ({
data: mockReferenceSetting.auto_upgrade
? {
@ -373,9 +280,6 @@ describe('ModelProviderPage', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockUseInstalledPluginList.mockClear()
mockRefreshModelProviders.mockClear()
mockInstalledModelPlugins.value = []
mockProviderContextState.isLoadingModelProviders = false
mockAutoUpgradeError.value = undefined
mockReferenceSetting.auto_upgrade = {
@ -514,107 +418,6 @@ describe('ModelProviderPage', () => {
expect(screen.getByText('anthropic')).toBeInTheDocument()
})
it('should use the model plugin installation list to attach plugin detail to provider cards', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:1.0.0',
name: 'openai',
label: { en_US: 'OpenAI Plugin' } as unknown as PluginDeclaration['label'],
}),
}),
]
renderModelProviderPage()
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, { category: PluginCategoryEnum.model })
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-id', 'langgenius/openai')
expect(screen.queryByText('OpenAI Plugin')).not.toBeInTheDocument()
})
it('should not render installed model plugins that are not registered as model providers', () => {
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/debug-model',
declaration: createPluginDeclaration({
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
description: { en_US: 'Debug model provider' } as unknown as PluginDeclaration['description'],
}),
}),
]
renderModelProviderPage()
expect(screen.queryByText('Debug Model')).not.toBeInTheDocument()
expect(screen.queryByText('langgenius/debug-model')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'plugin actions langgenius/debug-model' })).not.toBeInTheDocument()
})
it('should refresh model providers once when a debugging model plugin is missing from providers', () => {
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/debug-model',
declaration: createPluginDeclaration({
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
}),
}),
]
renderModelProviderPage()
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
})
it('should prefer debugging plugin detail when an installed model plugin shares the same plugin id', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:debug',
name: 'openai',
label: { en_US: 'OpenAI Debug Plugin' } as unknown as PluginDeclaration['label'],
}),
source: PluginSource.debugging,
}),
createPluginDetail({
plugin_id: 'langgenius/openai',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/openai:1.0.0',
name: 'openai',
label: { en_US: 'OpenAI Installed Plugin' } as unknown as PluginDeclaration['label'],
}),
source: PluginSource.marketplace,
}),
]
renderModelProviderPage()
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-id', 'langgenius/openai')
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-source', PluginSource.debugging)
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
})
it('should show provider placeholders while model providers are loading', () => {
mockProviderContextState.isLoadingModelProviders = true
@ -769,66 +572,4 @@ describe('ModelProviderPage', () => {
])
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
})
it('should prioritize debugging model plugins within their provider section', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI Fixed' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'zeta-provider',
label: { en_US: 'Zeta Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'langgenius/normal-model/normal-model',
label: { en_US: 'Normal Model' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'langgenius/debug-model/debug-model',
label: { en_US: 'Debug Model' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
mockInstalledModelPlugins.value = [
createPluginDetail({
plugin_id: 'langgenius/debug-model',
declaration: createPluginDeclaration({
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
name: 'debug-model',
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
}),
}),
]
renderModelProviderPage()
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
expect(renderedProviders).toEqual([
'langgenius/openai/openai',
'zeta-provider',
'langgenius/debug-model/debug-model',
'langgenius/normal-model/normal-model',
])
expect(screen.getAllByTestId('provider-card')[2]).toHaveAttribute('data-not-configured', 'true')
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
})
})

View File

@ -6,15 +6,15 @@ import type { PluginDetail } from '@/app/components/plugins/types'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useEffect, useMemo, useRef } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useInstalledPluginList } from '@/service/use-plugins'
import { useCheckInstalled } from '@/service/use-plugins'
import UpdateSettingDialog from '../update-setting-dialog'
import {
CustomConfigurationStatusEnum,
@ -25,6 +25,7 @@ import {
} from './hooks'
import ModelProviderPageBody from './model-provider-page-body'
import SystemModelSelector from './system-model-selector'
import { providerToPluginId } from './utils'
type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured'
@ -57,43 +58,23 @@ const ModelProviderPage = ({
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
const { modelProviders: providers, isLoadingModelProviders, refreshModelProviders } = useProviderContext()
const { modelProviders: providers, isLoadingModelProviders } = useProviderContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: installedModelPlugins } = useInstalledPluginList(false, 100, {
category: PluginCategoryEnum.model,
const allPluginIds = useMemo(() => {
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
}, [providers])
const { data: installedPlugins } = useCheckInstalled({
pluginIds: allPluginIds,
enabled: allPluginIds.length > 0,
})
const enrichedPlugins = usePluginsWithLatestVersion(installedModelPlugins?.plugins)
const enrichedPlugins = usePluginsWithLatestVersion(installedPlugins?.plugins)
const pluginDetailMap = useMemo(() => {
const map = new Map<string, PluginDetail>()
for (const plugin of enrichedPlugins) {
const existingPlugin = map.get(plugin.plugin_id)
if (!existingPlugin || plugin.source === PluginSource.debugging)
map.set(plugin.plugin_id, plugin)
}
for (const plugin of enrichedPlugins)
map.set(plugin.plugin_id, plugin)
return map
}, [enrichedPlugins])
const debuggingModelPluginKey = useMemo(() => {
const debuggingModelPluginIds = enrichedPlugins
.filter(plugin => plugin.source === PluginSource.debugging)
.map(plugin => `${plugin.plugin_id}:${plugin.plugin_unique_identifier}`)
.sort()
return debuggingModelPluginIds.join(',')
}, [enrichedPlugins])
const refreshedDebuggingModelPluginKeyRef = useRef('')
useEffect(() => {
if (!debuggingModelPluginKey) {
refreshedDebuggingModelPluginKeyRef.current = ''
return
}
if (refreshedDebuggingModelPluginKeyRef.current === debuggingModelPluginKey)
return
refreshedDebuggingModelPluginKeyRef.current = debuggingModelPluginKey
refreshModelProviders?.()
}, [debuggingModelPluginKey, refreshModelProviders])
const enableMarketplace = systemFeatures.enable_marketplace
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|| isEmbeddingsDefaultModelLoading

View File

@ -3,7 +3,6 @@ import type { ModelProvider } from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import { Trans, useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { PluginSource } from '@/app/components/plugins/types'
import { IS_CLOUD_EDITION } from '@/config'
import InstallFromMarketplace from './install-from-marketplace'
import ProviderAddedCard from './provider-added-card'
@ -100,40 +99,21 @@ type ProviderCardListProps = {
notConfigured?: boolean
}
function isDebuggingProvider(provider: ModelProvider, pluginDetailMap: Map<string, PluginDetail>) {
return pluginDetailMap.get(providerToPluginId(provider.provider))?.source === PluginSource.debugging
}
function ProviderCardList({
providers,
pluginDetailMap,
notConfigured,
}: ProviderCardListProps) {
const sortedProviders = [...providers]
.sort((a, b) => {
const aIsDebuggingPlugin = isDebuggingProvider(a, pluginDetailMap)
const bIsDebuggingPlugin = isDebuggingProvider(b, pluginDetailMap)
if (aIsDebuggingPlugin === bIsDebuggingPlugin)
return 0
return aIsDebuggingPlugin ? -1 : 1
})
return (
<div className="relative flex flex-col gap-2">
{sortedProviders.map((provider) => {
const pluginDetail = pluginDetailMap.get(providerToPluginId(provider.provider))
return (
<ProviderAddedCard
key={provider.provider}
notConfigured={notConfigured}
provider={provider}
pluginDetail={pluginDetail}
/>
)
})}
{providers.map(provider => (
<ProviderAddedCard
key={provider.provider}
notConfigured={notConfigured}
provider={provider}
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
/>
))}
</div>
)
}
@ -177,8 +157,8 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
<div className="flex flex-col gap-2 pt-2">
<div className="flex h-5 items-center system-md-semibold text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
<ProviderCardList
providers={filteredNotConfiguredProviders}
notConfigured
providers={filteredNotConfiguredProviders}
pluginDetailMap={pluginDetailMap}
/>
</div>

View File

@ -158,15 +158,6 @@ describe('ProviderCardActions', () => {
expect(mockHandleUpdate).toHaveBeenCalledWith(true)
})
it('should show a compact debug badge after the version for debugging plugins', () => {
render(<ProviderCardActions detail={createDetail({ source: PluginSource.debugging })} />)
const version = screen.getByText('1.0.0')
const debugBadge = screen.getByText('appDebug.operation.debugConfig')
expect(version.compareDocumentPosition(debugBadge) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('should trigger the latest marketplace update when clicking the update button', () => {
render(<ProviderCardActions detail={createDetail()} />)

View File

@ -30,7 +30,6 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
const { source, version, latest_version, latest_unique_identifier, meta } = detail
const author = detail.declaration?.author ?? ''
const name = detail.declaration?.name ?? detail.name
const isDebuggingPlugin = source === PluginSource.debugging
const {
modalStates,
@ -81,41 +80,31 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
return (
<>
{!!version && (
<>
<PluginVersionPicker
disabled={!isFromMarketplace || !canUpdatePlugin}
isShow={versionPicker.isShow}
onShowChange={versionPicker.setIsShow}
pluginID={detail.plugin_id}
currentVersion={version}
onSelect={handleVersionSelect}
sideOffset={4}
alignOffset={0}
trigger={(
<Badge
className={cn(
canUpdatePlugin && isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover',
)}
uppercase={false}
text={(
<>
<span>{version}</span>
{canUpdatePlugin && isFromMarketplace && <span className="ml-1 i-ri-arrow-left-right-line size-3" />}
</>
)}
hasRedCornerMark={hasNewVersion}
/>
)}
/>
{isDebuggingPlugin && (
<PluginVersionPicker
disabled={!isFromMarketplace || !canUpdatePlugin}
isShow={versionPicker.isShow}
onShowChange={versionPicker.setIsShow}
pluginID={detail.plugin_id}
currentVersion={version}
onSelect={handleVersionSelect}
sideOffset={4}
alignOffset={0}
trigger={(
<Badge
className="border-state-warning-active bg-state-warning-hover text-text-warning"
size="xs"
className={cn(
canUpdatePlugin && isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover',
)}
uppercase={false}
text={t('operation.debugConfig', { ns: 'appDebug' })}
text={(
<>
<span>{version}</span>
{canUpdatePlugin && isFromMarketplace && <span className="ml-1 i-ri-arrow-left-right-line size-3" />}
</>
)}
hasRedCornerMark={hasNewVersion}
/>
)}
</>
/>
)}
{canUpdatePlugin && (hasNewVersion || isFromGitHub) && (

View File

@ -1,21 +1,11 @@
'use client'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { X } from '@/app/components/base/icons/src/vender/line/general'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { env } from '@/env'
import { NOTICE_I18N } from '@/i18n-config/language'
import { useHideMaintenanceNotice } from './storage'
function MaintenanceNotice() {
if (!env.NEXT_PUBLIC_MAINTENANCE_NOTICE)
return null
return <MaintenanceNoticeContent />
}
function MaintenanceNoticeContent() {
const MaintenanceNotice = () => {
const { t } = useTranslation()
const locale = useLanguage()

View File

@ -26,7 +26,7 @@ export function SkipNav({
href={MAIN_CONTENT_HREF}
onClick={handleClick}
className={cn(
'fixed top-2 left-2 z-60 inline-flex h-9 -translate-y-[calc(100%+0.75rem)] items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text outline-hidden backdrop-blur-[5px] transition-transform duration-150 focus-visible:translate-y-0 focus-visible:shadow-lg focus-visible:ring-2 focus-visible:shadow-shadow-shadow-5 focus-visible:ring-state-accent-solid motion-reduce:transition-none',
'fixed top-2 left-2 z-60 inline-flex h-9 -translate-y-[calc(100%+0.75rem)] items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text outline-hidden transition-transform duration-150 focus-visible:translate-y-0 focus-visible:shadow-lg focus-visible:ring-2 focus-visible:shadow-shadow-shadow-5 focus-visible:ring-state-accent-solid motion-reduce:transition-none',
className,
)}
{...props}

View File

@ -1,18 +1,8 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { useHydrateAtoms } from 'jotai/react/utils'
import { Suspense } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import { initialWorkspaceInfo, useAppContext, useSelector } from '../app-context'
import { render, screen } from '@testing-library/react'
import { useAppContext, useSelector } from '../app-context'
import { AppContextProvider } from '../app-context-provider'
const mockGetRequest = vi.hoisted(() => vi.fn())
const mockInvalidateQueries = vi.hoisted(() => vi.fn())
const mockPermissionKeysState = vi.hoisted(() => ({
isPending: false,
permissionKeys: ['app.create_and_management'],
@ -29,84 +19,55 @@ const mockCurrentWorkspaceResponse = vi.hoisted(() => ({
next_credit_reset_date: 1706745600,
custom_config: {},
}))
const mockCurrentWorkspaceQueryState = vi.hoisted(() => ({
data: mockCurrentWorkspaceResponse as typeof mockCurrentWorkspaceResponse | undefined,
isPending: false,
}))
const mockUserProfileResponseState = vi.hoisted(() => ({
data: {
profile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
} as {
profile?: {
id: string
name: string
email: string
avatar: string
avatar_url: string
is_password_set: boolean
}
meta: {
currentVersion: string | null
currentEnv: string | null
}
},
}))
const mockSystemFeaturesState = vi.hoisted(() => ({
data: {
branding: {
enabled: false,
},
},
}))
const mockLangGeniusVersionState = vi.hoisted(() => ({
data: {
version: '1.0.1',
release_date: '',
release_notes: '',
can_auto_update: false,
} as {
version: string
release_date: string
release_notes: string
can_auto_update: boolean
} | undefined,
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
ZENDESK_FIELD_IDS: {
ENVIRONMENT: 'environment-field',
VERSION: 'version-field',
EMAIL: 'email-field',
WORKSPACE_ID: 'workspace-id-field',
},
}
})
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
invalidateQueries: mockInvalidateQueries,
}),
useSuspenseQuery: (options: { queryKey?: readonly unknown[] }) => {
if (options.queryKey?.[0] === 'system-features') {
return {
data: {
branding: {
enabled: false,
},
},
}
}
return {
data: {
profile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
},
}
},
useQuery: (options: { select?: (workspace: typeof mockCurrentWorkspaceResponse) => unknown }) => ({
data: options.select ? options.select(mockCurrentWorkspaceResponse) : mockCurrentWorkspaceResponse,
isFetching: false,
isPending: false,
}),
}))
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['system-features'],
queryFn: async () => mockSystemFeaturesState.data,
}),
}))
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({
queryKey: ['user-profile'],
queryFn: async () => mockUserProfileResponseState.data,
}),
}))
@ -116,16 +77,8 @@ vi.mock('@/service/client', () => ({
current: {
post: {
key: () => ['current-workspace'],
queryOptions: (options: {
select?: (workspace?: typeof mockCurrentWorkspaceResponse) => unknown
}) => ({
queryOptions: (options: Record<string, unknown>) => ({
queryKey: ['current-workspace'],
queryFn: async () => {
if (mockCurrentWorkspaceQueryState.isPending)
return new Promise(() => {})
return mockCurrentWorkspaceQueryState.data
},
...options,
}),
},
@ -134,9 +87,34 @@ vi.mock('@/service/client', () => ({
},
}))
vi.mock('@/service/base', () => ({
get: mockGetRequest,
post: vi.fn(),
vi.mock('@/service/access-control/use-permission-keys', () => ({
useWorkspacePermissionKeys: () => ({
data: {
workspace: {
permission_keys: mockPermissionKeysState.permissionKeys,
},
app: {
default_permission_keys: [],
overrides: [],
},
dataset: {
default_permission_keys: [],
overrides: [],
},
},
isPending: mockPermissionKeysState.isPending,
}),
}))
vi.mock('@/service/use-common', () => ({
useLangGeniusVersion: () => ({
data: {
version: '1.0.1',
release_date: '',
release_notes: '',
can_auto_update: false,
},
}),
}))
vi.mock('@/app/components/base/amplitude', () => ({
@ -167,303 +145,35 @@ function AppContextProbe() {
{selectedWorkspacePermissionKeys.join(',')}
</span>
<span>
permission loading:
loading:
{String(context.isLoadingWorkspacePermissionKeys)}
</span>
<span>
workspace loading:
{String(context.isLoadingCurrentWorkspace)}
</span>
<span>
workspace validating:
{String(context.isValidatingCurrentWorkspace)}
</span>
<span>
user:
{context.userProfile.email}
</span>
<span>
workspace:
{context.currentWorkspace.name}
</span>
<span>
role:
{context.currentWorkspace.role}
</span>
<span>
manager:
{String(context.isCurrentWorkspaceManager)}
</span>
<span>
owner:
{String(context.isCurrentWorkspaceOwner)}
</span>
<span>
editor:
{String(context.isCurrentWorkspaceEditor)}
</span>
<span>
dataset operator:
{String(context.isCurrentWorkspaceDatasetOperator)}
</span>
<span>
version:
{context.langGeniusVersionInfo.current_version}
/
{context.langGeniusVersionInfo.latest_version}
/
{context.langGeniusVersionInfo.current_env}
</span>
<button type="button" onClick={context.mutateUserProfile}>refresh user</button>
<button type="button" onClick={context.mutateCurrentWorkspace}>refresh workspace</button>
</>
)
}
function TestQueryClientHydrator({
children,
queryClient,
}: {
children: ReactNode
queryClient: QueryClient
}) {
useHydrateAtoms(new Map([[queryClientAtom, queryClient]]))
return children
}
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
},
},
})
}
function renderProvider() {
const queryClient = createTestQueryClient()
const view = render(
<JotaiProvider>
<QueryClientProvider client={queryClient}>
<TestQueryClientHydrator queryClient={queryClient}>
<Suspense fallback={<span>loading</span>}>
<AppContextProvider>
<AppContextProbe />
</AppContextProvider>
</Suspense>
</TestQueryClientHydrator>
</QueryClientProvider>
</JotaiProvider>,
)
return {
...view,
queryClient,
}
}
describe('AppContextProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPermissionKeysState.isPending = false
mockPermissionKeysState.permissionKeys = ['app.create_and_management']
mockCurrentWorkspaceQueryState.data = mockCurrentWorkspaceResponse
mockCurrentWorkspaceQueryState.isPending = false
mockUserProfileResponseState.data = {
profile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
}
mockSystemFeaturesState.data = {
branding: {
enabled: false,
},
}
mockLangGeniusVersionState.data = {
version: '1.0.1',
release_date: '',
release_notes: '',
can_auto_update: false,
}
mockGetRequest.mockImplementation((url: string) => {
if (url === '/workspaces/current/rbac/my-permissions') {
if (mockPermissionKeysState.isPending)
return new Promise(() => {})
return Promise.resolve({
workspace: {
permission_keys: mockPermissionKeysState.permissionKeys,
},
app: {
default_permission_keys: [],
overrides: [],
},
dataset: {
default_permission_keys: [],
overrides: [],
},
})
}
if (url === '/version')
return Promise.resolve(mockLangGeniusVersionState.data)
return Promise.reject(new Error(`Unexpected GET ${url}`))
})
})
describe('Context compatibility values', () => {
it('should provide profile, workspace, permissions, loading state, and version metadata', async () => {
renderProvider()
describe('Workspace Permission Keys', () => {
it('should provide current workspace permission keys from my-permissions', () => {
render(
<AppContextProvider>
<AppContextProbe />
</AppContextProvider>,
)
expect(await screen.findByText('user:user@example.com')).toBeInTheDocument()
expect(await screen.findByText('workspace:Workspace')).toBeInTheDocument()
expect(await screen.findByText('keys:app.create_and_management')).toBeInTheDocument()
expect(screen.getByText('permission loading:false')).toBeInTheDocument()
expect(screen.getByText('workspace loading:false')).toBeInTheDocument()
expect(screen.getByText('workspace validating:false')).toBeInTheDocument()
expect(await screen.findByText('version:1.0.0/1.0.1/cloud')).toBeInTheDocument()
})
it('should fall back to placeholder values when profile, workspace, permission, or version data is missing', async () => {
mockUserProfileResponseState.data = {
meta: {
currentVersion: null,
currentEnv: null,
},
}
mockCurrentWorkspaceQueryState.data = undefined
mockPermissionKeysState.permissionKeys = []
mockLangGeniusVersionState.data = undefined
renderProvider()
expect(await screen.findByText('user:')).toBeInTheDocument()
expect(screen.getByText(`workspace:${initialWorkspaceInfo.name}`)).toBeInTheDocument()
expect(screen.getByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
expect(screen.getByText('keys:')).toBeInTheDocument()
expect(screen.getByText('version://')).toBeInTheDocument()
})
it('should normalize invalid workspace roles to the initial workspace role', async () => {
mockCurrentWorkspaceQueryState.data = {
...mockCurrentWorkspaceResponse,
role: 'unsupported-role',
}
renderProvider()
expect(await screen.findByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
})
it('should derive role flags from the current workspace role', async () => {
mockCurrentWorkspaceQueryState.data = {
...mockCurrentWorkspaceResponse,
role: 'owner',
}
renderProvider()
expect(await screen.findByText('manager:true')).toBeInTheDocument()
expect(screen.getByText('owner:true')).toBeInTheDocument()
expect(screen.getByText('editor:true')).toBeInTheDocument()
expect(screen.getByText('dataset operator:false')).toBeInTheDocument()
})
it('should expose query loading and validating state', async () => {
mockPermissionKeysState.isPending = true
mockCurrentWorkspaceQueryState.isPending = true
renderProvider()
expect(await screen.findByText('workspace loading:true')).toBeInTheDocument()
expect(screen.getByText('workspace validating:true')).toBeInTheDocument()
expect(screen.getByText('permission loading:true')).toBeInTheDocument()
})
})
describe('Refresh actions', () => {
it('should invalidate the source queries when refresh actions are called', async () => {
const { queryClient } = renderProvider()
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
fireEvent.click(await screen.findByRole('button', { name: /refresh user/i }))
fireEvent.click(screen.getByRole('button', { name: /refresh workspace/i }))
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['user-profile'] })
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['current-workspace'] })
})
})
describe('External side effects', () => {
it('should sync Zendesk fields and Amplitude identity when bootstrap data is available', async () => {
renderProvider()
await waitFor(() => {
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: 'cloud',
}])
})
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.VERSION,
value: '1.0.1',
}])
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.EMAIL,
value: 'user@example.com',
}])
await waitFor(() => {
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: 'workspace-1',
}])
})
await waitFor(() => {
expect(setUserId).toHaveBeenCalledWith('user@example.com')
expect(setUserProperties).toHaveBeenCalledWith(expect.objectContaining({
email: 'user@example.com',
workspace_id: 'workspace-1',
workspace_role: 'editor',
}))
expect(flushRegistrationSuccess).toHaveBeenCalled()
})
})
it('should not sync Amplitude identity when user id is missing', async () => {
mockUserProfileResponseState.data = {
profile: {
id: '',
name: '',
email: '',
avatar: '',
avatar_url: '',
is_password_set: false,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
}
renderProvider()
await screen.findByText('user:')
expect(setUserId).not.toHaveBeenCalled()
expect(setUserProperties).not.toHaveBeenCalled()
expect(flushRegistrationSuccess).not.toHaveBeenCalled()
expect(screen.getByText('keys:app.create_and_management')).toBeInTheDocument()
expect(screen.getByText('loading:false')).toBeInTheDocument()
expect(screen.getByText('role:editor')).toBeInTheDocument()
})
})
})

View File

@ -1,82 +0,0 @@
'use client'
import { useAtomValue } from 'jotai'
import { useEffect } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import {
currentWorkspaceAtom,
langGeniusVersionInfoAtom,
userProfileAtom,
} from './app-context-state'
export function useSyncZendeskFields() {
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
useEffect(() => {
if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: langGeniusVersionInfo.current_env.toLowerCase(),
}])
}
}, [langGeniusVersionInfo?.current_env])
useEffect(() => {
if (ZENDESK_FIELD_IDS.VERSION && langGeniusVersionInfo?.version) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.VERSION,
value: langGeniusVersionInfo.version,
}])
}
}, [langGeniusVersionInfo?.version])
useEffect(() => {
if (ZENDESK_FIELD_IDS.EMAIL && userProfile?.email) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.EMAIL,
value: userProfile.email,
}])
}
}, [userProfile?.email])
useEffect(() => {
if (ZENDESK_FIELD_IDS.WORKSPACE_ID && currentWorkspace?.id) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: currentWorkspace.id,
}])
}
}, [currentWorkspace?.id])
}
export function useSyncAmplitudeIdentity() {
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
useEffect(() => {
if (userProfile?.id) {
setUserId(userProfile.email)
const properties: Record<string, string | number | boolean> = {
email: userProfile.email,
name: userProfile.name,
has_password: userProfile.is_password_set,
}
if (currentWorkspace?.id) {
properties.workspace_id = currentWorkspace.id
properties.workspace_name = currentWorkspace.name
properties.workspace_plan = currentWorkspace.plan
properties.workspace_status = currentWorkspace.status
properties.workspace_role = currentWorkspace.role
}
setUserProperties(properties)
flushRegistrationSuccess()
}
}, [userProfile, currentWorkspace])
}

View File

@ -1,78 +0,0 @@
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common'
import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from './app-context'
const workspaceRoles = new Set<ICurrentWorkspace['role']>(['owner', 'admin', 'editor', 'dataset_operator', 'normal'])
export const emptyWorkspacePermissionKeys: string[] = []
export type WorkspaceRoleFlags = {
isCurrentWorkspaceManager: boolean
isCurrentWorkspaceOwner: boolean
isCurrentWorkspaceEditor: boolean
isCurrentWorkspaceDatasetOperator: boolean
}
export type ProfileMeta = {
currentVersion: string | null
currentEnv: string | null
}
function resolveWorkspaceRole(role: PostWorkspacesCurrentResponse['role']): ICurrentWorkspace['role'] {
if (role && workspaceRoles.has(role as ICurrentWorkspace['role']))
return role as ICurrentWorkspace['role']
return initialWorkspaceInfo.role
}
export function normalizeCurrentWorkspace(workspace?: PostWorkspacesCurrentResponse): ICurrentWorkspace {
if (!workspace)
return initialWorkspaceInfo
return {
id: workspace.id,
name: workspace.name ?? initialWorkspaceInfo.name,
plan: workspace.plan ?? initialWorkspaceInfo.plan,
status: workspace.status ?? initialWorkspaceInfo.status,
created_at: workspace.created_at ?? initialWorkspaceInfo.created_at,
role: resolveWorkspaceRole(workspace.role),
providers: initialWorkspaceInfo.providers,
trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits,
trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used,
next_credit_reset_date: workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date,
trial_end_reason: workspace.trial_end_reason ?? undefined,
custom_config: workspace.custom_config
? {
remove_webapp_brand: workspace.custom_config.remove_webapp_brand ?? undefined,
replace_webapp_logo: workspace.custom_config.replace_webapp_logo ?? undefined,
}
: undefined,
}
}
export function getWorkspaceRoleFlags(currentWorkspace: ICurrentWorkspace): WorkspaceRoleFlags {
return {
isCurrentWorkspaceManager: ['owner', 'admin'].includes(currentWorkspace.role),
isCurrentWorkspaceOwner: currentWorkspace.role === 'owner',
isCurrentWorkspaceEditor: ['owner', 'admin', 'editor'].includes(currentWorkspace.role),
isCurrentWorkspaceDatasetOperator: currentWorkspace.role === 'dataset_operator',
}
}
export function getLangGeniusVersionInfo({
meta,
versionData,
}: {
meta: ProfileMeta
versionData?: Omit<LangGeniusVersionResponse, 'current_version' | 'latest_version' | 'current_env'>
}): LangGeniusVersionResponse {
if (!meta.currentVersion || !versionData)
return initialLangGeniusVersionInfo
return {
...versionData,
current_version: meta.currentVersion,
latest_version: versionData.version,
current_env: meta.currentEnv || '',
}
}

View File

@ -1,68 +1,203 @@
'use client'
import type { ReactNode } from 'react'
import { useAtomValue, useSetAtom } from 'jotai'
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { FC, ReactNode } from 'react'
import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common'
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import { ZENDESK_FIELD_IDS } from '@/config'
import {
AppContext,
initialLangGeniusVersionInfo,
initialWorkspaceInfo,
userProfilePlaceholder,
useSelector,
} from '@/context/app-context'
import { env } from '@/env'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useWorkspacePermissionKeys } from '@/service/access-control/use-permission-keys'
import { consoleQuery } from '@/service/client'
import {
currentWorkspaceAtom,
currentWorkspaceLoadingAtom,
currentWorkspaceValidatingAtom,
langGeniusVersionInfoAtom,
refreshCurrentWorkspaceAtom,
refreshUserProfileAtom,
userProfileAtom,
workspacePermissionKeysAtom,
workspacePermissionKeysLoadingAtom,
workspaceRoleFlagsAtom,
} from '@/context/app-context-state'
import {
useSyncAmplitudeIdentity,
useSyncZendeskFields,
} from './app-context-effects'
useLangGeniusVersion,
} from '@/service/use-common'
type AppContextProviderProps = {
children: ReactNode
}
export function AppContextProvider({ children }: AppContextProviderProps) {
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const roleFlags = useAtomValue(workspaceRoleFlagsAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
const isValidatingCurrentWorkspace = useAtomValue(currentWorkspaceValidatingAtom)
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
const workspaceRoles = new Set<ICurrentWorkspace['role']>(['owner', 'admin', 'editor', 'dataset_operator', 'normal'])
const emptyWorkspacePermissionKeys: string[] = []
const refreshUserProfile = useSetAtom(refreshUserProfileAtom)
const refreshCurrentWorkspace = useSetAtom(refreshCurrentWorkspaceAtom)
const resolveWorkspaceRole = (role: PostWorkspacesCurrentResponse['role']): ICurrentWorkspace['role'] => {
if (role && workspaceRoles.has(role as ICurrentWorkspace['role']))
return role as ICurrentWorkspace['role']
useSyncZendeskFields()
useSyncAmplitudeIdentity()
return initialWorkspaceInfo.role
}
const normalizeCurrentWorkspace = (workspace?: PostWorkspacesCurrentResponse): ICurrentWorkspace => {
if (!workspace)
return initialWorkspaceInfo
return {
id: workspace.id,
name: workspace.name ?? initialWorkspaceInfo.name,
plan: workspace.plan ?? initialWorkspaceInfo.plan,
status: workspace.status ?? initialWorkspaceInfo.status,
created_at: workspace.created_at ?? initialWorkspaceInfo.created_at,
role: resolveWorkspaceRole(workspace.role),
providers: initialWorkspaceInfo.providers,
trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits,
trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used,
next_credit_reset_date: workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date,
trial_end_reason: workspace.trial_end_reason ?? undefined,
custom_config: workspace.custom_config
? {
remove_webapp_brand: workspace.custom_config.remove_webapp_brand ?? undefined,
replace_webapp_logo: workspace.custom_config.replace_webapp_logo ?? undefined,
}
: undefined,
}
}
export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) => {
const queryClient = useQueryClient()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const currentWorkspaceQuery = useQuery(consoleQuery.workspaces.current.post.queryOptions({
select: normalizeCurrentWorkspace,
}))
const workspacePermissionKeysQuery = useWorkspacePermissionKeys()
const langGeniusVersionQuery = useLangGeniusVersion(
userProfileResp?.meta.currentVersion,
!systemFeatures.branding.enabled,
)
const userProfile = useMemo<GetAccountProfileResponse>(() => userProfileResp?.profile || userProfilePlaceholder, [userProfileResp?.profile])
const currentWorkspace = currentWorkspaceQuery.data ?? initialWorkspaceInfo
const langGeniusVersionInfo = useMemo<LangGeniusVersionResponse>(() => {
if (!userProfileResp?.meta?.currentVersion || !langGeniusVersionQuery.data)
return initialLangGeniusVersionInfo
const current_version = userProfileResp.meta.currentVersion
const current_env = userProfileResp.meta.currentEnv || ''
const versionData = langGeniusVersionQuery.data
return {
...versionData,
current_version,
latest_version: versionData.version,
current_env,
}
}, [langGeniusVersionQuery.data, userProfileResp?.meta])
const isCurrentWorkspaceManager = useMemo(() => ['owner', 'admin'].includes(currentWorkspace.role), [currentWorkspace.role])
const isCurrentWorkspaceOwner = useMemo(() => currentWorkspace.role === 'owner', [currentWorkspace.role])
const isCurrentWorkspaceEditor = useMemo(() => ['owner', 'admin', 'editor'].includes(currentWorkspace.role), [currentWorkspace.role])
const isCurrentWorkspaceDatasetOperator = useMemo(() => currentWorkspace.role === 'dataset_operator', [currentWorkspace.role])
const mutateUserProfile = useCallback(() => {
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
}, [queryClient])
const mutateCurrentWorkspace = useCallback(() => {
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() })
}, [queryClient])
// #region Zendesk conversation fields
useEffect(() => {
if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: langGeniusVersionInfo.current_env.toLowerCase(),
}])
}
}, [langGeniusVersionInfo?.current_env])
useEffect(() => {
if (ZENDESK_FIELD_IDS.VERSION && langGeniusVersionInfo?.version) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.VERSION,
value: langGeniusVersionInfo.version,
}])
}
}, [langGeniusVersionInfo?.version])
useEffect(() => {
if (ZENDESK_FIELD_IDS.EMAIL && userProfile?.email) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.EMAIL,
value: userProfile.email,
}])
}
}, [userProfile?.email])
useEffect(() => {
if (ZENDESK_FIELD_IDS.WORKSPACE_ID && currentWorkspace?.id) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: currentWorkspace.id,
}])
}
}, [currentWorkspace?.id])
// #endregion Zendesk conversation fields
useEffect(() => {
// Report user and workspace info to Amplitude when loaded
if (userProfile?.id) {
setUserId(userProfile.email)
const properties: Record<string, string | number | boolean> = {
email: userProfile.email,
name: userProfile.name,
has_password: userProfile.is_password_set,
}
if (currentWorkspace?.id) {
properties.workspace_id = currentWorkspace.id
properties.workspace_name = currentWorkspace.name
properties.workspace_plan = currentWorkspace.plan
properties.workspace_status = currentWorkspace.status
properties.workspace_role = currentWorkspace.role
}
setUserProperties(properties)
// The user ID is now attached, so replay any registration success event captured
// at signup time. This makes it land on the identified Amplitude profile instead
// of an anonymous one (no-op when nothing was deferred).
flushRegistrationSuccess()
}
}, [userProfile, currentWorkspace])
return (
<AppContext.Provider value={{
userProfile,
mutateUserProfile: () => {
refreshUserProfile()
},
mutateUserProfile,
langGeniusVersionInfo,
useSelector,
currentWorkspace,
...roleFlags,
mutateCurrentWorkspace: () => {
refreshCurrentWorkspace()
},
isLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys,
isValidatingCurrentWorkspace,
workspacePermissionKeys,
isCurrentWorkspaceManager,
isCurrentWorkspaceOwner,
isCurrentWorkspaceEditor,
isCurrentWorkspaceDatasetOperator,
mutateCurrentWorkspace,
isLoadingCurrentWorkspace: currentWorkspaceQuery.isPending,
isLoadingWorkspacePermissionKeys: workspacePermissionKeysQuery.isPending,
isValidatingCurrentWorkspace: currentWorkspaceQuery.isFetching,
workspacePermissionKeys: workspacePermissionKeysQuery.data?.workspace.permission_keys ?? emptyWorkspacePermissionKeys,
}}
>
{children}
<div className="flex h-full flex-col overflow-hidden">
{env.NEXT_PUBLIC_MAINTENANCE_NOTICE && <MaintenanceNotice />}
<div className="relative flex h-0 min-h-0 grow flex-col overflow-hidden bg-background-body">
{children}
</div>
</div>
</AppContext.Provider>
)
}

View File

@ -1,116 +0,0 @@
'use client'
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
import type { DefinedQueryObserverResult } from '@tanstack/react-query'
import type { UserProfileWithMeta } from '@/features/account-profile/client'
import { atom } from 'jotai'
import { atomWithQuery, atomWithSuspenseQuery, queryClientAtom } from 'jotai-tanstack-query'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { workspacePermissionKeysQueryOptions } from '@/service/access-control/use-permission-keys'
import { consoleQuery } from '@/service/client'
import { langGeniusVersionQueryOptions } from '@/service/lang-genius-version'
import {
initialLangGeniusVersionInfo,
initialWorkspaceInfo,
userProfilePlaceholder,
} from './app-context'
import {
emptyWorkspacePermissionKeys,
getLangGeniusVersionInfo,
getWorkspaceRoleFlags,
normalizeCurrentWorkspace,
} from './app-context-normalizers'
type SuspenseQueryResult<T> = Omit<DefinedQueryObserverResult<T>, 'isPlaceholderData'>
const accountProfileQueryAtom = atomWithSuspenseQuery(() => userProfileQueryOptions())
const systemFeaturesQueryAtom = atomWithSuspenseQuery(() => systemFeaturesQueryOptions())
export const userProfileAtom = atom((get): GetAccountProfileResponse => {
const accountProfileQuery = get(accountProfileQueryAtom) as SuspenseQueryResult<UserProfileWithMeta>
return accountProfileQuery.data?.profile || userProfilePlaceholder
})
const profileMetaAtom = atom((get) => {
const accountProfileQuery = get(accountProfileQueryAtom) as SuspenseQueryResult<UserProfileWithMeta>
return accountProfileQuery.data?.meta ?? {
currentVersion: null,
currentEnv: null,
}
})
const currentWorkspaceQueryAtom = atomWithQuery(() => {
return consoleQuery.workspaces.current.post.queryOptions({
select: normalizeCurrentWorkspace,
})
})
const normalizedCurrentWorkspaceAtom = atom((get) => {
return get(currentWorkspaceQueryAtom).data ?? initialWorkspaceInfo
})
export const currentWorkspaceAtom = atom((get) => {
return get(normalizedCurrentWorkspaceAtom)
})
export const workspaceRoleFlagsAtom = atom((get) => {
return getWorkspaceRoleFlags(get(currentWorkspaceAtom))
})
const workspacePermissionKeysQueryAtom = atomWithQuery((get) => {
const workspaceId = get(currentWorkspaceAtom).id
return workspacePermissionKeysQueryOptions(workspaceId)
})
export const workspacePermissionKeysAtom = atom((get) => {
return get(workspacePermissionKeysQueryAtom).data?.workspace.permission_keys ?? emptyWorkspacePermissionKeys
})
export const workspacePermissionKeysLoadingAtom = atom((get) => {
return get(workspacePermissionKeysQueryAtom).isPending
})
export const currentWorkspaceLoadingAtom = atom((get) => {
return get(currentWorkspaceQueryAtom).isPending
})
export const currentWorkspaceValidatingAtom = atom((get) => {
return get(currentWorkspaceQueryAtom).isFetching
})
const versionQueryAtom = atomWithQuery((get) => {
const meta = get(profileMetaAtom)
const systemFeaturesQuery = get(systemFeaturesQueryAtom) as SuspenseQueryResult<GetSystemFeaturesResponse>
const enabled = Boolean(meta.currentVersion && !systemFeaturesQuery.data?.branding.enabled)
return langGeniusVersionQueryOptions(meta.currentVersion, enabled)
})
export const langGeniusVersionInfoAtom = atom((get) => {
const meta = get(profileMetaAtom)
const versionData = get(versionQueryAtom).data
if (!versionData)
return initialLangGeniusVersionInfo
return getLangGeniusVersionInfo({
meta,
versionData,
})
})
export const refreshUserProfileAtom = atom(null, (get) => {
const queryClient = get(queryClientAtom)
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
})
export const refreshCurrentWorkspaceAtom = atom(null, (get) => {
const queryClient = get(queryClientAtom)
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() })
})

View File

@ -4,7 +4,6 @@ import type { Getter } from 'jotai/vanilla'
import { keepPreviousData, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { encodeDslContent } from '@/features/deployments/shared/domain/dsl'
import { consoleQuery } from '@/service/client'
import { effectiveMethodAtom, instanceNameAtom, submissionUnsupportedDslNodesAtom } from './primitives'
@ -54,11 +53,6 @@ export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => {
})
})
export const deployableEnvironmentsDataAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.data)
export const deployableEnvironmentsIsErrorAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.isError)
export const deployableEnvironmentsIsLoadingAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.isLoading)
export const deployableEnvironmentsIsFetchingAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.isFetching)
const precheckReleaseQueryAtom = atomWithQuery((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
@ -94,22 +88,17 @@ const precheckReleaseQueryAtom = atomWithQuery((get) => {
return precheckReleaseQueryOptions
})
const precheckReleaseDataAtom = selectAtom(precheckReleaseQueryAtom, query => query.data)
const precheckReleaseIsSuccessAtom = selectAtom(precheckReleaseQueryAtom, query => query.isSuccess)
const precheckReleaseIsLoadingAtom = selectAtom(precheckReleaseQueryAtom, query => query.isLoading)
const precheckReleaseIsFetchingAtom = selectAtom(precheckReleaseQueryAtom, query => query.isFetching)
function precheckReleaseReady(get: Getter) {
const precheckRelease = get(precheckReleaseDataAtom)
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
return sourceReady(get)
&& get(precheckReleaseIsSuccessAtom)
&& Boolean(precheckRelease?.canCreate)
&& (precheckRelease?.unsupportedNodes.length ?? 0) === 0
&& precheckReleaseQuery.isSuccess
&& Boolean(precheckReleaseQuery.data?.canCreate)
&& (precheckReleaseQuery.data?.unsupportedNodes.length ?? 0) === 0
&& get(submissionUnsupportedDslNodesAtom).length === 0
}
const deploymentOptionsQueryAtom = atomWithQuery((get) => {
export const deploymentOptionsQueryAtom = atomWithQuery((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const dslContent = get(dslContentAtom)
@ -144,12 +133,6 @@ const deploymentOptionsQueryAtom = atomWithQuery((get) => {
return deploymentOptionsQueryOptions
})
export const deploymentOptionsDataAtom = selectAtom(deploymentOptionsQueryAtom, query => query.data)
export const deploymentOptionsIsErrorAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isError)
export const deploymentOptionsIsLoadingAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isLoading)
export const deploymentOptionsIsFetchingAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isFetching)
const deploymentOptionsIsSuccessAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isSuccess)
export const unsupportedDslNodesAtom = atom((get) => {
const submissionUnsupportedDslNodes = get(submissionUnsupportedDslNodesAtom)
if (submissionUnsupportedDslNodes.length > 0)
@ -158,7 +141,7 @@ export const unsupportedDslNodesAtom = atom((get) => {
if (!sourceReady(get))
return []
return get(precheckReleaseDataAtom)?.unsupportedNodes ?? []
return get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? []
})
const precheckReleaseReadyAtom = atom((get) => {
@ -166,17 +149,21 @@ const precheckReleaseReadyAtom = atom((get) => {
})
export const deploymentOptionsReadyAtom = atom((get) => {
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
return sourceReady(get)
&& get(precheckReleaseReadyAtom)
&& get(deploymentOptionsIsSuccessAtom)
&& deploymentOptionsQuery.isSuccess
})
export const deploymentOptionsContentCheckedAtom = atom((get) => {
const isLoadingOptions = get(deploymentOptionsIsLoadingAtom) || (get(deploymentOptionsIsFetchingAtom) && !get(deploymentOptionsDataAtom))
const isCheckingReleaseContent = get(precheckReleaseIsLoadingAtom) || (get(precheckReleaseIsFetchingAtom) && !get(precheckReleaseDataAtom))
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
const isLoadingOptions = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
const isCheckingReleaseContent = precheckReleaseQuery.isLoading || (precheckReleaseQuery.isFetching && !precheckReleaseQuery.data)
if (!sourceReady(get) || isCheckingReleaseContent || isLoadingOptions)
return false
return get(precheckReleaseReadyAtom) && get(deploymentOptionsIsSuccessAtom)
return get(precheckReleaseReadyAtom) && deploymentOptionsQuery.isSuccess
})

View File

@ -5,7 +5,6 @@ import type { WorkflowSourceApp } from './types'
import { keepPreviousData, queryOptions } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { dslAppName, isWorkflowDsl } from '@/features/deployments/shared/domain/dsl'
import { consoleQuery } from '@/service/client'
import { normalizeAppPagination } from '@/service/use-apps'
@ -93,28 +92,18 @@ export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
})
})
const sourceAppsDataAtom = selectAtom(sourceAppsQueryAtom, query => query.data)
export const sourceAppsErrorAtom = selectAtom(sourceAppsQueryAtom, query => query.error)
export const sourceAppsFetchNextPageAtom = selectAtom(sourceAppsQueryAtom, query => query.fetchNextPage)
export const sourceAppsHasNextPageAtom = selectAtom(sourceAppsQueryAtom, query => query.hasNextPage)
export const sourceAppsIsFetchingAtom = selectAtom(sourceAppsQueryAtom, query => query.isFetching)
export const sourceAppsIsFetchingNextPageAtom = selectAtom(sourceAppsQueryAtom, query => query.isFetchingNextPage)
export const sourceAppsIsLoadingAtom = selectAtom(sourceAppsQueryAtom, query => query.isLoading)
export const sourceAppsIsPlaceholderDataAtom = selectAtom(sourceAppsQueryAtom, query => query.isPlaceholderData)
export const sourceAppsAtom = atom((get) => {
return (get(sourceAppsDataAtom)?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
})
export const effectiveSelectedAppAtom = atom((get) => {
const selectedApp = get(selectedAppAtom)
if (selectedApp)
return selectedApp
if (get(sourceAppsIsPlaceholderDataAtom))
const sourceAppsQuery = get(sourceAppsQueryAtom)
if (sourceAppsQuery.isPlaceholderData)
return undefined
return get(sourceAppsAtom)[0]
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
return sourceApps[0]
})
export function sourceReady(get: Getter) {

View File

@ -22,7 +22,7 @@ import {
selectedEnvironmentIdAtom,
submissionUnsupportedDslNodesAtom,
} from './primitives'
import { deployableEnvironmentsQueryAtom, deploymentOptionsDataAtom } from './queries'
import { deployableEnvironmentsQueryAtom, deploymentOptionsQueryAtom } from './queries'
import { submittedReleaseReadyAtom } from './release'
import { dslContentAtom, effectiveSelectedAppAtom } from './source'
import {
@ -75,7 +75,7 @@ export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, {
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
const deploymentOptions = get(deploymentOptionsDataAtom)?.options
const deploymentOptions = get(deploymentOptionsQueryAtom).data?.options
const envVarSlots = get(deploymentTargetEnvVarSlotsAtom)
const envVarValues = get(envVarValuesAtom)
const bindingSlots = get(deploymentTargetBindingSlotsAtom)

View File

@ -11,21 +11,23 @@ import {
import { dslEnvVarSlots } from '@/features/deployments/shared/domain/dsl'
import { environmentMatchesIdentifier } from './environment'
import { effectiveMethodAtom, envVarValuesAtom, manualBindingSelectionsAtom, selectedEnvironmentIdAtom } from './primitives'
import { deployableEnvironmentsDataAtom, deploymentOptionsDataAtom, deploymentOptionsReadyAtom } from './queries'
import { deployableEnvironmentsQueryAtom, deploymentOptionsQueryAtom, deploymentOptionsReadyAtom } from './queries'
import { submittedReleaseReadyAtom } from './release'
import { dslContentAtom, sourceReady } from './source'
import { envVarSelectionReady } from './utils'
export const deployableEnvironmentsAtom = atom((get) => {
const deployableEnvironments = get(deployableEnvironmentsDataAtom)
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
return sourceReady(get)
? deployableEnvironments?.environments ?? []
? deployableEnvironmentsQuery.data?.environments ?? []
: []
})
const deployableEnvironmentsReadyAtom = atom((get) => {
return sourceReady(get) && Boolean(get(deployableEnvironmentsDataAtom))
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
return sourceReady(get) && deployableEnvironmentsQuery.isSuccess
})
export const effectiveSelectedEnvironmentIdAtom = atom((get) => {
@ -33,10 +35,10 @@ export const effectiveSelectedEnvironmentIdAtom = atom((get) => {
})
export const deploymentTargetBindingSlotsAtom = atom((get) => {
const deploymentOptions = get(deploymentOptionsDataAtom)
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
return sourceReady(get)
? deploymentOptions?.options?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
? deploymentOptionsQuery.data?.options?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
: []
})
@ -57,8 +59,8 @@ export const requiredBindingsReadyAtom = atom((get) => {
export const deploymentTargetEnvVarSlotsAtom = atom((get) => {
const method = get(effectiveMethodAtom)
const deploymentOptions = get(deploymentOptionsDataAtom)
const slots = sourceReady(get) ? deploymentOptions?.options?.envVarSlots : undefined
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
const slots = sourceReady(get) ? deploymentOptionsQuery.data?.options?.envVarSlots : undefined
const dslContent = get(dslContentAtom)
// Deployment options own the canonical slot list; DSL metadata only enriches import-DSL defaults.

View File

@ -5,13 +5,12 @@ import { SourceStepContent } from '../source-step'
const mocks = vi.hoisted(() => {
const sourceAppsQuery = {
data: { pages: [{ data: [] }] },
error: null,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
fetchNextPage: vi.fn(),
}
return {
@ -47,14 +46,6 @@ vi.mock('@/features/deployments/create-guide/state/source', async () => {
dslUnsupportedModeAtom: atom(false),
effectiveSelectedAppAtom: atom(undefined),
isReadingDslAtom: atom(false),
sourceAppsAtom: atom(() => mocks.sourceAppsQuery.data.pages.flatMap(page => page.data)),
sourceAppsErrorAtom: atom(() => mocks.sourceAppsQuery.error),
sourceAppsFetchNextPageAtom: atom(() => mocks.sourceAppsQuery.fetchNextPage),
sourceAppsHasNextPageAtom: atom(() => mocks.sourceAppsQuery.hasNextPage),
sourceAppsIsFetchingAtom: atom(() => mocks.sourceAppsQuery.isFetching),
sourceAppsIsFetchingNextPageAtom: atom(() => mocks.sourceAppsQuery.isFetchingNextPage),
sourceAppsIsLoadingAtom: atom(() => mocks.sourceAppsQuery.isLoading),
sourceAppsIsPlaceholderDataAtom: atom(() => mocks.sourceAppsQuery.isPlaceholderData),
sourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
}
})
@ -89,13 +80,12 @@ describe('SourceStepContent', () => {
vi.clearAllMocks()
Object.assign(mocks.sourceAppsQuery, {
data: { pages: [{ data: [] }] },
error: null,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
fetchNextPage: vi.fn(),
})
})
@ -124,13 +114,7 @@ describe('SourceStepContent', () => {
render(<SourceStepContent />)
expect(mocks.useInfiniteScroll).toHaveBeenCalledWith(
expect.objectContaining({
fetchNextPage: expect.any(Function),
hasNextPage: expect.any(Boolean),
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
}),
mocks.sourceAppsQuery,
expect.objectContaining({
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,

View File

@ -21,14 +21,7 @@ import {
dslUnsupportedModeAtom,
effectiveSelectedAppAtom,
isReadingDslAtom,
sourceAppsAtom,
sourceAppsErrorAtom,
sourceAppsFetchNextPageAtom,
sourceAppsHasNextPageAtom,
sourceAppsIsFetchingAtom,
sourceAppsIsFetchingNextPageAtom,
sourceAppsIsLoadingAtom,
sourceAppsIsPlaceholderDataAtom,
sourceAppsQueryAtom,
} from '@/features/deployments/create-guide/state/source'
import {
continueFromSourceAtom,
@ -196,23 +189,10 @@ function SourceAppList() {
const { t } = useTranslation('deployments')
const selectSourceApp = useSetAtom(selectSourceAppAtom)
const effectiveSelectedApp = useAtomValue(effectiveSelectedAppAtom)
const sourceApps = useAtomValue(sourceAppsAtom)
const sourceAppsError = useAtomValue(sourceAppsErrorAtom)
const sourceAppsFetchNextPage = useAtomValue(sourceAppsFetchNextPageAtom)
const sourceAppsHasNextPage = useAtomValue(sourceAppsHasNextPageAtom)
const sourceAppsIsFetching = useAtomValue(sourceAppsIsFetchingAtom)
const sourceAppsIsFetchingNextPage = useAtomValue(sourceAppsIsFetchingNextPageAtom)
const sourceAppsIsLoading = useAtomValue(sourceAppsIsLoadingAtom)
const sourceAppsIsPlaceholderData = useAtomValue(sourceAppsIsPlaceholderDataAtom)
const sourceAppsLoading = sourceAppsIsLoading || sourceAppsIsPlaceholderData || (sourceAppsIsFetching && sourceApps.length === 0)
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>({
error: sourceAppsError,
fetchNextPage: sourceAppsFetchNextPage,
hasNextPage: sourceAppsHasNextPage,
isFetching: sourceAppsIsFetching,
isFetchingNextPage: sourceAppsIsFetchingNextPage,
isLoading: sourceAppsIsLoading,
}, {
const sourceAppsQuery = useAtomValue(sourceAppsQueryAtom)
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
const sourceAppsLoading = sourceAppsQuery.isLoading || sourceAppsQuery.isPlaceholderData || (sourceAppsQuery.isFetching && sourceApps.length === 0)
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>(sourceAppsQuery, {
enabled: !sourceAppsLoading,
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,
@ -238,12 +218,12 @@ function SourceAppList() {
onSelect={() => selectSourceApp(app)}
/>
))}
{sourceAppsIsFetchingNextPage && (
{sourceAppsQuery.isFetchingNextPage && (
<div className="border-t border-divider-subtle px-3 py-2 text-center system-xs-regular text-text-tertiary">
{t('createModal.loadingApps')}
</div>
)}
{sourceAppsHasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />}
{sourceAppsQuery.hasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />}
</div>
)}
</div>

View File

@ -16,13 +16,8 @@ import {
stepAtom,
} from '@/features/deployments/create-guide/state/primitives'
import {
deployableEnvironmentsIsErrorAtom,
deployableEnvironmentsIsFetchingAtom,
deployableEnvironmentsIsLoadingAtom,
deploymentOptionsDataAtom,
deploymentOptionsIsErrorAtom,
deploymentOptionsIsFetchingAtom,
deploymentOptionsIsLoadingAtom,
deployableEnvironmentsQueryAtom,
deploymentOptionsQueryAtom,
unsupportedDslNodesAtom,
} from '@/features/deployments/create-guide/state/queries'
import {
@ -77,12 +72,11 @@ export function TargetStepContent() {
function TargetEnvironmentSection() {
const { t } = useTranslation('deployments')
const environmentsIsError = useAtomValue(deployableEnvironmentsIsErrorAtom)
const environmentsIsFetching = useAtomValue(deployableEnvironmentsIsFetchingAtom)
const environmentsIsLoading = useAtomValue(deployableEnvironmentsIsLoadingAtom)
const environmentsQuery = useAtomValue(deployableEnvironmentsQueryAtom)
const environments = useAtomValue(deployableEnvironmentsAtom)
const effectiveSelectedEnvironmentId = useAtomValue(effectiveSelectedEnvironmentIdAtom)
const isEnvironmentLoading = environmentsIsLoading || (environmentsIsFetching && environments.length === 0)
const isEnvironmentError = environmentsQuery.isError
const isEnvironmentLoading = environmentsQuery.isLoading || (environmentsQuery.isFetching && !environmentsQuery.data)
const selectEnvironment = useSetAtom(selectedEnvironmentIdAtom)
const hasEnvironmentOptions = environments.length > 0
@ -108,7 +102,7 @@ function TargetEnvironmentSection() {
? <TargetEnvironmentSkeleton />
: (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{environmentsIsError
{isEnvironmentError
? t('createGuide.target.loadEnvironmentsFailed')
: t('createGuide.target.noEnvironmentOptions')}
</div>
@ -150,14 +144,11 @@ function EnvironmentOptionRow({ environment }: {
function TargetBindingSection() {
const { t } = useTranslation('deployments')
const deploymentOptions = useAtomValue(deploymentOptionsDataAtom)
const deploymentOptionsIsError = useAtomValue(deploymentOptionsIsErrorAtom)
const deploymentOptionsIsFetching = useAtomValue(deploymentOptionsIsFetchingAtom)
const deploymentOptionsIsLoading = useAtomValue(deploymentOptionsIsLoadingAtom)
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
const bindingSlots = useAtomValue(deploymentTargetBindingSlotsAtom)
const bindingSelections = useAtomValue(deploymentTargetBindingSelectionsAtom)
const isBindingError = deploymentOptionsIsError
const isBindingLoading = deploymentOptionsIsLoading || (deploymentOptionsIsFetching && !deploymentOptions)
const isBindingError = deploymentOptionsQuery.isError
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
const selectBinding = useSetAtom(selectBindingAtom)
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
const shouldRender = !(isBindingError && unsupportedDslNodes.length > 0)
@ -205,13 +196,10 @@ function TargetEnvVarSection() {
const { t } = useTranslation('deployments')
const setEnvVar = useSetAtom(setEnvVarAtom)
const envVarValues = useAtomValue(envVarValuesAtom)
const deploymentOptions = useAtomValue(deploymentOptionsDataAtom)
const deploymentOptionsIsError = useAtomValue(deploymentOptionsIsErrorAtom)
const deploymentOptionsIsFetching = useAtomValue(deploymentOptionsIsFetchingAtom)
const deploymentOptionsIsLoading = useAtomValue(deploymentOptionsIsLoadingAtom)
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
const envVarSlots = useAtomValue(deploymentTargetEnvVarSlotsAtom)
const isBindingError = deploymentOptionsIsError
const isBindingLoading = deploymentOptionsIsLoading || (deploymentOptionsIsFetching && !deploymentOptions)
const isBindingError = deploymentOptionsQuery.isError
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
if (isBindingLoading || isBindingError)
return null

View File

@ -20,7 +20,6 @@ import {
atomWithQuery,
queryClientAtom,
} from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import * as z from 'zod'
import { consoleQuery } from '@/service/client'
import { normalizeAppPagination } from '@/service/use-apps'
@ -271,18 +270,6 @@ export const createReleaseSourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
})
})
const createReleaseSourceAppsDataAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.data)
export const createReleaseSourceAppsErrorAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.error)
export const createReleaseSourceAppsFetchNextPageAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.fetchNextPage)
export const createReleaseSourceAppsHasNextPageAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.hasNextPage)
export const createReleaseSourceAppsIsFetchingAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.isFetching)
export const createReleaseSourceAppsIsFetchingNextPageAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.isFetchingNextPage)
export const createReleaseSourceAppsIsLoadingAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.isLoading)
export const createReleaseSourceAppsAtom = atom((get) => {
return get(createReleaseSourceAppsDataAtom)?.pages.flatMap(page => page.data) ?? []
})
export const createReleaseDslContentAtom = atom((get) => {
return get(createReleaseDslFileContentQueryAtom).data ?? ''
})

View File

@ -35,13 +35,6 @@ vi.mock('@/features/deployments/create-release/state', async () => {
const { atom } = await import('jotai')
return {
createReleaseSourceAppsAtom: atom(() => mocks.sourceAppsQuery.data.pages.flatMap(page => page.data)),
createReleaseSourceAppsErrorAtom: atom(() => mocks.sourceAppsQuery.error),
createReleaseSourceAppsFetchNextPageAtom: atom(() => mocks.sourceAppsQuery.fetchNextPage),
createReleaseSourceAppsHasNextPageAtom: atom(() => mocks.sourceAppsQuery.hasNextPage),
createReleaseSourceAppsIsFetchingAtom: atom(() => mocks.sourceAppsQuery.isFetching),
createReleaseSourceAppsIsFetchingNextPageAtom: atom(() => mocks.sourceAppsQuery.isFetchingNextPage),
createReleaseSourceAppsIsLoadingAtom: atom(() => mocks.sourceAppsQuery.isLoading),
createReleaseSourceAppSearchTextAtom: atom(''),
createReleaseSourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
}
@ -105,13 +98,7 @@ describe('SourceAppPicker', () => {
renderSourceAppPicker(false)
expect(mocks.useInfiniteScroll).toHaveBeenCalledWith(
expect.objectContaining({
fetchNextPage: expect.any(Function),
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
}),
mocks.sourceAppsQuery,
expect.objectContaining({
enabled: false,
rootMargin: '0px 0px 160px 0px',
@ -123,13 +110,7 @@ describe('SourceAppPicker', () => {
await waitFor(() => {
expect(mocks.useInfiniteScroll).toHaveBeenLastCalledWith(
expect.objectContaining({
fetchNextPage: expect.any(Function),
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
}),
mocks.sourceAppsQuery,
expect.objectContaining({
enabled: true,
rootMargin: '0px 0px 160px 0px',

View File

@ -21,14 +21,8 @@ import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
import { TitleTooltip } from '../../shared/components/title-tooltip'
import {
createReleaseSourceAppsAtom,
createReleaseSourceAppSearchTextAtom,
createReleaseSourceAppsErrorAtom,
createReleaseSourceAppsFetchNextPageAtom,
createReleaseSourceAppsHasNextPageAtom,
createReleaseSourceAppsIsFetchingAtom,
createReleaseSourceAppsIsFetchingNextPageAtom,
createReleaseSourceAppsIsLoadingAtom,
createReleaseSourceAppsQueryAtom,
} from '../state'
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
@ -140,26 +134,21 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
const [isShow, setIsShow] = useState(false)
const searchText = useAtomValue(createReleaseSourceAppSearchTextAtom)
const setSearchText = useSetAtom(createReleaseSourceAppSearchTextAtom)
const apps = useAtomValue(createReleaseSourceAppsAtom)
const sourceAppsError = useAtomValue(createReleaseSourceAppsErrorAtom)
const sourceAppsFetchNextPage = useAtomValue(createReleaseSourceAppsFetchNextPageAtom)
const sourceAppsHasNextPage = useAtomValue(createReleaseSourceAppsHasNextPageAtom)
const sourceAppsIsFetching = useAtomValue(createReleaseSourceAppsIsFetchingAtom)
const sourceAppsIsFetchingNextPage = useAtomValue(createReleaseSourceAppsIsFetchingNextPageAtom)
const sourceAppsIsLoading = useAtomValue(createReleaseSourceAppsIsLoadingAtom)
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>({
error: sourceAppsError,
fetchNextPage: sourceAppsFetchNextPage,
hasNextPage: sourceAppsHasNextPage,
isFetching: sourceAppsIsFetching,
isFetchingNextPage: sourceAppsIsFetchingNextPage,
isLoading: sourceAppsIsLoading,
}, {
const sourceAppsQuery = useAtomValue(createReleaseSourceAppsQueryAtom)
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
} = sourceAppsQuery
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>(sourceAppsQuery, {
enabled: isShow && !disabled,
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,
})
const apps = data?.pages.flatMap(page => page.data) ?? []
return (
<Combobox<App>
items={apps}
@ -219,23 +208,23 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
</ComboboxInputGroup>
</div>
<div ref={rootRef} className="min-h-0 flex-1 overflow-y-auto p-1">
{(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
{(isLoading || isFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
<ComboboxList className="max-h-none p-0">
{(app: App) => (
<SourceAppOption key={app.id} app={app} />
)}
</ComboboxList>
{!(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && (
{!(isLoading || isFetchingNextPage) && (
<ComboboxEmpty>
{t('createModal.appSearchEmpty')}
</ComboboxEmpty>
)}
{sourceAppsIsFetchingNextPage && apps.length > 0 && (
{isFetchingNextPage && apps.length > 0 && (
<div className="px-3 py-2 text-center system-xs-regular text-text-tertiary">
{t('createModal.loadingApps')}
</div>
)}
{sourceAppsHasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />}
{hasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />}
</div>
</div>
</ComboboxContent>

View File

@ -18,7 +18,6 @@ import { toast } from '@langgenius/dify-ui/toast'
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { envVarBindingSlotFromContract } from '../../shared/components/env-var-bindings-utils'
import {
@ -82,10 +81,6 @@ export const releaseDeploymentViewQueryAtom = atomWithQuery((get) => {
})
})
export const releaseDeploymentViewAtom = selectAtom(releaseDeploymentViewQueryAtom, query => query.data)
export const releaseDeploymentViewIsLoadingAtom = selectAtom(releaseDeploymentViewQueryAtom, query => query.isLoading)
export const releaseDeploymentViewIsErrorAtom = selectAtom(releaseDeploymentViewQueryAtom, query => query.isError)
const selectedEnvIdAtom = atom<string | undefined>(undefined)
const selectedReleaseIdAtom = atom<string | undefined>(undefined)
const manualBindingsAtom = atom<RuntimeCredentialBindingSelections>({})
@ -228,47 +223,44 @@ const releaseDeploymentOptionsQueryAtom = atomWithQuery((get) => {
})
})
const releaseDeploymentOptionsAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.data)
const releaseDeploymentOptionsIsLoadingAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.isLoading)
const releaseDeploymentOptionsIsFetchingAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.isFetching)
const releaseDeploymentOptionsIsErrorAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.isError)
export const deployBindingSlotsAtom = atom((get) => {
const deploymentOptions = get(releaseDeploymentOptionsAtom)
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
return deploymentOptions?.options.credentialSlots.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
return deploymentOptionsQuery.data?.options.credentialSlots.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
})
export const deployEnvVarSlotsAtom = atom((get): EnvVarBindingSlot[] => {
const deploymentOptions = get(releaseDeploymentOptionsAtom)
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
return deploymentOptions?.options.envVarSlots.flatMap((slot): EnvVarBindingSlot[] => {
return deploymentOptionsQuery.data?.options.envVarSlots.flatMap((slot): EnvVarBindingSlot[] => {
const bindingSlot = envVarBindingSlotFromContract(slot)
return bindingSlot ? [bindingSlot] : []
}) ?? []
})
export const deployIsBindingOptionsLoadingAtom = atom((get) => {
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
const releaseId = get(deployTargetReleaseIdAtom)
return Boolean(
releaseId
&& get(deployHasSelectedEnvironmentAtom)
&& (get(releaseDeploymentOptionsIsLoadingAtom) || get(releaseDeploymentOptionsIsFetchingAtom)),
&& (deploymentOptionsQuery.isLoading || deploymentOptionsQuery.isFetching),
)
})
export const deployHasBindingOptionsErrorAtom = atom((get) => {
return get(releaseDeploymentOptionsIsErrorAtom)
return get(releaseDeploymentOptionsQueryAtom).isError
})
const deployIsBindingOptionsReadyAtom = atom((get) => {
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
const releaseId = get(deployTargetReleaseIdAtom)
return Boolean(
releaseId
&& get(deployHasSelectedEnvironmentAtom)
&& get(releaseDeploymentOptionsAtom)
&& deploymentOptionsQuery.data
&& !get(deployIsBindingOptionsLoadingAtom)
&& !get(deployHasBindingOptionsErrorAtom),
)

View File

@ -11,7 +11,7 @@ import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { EnvVarBindingsPanel } from '../../shared/components/env-var-bindings'
import { isAvailableDeploymentTarget } from '../../shared/domain/runtime-status'
import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployFormAppInstanceIdAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, releaseDeploymentViewAtom, releaseDeploymentViewIsErrorAtom, releaseDeploymentViewIsLoadingAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state'
import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployFormAppInstanceIdAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, releaseDeploymentViewQueryAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state'
import {
currentReleaseIdForEnvironment,
selectableDeployReleases,
@ -203,15 +203,13 @@ function DeployFormContent({
presetReleaseId,
}: DeployFormProps) {
const { t } = useTranslation('deployments')
const deploymentView = useAtomValue(releaseDeploymentViewAtom)
const isLoading = useAtomValue(releaseDeploymentViewIsLoadingAtom)
const isError = useAtomValue(releaseDeploymentViewIsErrorAtom)
const releaseDeploymentViewQuery = useAtomValue(releaseDeploymentViewQueryAtom)
if (isLoading) {
if (releaseDeploymentViewQuery.isLoading) {
return <DeployFormSkeleton />
}
if (isError) {
if (releaseDeploymentViewQuery.isError) {
return (
<div className="p-4 system-sm-regular text-text-destructive">
{t('common.loadFailed')}
@ -219,6 +217,7 @@ function DeployFormContent({
)
}
const deploymentView = releaseDeploymentViewQuery.data
if (!deploymentView) {
return (
<div className="p-4 system-sm-regular text-text-destructive">

View File

@ -2,11 +2,7 @@ import type { AccessChannels, AccessEndpoint } from '@dify/contracts/enterprise/
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { deploymentRouteAppInstanceIdAtom } from '../../../../route-state'
import {
accessSettingsAtom,
accessSettingsIsErrorAtom,
accessSettingsIsLoadingAtom,
} from '../../state'
import { accessSettingsQueryAtom } from '../../state'
import { AccessChannelsSection } from '../section'
const mockToggleAccessChannel = vi.hoisted(() => vi.fn())
@ -72,17 +68,17 @@ describe('AccessChannelsSection', () => {
mockUseAtomValue.mockImplementation((atom) => {
if (atom === deploymentRouteAppInstanceIdAtom)
return 'app-instance-1'
if (atom === accessSettingsAtom) {
if (atom === accessSettingsQueryAtom) {
return {
accessChannels: createAccessChannels(),
webAppEndpoints: [createEndpoint('https://app.example.com/webapp')],
cliEndpoint: createEndpoint('https://cli.example.com/entry'),
data: {
accessChannels: createAccessChannels(),
webAppEndpoints: [createEndpoint('https://app.example.com/webapp')],
cliEndpoint: createEndpoint('https://cli.example.com/entry'),
},
isLoading: false,
isError: false,
}
}
if (atom === accessSettingsIsLoadingAtom)
return false
if (atom === accessSettingsIsErrorAtom)
return false
return undefined
})
})

View File

@ -12,11 +12,7 @@ import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../shared/components/empty-state'
import { CopyPill, EndpointRow } from '../../../shared/components/endpoint'
import { Section } from '../../../shared/components/section'
import {
accessSettingsAtom,
accessSettingsIsErrorAtom,
accessSettingsIsLoadingAtom,
} from '../state'
import { accessSettingsQueryAtom } from '../state'
import { getUrlOrigin } from './url'
const ACCESS_CHANNEL_SKELETON_SECTIONS = [
@ -119,12 +115,12 @@ function ChannelRow({ info, children }: {
export function AccessChannelsSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const accessSettings = useAtomValue(accessSettingsAtom)
const isLoading = useAtomValue(accessSettingsIsLoadingAtom)
const isError = useAtomValue(accessSettingsIsErrorAtom)
const accessChannels = accessSettings?.accessChannels
const webAppEndpoints: AccessEndpoint[] | undefined = accessSettings?.webAppEndpoints
const cliEndpoint: AccessEndpoint | undefined = accessSettings?.cliEndpoint
const accessSettingsQuery = useAtomValue(accessSettingsQueryAtom)
const accessChannels = accessSettingsQuery.data?.accessChannels
const webAppEndpoints: AccessEndpoint[] | undefined = accessSettingsQuery.data?.webAppEndpoints
const cliEndpoint: AccessEndpoint | undefined = accessSettingsQuery.data?.cliEndpoint
const isLoading = accessSettingsQuery.isLoading
const isError = accessSettingsQuery.isError
const runEnabled = accessChannels?.webAppEnabled ?? false
const webappRows = webAppEndpoints?.flatMap((endpoint) => {
const endpointUrl = endpoint.endpointUrl

View File

@ -5,11 +5,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { deploymentRouteAppInstanceIdAtom } from '../../../../route-state'
import {
accessSettingsAtom,
accessSettingsIsErrorAtom,
accessSettingsIsLoadingAtom,
} from '../../state'
import { accessSettingsQueryAtom } from '../../state'
import { EnvironmentPermissionRow } from '../environment-permission-row'
import { AccessPermissionsSection } from '../section'
@ -240,15 +236,15 @@ describe('AccessPermissionsSection', () => {
mockUseAtomValue.mockImplementation((atom) => {
if (atom === deploymentRouteAppInstanceIdAtom)
return 'app-instance-1'
if (atom === accessSettingsAtom) {
if (atom === accessSettingsQueryAtom) {
return {
environmentPolicies: [createEnvironmentAccessPolicy()],
data: {
environmentPolicies: [createEnvironmentAccessPolicy()],
},
isLoading: false,
isError: false,
}
}
if (atom === accessSettingsIsLoadingAtom)
return false
if (atom === accessSettingsIsErrorAtom)
return false
return undefined
})
})

View File

@ -7,11 +7,7 @@ import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state'
import { Section } from '../../../shared/components/section'
import {
accessSettingsAtom,
accessSettingsIsErrorAtom,
accessSettingsIsLoadingAtom,
} from '../state'
import { accessSettingsQueryAtom } from '../state'
import { EnvironmentPermissionRow } from './environment-permission-row'
const ACCESS_PERMISSIONS_SKELETON_KEYS = ['production', 'staging', 'development']
@ -32,10 +28,10 @@ function AccessPermissionsSkeleton() {
export function AccessPermissionsSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const accessSettings = useAtomValue(accessSettingsAtom)
const isLoading = useAtomValue(accessSettingsIsLoadingAtom)
const isError = useAtomValue(accessSettingsIsErrorAtom)
const environmentPolicies: EnvironmentAccessPolicy[] | undefined = accessSettings?.environmentPolicies
const accessSettingsQuery = useAtomValue(accessSettingsQueryAtom)
const environmentPolicies: EnvironmentAccessPolicy[] | undefined = accessSettingsQuery.data?.environmentPolicies
const isLoading = accessSettingsQuery.isLoading
const isError = accessSettingsQuery.isError
const policyRows = environmentPolicies ?? []
return (

View File

@ -2,7 +2,6 @@
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
@ -18,7 +17,3 @@ export const accessSettingsQueryAtom = atomWithQuery((get) => {
enabled: Boolean(appInstanceId),
})
})
export const accessSettingsAtom = selectAtom(accessSettingsQueryAtom, query => query.data)
export const accessSettingsIsLoadingAtom = selectAtom(accessSettingsQueryAtom, query => query.isLoading)
export const accessSettingsIsErrorAtom = selectAtom(accessSettingsQueryAtom, query => query.isError)

View File

@ -16,11 +16,7 @@ import { ApiKeyGenerateMenu } from '../api-keys/api-key-generate-menu'
import { ApiKeyList } from '../api-keys/api-key-list'
import { CreatedApiTokenDialog } from '../api-keys/created-token-dialog'
import { DeveloperApiDocsDrawer } from '../docs/docs-drawer'
import {
developerApiSettingsAtom,
developerApiSettingsIsErrorAtom,
developerApiSettingsIsLoadingAtom,
} from '../state'
import { developerApiSettingsQueryAtom } from '../state'
import { DeveloperApiSkeleton } from './skeleton'
type CreatedApiToken = {
@ -90,23 +86,21 @@ export function DeveloperApiSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
const developerApiSettings = useAtomValue(developerApiSettingsAtom)
const isLoading = useAtomValue(developerApiSettingsIsLoadingAtom)
const isError = useAtomValue(developerApiSettingsIsErrorAtom)
const accessChannels = developerApiSettings?.accessChannels
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
const apiUrl = developerApiSettings?.developerApiUrl.apiUrl
const apiKeys: ApiKey[] = developerApiSettings?.apiKeys ?? []
const environments = developerApiSettings?.environments ?? []
const apiUrl = developerApiSettingsQuery.data?.developerApiUrl.apiUrl
const apiKeys: ApiKey[] = developerApiSettingsQuery.data?.apiKeys ?? []
const environments = developerApiSettingsQuery.data?.environments ?? []
const visibleCreatedApiToken = createdApiToken && createdApiToken.appInstanceId === appInstanceId
? createdApiToken.token
: undefined
const hasSelectableEnvironment = environments.some(environment => Boolean(environment.id))
if (isLoading)
if (developerApiSettingsQuery.isLoading)
return <DeveloperApiSkeleton />
if (isError || !appInstanceId)
if (developerApiSettingsQuery.isError || !appInstanceId)
return <DeploymentStateMessage variant="section">{t('common.loadFailed')}</DeploymentStateMessage>
if (!apiEnabled) {

View File

@ -7,11 +7,7 @@ import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import {
developerApiSettingsAtom,
developerApiSettingsIsErrorAtom,
developerApiSettingsIsLoadingAtom,
} from './state'
import { developerApiSettingsQueryAtom } from './state'
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
checked: boolean
@ -47,13 +43,11 @@ function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
export function DeveloperApiHeaderSwitch() {
const { t } = useTranslation('deployments')
const developerApiSettings = useAtomValue(developerApiSettingsAtom)
const isLoading = useAtomValue(developerApiSettingsIsLoadingAtom)
const isError = useAtomValue(developerApiSettingsIsErrorAtom)
const accessChannels = developerApiSettings?.accessChannels
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
if (isLoading)
if (developerApiSettingsQuery.isLoading)
return <SwitchSkeleton />
return (
@ -64,7 +58,7 @@ export function DeveloperApiHeaderSwitch() {
<DeveloperApiSwitch
checked={apiEnabled}
accessChannels={accessChannels}
disabled={isError}
disabled={developerApiSettingsQuery.isError}
/>
</div>
)

View File

@ -2,7 +2,6 @@
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
@ -18,7 +17,3 @@ export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
enabled: Boolean(appInstanceId),
})
})
export const developerApiSettingsAtom = selectAtom(developerApiSettingsQueryAtom, query => query.data)
export const developerApiSettingsIsLoadingAtom = selectAtom(developerApiSettingsQueryAtom, query => query.isLoading)
export const developerApiSettingsIsErrorAtom = selectAtom(developerApiSettingsQueryAtom, query => query.isError)

View File

@ -20,11 +20,7 @@ import { usePathname } from '@/next/navigation'
import { DeploymentActionsMenu } from '../deployment-actions'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
import { TitleTooltip } from '../shared/components/title-tooltip'
import {
deploymentDetailAppInstanceAtom,
deploymentDetailAppInstanceIsErrorAtom,
deploymentDetailAppInstanceIsLoadingAtom,
} from './state'
import { deploymentDetailAppInstanceQueryAtom } from './state'
type TabDef = {
key: InstanceDetailTabKey
@ -99,12 +95,10 @@ function DeploymentDetailInstanceInfo({ appInstanceId, expand }: {
expand: boolean
}) {
const { t } = useTranslation('deployments')
const overview = useAtomValue(deploymentDetailAppInstanceAtom)
const isOverviewLoading = useAtomValue(deploymentDetailAppInstanceIsLoadingAtom)
const isOverviewError = useAtomValue(deploymentDetailAppInstanceIsErrorAtom)
const app = overview?.appInstance
const isLoading = !app && isOverviewLoading
const isUnavailable = !app || isOverviewError
const overviewQuery = useAtomValue(deploymentDetailAppInstanceQueryAtom)
const app = overviewQuery.data?.appInstance
const isLoading = !app && overviewQuery.isLoading
const isUnavailable = !app || overviewQuery.isError
const instanceName = app ? app.displayName : appInstanceId
return (

View File

@ -6,8 +6,7 @@ import { useTranslation } from 'react-i18next'
import { openDeployDrawerAtom } from '../../../deploy-drawer/state'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import {
deploymentEnvironmentDeploymentsIsErrorAtom,
deploymentEnvironmentDeploymentsIsLoadingAtom,
deploymentEnvironmentDeploymentsQueryAtom,
deploymentRuntimeInstanceRowsAtom,
} from '../../state'
@ -35,11 +34,10 @@ export function NewDeploymentButton() {
}
export function NewDeploymentHeaderAction() {
const isLoading = useAtomValue(deploymentEnvironmentDeploymentsIsLoadingAtom)
const hasError = useAtomValue(deploymentEnvironmentDeploymentsIsErrorAtom)
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
const rows = useAtomValue(deploymentRuntimeInstanceRowsAtom)
if (isLoading || hasError || rows.length === 0)
if (environmentDeploymentsQuery.isLoading || environmentDeploymentsQuery.isError || rows.length === 0)
return null
return <NewDeploymentButton />

View File

@ -14,8 +14,7 @@ import {
} from '../../shared/components/detail-table'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../shared/components/empty-state'
import {
deploymentEnvironmentDeploymentsIsErrorAtom,
deploymentEnvironmentDeploymentsIsLoadingAtom,
deploymentEnvironmentDeploymentsQueryAtom,
deploymentRuntimeInstanceRowsAtom,
} from '../state'
import { DeploymentEnvironmentList } from './environment-list/deployment-environment-list'
@ -90,9 +89,10 @@ function DeploymentEnvironmentListSkeleton() {
export function DeploymentInstances() {
const { t } = useTranslation('deployments')
const isLoading = useAtomValue(deploymentEnvironmentDeploymentsIsLoadingAtom)
const hasError = useAtomValue(deploymentEnvironmentDeploymentsIsErrorAtom)
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
const rows = useAtomValue(deploymentRuntimeInstanceRowsAtom)
const isLoading = environmentDeploymentsQuery.isLoading
const hasError = environmentDeploymentsQuery.isError
return (
<div className="flex w-full min-w-0 flex-col gap-4 px-6 py-6">

View File

@ -9,11 +9,7 @@ import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status
import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './access-summary/access-status-section'
import { EnvironmentStrip, EnvironmentStripSkeleton } from './environment-status/environment-strip'
import { ReleaseHero, ReleaseHeroSkeleton } from './release-summary/release-hero'
import {
deploymentOverviewAtom,
deploymentOverviewIsErrorAtom,
deploymentOverviewIsLoadingAtom,
} from './state'
import { deploymentOverviewQueryAtom } from './state'
function OverviewLayout({ children }: { children: React.ReactNode }) {
return (
@ -67,14 +63,13 @@ function OverviewLoadingSkeleton() {
export function DeploymentOverview() {
const { t } = useTranslation('deployments')
const overview = useAtomValue(deploymentOverviewAtom)
const isLoading = useAtomValue(deploymentOverviewIsLoadingAtom)
const isError = useAtomValue(deploymentOverviewIsErrorAtom)
const overviewQuery = useAtomValue(deploymentOverviewQueryAtom)
const overview = overviewQuery.data
if (isLoading)
if (overviewQuery.isLoading)
return <OverviewLoadingSkeleton />
if (isError) {
if (overviewQuery.isError) {
return (
<OverviewLayout>
<DeploymentStateMessage variant="section">{t('common.loadFailed')}</DeploymentStateMessage>

View File

@ -2,7 +2,6 @@
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
@ -18,7 +17,3 @@ export const deploymentOverviewQueryAtom = atomWithQuery((get) => {
enabled: Boolean(appInstanceId),
})
})
export const deploymentOverviewAtom = selectAtom(deploymentOverviewQueryAtom, query => query.data)
export const deploymentOverviewIsLoadingAtom = selectAtom(deploymentOverviewQueryAtom, query => query.isLoading)
export const deploymentOverviewIsErrorAtom = selectAtom(deploymentOverviewQueryAtom, query => query.isError)

View File

@ -41,10 +41,8 @@ vi.mock('../state', async (importOriginal) => {
return {
...actual,
deployReleaseMenuEnvironmentDeploymentsAtom: atom(undefined),
deployReleaseMenuEnvironmentDeploymentsIsErrorAtom: atom(true),
deployReleaseMenuEnvironmentDeploymentsIsLoadingAtom: atom(false),
deployReleaseMenuAppInstanceNameAtom: atom('Deployment 1'),
deployReleaseMenuEnvironmentDeploymentsQueryAtom: atom(environmentDeploymentsErrorResult()),
deployReleaseMenuAppInstanceQueryAtom: atom(appInstanceResult()),
}
})
@ -84,6 +82,24 @@ function createRelease(): Release {
}
}
function environmentDeploymentsErrorResult() {
return {
isError: true,
isLoading: false,
data: undefined,
}
}
function appInstanceResult() {
return {
data: {
appInstance: {
displayName: 'Deployment 1',
},
},
}
}
describe('DeployReleaseMenu', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@ -28,10 +28,8 @@ import { EditReleaseDialog } from './edit-release-dialog'
import { exportReleaseDsl } from './release-dsl-export'
import {
deleteReleaseDialogOpenAtom,
deployReleaseMenuAppInstanceNameAtom,
deployReleaseMenuEnvironmentDeploymentsAtom,
deployReleaseMenuEnvironmentDeploymentsIsErrorAtom,
deployReleaseMenuEnvironmentDeploymentsIsLoadingAtom,
deployReleaseMenuAppInstanceQueryAtom,
deployReleaseMenuEnvironmentDeploymentsQueryAtom,
deployReleaseMenuOpenAtom,
openDeleteReleaseDialogAtom,
openEditReleaseDialogAtom,
@ -56,21 +54,19 @@ function DeployReleaseMenuContent({ onDeleted }: {
const setDeleteReleaseDialogOpen = useSetAtom(deleteReleaseDialogOpenAtom)
const openEditReleaseDialog = useSetAtom(openEditReleaseDialogAtom)
const openDeleteReleaseDialog = useSetAtom(openDeleteReleaseDialogAtom)
const environmentDeployments = useAtomValue(deployReleaseMenuEnvironmentDeploymentsAtom)
const environmentDeploymentsIsLoading = useAtomValue(deployReleaseMenuEnvironmentDeploymentsIsLoadingAtom)
const environmentDeploymentsIsError = useAtomValue(deployReleaseMenuEnvironmentDeploymentsIsErrorAtom)
const appInstanceName = useAtomValue(deployReleaseMenuAppInstanceNameAtom)
const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom)
const appInstanceQuery = useAtomValue(deployReleaseMenuAppInstanceQueryAtom)
const deleteRelease = useMutation(consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions())
const exportReleaseDslMutation = useMutation(mutationOptions({
mutationKey: ['deployments', 'release-dsl-export'],
mutationFn: (input: ExportReleaseDslInput) => exportReleaseDsl(input),
}))
const deploymentEnvironmentRows = environmentDeployments?.environmentDeployments ?? []
const environments = deploymentEnvironmentRows
const environments = (environmentDeploymentsQuery.data?.environmentDeployments ?? [])
.map(row => row.environment)
const deploymentRows = deploymentEnvironmentRows.filter(row => !isUndeployedDeploymentRow(row))
const deploymentRows = environmentDeploymentsQuery.data?.environmentDeployments.filter(row => !isUndeployedDeploymentRow(row)) ?? []
const targetRelease = releaseRows.find(release => release.id === releaseId)
const appInstanceName = appInstanceQuery.data?.appInstance.displayName
if (!targetRelease)
return null
@ -78,8 +74,8 @@ function DeployReleaseMenuContent({ onDeleted }: {
const release = targetRelease
const targetReleaseName = release.displayName
const deleteUsageCount = releaseUsageCount(releaseId, deploymentRows)
const isCheckingDeleteUsage = open && environmentDeploymentsIsLoading
const hasDeleteUsageCheckFailed = open && environmentDeploymentsIsError
const isCheckingDeleteUsage = open && environmentDeploymentsQuery.isLoading
const hasDeleteUsageCheckFailed = open && environmentDeploymentsQuery.isError
const isReleaseInUse = deleteUsageCount > 0
const isDeletingRelease = deleteRelease.isPending
const isExportingDsl = exportReleaseDslMutation.isPending
@ -134,7 +130,7 @@ function DeployReleaseMenuContent({ onDeleted }: {
const groupedRows = buildDeployMenuSections({
environments,
environmentDeployments: deploymentEnvironmentRows,
environmentDeployments: environmentDeploymentsQuery.data?.environmentDeployments ?? [],
releaseRows,
releaseId,
targetRelease: release,

View File

@ -4,7 +4,7 @@ import type { Release } from '@dify/contracts/enterprise/types.gen'
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { atomWithLazy, selectAtom } from 'jotai/utils'
import { atomWithLazy } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
@ -35,19 +35,6 @@ export const deployReleaseMenuEnvironmentDeploymentsQueryAtom = atomWithQuery((g
})
})
export const deployReleaseMenuEnvironmentDeploymentsAtom = selectAtom(
deployReleaseMenuEnvironmentDeploymentsQueryAtom,
query => query.data,
)
export const deployReleaseMenuEnvironmentDeploymentsIsLoadingAtom = selectAtom(
deployReleaseMenuEnvironmentDeploymentsQueryAtom,
query => query.isLoading,
)
export const deployReleaseMenuEnvironmentDeploymentsIsErrorAtom = selectAtom(
deployReleaseMenuEnvironmentDeploymentsQueryAtom,
query => query.isError,
)
export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
const menuOpen = get(deployReleaseMenuOpenAtom)
@ -62,11 +49,6 @@ export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => {
})
})
export const deployReleaseMenuAppInstanceNameAtom = selectAtom(
deployReleaseMenuAppInstanceQueryAtom,
query => query.data?.appInstance.displayName,
)
export const openEditReleaseDialogAtom = atom(null, (_get, set) => {
set(deployReleaseMenuOpenAtom, false)
set(deleteReleaseDialogOpenAtom, false)

View File

@ -7,10 +7,8 @@ import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/co
import {
adjustReleaseHistoryPageAfterDeleteAtom,
RELEASE_HISTORY_PAGE_SIZE,
releaseHistoryAtom,
releaseHistoryCurrentPageAtom,
releaseHistoryIsErrorAtom,
releaseHistoryIsLoadingAtom,
releaseHistoryQueryAtom,
setReleaseHistoryCurrentPageAtom,
} from '../state'
import { ReleaseHistoryRows } from './release-history-rows'
@ -22,9 +20,9 @@ export function ReleaseHistoryTable() {
const currentPage = useAtomValue(releaseHistoryCurrentPageAtom)
const setCurrentPage = useSetAtom(setReleaseHistoryCurrentPageAtom)
const adjustPageAfterDelete = useSetAtom(adjustReleaseHistoryPageAfterDeleteAtom)
const releaseHistory = useAtomValue(releaseHistoryAtom)
const isLoading = useAtomValue(releaseHistoryIsLoadingAtom)
const hasError = useAtomValue(releaseHistoryIsErrorAtom)
const releaseHistoryQuery = useAtomValue(releaseHistoryQueryAtom)
const isLoading = releaseHistoryQuery.isLoading
const hasError = releaseHistoryQuery.isError
if (isLoading)
return <ReleaseHistoryTableSkeleton />
@ -37,6 +35,7 @@ export function ReleaseHistoryTable() {
)
}
const releaseHistory = releaseHistoryQuery.data
if (!releaseHistory) {
return (
<DeploymentStateMessage variant="list">

View File

@ -3,7 +3,6 @@
import { keepPreviousData, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
@ -30,10 +29,6 @@ export const releaseHistoryQueryAtom = atomWithQuery((get) => {
})
})
export const releaseHistoryAtom = selectAtom(releaseHistoryQueryAtom, query => query.data)
export const releaseHistoryIsLoadingAtom = selectAtom(releaseHistoryQueryAtom, query => query.isLoading)
export const releaseHistoryIsErrorAtom = selectAtom(releaseHistoryQueryAtom, query => query.isError)
export const setReleaseHistoryCurrentPageAtom = atom(null, (_get, set, page: number) => {
set(releaseHistoryCurrentPageAtom, Math.max(page, 0))
})

View File

@ -3,7 +3,6 @@
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { nextPathnameAtom } from '@/app/components/next-route-state/atoms'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
@ -36,10 +35,6 @@ export const deploymentDetailAppInstanceQueryAtom = atomWithQuery((get) => {
})
})
export const deploymentDetailAppInstanceAtom = selectAtom(deploymentDetailAppInstanceQueryAtom, query => query.data)
export const deploymentDetailAppInstanceIsLoadingAtom = selectAtom(deploymentDetailAppInstanceQueryAtom, query => query.isLoading)
export const deploymentDetailAppInstanceIsErrorAtom = selectAtom(deploymentDetailAppInstanceQueryAtom, query => query.isError)
export const deploymentEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
@ -54,10 +49,6 @@ export const deploymentEnvironmentDeploymentsQueryAtom = atomWithQuery((get) =>
})
})
export const deploymentEnvironmentDeploymentsAtom = selectAtom(deploymentEnvironmentDeploymentsQueryAtom, query => query.data)
export const deploymentEnvironmentDeploymentsIsLoadingAtom = selectAtom(deploymentEnvironmentDeploymentsQueryAtom, query => query.isLoading)
export const deploymentEnvironmentDeploymentsIsErrorAtom = selectAtom(deploymentEnvironmentDeploymentsQueryAtom, query => query.isError)
export const deploymentRuntimeInstanceRowsAtom = atom((get) => {
return get(deploymentEnvironmentDeploymentsAtom)?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? []
return get(deploymentEnvironmentDeploymentsQueryAtom).data?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? []
})

View File

@ -4,7 +4,7 @@ import type { ReactNode } from 'react'
import { keepPreviousData } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom, useHydrateAtoms } from 'jotai/utils'
import { useHydrateAtoms } from 'jotai/utils'
import { parseAsString, useQueryState } from 'nuqs'
import { consoleQuery } from '@/service/client'
import { deploymentStatusPollingInterval } from '../../shared/domain/runtime-status'
@ -52,10 +52,8 @@ const deploymentsListEnvironmentsQueryAtom = atomWithQuery(() => {
})
})
const deploymentsListEnvironmentsDataAtom = selectAtom(deploymentsListEnvironmentsQueryAtom, query => query.data)
export const deploymentsListEnvironmentFilterOptionsAtom = atom((get): DeploymentsListEnvironmentFilterOption[] => {
const environments = get(deploymentsListEnvironmentsDataAtom)?.environments ?? []
const environments = get(deploymentsListEnvironmentsQueryAtom).data?.environments ?? []
return [
{
@ -85,7 +83,7 @@ export const deploymentsListSelectedEnvironmentFilterOptionAtom = atom((get): De
: allOption)
})
const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => {
export const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => {
const queryKeywords = get(deploymentsListKeywordsAtom).trim()
const queryEnvironmentId = get(deploymentsListEnvironmentIdAtom) ?? undefined
@ -116,34 +114,26 @@ const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => {
})
})
const deploymentsListDataAtom = selectAtom(deploymentsListQueryAtom, query => query.data)
export const deploymentsListErrorAtom = selectAtom(deploymentsListQueryAtom, query => query.error)
export const deploymentsListFetchNextPageAtom = selectAtom(deploymentsListQueryAtom, query => query.fetchNextPage)
export const deploymentsListHasNextPageAtom = selectAtom(deploymentsListQueryAtom, query => query.hasNextPage)
export const deploymentsListIsFetchingAtom = selectAtom(deploymentsListQueryAtom, query => query.isFetching)
export const deploymentsListIsFetchingNextPageAtom = selectAtom(deploymentsListQueryAtom, query => query.isFetchingNextPage)
export const deploymentsListIsLoadingAtom = selectAtom(deploymentsListQueryAtom, query => query.isLoading)
const deploymentsListIsErrorAtom = selectAtom(deploymentsListQueryAtom, query => query.isError)
export const deploymentsListRowsAtom = atom((get) => {
return get(deploymentsListDataAtom)?.pages.flatMap(page => page.appInstanceSummaries) ?? []
return get(deploymentsListQueryAtom).data?.pages.flatMap(page => page.appInstanceSummaries) ?? []
})
export const deploymentsListShowSkeletonAtom = atom((get) => {
const pages = get(deploymentsListDataAtom)?.pages ?? []
const deploymentsListQuery = get(deploymentsListQueryAtom)
const pages = deploymentsListQuery.data?.pages ?? []
return get(deploymentsListIsLoadingAtom) || (get(deploymentsListIsFetchingAtom) && pages.length === 0)
return deploymentsListQuery.isLoading || (deploymentsListQuery.isFetching && pages.length === 0)
})
export const deploymentsListShowEmptyStateAtom = atom((get) => {
return !get(deploymentsListShowSkeletonAtom)
&& !get(deploymentsListIsErrorAtom)
&& !get(deploymentsListQueryAtom).isError
&& get(deploymentsListRowsAtom).length === 0
})
export const deploymentsListShowErrorStateAtom = atom((get) => {
return !get(deploymentsListShowSkeletonAtom)
&& get(deploymentsListIsErrorAtom)
&& get(deploymentsListQueryAtom).isError
})
export const deploymentsListHasFilterAtom = atom((get) => {

View File

@ -12,13 +12,8 @@ import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../shared/components/empty-state'
import { useInfiniteScroll } from '../../shared/hooks/use-infinite-scroll'
import {
deploymentsListErrorAtom,
deploymentsListFetchNextPageAtom,
deploymentsListHasFilterAtom,
deploymentsListHasNextPageAtom,
deploymentsListIsFetchingAtom,
deploymentsListIsFetchingNextPageAtom,
deploymentsListIsLoadingAtom,
deploymentsListQueryAtom,
deploymentsListRowsAtom,
deploymentsListShowEmptyStateAtom,
deploymentsListShowErrorStateAtom,
@ -162,25 +157,13 @@ function DeploymentsListControls() {
export function DeploymentsListShell() {
const { t } = useTranslation('deployments')
const deploymentsListError = useAtomValue(deploymentsListErrorAtom)
const deploymentsListFetchNextPage = useAtomValue(deploymentsListFetchNextPageAtom)
const deploymentsListHasNextPage = useAtomValue(deploymentsListHasNextPageAtom)
const deploymentsListIsFetching = useAtomValue(deploymentsListIsFetchingAtom)
const deploymentsListIsFetchingNextPage = useAtomValue(deploymentsListIsFetchingNextPageAtom)
const deploymentsListIsLoading = useAtomValue(deploymentsListIsLoadingAtom)
const deploymentsListQuery = useAtomValue(deploymentsListQueryAtom)
const appInstanceSummaries = useAtomValue(deploymentsListRowsAtom)
const showSkeleton = useAtomValue(deploymentsListShowSkeletonAtom)
const showErrorState = useAtomValue(deploymentsListShowErrorStateAtom)
const showEmptyState = useAtomValue(deploymentsListShowEmptyStateAtom)
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>({
error: deploymentsListError,
fetchNextPage: deploymentsListFetchNextPage,
hasNextPage: deploymentsListHasNextPage,
isFetching: deploymentsListIsFetching,
isFetchingNextPage: deploymentsListIsFetchingNextPage,
isLoading: deploymentsListIsLoading,
})
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>(deploymentsListQuery)
return (
<div ref={rootRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
@ -202,7 +185,7 @@ export function DeploymentsListShell() {
summary={summary}
/>
))}
{deploymentsListIsFetchingNextPage && <DeploymentsListSkeleton />}
{deploymentsListQuery.isFetchingNextPage && <DeploymentsListSkeleton />}
<div ref={sentinelRef} aria-hidden="true" className="col-span-full h-px" />
</div>
</div>

View File

@ -1,13 +1,26 @@
import { QueryClient } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { get } from '@/service/base'
import { workspacePermissionKeysQueryOptions } from '../use-permission-keys'
import { useWorkspacePermissionKeys } from '../use-permission-keys'
vi.mock('@/service/base', () => ({
get: vi.fn(),
}))
describe('workspacePermissionKeysQueryOptions', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
describe('useWorkspacePermissionKeys', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(get).mockResolvedValue({
@ -20,15 +33,11 @@ describe('workspacePermissionKeysQueryOptions', () => {
// Current-user permissions come from the my-permissions RBAC endpoint.
describe('Queries', () => {
it('should fetch workspace permission keys', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
renderHook(() => useWorkspacePermissionKeys(), { wrapper: createWrapper() })
await waitFor(() => {
expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/my-permissions')
})
await queryClient.fetchQuery(workspacePermissionKeysQueryOptions())
expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/my-permissions')
})
})
})

View File

@ -1,18 +1,12 @@
import type { PermissionKeysResponse } from '@/models/access-control'
import { queryOptions } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import { useQuery } from '@tanstack/react-query'
import { get } from '../base'
const NAME_SPACE = 'workspace-permission-keys'
const workspacePermissionKeysQueryKey = (workspaceId?: string) => {
return workspaceId ? [NAME_SPACE, workspaceId] as const : [NAME_SPACE] as const
}
export const workspacePermissionKeysQueryOptions = (workspaceId?: string) => {
return queryOptions<PermissionKeysResponse>({
queryKey: workspacePermissionKeysQueryKey(workspaceId),
export const useWorkspacePermissionKeys = () => {
return useQuery({
queryKey: [NAME_SPACE],
queryFn: () => get<PermissionKeysResponse>('/workspaces/current/rbac/my-permissions'),
enabled: workspaceId === undefined || Boolean(workspaceId),
})
}

View File

@ -1,13 +0,0 @@
import type { LangGeniusVersionResponse } from '@/models/common'
import { queryOptions } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import { get } from './base'
import { commonQueryKeys } from './use-common'
export const langGeniusVersionQueryOptions = (currentVersion?: string | null, enabled?: boolean) => {
return queryOptions<LangGeniusVersionResponse>({
queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined),
queryFn: () => get<LangGeniusVersionResponse>('/version', { params: { current_version: currentVersion } }),
enabled: !!currentVersion && (enabled ?? true),
})
}

View File

@ -10,13 +10,13 @@ import type {
CodeBasedExtension,
CommonResponse,
FileUploadConfigResponse,
LangGeniusVersionResponse,
Member,
StructuredOutputRulesRequestBody,
StructuredOutputRulesResponse,
} from '@/models/common'
import type { RETRIEVE_METHOD } from '@/types/app'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import { get, post } from './base'
const NAME_SPACE = 'common'
@ -54,6 +54,14 @@ export const useFileUploadConfig = () => {
})
}
export const useLangGeniusVersion = (currentVersion?: string | null, enabled?: boolean) => {
return useQuery<LangGeniusVersionResponse>({
queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined),
queryFn: () => get<LangGeniusVersionResponse>('/version', { params: { current_version: currentVersion } }),
enabled: !!currentVersion && (enabled ?? true),
})
}
export const useGenerateStructuredOutputRules = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'generate-structured-output-rules'],
@ -87,7 +95,7 @@ export const useMailValidity = () => {
})
}
export type MailRegisterResponse = { result: string, data: Record<string, never> }
export type MailRegisterResponse = { result: string, data: {} }
export const useMailRegister = () => {
return useMutation({
@ -141,7 +149,7 @@ export const useFilePreview = (fileID: string) => {
export type SchemaTypeDefinition = {
name: string
schema: {
properties: Record<string, unknown>
properties: Record<string, any>
}
}