mirror of
https://github.com/langgenius/dify.git
synced 2026-07-09 06:38:10 +08:00
Compare commits
3 Commits
codex/pr-3
...
codex/pr-3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ee3a2e6cb | |||
| d5617b1a82 | |||
| 315dd3f82a |
@ -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.
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 "")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()} />)
|
||||
|
||||
|
||||
@ -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) && (
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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])
|
||||
}
|
||||
@ -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 || '',
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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() })
|
||||
})
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ?? ''
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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))
|
||||
})
|
||||
|
||||
@ -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) ?? []
|
||||
})
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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),
|
||||
})
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user