Merge branch 'main' into feat/refine-snippet-siderbar

This commit is contained in:
JzoNg 2026-06-24 14:03:09 +08:00
commit f2c07194f8
80 changed files with 1127 additions and 796 deletions

View File

@ -39,12 +39,15 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- For Jotai-backed surfaces, put shared query atoms, mutation atoms, derived state, and write actions in the feature state file when they coordinate multiple descendants. Do not create a query or mutation atom only because the surrounding feature uses Jotai. If the query or mutation does not read atom state, feed another atom, or participate in shared workflow orchestration, use `useQuery` or `useMutation` directly at the lowest owner.
- For repeated row/menu action surfaces that need reset, hydrate the stable identity at the surface entry and scope only the primitives that truly need per-instance reset, such as open flags, drafts, or selected local options.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
- Default to uncontrolled form and DOM state. Add controlled props or atom-backed drafts only when live cross-component reactions, multi-step persistence, or external synchronization require them.
## Feature-Scoped Jotai State
- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, shared query atoms, derived atoms, write-only action atoms, shared mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Keep synchronous UI state local when one component owns it, even inside Jotai-backed features. Dialog open flags, menu/popover visibility, confirmation visibility, form/input drafts, and selected local options usually belong in component state.
- Do not put simple form drafts in Jotai atoms. For edit/create forms whose fields are only read at submit time, use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls with `defaultValue`, browser/form validation, and keyed remounts for query-backed initial values.
- Promote form state to Jotai only when another component must react to in-progress field changes, the draft must survive unmount/remount within the same scoped workflow, or multiple steps/surfaces share the same editable draft before submit.
- Keep submit-time normalization, dirty checks, and payload shaping beside the form submit handler. Do not create form atoms, field atoms, or derived can-save atoms only to mirror uncontrolled form values or disable a submit button.
- In Jotai-backed feature surfaces, never hand-roll async loading, error, or in-flight guards with `useState` or `useRef`. For async work that depends on atom state, feeds derived atoms, or participates in shared submission orchestration, model the work with `atomWithQuery` or `atomWithMutation`; write atoms should only update the inputs that drive those atoms. For component-owned remote work that does not participate in atom state, use TanStack Query hooks directly.
- Row-local async state should belong to the row owner. Use `useQuery` or `useMutation` directly for row actions that do not depend on atom state and are not consumed by other atoms. Use a per-instance query or mutation atom only when the row action participates in a Jotai-backed shared workflow or needs atom-scoped reset semantics.
- Promote UI state to an atom only when siblings need the same source of truth, the value drives a query or mutation atom, a parent workflow coordinates the state, or the state intentionally persists across hidden or unmounted descendants within a scoped surface.
@ -60,6 +63,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query and mutation atoms keep shared cache behavior through the shared QueryClient.
- Do not put `atomWithQuery`, `atomWithInfiniteQuery`, `atomWithMutation`, or broad derived orchestration atoms in a `ScopeProvider` just to reset a surface. Scoped derived atoms implicitly scope their dependencies, which can duplicate query client access and break shared invalidation. Leave query/mutation atoms unscoped; let them read scoped primitive inputs.
- Scope providers should list resettable primitive atoms and explicit hydration tuples. If a derived atom must be scoped, confirm that every dependency it implicitly scopes is meant to be private to that surface.
- For scoped primitives that are always hydrated by a `ScopeProvider` tuple, prefer `atomWithLazy<T>(() => { throw new Error(...) })` when consumers should see a non-null type. This keeps missing provider hydration as a runtime invariant without leaking `T | undefined` or adding pass-through "required" derived atoms only for narrowing.
- Keep independent dialog lifecycles separate. Avoid a single discriminated "current action dialog" atom when edit, delete, and other dialogs have their own open state, loading guard, or reset behavior.
- Route-derived stable identities that do not need instance reset or scoped isolation can be hydrated at the route or layout boundary into a feature route atom. Use scoped atoms only when stale cross-instance state or per-surface reset semantics are needed.
@ -106,6 +110,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- Do not promote a query or mutation to an atom just because the feature already has a state file. Use `atomWithQuery` or `atomWithMutation` only when the query/mutation reads atom state, is consumed by another atom, or is part of shared workflow orchestration.
- In `atomWithQuery` and `atomWithInfiniteQuery`, return generated `queryOptions()` or `infiniteOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into that call instead of spreading generated options into a hand-built object.
- When prefetch and render consume the same server request, extract local query options or a query-options atom so `queryClient.prefetchQuery(...)` and `useQuery`/`atomWithQuery` share the exact generated query options.
- In `atomWithMutation`, return generated `mutationOptions()` directly when using generated clients. Put request shaping and submit orchestration in write atoms; do not rebuild mutation option objects just to pass through the generated mutation function.
- For custom query functions that do not come from generated clients, wrap the options object with TanStack `queryOptions(...)` so query atoms still return a query options contract.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it.
@ -116,6 +121,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when the mutation is owned by one component, menu, dialog, or row and its pending/error state is not consumed by feature atoms. In Jotai-backed workflow orchestration, expose mutations from feature state with `atomWithMutation` so pending/error state stays attached to the mutation atom. For component-owned custom mutation functions, use `useMutation(mutationOptions(...))` at the owner.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
- Component or atom mutation callbacks can handle local UI feedback such as toasts, closing dialogs, or navigation. They should not replace shared invalidation or add local cache patches for shared server state.
- For overlays that may open a heavier secondary surface, prefetch server data from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when the primitive exposes `onOpenChange`. Do not mount a hidden component or subscribe to a query only to warm the cache. Do not make an otherwise uncontrolled menu controlled only for prefetching.
- Do not use deprecated `useInvalid` or `useReset`.
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`.
@ -128,6 +134,9 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- When a dialog, dropdown, or popover component already accepts controlled `open` state, mount the surface unconditionally unless unmounting is required for performance or reset semantics. Use keyed scope or local state reset for reset behavior instead of `{open && <Surface />}` wrappers.
- When opening a dialog from a menu item, keep the menu and dialog as sibling surfaces. Let the menu item command open the dialog through local state or scoped atoms, and mount the dialog outside the menu popup content. Avoid wrapping menu items with dialog triggers when the menu primitive already owns item activation and dismissal behavior.
- For dialogs and alert dialogs, keep the root component responsible for `open` wiring and put query/mutation hooks inside the content component when the work should only mount after the overlay opens. Do not put closed-surface remote work in the root just because the root owns the open atom.
- Prefer uncontrolled overlay roots when the library can own their open state. Use `onOpenChange` for side effects such as prefetching, and CSS/data selectors for visual open-state styling instead of adding controlled state only for observation.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, children-as-pass-through composition, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook, forwards props, or passes trigger/content through to one child, move the logic into that child or make the wrapper own a real surface.

View File

@ -25,7 +25,7 @@ def reset_password(email, new_password, password_confirm):
return
normalized_email = email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(email.strip())
account = AccountService.get_account_by_email_with_case_fallback(db.session, email.strip())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@ -67,7 +67,7 @@ def reset_email(email, new_email, email_confirm):
return
normalized_new_email = new_email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(email.strip())
account = AccountService.get_account_by_email_with_case_fallback(db.session, email.strip())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))

View File

@ -91,7 +91,10 @@ class AgentAppFeatureConfigResource(Resource):
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
new_app_model_config = AgentAppFeatureConfigService.update_features(
app_model=app_model, account=current_user, config=args.model_dump(exclude_none=True), session=db.session
app_model=app_model,
account=current_user,
config=args.model_dump(exclude_none=True),
session=db.session,
)
app_model_config_was_updated.send(app_model, app_model_config=new_app_model_config)

View File

@ -30,6 +30,7 @@ from controllers.console.wraps import (
setup_required,
)
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import login_required
from models import App, AppMode
@ -142,6 +143,7 @@ class ChatMessageTextApi(Resource):
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
text=payload.text,
voice=payload.voice,
message_id=payload.message_id,

View File

@ -15,6 +15,7 @@ from controllers.console.auth.error import (
InvalidTokenError,
PasswordMismatchError,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import EmailStr, extract_remote_ip
from libs.helper import timezone as validate_timezone_string
@ -100,7 +101,7 @@ class EmailRegisterSendEmailApi(Resource):
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
raise AccountInFreezeError()
account = AccountService.get_account_by_email_with_case_fallback(args.email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, args.email)
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
return {"result": "success", "data": token}
@ -175,7 +176,7 @@ class EmailRegisterResetApi(Resource):
email = register_data.get("email", "")
normalized_email = email.lower()
account = AccountService.get_account_by_email_with_case_fallback(email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
if account:
raise EmailAlreadyInUseError()

View File

@ -82,7 +82,7 @@ class ForgotPasswordSendEmailApi(Resource):
else:
language = "en-US"
account = AccountService.get_account_by_email_with_case_fallback(args.email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, args.email)
token = AccountService.send_reset_password_email(
account=account,
@ -180,7 +180,7 @@ class ForgotPasswordResetApi(Resource):
password_hashed = hash_password(args.new_password, salt)
email = reset_data.get("email", "")
account = AccountService.get_account_by_email_with_case_fallback(email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
if account:
account = db.session.merge(account)

View File

@ -224,7 +224,7 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
account: Account | None = Account.get_by_openid(provider, user_info.id)
if not account:
account = AccountService.get_account_by_email_with_case_fallback(user_info.email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, user_info.email)
return account

View File

@ -20,6 +20,7 @@ from controllers.console.app.error import (
)
from controllers.console.explore.wraps import InstalledAppResource
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from models.model import InstalledApp
from services.audio_service import AudioService
@ -99,7 +100,13 @@ class ChatTextApi(InstalledAppResource):
text = payload.text
voice = payload.voice
response = AudioService.transcript_tts(app_model=app_model, text=text, voice=voice, message_id=message_id)
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
text=text,
voice=voice,
message_id=message_id,
)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")

View File

@ -419,7 +419,13 @@ class TrialChatTextApi(TrialAppResource):
app_id = app_model.id
user_id = current_user.id
response = AudioService.transcript_tts(app_model=app_model, text=text, voice=voice, message_id=message_id)
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
text=text,
voice=voice,
message_id=message_id,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:

View File

@ -131,7 +131,7 @@ def _normalize_invitee_emails(emails: list[str]) -> list[str]:
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> int:
new_member_count = 0
for email in emails:
account = AccountService.get_account_by_email_with_case_fallback(email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
if not account:
new_member_count += 1
continue

View File

@ -14,6 +14,7 @@ from controllers.common.schema import register_response_schema_models, register_
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import plugin_inner_api_only
from core.workflow.nodes.knowledge_retrieval import exc as retrieval_exc
from extensions.ext_database import db
from libs.exception import BaseHTTPException
from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest, InnerKnowledgeRetrieveResponse
from services.errors.knowledge_retrieval import ExternalKnowledgeRetrievalError, InnerKnowledgeRetrievalServiceError
@ -81,7 +82,7 @@ class InnerKnowledgeRetrieveApi(Resource):
) from exc
try:
response = InnerKnowledgeRetrievalService().retrieve(payload)
response = InnerKnowledgeRetrievalService().retrieve(payload, session=db.session)
except InnerKnowledgeRetrievalServiceError as exc:
raise InnerKnowledgeRetrievalHttpError(
error_code=exc.error_code,

View File

@ -193,7 +193,7 @@ class WorkspaceMembersApi(Resource):
raise BadRequest(str(exc))
normalized_email = body.email.lower()
member = AccountService.get_account_by_email_with_case_fallback(normalized_email)
member = AccountService.get_account_by_email_with_case_fallback(db.session, normalized_email)
if member is None:
# invite_new_member just created or fetched this account.
raise RuntimeError("invited member missing from DB after invite")

View File

@ -23,6 +23,7 @@ from controllers.service_api.app.error import (
from controllers.service_api.schema import binary_response, expect_with_user, multipart_file_params
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from models.model import App, EndUser
from services.audio_service import AudioService
@ -177,7 +178,12 @@ class TextApi(Resource):
text = payload.text
voice = payload.voice
response = AudioService.transcript_tts(
app_model=app_model, text=text, voice=voice, end_user=end_user.external_user_id, message_id=message_id
app_model=app_model,
session=db.session,
text=text,
voice=voice,
end_user=end_user.external_user_id,
message_id=message_id,
)
return response

View File

@ -22,6 +22,7 @@ from controllers.web.error import (
)
from controllers.web.wraps import WebApiResource
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import uuid_value
from models.model import App, EndUser
@ -130,7 +131,12 @@ class TextApi(WebApiResource):
text = payload.text
voice = payload.voice
response = AudioService.transcript_tts(
app_model=app_model, text=text, voice=voice, end_user=end_user.external_user_id, message_id=message_id
app_model=app_model,
session=db.session,
text=text,
voice=voice,
end_user=end_user.external_user_id,
message_id=message_id,
)
return response

View File

@ -69,7 +69,7 @@ class ForgotPasswordSendEmailApi(Resource):
else:
language = "en-US"
account = AccountService.get_account_by_email_with_case_fallback(request_email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, request_email)
if account is None:
raise AuthenticationFailedError()
else:
@ -168,7 +168,7 @@ class ForgotPasswordResetApi(Resource):
email = reset_data.get("email", "")
account = AccountService.get_account_by_email_with_case_fallback(email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
if account:
account = db.session.merge(account)

View File

@ -14,7 +14,6 @@ from werkzeug.exceptions import Unauthorized
from configs import dify_config
from constants.languages import get_valid_language, language_timezone_mapping
from core.db.session_factory import session_factory
from events.tenant_event import tenant_was_created
from extensions.ext_database import db
from extensions.ext_redis import redis_client, redis_fallback
@ -981,19 +980,18 @@ class AccountService:
return token
@staticmethod
def get_account_by_email_with_case_fallback(email: str) -> Account | None:
def get_account_by_email_with_case_fallback(session: Session | scoped_session, email: str) -> Account | None:
"""
Retrieve an account by email and fall back to the lowercase email if the original lookup fails.
This keeps backward compatibility for older records that stored uppercase emails while the
rest of the system gradually normalizes new inputs.
"""
with session_factory.create_session() as session:
account = session.execute(select(Account).where(Account.email == email)).scalar_one_or_none()
if account or email == email.lower():
return account
account = session.execute(select(Account).where(Account.email == email)).scalar_one_or_none()
if account or email == email.lower():
return account
return session.execute(select(Account).where(Account.email == email.lower())).scalar_one_or_none()
return session.execute(select(Account).where(Account.email == email.lower())).scalar_one_or_none()
@classmethod
def get_email_code_login_data(cls, token: str) -> dict[str, Any] | None:
@ -1958,7 +1956,7 @@ class RegisterService:
check_workspace_member_invite_permission(tenant.id)
account = AccountService.get_account_by_email_with_case_fallback(email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
requires_setup = False
if not account:

View File

@ -69,7 +69,12 @@ class AgentAppFeatureConfigService:
@classmethod
def update_features(
cls, *, app_model: App, account: Account, config: dict[str, Any], session: scoped_session
cls,
*,
app_model: App,
account: Account,
config: dict[str, Any],
session: scoped_session,
) -> AppModelConfig:
"""Persist the presentation features as a new app_model_config version.

View File

@ -5,11 +5,11 @@ from collections.abc import Generator
from typing import cast
from flask import Response, stream_with_context
from sqlalchemy.orm import Session, scoped_session
from werkzeug.datastructures import FileStorage
from constants import AUDIO_EXTENSIONS
from core.model_manager import ModelManager
from extensions.ext_database import db
from graphon.model_runtime.entities.model_entities import ModelType
from models.enums import MessageStatus
from models.model import App, AppMode, Message
@ -77,6 +77,8 @@ class AudioService:
def transcript_tts(
cls,
app_model: App,
*,
session: Session | scoped_session,
text: str | None = None,
voice: str | None = None,
end_user: str | None = None,
@ -87,7 +89,7 @@ class AudioService:
if voice is None:
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
if is_draft:
workflow = WorkflowService().get_draft_workflow(app_model=app_model)
workflow = WorkflowService().get_draft_workflow(app_model=app_model, session=session)
else:
workflow = app_model.workflow
if (
@ -132,7 +134,7 @@ class AudioService:
uuid.UUID(message_id)
except ValueError:
return None
message = db.session.get(Message, message_id)
message = session.get(Message, message_id)
if message is None:
return None
if message.answer == "" and message.status in {MessageStatus.NORMAL, MessageStatus.PAUSED}:

View File

@ -13,11 +13,11 @@ of a separate validation error.
"""
from sqlalchemy import select
from sqlalchemy.orm import scoped_session
from core.rag.entities.metadata_entities import Condition, MetadataFilteringCondition
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
from core.workflow.nodes.knowledge_retrieval.retrieval import KnowledgeRetrievalRequest
from extensions.ext_database import db
from graphon.model_runtime.utils.encoders import jsonable_encoder
from graphon.nodes.llm.entities import ModelConfig
from models.dataset import Dataset
@ -38,7 +38,11 @@ from services.errors.knowledge_retrieval import (
class InnerKnowledgeRetrievalService:
"""Validate inner caller scope and delegate to workflow dataset retrieval."""
def retrieve(self, request: InnerKnowledgeRetrieveRequest) -> InnerKnowledgeRetrieveResponse:
def retrieve(
self,
request: InnerKnowledgeRetrieveRequest,
session: scoped_session,
) -> InnerKnowledgeRetrieveResponse:
"""Run tenant-scoped retrieval for a trusted internal caller.
This method only rejects caller app existence/tenant mismatches and
@ -56,8 +60,8 @@ class InnerKnowledgeRetrievalService:
InnerKnowledgeRetrieveDatasetTenantMismatchError:
At least one requested dataset is outside the caller tenant.
"""
self._validate_caller_app(tenant_id=request.caller.tenant_id, app_id=request.caller.app_id)
self._validate_datasets(tenant_id=request.caller.tenant_id, dataset_ids=request.dataset_ids)
self._validate_caller_app(tenant_id=request.caller.tenant_id, app_id=request.caller.app_id, session=session)
self._validate_datasets(tenant_id=request.caller.tenant_id, dataset_ids=request.dataset_ids, session=session)
rag = DatasetRetrieval()
results = rag.knowledge_retrieval(request=self._to_rag_request(request))
@ -66,8 +70,8 @@ class InnerKnowledgeRetrievalService:
usage=InnerKnowledgeRetrieveUsage.model_validate(jsonable_encoder(rag.llm_usage)),
)
def _validate_caller_app(self, *, tenant_id: str, app_id: str) -> None:
app = db.session.scalar(select(App).where(App.id == app_id).limit(1))
def _validate_caller_app(self, *, tenant_id: str, app_id: str, session: scoped_session) -> None:
app = session.scalar(select(App).where(App.id == app_id).limit(1))
if app is None:
raise InnerKnowledgeRetrieveAppNotFoundError(f"App '{app_id}' not found")
if app.tenant_id != tenant_id:
@ -75,8 +79,8 @@ class InnerKnowledgeRetrievalService:
f"App '{app_id}' does not belong to tenant '{tenant_id}'"
)
def _validate_datasets(self, *, tenant_id: str, dataset_ids: list[str]) -> None:
datasets = db.session.scalars(select(Dataset).where(Dataset.id.in_(dataset_ids))).all()
def _validate_datasets(self, *, tenant_id: str, dataset_ids: list[str], session: scoped_session) -> None:
datasets = session.scalars(select(Dataset).where(Dataset.id.in_(dataset_ids))).all()
found_ids = {dataset.id for dataset in datasets}
missing_ids = sorted(set(dataset_ids) - found_ids)

View File

@ -35,7 +35,7 @@ class WebAppAuthService:
@staticmethod
def authenticate(email: str, password: str) -> Account:
"""authenticate account with email and password"""
account = AccountService.get_account_by_email_with_case_fallback(email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
if not account:
raise AccountNotFoundError()
@ -55,7 +55,7 @@ class WebAppAuthService:
@classmethod
def get_user_through_email(cls, email: str):
account = AccountService.get_account_by_email_with_case_fallback(email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
if not account:
return None

View File

@ -6,7 +6,7 @@ from collections.abc import Callable, Generator, Mapping, Sequence
from typing import Any, cast
from sqlalchemy import exists, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from configs import dify_config
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
@ -142,7 +142,7 @@ class WorkflowService:
return db.session.execute(stmt).scalar_one()
def get_draft_workflow(
self, app_model: App, workflow_id: str | None = None, session: Session | None = None
self, app_model: App, workflow_id: str | None = None, session: Session | scoped_session | None = None
) -> Workflow | None:
"""
Get draft workflow
@ -169,7 +169,7 @@ class WorkflowService:
return workflow
def get_published_workflow_by_id(
self, app_model: App, workflow_id: str, session: Session | None = None
self, app_model: App, workflow_id: str, session: Session | scoped_session | None = None
) -> Workflow | None:
"""
fetch published workflow by workflow_id

View File

@ -270,10 +270,7 @@ def test_get_account_by_email_with_case_fallback_falls_back_to_lowercase():
second_result.scalar_one_or_none.return_value = expected_account
mock_session.execute.side_effect = [first_result, second_result]
with patch("services.account_service.session_factory") as mock_factory:
mock_factory.create_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_factory.create_session.return_value.__exit__ = MagicMock(return_value=False)
result = AccountService.get_account_by_email_with_case_fallback("Case@Test.com")
result = AccountService.get_account_by_email_with_case_fallback(mock_session, "Case@Test.com")
assert result is expected_account
assert mock_session.execute.call_count == 2

View File

@ -165,10 +165,7 @@ def test_get_account_by_email_with_case_fallback_falls_back_to_lowercase():
second_result.scalar_one_or_none.return_value = expected_account
mock_session.execute.side_effect = [first_result, second_result]
with patch("services.account_service.session_factory") as mock_factory:
mock_factory.create_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_factory.create_session.return_value.__exit__ = MagicMock(return_value=False)
result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com")
result = AccountService.get_account_by_email_with_case_fallback(mock_session, "Mixed@Test.com")
assert result is expected_account
assert mock_session.execute.call_count == 2

View File

@ -494,10 +494,7 @@ class TestAccountGeneration:
second_result.scalar_one_or_none.return_value = expected_account
mock_session.execute.side_effect = [first_result, second_result]
with patch("services.account_service.session_factory") as mock_factory:
mock_factory.create_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_factory.create_session.return_value.__exit__ = MagicMock(return_value=False)
result = AccountService.get_account_by_email_with_case_fallback("Case@Test.com")
result = AccountService.get_account_by_email_with_case_fallback(mock_session, "Case@Test.com")
assert result is expected_account
assert mock_session.execute.call_count == 2

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import base64
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from unittest.mock import ANY, MagicMock, patch
import pytest
from flask import Flask
@ -57,7 +57,7 @@ class TestForgotPasswordSendEmailApi:
response = ForgotPasswordSendEmailApi().post()
assert response == {"result": "success", "data": "token-123"}
mock_get_account.assert_called_once_with("User@Example.com")
mock_get_account.assert_called_once_with(ANY, "User@Example.com")
mock_send_mail.assert_called_once_with(account=mock_account, email="user@example.com", language="zh-Hans")
mock_extract_ip.assert_called_once()
mock_rate_limit.assert_called_once_with("127.0.0.1")
@ -177,7 +177,7 @@ class TestForgotPasswordResetApi:
response = ForgotPasswordResetApi().post()
assert response == {"result": "success"}
mock_get_account.assert_called_once_with("User@Example.com")
mock_get_account.assert_called_once_with(ANY, "User@Example.com")
mock_update_account.assert_called_once()
mock_revoke_token.assert_called_once_with("token-123")

View File

@ -158,6 +158,7 @@ class TestAudioServiceTranscriptTTSMessageLookup:
with patch("services.audio_service.ModelManager.for_tenant", return_value=mock_model_manager):
result = AudioService.transcript_tts(
app_model=app,
session=db_session_with_containers,
message_id=message.id,
voice="en-US-Neural",
)
@ -174,6 +175,7 @@ class TestAudioServiceTranscriptTTSMessageLookup:
result = AudioService.transcript_tts(
app_model=app,
session=db_session_with_containers,
message_id="invalid-uuid",
)
@ -185,6 +187,7 @@ class TestAudioServiceTranscriptTTSMessageLookup:
result = AudioService.transcript_tts(
app_model=app,
session=db_session_with_containers,
message_id=str(uuid4()),
)
@ -205,6 +208,7 @@ class TestAudioServiceTranscriptTTSMessageLookup:
result = AudioService.transcript_tts(
app_model=app,
session=db_session_with_containers,
message_id=message.id,
)

View File

@ -692,12 +692,7 @@ def test_get_account_by_email_with_case_fallback_uses_lowercase_lookup():
second.scalar_one_or_none.return_value = expected_account
mock_session.execute.side_effect = [first, second]
mock_factory = MagicMock()
mock_factory.create_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_factory.create_session.return_value.__exit__ = MagicMock(return_value=False)
with patch("services.account_service.session_factory", mock_factory):
result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com")
result = AccountService.get_account_by_email_with_case_fallback(mock_session, "Mixed@Test.com")
assert result is expected_account
assert mock_session.execute.call_count == 2

View File

@ -176,6 +176,7 @@ class TestAudioServiceMockedBehavior:
result = AudioService.transcript_tts(
app_model=mock_app,
session=Mock(),
text="Hello world",
voice="nova",
end_user="user_123",

View File

@ -1821,7 +1821,7 @@ class TestRegisterService:
status=AccountStatus.PENDING,
is_setup=True,
)
mock_lookup.assert_called_once_with("newuser@example.com")
mock_lookup.assert_called_once_with(mock_db_dependencies["db"].session, "newuser@example.com")
def test_invite_new_member_normalizes_new_account_email(
self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies
@ -1865,7 +1865,7 @@ class TestRegisterService:
status=AccountStatus.PENDING,
is_setup=True,
)
mock_lookup.assert_called_once_with(mixed_email)
mock_lookup.assert_called_once_with(mock_db_dependencies["db"].session, mixed_email)
mock_check_permission.assert_called_once_with(mock_tenant, mock_inviter, None, "add")
mock_create_member.assert_called_once_with(
mock_tenant, mock_new_account, mock_db_dependencies["db"].session, "normal"
@ -1923,7 +1923,7 @@ class TestRegisterService:
mock_tenant, mock_existing_account, "normal", requires_setup=True
)
mock_task_dependencies.delay.assert_called_once()
mock_lookup.assert_called_once_with("existing@example.com")
mock_lookup.assert_called_once_with(mock_db_dependencies["db"].session, "existing@example.com")
def test_invite_existing_active_account_requires_acceptance_before_joining(
self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies

View File

@ -398,6 +398,7 @@ class TestAudioServiceTTS:
# Act
result = AudioService.transcript_tts(
app_model=app,
session=MagicMock(),
text="Hello world",
voice="en-US-Neural",
end_user="user-123",
@ -432,6 +433,7 @@ class TestAudioServiceTTS:
# Act
result = AudioService.transcript_tts(
app_model=app,
session=MagicMock(),
text="Test",
)
@ -465,6 +467,7 @@ class TestAudioServiceTTS:
# Act
result = AudioService.transcript_tts(
app_model=app,
session=MagicMock(),
text="Test",
)
@ -496,17 +499,52 @@ class TestAudioServiceTTS:
mock_model_instance = MagicMock()
mock_model_instance.invoke_tts.return_value = b"draft audio"
mock_model_manager.get_default_model_instance.return_value = mock_model_instance
session = MagicMock()
# Act
result = AudioService.transcript_tts(
app_model=app,
session=session,
text="Draft test",
is_draft=True,
)
# Assert
assert result == b"draft audio"
mock_workflow_service.get_draft_workflow.assert_called_once_with(app_model=app)
mock_workflow_service.get_draft_workflow.assert_called_once_with(app_model=app, session=session)
@patch("services.audio_service.ModelManager.for_tenant", autospec=True)
def test_transcript_tts_message_id_uses_provided_session(
self, mock_model_manager_class, factory: AudioServiceTestDataFactory
):
"""Test TTS message lookup uses the injected session."""
# Arrange
app = factory.create_app_mock(mode=AppMode.CHAT)
message_id = "00000000-0000-0000-0000-000000000001"
message = factory.create_message_mock(message_id=message_id, answer="Message answer")
session = MagicMock()
session.get.return_value = message
mock_model_manager = mock_model_manager_class.return_value
mock_model_instance = MagicMock()
mock_model_instance.invoke_tts.return_value = b"message audio"
mock_model_manager.get_default_model_instance.return_value = mock_model_instance
# Act
result = AudioService.transcript_tts(
app_model=app,
session=session,
message_id=message_id,
voice="message-voice",
)
# Assert
assert result == b"message audio"
session.get.assert_called_once_with(Message, message_id)
mock_model_instance.invoke_tts.assert_called_once_with(
content_text="Message answer",
voice="message-voice",
)
def test_transcript_tts_raises_error_when_text_missing(self, factory: AudioServiceTestDataFactory):
"""Test that TTS raises error when text is missing."""
@ -515,7 +553,7 @@ class TestAudioServiceTTS:
# Act & Assert
with pytest.raises(ValueError, match="Text is required"):
AudioService.transcript_tts(app_model=app, text=None)
AudioService.transcript_tts(app_model=app, session=MagicMock(), text=None)
@patch("services.audio_service.ModelManager.for_tenant", autospec=True)
def test_transcript_tts_raises_error_when_no_voices_available(
@ -539,7 +577,7 @@ class TestAudioServiceTTS:
# Act & Assert
with pytest.raises(ValueError, match="Sorry, no voice available"):
AudioService.transcript_tts(app_model=app, text="Test")
AudioService.transcript_tts(app_model=app, session=MagicMock(), text="Test")
class TestAudioServiceTTSVoices:

View File

@ -74,14 +74,14 @@ def _build_source() -> Source:
class TestInnerKnowledgeRetrievalService:
@patch("services.knowledge_retrieval_inner_service.DatasetRetrieval")
@patch("services.knowledge_retrieval_inner_service.db")
def test_retrieve_maps_multiple_request_and_skips_enable_api_check(self, mock_db, mock_rag_cls):
def test_retrieve_maps_multiple_request_and_skips_enable_api_check(self, mock_rag_cls):
request = _build_request()
mock_session = MagicMock()
mock_app = MagicMock(id="app-1", tenant_id="tenant-1")
dataset_1 = MagicMock(id="dataset-1", tenant_id="tenant-1", enable_api=False)
dataset_2 = MagicMock(id="dataset-2", tenant_id="tenant-1", enable_api=True)
mock_db.session.scalar.return_value = mock_app
mock_db.session.scalars.return_value.all.return_value = [dataset_1, dataset_2]
mock_session.scalar.return_value = mock_app
mock_session.scalars.return_value.all.return_value = [dataset_1, dataset_2]
rag = MagicMock()
rag.knowledge_retrieval.return_value = [_build_source()]
@ -101,7 +101,7 @@ class TestInnerKnowledgeRetrievalService:
}
mock_rag_cls.return_value = rag
response = InnerKnowledgeRetrievalService().retrieve(request)
response = InnerKnowledgeRetrievalService().retrieve(request, mock_session)
rag_request = rag.knowledge_retrieval.call_args.kwargs["request"]
assert rag_request.tenant_id == "tenant-1"
@ -127,8 +127,7 @@ class TestInnerKnowledgeRetrievalService:
assert response.usage.currency == "USD"
@patch("services.knowledge_retrieval_inner_service.DatasetRetrieval")
@patch("services.knowledge_retrieval_inner_service.db")
def test_retrieve_maps_single_request(self, mock_db, mock_rag_cls):
def test_retrieve_maps_single_request(self, mock_rag_cls):
request = _build_request(
dataset_ids=["dataset-1"],
retrieval={
@ -151,8 +150,9 @@ class TestInnerKnowledgeRetrievalService:
},
attachment_ids=[],
)
mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1")
mock_db.session.scalars.return_value.all.return_value = [MagicMock(id="dataset-1", tenant_id="tenant-1")]
mock_session = MagicMock()
mock_session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1")
mock_session.scalars.return_value.all.return_value = [MagicMock(id="dataset-1", tenant_id="tenant-1")]
rag = MagicMock()
rag.knowledge_retrieval.return_value = []
@ -172,7 +172,7 @@ class TestInnerKnowledgeRetrievalService:
}
mock_rag_cls.return_value = rag
InnerKnowledgeRetrievalService().retrieve(request)
InnerKnowledgeRetrievalService().retrieve(request, mock_session)
rag_request = rag.knowledge_retrieval.call_args.kwargs["request"]
assert rag_request.retrieval_mode == "single"
@ -184,35 +184,35 @@ class TestInnerKnowledgeRetrievalService:
assert rag_request.metadata_model_config is not None
assert rag_request.metadata_model_config.provider == "openai"
@patch("services.knowledge_retrieval_inner_service.db")
def test_retrieve_raises_when_app_missing(self, mock_db):
mock_db.session.scalar.return_value = None
def test_retrieve_raises_when_app_missing(self):
mock_session = MagicMock()
mock_session.scalar.return_value = None
with pytest.raises(InnerKnowledgeRetrieveAppNotFoundError):
InnerKnowledgeRetrievalService().retrieve(_build_request())
InnerKnowledgeRetrievalService().retrieve(_build_request(), mock_session)
@patch("services.knowledge_retrieval_inner_service.db")
def test_retrieve_raises_when_app_belongs_to_other_tenant(self, mock_db):
mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-2")
def test_retrieve_raises_when_app_belongs_to_other_tenant(self):
mock_session = MagicMock()
mock_session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-2")
with pytest.raises(InnerKnowledgeRetrieveAppTenantMismatchError):
InnerKnowledgeRetrievalService().retrieve(_build_request())
InnerKnowledgeRetrievalService().retrieve(_build_request(), mock_session)
@patch("services.knowledge_retrieval_inner_service.db")
def test_retrieve_raises_when_dataset_missing(self, mock_db):
mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1")
mock_db.session.scalars.return_value.all.return_value = [MagicMock(id="dataset-1", tenant_id="tenant-1")]
def test_retrieve_raises_when_dataset_missing(self):
mock_session = MagicMock()
mock_session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1")
mock_session.scalars.return_value.all.return_value = [MagicMock(id="dataset-1", tenant_id="tenant-1")]
with pytest.raises(InnerKnowledgeRetrieveDatasetNotFoundError):
InnerKnowledgeRetrievalService().retrieve(_build_request())
InnerKnowledgeRetrievalService().retrieve(_build_request(), mock_session)
@patch("services.knowledge_retrieval_inner_service.db")
def test_retrieve_raises_when_dataset_belongs_to_other_tenant(self, mock_db):
mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1")
mock_db.session.scalars.return_value.all.return_value = [
def test_retrieve_raises_when_dataset_belongs_to_other_tenant(self):
mock_session = MagicMock()
mock_session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1")
mock_session.scalars.return_value.all.return_value = [
MagicMock(id="dataset-1", tenant_id="tenant-1"),
MagicMock(id="dataset-2", tenant_id="tenant-2"),
]
with pytest.raises(InnerKnowledgeRetrieveDatasetTenantMismatchError):
InnerKnowledgeRetrievalService().retrieve(_build_request())
InnerKnowledgeRetrievalService().retrieve(_build_request(), mock_session)

View File

@ -3668,11 +3668,6 @@
"count": 1
}
},
"web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx": {
"jsx-a11y/role-has-required-aria-props": {
"count": 1
}
},
"web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx": {
"jsx-a11y/no-autofocus": {
"count": 1

View File

@ -162,6 +162,7 @@ html[data-theme="dark"] {
--color-components-main-nav-glass-surface-middle-2: #0033ff1a;
--color-components-main-nav-glass-surface-end: #0033ff14;
--color-components-main-nav-glass-edge-highlight-first: #fffffffa;
--color-components-main-nav-glass-edge-highlight-middle: #ffffff00;
--color-components-main-nav-glass-edge-highlight-end: #ffffff6b;
--color-components-main-nav-glass-edge-reflection-first: #0033ff00;
--color-components-main-nav-glass-edge-reflection-middle: #0033ff99;

View File

@ -162,6 +162,7 @@ html[data-theme="light"] {
--color-components-main-nav-glass-surface-middle-2: #0033ff1a;
--color-components-main-nav-glass-surface-end: #0033ff14;
--color-components-main-nav-glass-edge-highlight-first: #fffffffa;
--color-components-main-nav-glass-edge-highlight-middle: #ffffff00;
--color-components-main-nav-glass-edge-highlight-end: #ffffff6b;
--color-components-main-nav-glass-edge-reflection-first: #0033ff00;
--color-components-main-nav-glass-edge-reflection-middle: #0033ff99;

View File

@ -169,6 +169,7 @@
--color-components-main-nav-glass-surface-middle-2: var(--color-components-main-nav-glass-surface-middle-2);
--color-components-main-nav-glass-surface-end: var(--color-components-main-nav-glass-surface-end);
--color-components-main-nav-glass-edge-highlight-first: var(--color-components-main-nav-glass-edge-highlight-first);
--color-components-main-nav-glass-edge-highlight-middle: var(--color-components-main-nav-glass-edge-highlight-middle);
--color-components-main-nav-glass-edge-highlight-end: var(--color-components-main-nav-glass-edge-highlight-end);
--color-components-main-nav-glass-edge-reflection-first: var(--color-components-main-nav-glass-edge-reflection-first);
--color-components-main-nav-glass-edge-reflection-middle: var(--color-components-main-nav-glass-edge-reflection-middle);

View File

@ -248,7 +248,7 @@ describe('AccountDropdown', () => {
fireEvent.click(screen.getByText('common.settings.preferences'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.LANGUAGE })
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })
})
it('should show Appearance after Preferences in the main nav account dropdown', () => {

View File

@ -127,7 +127,7 @@ export function MainNavMenuContent({
</DropdownMenuLinkItem>
<DropdownMenuItem
className={mainNavMenuItemClassName}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.LANGUAGE })}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })}
>
<MenuItemContent
iconClassName="i-ri-equalizer-2-line"

View File

@ -26,6 +26,7 @@ describe('AccountSetting Constants', () => {
expect(ACCOUNT_SETTING_TAB.DATA_SOURCE).toBe('data-source')
expect(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION).toBe('custom-endpoint')
expect(ACCOUNT_SETTING_TAB.CUSTOM).toBe('custom')
expect(ACCOUNT_SETTING_TAB.PREFERENCES).toBe('preferences')
expect(ACCOUNT_SETTING_TAB.LANGUAGE).toBe('language')
})
@ -42,6 +43,7 @@ describe('AccountSetting Constants', () => {
expect(isValidAccountSettingTab('data-source')).toBe(true)
expect(isValidAccountSettingTab('custom-endpoint')).toBe(true)
expect(isValidAccountSettingTab('custom')).toBe(true)
expect(isValidAccountSettingTab('preferences')).toBe(true)
expect(isValidAccountSettingTab('language')).toBe(true)
})
@ -55,6 +57,7 @@ describe('AccountSetting Constants', () => {
expect(isValidSettingsTab('permissions')).toBe(true)
expect(isValidSettingsTab('access-rules')).toBe(true)
expect(isValidSettingsTab('billing')).toBe(true)
expect(isValidSettingsTab('preferences')).toBe(true)
expect(isValidSettingsTab('language')).toBe(true)
expect(isValidSettingsTab('provider')).toBe(true)
expect(isValidSettingsTab('mcp')).toBe(true)

View File

@ -257,6 +257,17 @@ describe('AccountSetting', () => {
expect(screen.getByText('common.settings.dataSource'))!.toBeInTheDocument()
})
it('should normalize legacy language tab entries to preferences', () => {
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.LANGUAGE })
// Assert
const preferencesButton = screen.getByRole('button', { name: 'common.settings.preferences' })
expect(preferencesButton.querySelector('.i-ri-equalizer-2-fill')).toBeInTheDocument()
expect(screen.getByText('common.account.general')).toBeInTheDocument()
expect(screen.getByText('common.account.appearanceLabel')).toBeInTheDocument()
})
it('should hide sidebar labels on mobile', () => {
// Arrange
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)

View File

@ -0,0 +1,88 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { ACCOUNT_SETTING_TAB } from '../constants'
import UpdateSettingDialogForm from '../update-setting-dialog-form'
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: (selector: (s: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => typeof mockSetShowAccountSettingModal) => {
return selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal })
},
}))
vi.mock('react-i18next', () => ({
useTranslation: (defaultNs?: string) => ({
t: (key: string, options?: Record<string, unknown>) => {
const ns = (options?.ns as string | undefined) ?? defaultNs
return `${ns ? `${ns}.` : ''}${key}`
},
i18n: {
language: 'en',
changeLanguage: vi.fn(),
},
}),
Trans: ({ i18nKey, components }: {
i18nKey: string
components?: Record<string, React.ReactElement>
}) => {
const setTimezone = components?.setTimezone
if (setTimezone)
return React.cloneElement(setTimezone, undefined, i18nKey)
return <span>{i18nKey}</span>
},
}))
vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
default: () => <div data-testid="time-picker" />,
}))
vi.mock('@/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker', () => ({
default: () => <div data-testid="plugins-picker" />,
}))
describe('UpdateSettingDialogForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should open preferences after closing the update setting dialog when timezone link is clicked', () => {
const onRequestClose = vi.fn()
render(
<UpdateSettingDialogForm
autoUpgrade={{
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.update_all,
exclude_plugins: [],
include_plugins: [],
}}
category={PluginCategoryEnum.tool}
plugins={[]}
scopeOptions={[
{ value: AUTO_UPDATE_MODE.update_all, label: 'All' },
]}
strategyOptions={[
{ value: AUTO_UPDATE_STRATEGY.fixOnly, label: 'Fix only' },
]}
timezone="UTC"
updateTimeValue="00:00"
minuteFilter={minutes => minutes}
onAutoUpgradeChange={vi.fn()}
onPluginsChange={vi.fn()}
onRequestClose={onRequestClose}
onUpdateTimeChange={vi.fn()}
renderTimePickerTrigger={() => <button type="button">Pick time</button>}
/>,
)
fireEvent.click(screen.getByText('autoUpdate.changeTimezone'))
expect(onRequestClose).toHaveBeenCalledTimes(1)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })
})
})

View File

@ -12,6 +12,7 @@ export const ACCOUNT_SETTING_TAB = {
DATA_SOURCE: 'data-source',
API_BASED_EXTENSION: 'custom-endpoint',
CUSTOM: 'custom',
PREFERENCES: 'preferences',
LANGUAGE: 'language',
} as const
@ -30,6 +31,7 @@ const WORKSPACE_SETTING_TAB_VALUES = [
export type WorkspaceSettingTab = typeof WORKSPACE_SETTING_TAB_VALUES[number]
const USER_SETTING_TAB_VALUES = [
ACCOUNT_SETTING_TAB.PREFERENCES,
ACCOUNT_SETTING_TAB.LANGUAGE,
] as const

View File

@ -20,11 +20,11 @@ import { BillingPermission, hasPermission } from '@/utils/permission'
import AccessRulesPage from './access-rules-page'
import { ApiBasedExtensionPage } from './api-based-extension-page'
import DataSourcePage from './data-source-page-new'
import LanguagePage from './language-page'
import MembersPage from './members-page'
import ModelProviderPage from './model-provider-page'
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
import PermissionsPage from './permissions-page'
import PreferencePage from './preference-page'
const iconClassName = `
w-4 h-4 mr-2
@ -58,12 +58,14 @@ export default function AccountSetting({
const isRbacEnabled = systemFeatures.rbac_enabled
const canManageWorkspaceRoles = isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage')
const canViewBilling = enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View)
// Keep legacy `language` deep links opening Preferences during the tab rename migration.
const normalizedActiveTab = activeTab === ACCOUNT_SETTING_TAB.LANGUAGE ? ACCOUNT_SETTING_TAB.PREFERENCES : activeTab
const activeMenu = (() => {
if (activeTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling)
return ACCOUNT_SETTING_TAB.LANGUAGE
if ((activeTab === ACCOUNT_SETTING_TAB.PERMISSIONS || activeTab === ACCOUNT_SETTING_TAB.ACCESS_RULES) && !canManageWorkspaceRoles)
if (normalizedActiveTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling)
return ACCOUNT_SETTING_TAB.PREFERENCES
if ((normalizedActiveTab === ACCOUNT_SETTING_TAB.PERMISSIONS || normalizedActiveTab === ACCOUNT_SETTING_TAB.ACCESS_RULES) && !canManageWorkspaceRoles)
return ACCOUNT_SETTING_TAB.MEMBERS
return activeTab
return normalizedActiveTab
})()
const scrollContainerRef = useRef<HTMLDivElement>(null)
@ -119,7 +121,7 @@ export default function AccountSetting({
activeIcon: <span className={cn('i-ri-color-filter-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.LANGUAGE,
key: ACCOUNT_SETTING_TAB.PREFERENCES,
name: t('settings.preferences', { ns: 'common' }),
title: t('account.general', { ns: 'common' }),
icon: <span className={cn('i-ri-equalizer-2-line', iconClassName)} />,
@ -151,7 +153,7 @@ export default function AccountSetting({
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const languageItem = settingItems.find(item => item.key === ACCOUNT_SETTING_TAB.LANGUAGE)
const preferenceItem = settingItems.find(item => item.key === ACCOUNT_SETTING_TAB.PREFERENCES)
const menuItems = [
{
@ -161,7 +163,7 @@ export default function AccountSetting({
},
{
key: 'user-group',
items: languageItem ? [languageItem] : [],
items: preferenceItem ? [preferenceItem] : [],
},
]
@ -266,7 +268,7 @@ export default function AccountSetting({
{activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && <DataSourcePage />}
{activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && <ApiBasedExtensionPage />}
{activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && <CustomPage />}
{activeMenu === ACCOUNT_SETTING_TAB.LANGUAGE && <LanguagePage />}
{activeMenu === ACCOUNT_SETTING_TAB.PREFERENCES && <PreferencePage />}
</div>
</ScrollArea>
</div>

View File

@ -4,7 +4,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar
import { languages } from '@/i18n-config/language'
import { updateUserProfile } from '@/service/common'
import { timezones } from '@/utils/timezone'
import LanguagePage from '../index'
import PreferencePage from '../index'
const mockRefresh = vi.fn()
const mockMutateUserProfile = vi.fn()
@ -54,7 +54,7 @@ vi.mock('@langgenius/dify-ui/select', async () => {
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button type="button" role="option" onClick={() => context.onValueChange?.(value)}>
<button type="button" role="option" aria-selected={false} onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
@ -104,7 +104,7 @@ const createUserProfile = (overrides: Partial<GetAccountProfileResponse> = {}):
const renderPage = () => {
render(
<>
<LanguagePage />
<PreferencePage />
<ToastHost />
</>,
)
@ -150,7 +150,7 @@ beforeEach(() => {
})
// Rendering
describe('LanguagePage - Rendering', () => {
describe('PreferencePage - Rendering', () => {
it('should render default language and timezone labels', () => {
const english = getLanguageOption('en-US')
const niueTimezone = getTimezoneOption('Pacific/Niue')
@ -182,7 +182,7 @@ describe('LanguagePage - Rendering', () => {
})
// Interactions
describe('LanguagePage - Interactions', () => {
describe('PreferencePage - Interactions', () => {
it('should show success toast when language updates', async () => {
const chinese = getLanguageOption('zh-Hans')
mockUserProfile = createUserProfile({ interface_language: 'en-US' })

View File

@ -33,7 +33,7 @@ const isThemeOption = (value: string): value is ThemeOption => {
return (themes as readonly string[]).includes(value)
}
export default function LanguagePage() {
export default function PreferencePage() {
const locale = useLocale()
const { userProfile, mutateUserProfile } = useAppContext()
const [editing, setEditing] = useState(false)

View File

@ -53,7 +53,7 @@ function SettingTimeZone({
className="cursor-pointer border-none bg-transparent p-0 text-left body-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => {
onRequestClose()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.LANGUAGE })
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })
}}
>
{children}

View File

@ -25,7 +25,8 @@ import { AppModeEnum } from '@/types/app'
import MainNav from '../index'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
const activeEdgeClassName = 'before:pointer-events-none'
const activeGradientMaskClassName = 'aria-[current=page]:main-nav-active-glass'
const activeStackingClassName = 'aria-[current=page]:z-1'
type SnippetNavigationTestState = {
onFieldsChange?: (fields: SnippetInputField[]) => void
@ -503,7 +504,7 @@ describe('MainNav', () => {
expect(logoLink.parentElement).toHaveClass('pt-3', 'pr-2', 'pb-2', 'pl-4')
const homeLink = screen.getByRole('link', { name: /common.mainNav.home/ })
expect(homeLink.closest('nav')).toHaveClass('flex', 'flex-col', 'gap-px', 'p-2')
expect(homeLink.closest('nav')).toHaveClass('isolate', 'flex', 'flex-col', 'gap-px', 'p-2')
expect(homeLink).toHaveClass('h-8', 'w-full', 'rounded-[10px]', 'px-2', 'py-1.5')
const webAppsButton = screen.getByRole('button', { name: 'explore.sidebar.webApps' })
@ -641,8 +642,7 @@ describe('MainNav', () => {
renderMainNav()
const datasetsLink = screen.getByRole('link', { name: /common.menus.datasets/ })
expect(datasetsLink.className).toContain('bg-[linear-gradient(98.077deg')
expect(datasetsLink).toHaveClass(activeEdgeClassName)
expect(datasetsLink).toHaveClass(activeGradientMaskClassName)
expect(datasetsLink).toHaveAttribute('aria-current', 'page')
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current')
})
@ -653,7 +653,7 @@ describe('MainNav', () => {
renderMainNav()
const studioLink = screen.getByRole('link', { name: /common.menus.apps/ })
expect(studioLink).toHaveClass(activeEdgeClassName)
expect(studioLink).toHaveClass(activeGradientMaskClassName)
expect(studioLink).toHaveAttribute('aria-current', 'page')
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current')
})
@ -959,7 +959,7 @@ describe('MainNav', () => {
renderMainNav()
const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ })
expect(marketplaceLink).toHaveClass(activeEdgeClassName)
expect(marketplaceLink).toHaveClass(activeGradientMaskClassName)
})
it('marks roster active on roster routes', () => {
@ -968,7 +968,7 @@ describe('MainNav', () => {
renderMainNav()
const rosterLink = screen.getByRole('link', { name: /common.menus.roster/ })
expect(rosterLink).toHaveClass(activeEdgeClassName)
expect(rosterLink).toHaveClass(activeGradientMaskClassName)
expect(rosterLink).toHaveAttribute('aria-current', 'page')
})
@ -979,13 +979,8 @@ describe('MainNav', () => {
const homeLink = screen.getByRole('link', { name: /common.mainNav.home/ })
expect(homeLink).toHaveClass(
'backdrop-blur-[5px]',
'text-saas-dify-blue-inverted',
activeEdgeClassName,
'after:border-components-main-nav-glass-edge-highlight-first',
)
expect(homeLink.className).toContain('var(--color-components-main-nav-glass-surface-first)')
expect(homeLink).toHaveClass(activeGradientMaskClassName)
expect(homeLink).toHaveClass(activeStackingClassName)
})
it('keeps Home active on the legacy explore apps route only', () => {

View File

@ -0,0 +1,48 @@
@utility main-nav-active-glass {
@apply overflow-hidden system-md-semibold text-saas-dify-blue-inverted backdrop-blur-[5px];
background-image: linear-gradient(
91.46deg,
var(--color-components-main-nav-glass-surface-first, rgb(0 51 255 / 0.08)) 0%,
var(--color-components-main-nav-glass-surface-middle-1, rgb(0 51 255 / 0.12)) 17.98%,
var(--color-components-main-nav-glass-surface-middle-2, rgb(0 51 255 / 0.1)) 58.75%,
var(--color-components-main-nav-glass-surface-end, rgb(0 51 255 / 0.08)) 101.09%
);
box-shadow:
0 4px 8px 0 var(--color-components-main-nav-glass-shadow-reflection-glow),
0 10px 12px -4px var(--color-shadow-shadow-4),
0 3px 5px -2px var(--color-shadow-shadow-1),
0 8px 16px -4px var(--color-components-main-nav-glass-shadow-reflection);
&::before {
content: "";
pointer-events: none;
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid transparent;
background: linear-gradient(
0deg,
var(--color-components-main-nav-glass-edge-reflection-first, rgb(0 51 255 / 0)) 0%,
var(--color-components-main-nav-glass-edge-reflection-middle, rgb(0 51 255 / 0.6)) 50%,
var(--color-components-main-nav-glass-edge-reflection-end, rgb(0 51 255 / 0)) 100%
) border-box;
-webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: destination-out;
mask-composite: exclude;
}
&::after {
content: "";
pointer-events: none;
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid transparent;
background: linear-gradient(180deg, var(--color-components-main-nav-glass-edge-highlight-first, rgb(255 255 255 / 0.98)) 0%, var(--color-components-main-nav-glass-edge-highlight-middle, rgb(255 255 255 / 0)) 18%, var(--color-components-main-nav-glass-edge-highlight-end, rgb(255 255 255 / 0.42)) 100%) border-box;
-webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: destination-out;
mask-composite: exclude;
box-shadow: inset 0 0 8px 0 var(--color-components-main-nav-glass-inner-glow);
}
}

View File

@ -4,21 +4,6 @@ import type { MainNavItem } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import Link from '@/next/link'
const navItemClassName = 'group relative flex h-8 w-full items-center gap-2 rounded-[10px] px-2 py-1.5 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-state-accent-solid'
const activeNavItemClassName = cn(
'overflow-hidden',
'bg-[linear-gradient(98.077deg,var(--color-components-main-nav-glass-surface-first)_0%,var(--color-components-main-nav-glass-surface-middle-1)_17.98%,var(--color-components-main-nav-glass-surface-middle-2)_58.75%,var(--color-components-main-nav-glass-surface-end)_101.09%)]',
'system-md-semibold text-saas-dify-blue-inverted backdrop-blur-[5px]',
'shadow-[0px_4px_8px_0px_var(--color-components-main-nav-glass-shadow-reflection-glow),0px_12px_16px_-4px_var(--color-shadow-shadow-4),0px_4px_6px_-2px_var(--color-shadow-shadow-1),0px_10px_16px_-4px_var(--color-components-main-nav-glass-shadow-reflection)]',
'before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:p-px before:content-[\'\']',
'before:bg-[linear-gradient(var(--color-components-main-nav-glass-edge-highlight-first),var(--color-components-main-nav-glass-edge-highlight-first))_top/100%_1px_no-repeat,linear-gradient(var(--color-components-main-nav-glass-edge-highlight-end),var(--color-components-main-nav-glass-edge-highlight-end))_bottom/100%_1px_no-repeat,linear-gradient(180deg,var(--color-components-main-nav-glass-edge-reflection-first)_0%,var(--color-components-main-nav-glass-edge-reflection-middle)_50%,var(--color-components-main-nav-glass-edge-reflection-end)_100%)_left/1px_100%_no-repeat,linear-gradient(180deg,var(--color-components-main-nav-glass-edge-reflection-first)_0%,var(--color-components-main-nav-glass-edge-reflection-middle)_50%,var(--color-components-main-nav-glass-edge-reflection-end)_100%)_right/1px_100%_no-repeat]',
'before:[mask-composite:exclude] before:[-webkit-mask-composite:xor] before:[-webkit-mask:linear-gradient(#000_0_0)_content-box,linear-gradient(#000_0_0)] before:[mask:linear-gradient(#000_0_0)_content-box,linear-gradient(#000_0_0)]',
'after:pointer-events-none after:absolute after:inset-[-1px] after:rounded-[inherit] after:border after:border-components-main-nav-glass-edge-highlight-first after:shadow-[inset_0_0_8px_0_var(--color-components-main-nav-glass-inner-glow)] after:content-[\'\']',
)
const inactiveNavItemClassName = 'system-md-medium bg-components-main-nav-nav-button-bg text-components-main-nav-nav-button-text hover:bg-components-main-nav-nav-button-bg-hover hover:text-components-main-nav-nav-button-text'
const NavIcon = ({
icon,
className,
@ -46,12 +31,14 @@ const MainNavLink = ({
aria-current={activated ? 'page' : undefined}
title={item.label}
className={cn(
navItemClassName,
activated ? activeNavItemClassName : inactiveNavItemClassName,
'group relative flex h-8 w-full items-center gap-2 rounded-[10px] px-2 py-1.5 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset',
'not-aria-[current=page]:bg-components-main-nav-nav-button-bg not-aria-[current=page]:system-md-medium not-aria-[current=page]:text-components-main-nav-nav-button-text not-aria-[current=page]:hover:bg-components-main-nav-nav-button-bg-hover not-aria-[current=page]:hover:text-components-main-nav-nav-button-text',
'aria-[current=page]:main-nav-active-glass aria-[current=page]:z-1',
)}
>
<NavIcon icon={activated ? item.activeIcon : item.icon} />
<span className={cn('truncate', activated && 'text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]')}>{item.label}</span>
<NavIcon icon={item.icon} className="group-aria-[current=page]:hidden" />
<NavIcon icon={item.activeIcon} className="hidden group-aria-[current=page]:block" />
<span className="truncate group-aria-[current=page]:text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]">{item.label}</span>
</Link>
)
}

View File

@ -320,7 +320,7 @@ const MainNav = ({
: <SnippetCollapsedPreview inputFieldCount={snippetInputFields.length} />
: (
<>
<nav className="flex flex-col gap-px p-2">
<nav className="isolate flex flex-col gap-px p-2">
{navItems.map(item => (
<MainNavLink key={item.href} item={item} pathname={pathname} />
))}

View File

@ -8,6 +8,7 @@ import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { createAccountProfileQueryClient } from '@/test/account-profile-query'
import { PluginCategoryEnum, PluginSource } from '../../../types'
import AutoUpdateSetting from '../index'
@ -53,6 +54,32 @@ vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
}))
vi.mock('react-i18next', () => ({
useTranslation: (defaultNs?: string) => ({
t: (key: string, options?: Record<string, unknown>) => {
const ns = (options?.ns as string | undefined) ?? defaultNs
const params = { ...options }
delete params.ns
const suffix = Object.keys(params).length > 0 ? `:${JSON.stringify(params)}` : ''
return `${ns ? `${ns}.` : ''}${key}${suffix}`
},
i18n: {
language: 'en',
changeLanguage: vi.fn(),
},
}),
Trans: ({ i18nKey, components }: {
i18nKey: string
components?: Record<string, React.ReactElement>
}) => {
const setTimezone = components?.setTimezone
if (setTimezone)
return React.cloneElement(setTimezone, undefined, i18nKey)
return <span>{i18nKey}</span>
},
}))
// Mock plugins service
const mockPluginsData: { plugins: PluginDetail[] } = { plugins: [] }
vi.mock('@/service/use-plugins', () => ({
@ -1330,8 +1357,10 @@ describe('auto-update-setting', () => {
// Act
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
fireEvent.click(screen.getByText('autoUpdate.changeTimezone'))
expect(screen.getByText('autoUpdate.changeTimezone')).toBeInTheDocument()
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })
})
})

View File

@ -37,7 +37,7 @@ const SettingTimeZone: FC<{
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left body-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.LANGUAGE })}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })}
>
{children}
</button>

View File

@ -28,6 +28,7 @@
@import '../components/base/action-button/index.css';
@import '../components/base/badge/index.css';
@import '../components/base/premium-badge/index.css';
@import '../components/main-nav/components/nav-link.css';
/* ---------- JS plugins ------------------------------------------------ */
@plugin './plugins/icons.ts';

View File

@ -106,7 +106,7 @@ const PreferencesOpener = () => {
return (
<button
type="button"
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.LANGUAGE })}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })}
>
open preferences
</button>
@ -191,7 +191,7 @@ describe('ModalContextProvider trigger events limit modal', () => {
await user.click(screen.getByRole('button', { name: 'open preferences' }))
expect(await screen.findByTestId('account-setting-active-tab')).toHaveTextContent(ACCOUNT_SETTING_TAB.LANGUAGE)
expect(await screen.findByTestId('account-setting-active-tab')).toHaveTextContent(ACCOUNT_SETTING_TAB.PREFERENCES)
})
it('relies on the in-memory guard when localStorage reads throw', async () => {

View File

@ -0,0 +1,163 @@
import type { Getter } from 'jotai/vanilla'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ScopeProvider } from 'jotai-scope'
import { DeleteDeploymentDialog } from '../delete-dialog'
import {
deleteDeploymentDialogOpenAtom,
deploymentActionAppInstanceIdAtom,
} from '../state'
type QueryOptions = {
input?: unknown
queryKey?: readonly unknown[]
}
type QueryResult = {
data?: unknown
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const useQueryMock = vi.hoisted(() =>
vi.fn((options: QueryOptions) => {
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = mockQueryResults.current.get(queryName)
return {
...options,
data: queryResult?.data,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: Boolean(queryResult?.data),
}
}),
)
const deleteMutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
}))
const useMutationMock = vi.hoisted(() =>
vi.fn(() => ({
isPending: deleteMutationMock.isPending,
mutate: deleteMutationMock.mutate,
})),
)
const routerMock = vi.hoisted(() => ({
push: vi.fn(),
}))
const toastMock = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
}))
vi.mock('jotai-tanstack-query', async () => {
const { atom } = await import('jotai')
return {
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
return useQueryMock(createOptions(get))
}),
}
})
vi.mock('@tanstack/react-query', () => ({
useMutation: useMutationMock,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => routerMock,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
deleteAppInstance: {
mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }),
},
},
},
},
}))
function setAppInstance() {
mockQueryResults.current.set('getAppInstance', {
data: {
appInstance: {
id: 'app-instance-1',
displayName: 'Deployment 1',
},
},
})
}
function renderDialog({
open = true,
}: {
open?: boolean
} = {}) {
render(
<ScopeProvider
atoms={[
[deploymentActionAppInstanceIdAtom, 'app-instance-1'],
[deleteDeploymentDialogOpenAtom, open],
]}
name="DeleteDeploymentDialogTest"
>
<DeleteDeploymentDialog />
</ScopeProvider>,
)
}
describe('DeleteDeploymentDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
deleteMutationMock.isPending = false
setAppInstance()
})
describe('Delete action', () => {
it('should not mount the query or delete mutation before the dialog is opened', () => {
renderDialog({ open: false })
expect(useQueryMock).not.toHaveBeenCalled()
expect(useMutationMock).not.toHaveBeenCalled()
})
it('should delete the deployment through the component mutation', async () => {
const user = userEvent.setup()
renderDialog()
await user.click(screen.getByRole('button', { name: 'deployments.settings.delete' }))
expect(deleteMutationMock.mutate).toHaveBeenCalledWith({
params: {
appInstanceId: 'app-instance-1',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
onSettled: expect.any(Function),
}))
})
})
})

View File

@ -0,0 +1,185 @@
import type { Getter } from 'jotai/vanilla'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ScopeProvider } from 'jotai-scope'
import { EditDeploymentDialog } from '../edit-dialog'
import {
deploymentActionAppInstanceIdAtom,
editDeploymentDialogOpenAtom,
} from '../state'
type QueryOptions = {
input?: unknown
queryKey?: readonly unknown[]
}
type QueryResult = {
data?: unknown
isError?: boolean
isLoading?: boolean
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const useQueryMock = vi.hoisted(() =>
vi.fn((options: QueryOptions) => {
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = mockQueryResults.current.get(queryName)
return {
...options,
data: queryResult?.data,
isError: queryResult?.isError ?? false,
isFetching: false,
isLoading: queryResult?.isLoading ?? false,
isSuccess: Boolean(queryResult?.data),
}
}),
)
const updateMutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
}))
const useMutationMock = vi.hoisted(() =>
vi.fn(() => ({
isPending: updateMutationMock.isPending,
mutate: updateMutationMock.mutate,
})),
)
const toastMock = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
}))
vi.mock('jotai-tanstack-query', async () => {
const { atom } = await import('jotai')
return {
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
return useQueryMock(createOptions(get))
}),
}
})
vi.mock('@tanstack/react-query', () => ({
useMutation: useMutationMock,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
updateAppInstance: {
mutationOptions: () => ({ mutationKey: ['updateAppInstance'] }),
},
deleteAppInstance: {
mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }),
},
},
},
},
}))
function setAppInstance(overrides: Record<string, unknown> = {}) {
mockQueryResults.current.set('getAppInstance', {
data: {
appInstance: {
id: 'app-instance-1',
displayName: 'Deployment 1',
description: 'Initial description',
...overrides,
},
},
})
}
function setAppInstanceLoading() {
mockQueryResults.current.set('getAppInstance', {
isLoading: true,
})
}
function renderDialog({
open = true,
}: {
open?: boolean
} = {}) {
render(
<ScopeProvider
atoms={[
[deploymentActionAppInstanceIdAtom, 'app-instance-1'],
[editDeploymentDialogOpenAtom, open],
]}
name="EditDeploymentDialogTest"
>
<EditDeploymentDialog />
</ScopeProvider>,
)
}
describe('EditDeploymentDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
updateMutationMock.isPending = false
setAppInstance()
})
describe('Form submission', () => {
it('should not mount the query or update mutation before the dialog is opened', () => {
renderDialog({ open: false })
expect(useQueryMock).not.toHaveBeenCalled()
expect(useMutationMock).not.toHaveBeenCalled()
})
it('should create the update mutation only after the edit form is ready', () => {
setAppInstanceLoading()
renderDialog()
expect(useMutationMock).not.toHaveBeenCalled()
})
it('should submit trimmed deployment metadata through the component mutation', async () => {
const user = userEvent.setup()
renderDialog()
const dialog = screen.getByRole('dialog', { name: 'deployments.card.menu.editInfo' })
await user.clear(within(dialog).getByRole('textbox', { name: 'deployments.settings.name' }))
await user.type(within(dialog).getByRole('textbox', { name: 'deployments.settings.name' }), ' Deployment 2 ')
await user.clear(within(dialog).getByRole('textbox', { name: 'deployments.settings.description' }))
await user.type(within(dialog).getByRole('textbox', { name: 'deployments.settings.description' }), ' Updated description ')
await user.click(within(dialog).getByRole('button', { name: 'deployments.settings.save' }))
expect(updateMutationMock.mutate).toHaveBeenCalledWith({
params: {
appInstanceId: 'app-instance-1',
},
body: {
appInstanceId: 'app-instance-1',
displayName: 'Deployment 2',
description: 'Updated description',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
})
})
})

View File

@ -1,222 +0,0 @@
import type { Getter } from 'jotai/vanilla'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
}
type QueryResult = {
data?: unknown
}
type MutationOptions = {
mutationKey?: readonly string[]
}
type MutationResult = {
isPending: boolean
mutate: ReturnType<typeof vi.fn>
mutateAsync: ReturnType<typeof vi.fn>
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const mockUpdateMutation = vi.hoisted<{ current: MutationResult }>(() => ({
current: {
isPending: false,
mutate: vi.fn(),
mutateAsync: vi.fn(),
},
}))
const mockDeleteMutation = vi.hoisted<{ current: MutationResult }>(() => ({
current: {
isPending: false,
mutate: vi.fn(),
mutateAsync: vi.fn(),
},
}))
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
const options = createOptions(get)
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = options.enabled === false
? undefined
: mockQueryResults.current.get(queryName)
return {
...options,
data: queryResult?.data,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: Boolean(queryResult?.data),
}
}),
atomWithMutation: (createOptions: () => MutationOptions) => atom(() => {
const options = createOptions()
return options.mutationKey?.[0] === 'deleteAppInstance'
? mockDeleteMutation.current
: mockUpdateMutation.current
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
updateAppInstance: {
mutationOptions: () => ({ mutationKey: ['updateAppInstance'] }),
},
deleteAppInstance: {
mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }),
},
},
},
},
}))
async function loadState() {
return await import('../state')
}
async function mountedStore() {
const state = await loadState()
const store = createStore()
const unsubscribe = store.sub(state.editDeploymentFormCanSaveAtom, () => undefined)
store.set(state.deploymentActionAppInstanceIdHydrationAtom, 'app-instance-1')
return {
state,
store,
unsubscribe,
}
}
function setAppInstance(overrides: Record<string, unknown> = {}) {
mockQueryResults.current.set('getAppInstance', {
data: {
appInstance: {
id: 'app-instance-1',
displayName: 'Deployment 1',
description: 'Initial description',
...overrides,
},
},
})
}
describe('deployment action state', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
mockUpdateMutation.current = {
isPending: false,
mutate: vi.fn(),
mutateAsync: vi.fn(),
}
mockDeleteMutation.current = {
isPending: false,
mutate: vi.fn(),
mutateAsync: vi.fn(),
}
})
it('should fetch app instance data only while an action dialog is open', async () => {
const { state, store, unsubscribe } = await mountedStore()
expect(store.get(state.deploymentActionAppInstanceQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
store.set(state.editDeploymentDialogOpenAtom, true)
expect(store.get(state.deploymentActionAppInstanceQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
store.set(state.editDeploymentDialogOpenAtom, false)
store.set(state.deleteDeploymentDialogOpenAtom, true)
expect(store.get(state.deploymentActionAppInstanceQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
unsubscribe()
})
it('should keep an edit dialog open while update is pending', async () => {
const { state, store, unsubscribe } = await mountedStore()
mockUpdateMutation.current = {
isPending: true,
mutate: vi.fn(),
mutateAsync: vi.fn(),
}
store.set(state.editDeploymentDialogOpenAtom, true)
store.set(state.setEditDeploymentDialogOpenAtom, false)
expect(store.get(state.editDeploymentDialogOpenAtom)).toBe(true)
unsubscribe()
})
it('should submit edited deployment metadata with trimmed values', async () => {
const { state, store, unsubscribe } = await mountedStore()
const response = { appInstance: { id: 'app-instance-1' } }
setAppInstance()
mockUpdateMutation.current.mutateAsync.mockResolvedValue(response)
store.set(state.editDeploymentDialogOpenAtom, true)
store.set(state.editDeploymentNameFieldAtom, ' Deployment 2 ')
store.set(state.editDeploymentDescriptionFieldAtom, ' Updated description ')
const result = await store.set(state.submitEditDeploymentFormAtom)
expect(result).toBe(true)
expect(mockUpdateMutation.current.mutateAsync).toHaveBeenCalledWith({
params: {
appInstanceId: 'app-instance-1',
},
body: {
appInstanceId: 'app-instance-1',
displayName: 'Deployment 2',
description: 'Updated description',
},
})
unsubscribe()
})
it('should submit delete with the hydrated app instance id and caller callbacks', async () => {
const { state, store, unsubscribe } = await mountedStore()
const onSuccess = vi.fn()
store.set(state.submitDeleteDeploymentInstanceAtom, { onSuccess })
expect(mockDeleteMutation.current.mutate).toHaveBeenCalledWith(
{
params: {
appInstanceId: 'app-instance-1',
},
},
{ onSuccess },
)
unsubscribe()
})
})

View File

@ -10,68 +10,80 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import {
deleteDeploymentDialogOpenAtom,
deleteDeploymentInstanceMutationAtom,
deploymentActionDisplayNameAtom,
submitDeleteDeploymentInstanceAtom,
deploymentActionAppInstanceIdAtom,
deploymentActionAppInstanceQueryAtom,
} from './state'
export function DeleteDeploymentDialog() {
function DeleteDeploymentDialogContent() {
const { t } = useTranslation('deployments')
const router = useRouter()
const [open, setOpen] = useAtom(deleteDeploymentDialogOpenAtom)
const deleteInstance = useAtomValue(deleteDeploymentInstanceMutationAtom)
const submitDeleteInstance = useSetAtom(submitDeleteDeploymentInstanceAtom)
const displayName = useAtomValue(deploymentActionDisplayNameAtom)
const appInstanceId = useAtomValue(deploymentActionAppInstanceIdAtom)
const setOpen = useSetAtom(deleteDeploymentDialogOpenAtom)
const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom)
const deleteInstance = useMutation(consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions())
const displayName = instanceQuery.data?.appInstance.displayName || appInstanceId
function handleDelete() {
submitDeleteInstance({
onSuccess: () => {
toast.success(t('settings.deleted'))
router.push('/deployments')
deleteInstance.mutate(
{
params: {
appInstanceId,
},
},
onError: () => {
toast.error(t('settings.deleteFailed'))
{
onSuccess: () => {
toast.success(t('settings.deleted'))
router.push('/deployments')
},
onError: () => {
toast.error(t('settings.deleteFailed'))
},
onSettled: () => {
setOpen(false)
},
},
onSettled: () => {
setOpen(false)
},
})
)
}
return (
<AlertDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && deleteInstance.isPending)
return
setOpen(nextOpen)
}}
>
<>
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('settings.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('settings.deleteConfirmDesc', { name: displayName })}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-3">
<AlertDialogCancelButton variant="secondary" disabled={deleteInstance.isPending}>
{t('createModal.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteInstance.isPending}
onClick={handleDelete}
>
{t('settings.delete')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</>
)
}
export function DeleteDeploymentDialog() {
const [open, setOpen] = useAtom(deleteDeploymentDialogOpenAtom)
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent className="w-120">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('settings.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('settings.deleteConfirmDesc', { name: displayName })}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-3">
<AlertDialogCancelButton variant="secondary" disabled={deleteInstance.isPending}>
{t('createModal.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteInstance.isPending}
onClick={handleDelete}
>
{t('settings.delete')}
</AlertDialogConfirmButton>
</AlertDialogActions>
<DeleteDeploymentDialogContent />
</AlertDialogContent>
</AlertDialog>
)

View File

@ -1,6 +1,5 @@
'use client'
import type { FormEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
@ -8,26 +7,45 @@ import {
DialogContent,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import {
deploymentActionAppInstanceIdAtom,
deploymentActionAppInstanceQueryAtom,
editDeploymentDescriptionFieldAtom,
editDeploymentDialogOpenAtom,
editDeploymentFormAtom,
editDeploymentFormCanSaveAtom,
editDeploymentFormSavePendingAtom,
editDeploymentNameFieldAtom,
setEditDeploymentDialogOpenAtom,
submitEditDeploymentFormAtom,
updateDeploymentInstanceMutationAtom,
} from './state'
type EditDeploymentFormValues = {
name: string
description: string
}
function normalizedEditDeploymentFormValues(value: EditDeploymentFormValues) {
return {
name: value.name.trim(),
description: value.description.trim(),
}
}
function canSubmitEditDeploymentForm(initialValues: EditDeploymentFormValues, value: EditDeploymentFormValues) {
const normalizedValues = normalizedEditDeploymentFormValues(value)
return Boolean(
normalizedValues.name
&& (
normalizedValues.name !== initialValues.name
|| normalizedValues.description !== initialValues.description
),
)
}
function EditDeploymentFormSkeleton() {
return (
<div className="flex flex-col gap-4">
@ -47,124 +65,142 @@ function EditDeploymentFormSkeleton() {
)
}
function EditDeploymentForm() {
function EditDeploymentForm({
initialValues,
}: {
initialValues: EditDeploymentFormValues
}) {
const { t } = useTranslation('deployments')
const [nameField, setNameField] = useAtom(editDeploymentNameFieldAtom)
const [descriptionField, setDescriptionField] = useAtom(editDeploymentDescriptionFieldAtom)
const canSave = useAtomValue(editDeploymentFormCanSaveAtom)
const savePending = useAtomValue(editDeploymentFormSavePendingAtom)
const submitEditDeploymentForm = useSetAtom(submitEditDeploymentFormAtom)
const requestOpenChange = useSetAtom(setEditDeploymentDialogOpenAtom)
const nameLabel = t('settings.name')
const appInstanceId = useAtomValue(deploymentActionAppInstanceIdAtom)
const setOpen = useSetAtom(editDeploymentDialogOpenAtom)
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
event.stopPropagation()
if (!canSave)
function handleClose() {
if (updateInstance.isPending)
return
try {
const didSubmit = await submitEditDeploymentForm()
if (!didSubmit)
return
setOpen(false)
}
toast.success(t('settings.updated'))
setOpen(false)
}
catch {
toast.error(t('settings.updateFailed'))
}
function handleSubmit(values: EditDeploymentFormValues) {
if (!canSubmitEditDeploymentForm(initialValues, values))
return
const normalizedValues = normalizedEditDeploymentFormValues(values)
updateInstance.mutate(
{
params: {
appInstanceId,
},
body: {
appInstanceId,
displayName: normalizedValues.name,
description: normalizedValues.description,
},
},
{
onSuccess: () => {
toast.success(t('settings.updated'))
setOpen(false)
},
onError: () => {
toast.error(t('settings.updateFailed'))
},
},
)
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="deployment-edit-name">
{t('settings.name')}
</label>
<Input
id="deployment-edit-name"
name="name"
type="text"
value={nameField.value}
onChange={event => setNameField(event.target.value)}
className="h-8"
/>
<>
<DialogCloseButton disabled={updateInstance.isPending} />
<Form<EditDeploymentFormValues> className="flex flex-col gap-4" onFormSubmit={handleSubmit}>
<FieldRoot name="name" className="gap-2">
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
{nameLabel}
</FieldLabel>
<FieldControl
type="text"
required
defaultValue={initialValues.name}
className="h-8"
/>
<FieldError match="valueMissing">{t('errorMsg.fieldRequired', { ns: 'common', field: nameLabel })}</FieldError>
</FieldRoot>
<FieldRoot name="description" className="gap-2">
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
{t('settings.description')}
</FieldLabel>
<Textarea
defaultValue={initialValues.description}
className="min-h-24"
/>
</FieldRoot>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
disabled={updateInstance.isPending}
onClick={handleClose}
>
{t('createModal.cancel')}
</Button>
<Button
type="submit"
variant="primary"
disabled={updateInstance.isPending}
loading={updateInstance.isPending}
>
{t('settings.save')}
</Button>
</div>
</Form>
</>
)
}
function EditDeploymentDialogContent() {
const { t } = useTranslation('deployments')
const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom)
const app = instanceQuery.data?.appInstance
return (
<>
{!app && <DialogCloseButton />}
<div className="border-b border-divider-subtle px-6 py-5">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('card.menu.editInfo')}
</DialogTitle>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="deployment-edit-description">
{t('settings.description')}
</label>
<Textarea
id="deployment-edit-description"
name="description"
value={descriptionField.value}
onValueChange={value => setDescriptionField(value)}
className="min-h-24"
/>
<div className="px-6 py-5">
{instanceQuery.isLoading
? <EditDeploymentFormSkeleton />
: instanceQuery.isError
? <div className="system-sm-regular text-text-tertiary">{t('common.loadFailed')}</div>
: app
? (
<EditDeploymentForm
key={`${app.id}-${app.displayName}-${app.description}`}
initialValues={{
name: app.displayName,
description: app.description,
}}
/>
)
: <div className="system-sm-regular text-text-tertiary">{t('detail.notFound')}</div>}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
disabled={savePending}
onClick={() => requestOpenChange(false)}
>
{t('createModal.cancel')}
</Button>
<Button
type="submit"
variant="primary"
disabled={!canSave}
loading={savePending}
>
{t('settings.save')}
</Button>
</div>
</form>
</>
)
}
export function EditDeploymentDialog() {
const { t } = useTranslation('deployments')
const open = useAtomValue(editDeploymentDialogOpenAtom)
const setOpen = useSetAtom(setEditDeploymentDialogOpenAtom)
const updateInstance = useAtomValue(updateDeploymentInstanceMutationAtom)
const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom)
const app = instanceQuery.data?.appInstance
const formKey = app ? `${app.id}-${app.displayName}-${app.description}` : 'loading'
const [open, setOpen] = useAtom(editDeploymentDialogOpenAtom)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] p-0">
<DialogCloseButton disabled={updateInstance.isPending} />
<div className="border-b border-divider-subtle px-6 py-5">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('card.menu.editInfo')}
</DialogTitle>
</div>
<div className="px-6 py-5">
{instanceQuery.isLoading
? <EditDeploymentFormSkeleton />
: instanceQuery.isError
? <div className="system-sm-regular text-text-tertiary">{t('common.loadFailed')}</div>
: app
? (
<ScopeProvider
key={formKey}
atoms={[
editDeploymentFormAtom,
[editDeploymentNameFieldAtom, app.displayName],
[editDeploymentDescriptionFieldAtom, app.description],
]}
name="EditDeploymentForm"
>
<EditDeploymentForm />
</ScopeProvider>
)
: <div className="system-sm-regular text-text-tertiary">{t('detail.notFound')}</div>}
</div>
<EditDeploymentDialogContent />
</DialogContent>
</Dialog>
)

View File

@ -1,8 +1,37 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DeploymentActionsMenu } from './index'
vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu'))
type QueryOptions = {
input?: unknown
queryKey?: readonly unknown[]
}
const editDialogMock = vi.hoisted(() => vi.fn())
const deleteDialogMock = vi.hoisted(() => vi.fn())
const prefetchQueryMock = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
prefetchQuery: prefetchQueryMock,
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
},
},
},
}))
vi.mock('./edit-dialog', async () => {
const { useAtomValue } = await import('jotai')
@ -11,6 +40,7 @@ vi.mock('./edit-dialog', async () => {
return {
EditDeploymentDialog: () => {
const open = useAtomValue(editDeploymentDialogOpenAtom)
editDialogMock({ open })
return <div data-testid="edit-dialog" data-open={String(open)} />
},
@ -24,6 +54,7 @@ vi.mock('./delete-dialog', async () => {
return {
DeleteDeploymentDialog: () => {
const open = useAtomValue(deleteDeploymentDialogOpenAtom)
deleteDialogMock({ open })
return <div data-testid="delete-dialog" data-open={String(open)} />
},
@ -31,7 +62,11 @@ vi.mock('./delete-dialog', async () => {
})
describe('DeploymentActionsMenu', () => {
it('keeps the trigger wrapper visible while the menu is open', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps the trigger wrapper visible through uncontrolled menu state', () => {
const { container } = render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
@ -41,16 +76,17 @@ describe('DeploymentActionsMenu', () => {
)
const wrapper = container.querySelector('[role="presentation"]') as HTMLElement
expect(wrapper).toHaveClass('pointer-events-none', 'opacity-0')
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
expect(screen.getByText('deployments.card.menu.editInfo')).toBeInTheDocument()
expect(wrapper).toHaveClass('pointer-events-auto', 'opacity-100')
expect(wrapper).not.toHaveClass('pointer-events-none', 'opacity-0')
expect(wrapper).toHaveClass(
'pointer-events-none',
'opacity-0',
'[&:has([data-popup-open])]:pointer-events-auto',
'[&:has([data-popup-open])]:opacity-100',
)
})
it('keeps edit and delete dialog open state independent', () => {
it('prefetches the app instance when the menu opens', async () => {
const user = userEvent.setup()
render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
@ -58,43 +94,50 @@ describe('DeploymentActionsMenu', () => {
/>,
)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(screen.getByText('deployments.card.menu.editInfo'))
expect(prefetchQueryMock).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'deployments.card.moreActions' }))
await screen.findByRole('menuitem', { name: 'deployments.card.menu.editInfo' })
expect(prefetchQueryMock).toHaveBeenCalledWith(expect.objectContaining({
input: {
params: {
appInstanceId: 'app-instance-1',
},
},
queryKey: ['getAppInstance', {
params: {
appInstanceId: 'app-instance-1',
},
}],
}))
})
it('opens edit and delete dialogs from menu items', async () => {
const user = userEvent.setup()
render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
placement="bottom-end"
/>,
)
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'false')
await user.click(screen.getByRole('button', { name: 'deployments.card.moreActions' }))
await user.click(await screen.findByRole('menuitem', { name: 'deployments.card.menu.editInfo' }))
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'false')
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(screen.getByText('deployments.card.menu.delete'))
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'true')
await user.click(screen.getByRole('button', { name: 'deployments.card.moreActions' }))
await user.click(await screen.findByRole('menuitem', { name: 'deployments.card.menu.delete' }))
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'true')
})
it('resets dialog state when the menu app instance changes', () => {
const { rerender } = render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
placement="bottom-end"
/>,
)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(screen.getByText('deployments.card.menu.editInfo'))
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'true')
rerender(
<DeploymentActionsMenu
appInstanceId="app-instance-2"
placement="bottom-end"
/>,
)
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false')
rerender(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
placement="bottom-end"
/>,
)
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false')
expect(editDialogMock).toHaveBeenLastCalledWith({ open: false })
expect(deleteDialogMock).toHaveBeenLastCalledWith({ open: true })
})
})

View File

@ -9,17 +9,18 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useSetAtom } from 'jotai'
import { useQueryClient } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DeleteDeploymentDialog } from './delete-dialog'
import { EditDeploymentDialog } from './edit-dialog'
import {
deleteDeploymentDialogOpenAtom,
deploymentActionAppInstanceIdHydrationAtom,
deploymentActionAppInstanceIdAtom,
deploymentActionAppInstanceQueryOptionsAtom,
deploymentActionsLocalAtoms,
editDeploymentDialogOpenAtom,
openDeleteDeploymentDialogAtom,
openEditDeploymentDialogAtom,
} from './state'
const ACTION_TRIGGER_CLASS_NAME = cn(
@ -36,37 +37,34 @@ type DeploymentActionsMenuProps = {
sideOffset?: ComponentProps<typeof DropdownMenuContent>['sideOffset']
}
type DeploymentActionsMenuContentProps = Omit<DeploymentActionsMenuProps, 'appInstanceId'>
function DeploymentActionsMenuContent({
className,
triggerClassName,
placement,
sideOffset,
}: DeploymentActionsMenuContentProps) {
}: Omit<DeploymentActionsMenuProps, 'appInstanceId'>) {
const { t } = useTranslation('deployments')
const setEditOpen = useSetAtom(editDeploymentDialogOpenAtom)
const setDeleteOpen = useSetAtom(deleteDeploymentDialogOpenAtom)
const [menuOpen, setMenuOpen] = useState(false)
const queryClient = useQueryClient()
const appInstanceQueryOptions = useAtomValue(deploymentActionAppInstanceQueryOptionsAtom)
const openEditDialog = useSetAtom(openEditDeploymentDialogAtom)
const openDeleteDialog = useSetAtom(openDeleteDeploymentDialogAtom)
function openEditDialog() {
setMenuOpen(false)
setEditOpen(true)
}
function openDeleteDialog() {
setMenuOpen(false)
setDeleteOpen(true)
function handleMenuOpenChange(open: boolean) {
if (open)
void queryClient.prefetchQuery(appInstanceQueryOptions)
}
return (
<div
role="presentation"
className={cn(className, menuOpen && 'pointer-events-auto opacity-100')}
className={cn(
className,
'[&:has([data-popup-open])]:pointer-events-auto [&:has([data-popup-open])]:opacity-100',
)}
onClick={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenu modal={false} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger
aria-label={t('card.moreActions')}
className={cn(ACTION_TRIGGER_CLASS_NAME, triggerClassName)}
@ -89,7 +87,6 @@ function DeploymentActionsMenuContent({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditDeploymentDialog />
<DeleteDeploymentDialog />
</div>
@ -104,7 +101,7 @@ export function DeploymentActionsMenu({
<ScopeProvider
key={appInstanceId}
atoms={[
[deploymentActionAppInstanceIdHydrationAtom, appInstanceId],
[deploymentActionAppInstanceIdAtom, appInstanceId],
...deploymentActionsLocalAtoms,
]}
name="DeploymentActionsMenu"

View File

@ -1,165 +1,39 @@
'use client'
import type { ExtractAtomValue } from 'jotai'
import type { Getter } from 'jotai/vanilla'
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import {
atomWithForm,
createFormAtoms,
} from 'jotai-tanstack-form'
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { atomWithLazy } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
export type EditDeploymentFormValues = {
name: string
description: string
}
const DEFAULT_EDIT_DEPLOYMENT_FORM_VALUES: EditDeploymentFormValues = {
name: '',
description: '',
}
export const deploymentActionAppInstanceIdHydrationAtom = atom<string | undefined>(undefined)
export const deploymentActionAppInstanceIdAtom = atomWithLazy<string>(() => {
throw new Error('Missing deployment action app instance id.')
})
export const editDeploymentDialogOpenAtom = atom(false)
export const deleteDeploymentDialogOpenAtom = atom(false)
export const editDeploymentFormAtom = atomWithForm({
defaultValues: DEFAULT_EDIT_DEPLOYMENT_FORM_VALUES,
})
const editDeploymentFormAtoms = createFormAtoms(editDeploymentFormAtom)
const editDeploymentFormValuesAtom = editDeploymentFormAtoms.valuesAtom
export const editDeploymentNameFieldAtom = editDeploymentFormAtoms.fieldAtom('name')
export const editDeploymentDescriptionFieldAtom = editDeploymentFormAtoms.fieldAtom('description')
const deploymentActionAppInstanceIdAtom = atom((get): string => {
const appInstanceId = get(deploymentActionAppInstanceIdHydrationAtom)
if (!appInstanceId)
throw new Error('Missing deployment action app instance id.')
return appInstanceId
})
function normalizedEditDeploymentFormValues(value: EditDeploymentFormValues) {
return {
name: value.name.trim(),
description: value.description.trim(),
}
}
export const deploymentActionAppInstanceQueryAtom = atomWithQuery((get) => {
export const deploymentActionAppInstanceQueryOptionsAtom = atom((get) => {
const appInstanceId = get(deploymentActionAppInstanceIdAtom)
const editOpen = get(editDeploymentDialogOpenAtom)
const deleteOpen = get(deleteDeploymentDialogOpenAtom)
const enabled = editOpen || deleteOpen
return consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: enabled
? {
params: { appInstanceId },
}
: skipToken,
enabled,
})
})
export const deploymentActionDisplayNameAtom = atom((get): string => {
return get(deploymentActionAppInstanceQueryAtom).data?.appInstance.displayName || get(deploymentActionAppInstanceIdAtom)
})
function editDeploymentInitialFormValues(get: Getter): EditDeploymentFormValues | undefined {
const app = get(deploymentActionAppInstanceQueryAtom).data?.appInstance
if (!app)
return undefined
return {
name: app.displayName,
description: app.description,
}
}
function canSubmitEditDeploymentForm(get: Getter, value: EditDeploymentFormValues) {
const initialValues = editDeploymentInitialFormValues(get)
if (!initialValues)
return false
const normalizedValues = normalizedEditDeploymentFormValues(value)
return Boolean(
normalizedValues.name
&& (
normalizedValues.name !== initialValues.name
|| normalizedValues.description !== initialValues.description
),
)
}
export const updateDeploymentInstanceMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions(),
)
export const deleteDeploymentInstanceMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions(),
)
export const setEditDeploymentDialogOpenAtom = atom(null, (get, set, open: boolean) => {
if (!open && get(updateDeploymentInstanceMutationAtom).isPending)
return
set(editDeploymentDialogOpenAtom, open)
})
export const editDeploymentFormSavePendingAtom = atom((get) => {
return get(updateDeploymentInstanceMutationAtom).isPending
})
export const editDeploymentFormCanSaveAtom = atom((get) => {
return canSubmitEditDeploymentForm(get, get(editDeploymentFormValuesAtom))
&& !get(editDeploymentFormSavePendingAtom)
})
const submitEditDeploymentInstanceAtom = atom(null, async (get, _set, value: EditDeploymentFormValues) => {
if (!canSubmitEditDeploymentForm(get, value))
return undefined
const appInstanceId = get(deploymentActionAppInstanceIdAtom)
const updateInstance = get(updateDeploymentInstanceMutationAtom)
const normalizedValues = normalizedEditDeploymentFormValues(value)
return await updateInstance.mutateAsync({
params: {
appInstanceId,
},
body: {
appInstanceId,
displayName: normalizedValues.name,
description: normalizedValues.description,
input: {
params: { appInstanceId },
},
})
})
export const submitEditDeploymentFormAtom = atom(null, async (get, set) => {
const response = await set(submitEditDeploymentInstanceAtom, get(editDeploymentFormValuesAtom))
return Boolean(response)
export const deploymentActionAppInstanceQueryAtom = atomWithQuery((get) => {
return get(deploymentActionAppInstanceQueryOptionsAtom)
})
type DeleteDeploymentInstanceMutationCallbacks = Parameters<ExtractAtomValue<typeof deleteDeploymentInstanceMutationAtom>['mutate']>[1]
export const openEditDeploymentDialogAtom = atom(null, (_get, set) => {
set(deleteDeploymentDialogOpenAtom, false)
set(editDeploymentDialogOpenAtom, true)
})
export const submitDeleteDeploymentInstanceAtom = atom(null, (get, _set, callbacks?: DeleteDeploymentInstanceMutationCallbacks) => {
const appInstanceId = get(deploymentActionAppInstanceIdAtom)
const deleteInstance = get(deleteDeploymentInstanceMutationAtom)
deleteInstance.mutate(
{
params: {
appInstanceId,
},
},
callbacks,
)
export const openDeleteDeploymentDialogAtom = atom(null, (_get, set) => {
set(editDeploymentDialogOpenAtom, false)
set(deleteDeploymentDialogOpenAtom, true)
})
export const deploymentActionsLocalAtoms = [

View File

@ -15,7 +15,7 @@
"conversion.confirm.content": "This action is permanent. You won't be able to revert to the previous method.Please confirm to convert.",
"conversion.confirm.title": "Confirmation",
"conversion.descriptionChunk1": "You can now convert your existing knowledge base to use the Knowledge Pipeline for document processing",
"conversion.descriptionChunk2": " — a more open and flexible approach with access to plugins from our marketplace. This will apply the new processing method to all future documents.",
"conversion.descriptionChunk2": " — a more open and flexible approach with access to integrations from the Marketplace. This will apply the new processing method to all future documents.",
"conversion.errorMessage": "Failed to convert the dataset to a pipeline",
"conversion.successMessage": "Successfully converted the dataset to a pipeline",
"conversion.title": "Convert to Knowledge Pipeline",

View File

@ -218,16 +218,16 @@
"list.source.github": "Install from GitHub",
"list.source.local": "Install from Local Package File",
"list.source.marketplace": "Install from Marketplace",
"marketplace.allPlugins": "All plugins",
"marketplace.allPlugins": "All integrations",
"marketplace.and": "and",
"marketplace.becomePartner": "Become a Partner",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Discover",
"marketplace.empower": "Empower your AI development",
"marketplace.moreFrom": "More from Marketplace",
"marketplace.noPluginFound": "No plugin found",
"marketplace.noPluginFound": "No integration found",
"marketplace.partnerTip": "Verified by a Dify partner",
"marketplace.pluginsHeroSubtitle": "Use community-built plugins to power your AI development.",
"marketplace.pluginsHeroSubtitle": "Use community-built integrations to power your AI development.",
"marketplace.pluginsHeroTitle": "Discover. Extend. Build.",
"marketplace.pluginsResult": "{{num}} results",
"marketplace.sortBy": "Sort by",

View File

@ -464,7 +464,7 @@
"modelProvider.embeddingModel.required": "埋め込みモデルが必要です",
"modelProvider.embeddingModel.tip": "ナレッジのドキュメント埋め込み処理のデフォルトモデルを設定します。ナレッジの取得とインポートの両方に、この埋め込みモデルをベクトル化処理に使用します。切り替えると、インポートされたナレッジと質問の間のベクトル次元が一致せず、取得に失敗します。取得の失敗を避けるためには、このモデルを任意に切り替えないでください。",
"modelProvider.emptyProviderTip": "最初にモデルプロバイダーをインストールしてください。",
"modelProvider.emptyProviderTipWithMarketplace": "最初にモデルプロバイダーをインストールしてください。<marketplace>Marketplace</marketplace> からモデルをインストールできます。",
"modelProvider.emptyProviderTipWithMarketplace": "最初にモデルプロバイダーをインストールしてください。<marketplace>マーケットプレイス</marketplace>からモデルをインストールできます。",
"modelProvider.emptyProviderTitle": "モデルプロバイダーが設定されていません",
"modelProvider.encrypted.back": " の技術で暗号化されて保存されます。",
"modelProvider.encrypted.front": "API キーは",
@ -740,7 +740,7 @@
"theme.dark": "暗い",
"theme.light": "明るい",
"theme.theme": "テーマ",
"toolsPage.description": "ワークスペースで利用可能なすべてのツール —— 組み込みのものと Marketplace からインストールしたものを含みます。",
"toolsPage.description": "ワークスペースで利用可能なすべてのツール —— 組み込みのものとマーケットプレイスからインストールしたものを含みます。",
"toolsPage.toolPlugin": "ツールプラグイン",
"triggerPage.description": "サードパーティのイベントを、アプリが認識・処理できる入力に変換——手動トリガーなしでワークフローを自動化できます。",
"unit.char": "文字",

View File

@ -15,7 +15,7 @@
"conversion.confirm.content": "この操作は永久的です。以前の方法に戻すことはできません。変換することを確認してください。",
"conversion.confirm.title": "確認",
"conversion.descriptionChunk1": "既存のナレッジベースを文書処理のためにナレッジパイプラインを使用するように変換できます。",
"conversion.descriptionChunk2": "— よりオープンで柔軟なアプローチを採用し、私たちのマーケットプレイスからのプラグインへのアクセスを提供します。これにより、すべての将来のドキュメントに新しい処理方法が適用されることになります。",
"conversion.descriptionChunk2": "— よりオープンで柔軟なアプローチを採用し、マーケットプレイスからのインテグレーションへのアクセスを提供します。これにより、すべての将来のドキュメントに新しい処理方法が適用されることになります。",
"conversion.errorMessage": "データセットをパイプラインに変換できませんでした",
"conversion.successMessage": "データセットをパイプラインに正常に変換しました",
"conversion.title": "ナレッジパイプラインに変換する",

View File

@ -207,7 +207,7 @@
"marketplace.discover": "探索",
"marketplace.empower": "AI 開発をサポートする",
"marketplace.moreFrom": "マーケットプレイスからのさらなる情報",
"marketplace.noPluginFound": "プラグインが見つかりません",
"marketplace.noPluginFound": "ンテグレーションが見つかりません",
"marketplace.partnerTip": "このプラグインは Dify のパートナーによって認証されています",
"marketplace.pluginsResult": "{{num}} 件の結果",
"marketplace.sortBy": "並べ替え",

View File

@ -978,8 +978,8 @@
"nodes.start.outputVars.query": "ユーザー入力",
"nodes.start.required": "必須",
"nodes.start.userInputTipDescription": "ワークフローがオンデマンドで開始されるときにエンドユーザーから収集する入力を定義します。",
"nodes.startPlaceholder.browseMoreOnMarketplace": "Marketplace でもっと探す",
"nodes.startPlaceholder.findMoreToolsInMarketplace": "Marketplace でさらにツールを探す",
"nodes.startPlaceholder.browseMoreOnMarketplace": "マーケットプレイスでもっと探す",
"nodes.startPlaceholder.findMoreToolsInMarketplace": "マーケットプレイスでさらにツールを探す",
"nodes.startPlaceholder.noTriggersFound": "トリガーが見つかりませんでした",
"nodes.startPlaceholder.nodeCollapsedDescription": "クリックして開始ノードを設定",
"nodes.startPlaceholder.nodeDescription": "右側のパネルから開始ノードを選択",

View File

@ -152,11 +152,11 @@
"marketplace.template.fetchFailed": "获取模板失败",
"marketplace.template.importConfirm": "导入",
"marketplace.template.importFailed": "导入模板失败",
"marketplace.template.modalTitle": "从市场导入",
"marketplace.template.modalTitle": "从 Marketplace 导入",
"marketplace.template.overview": "概述",
"marketplace.template.publishedBy": "来自",
"marketplace.template.usageCount": "使用次数",
"marketplace.template.viewOnMarketplace": "在市场查看",
"marketplace.template.viewOnMarketplace": "在 Marketplace 查看",
"maxActiveRequests": "最大活跃请求数",
"maxActiveRequestsPlaceholder": "0 表示不限制",
"maxActiveRequestsTip": "当前应用的最大活跃请求数0 表示不限制)",

View File

@ -52,7 +52,7 @@
"plans.premium.includesTitle": "Community 版的所有功能,加上:",
"plans.premium.name": "Premium",
"plans.premium.price": "可扩展",
"plans.premium.priceTip": "基于云市场",
"plans.premium.priceTip": "基于 Cloud Marketplace",
"plans.professional.description": "适合准备构建生产级 AI 应用的独立开发者和小团队。",
"plans.professional.for": "适合独立开发者或小团队",
"plans.professional.name": "Professional",

View File

@ -235,7 +235,7 @@
"mainNav.help.openMenu": "打开帮助菜单",
"mainNav.home": "主页",
"mainNav.integrations": "集成",
"mainNav.marketplace": "插件市场",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "未找到 Web 应用",
"mainNav.webApps.searchPlaceholder": "搜索 Web 应用",
"mainNav.workspace.credits": "{{count}} 消息额度",
@ -516,17 +516,17 @@
"modelProvider.selector.creditsExhausted": "额度已用尽",
"modelProvider.selector.creditsExhaustedTip": "AI 消息额度已用尽,请升级计划或添加 API Key。",
"modelProvider.selector.disabled": "已禁用",
"modelProvider.selector.discoverMoreInMarketplace": "在插件市场发现更多",
"modelProvider.selector.discoverMoreInMarketplace": "在 Marketplace 发现更多",
"modelProvider.selector.emptySetting": "请前往设置进行配置",
"modelProvider.selector.emptyTip": "无可用模型",
"modelProvider.selector.fromMarketplace": "从插件市场安装",
"modelProvider.selector.fromMarketplace": "从 Marketplace 安装",
"modelProvider.selector.incompatible": "不兼容",
"modelProvider.selector.incompatibleTip": "该模型在当前版本中不可用,请选择其他可用模型。",
"modelProvider.selector.install": "安装",
"modelProvider.selector.modelProviderSettings": "模型供应商设置",
"modelProvider.selector.noModelFoundForSearch": "未找到“{{query}}”相关模型",
"modelProvider.selector.noProviderConfigured": "未配置模型提供商",
"modelProvider.selector.noProviderConfiguredDesc": "前往插件市场安装,或在设置中配置提供商。",
"modelProvider.selector.noProviderConfiguredDesc": "前往 Marketplace 安装,或在设置中配置提供商。",
"modelProvider.selector.onlyCompatibleModelsShown": "仅显示兼容的模型",
"modelProvider.selector.rerankTip": "请设置 Rerank 模型",
"modelProvider.selector.tip": "该模型已被删除。请添模型或选择其他模型。",
@ -701,7 +701,7 @@
"settings.customEndpoint": "自定义端点",
"settings.customTool": "自定义工具",
"settings.dataSource": "数据来源",
"settings.discoverMoreIntegrationsInMarketplace": "在插件市场发现更多集成",
"settings.discoverMoreIntegrationsInMarketplace": "在 Marketplace 发现更多集成",
"settings.expand": "展开",
"settings.extension": "扩展",
"settings.filter": "筛选",

View File

@ -15,7 +15,7 @@
"conversion.confirm.content": "此操作是永久性的。您将无法恢复到之前的方式。请确认转换。",
"conversion.confirm.title": "确认",
"conversion.descriptionChunk1": "您现在可以将现有知识库转换为使用知识流水线来处理文档",
"conversion.descriptionChunk2": " —— 这是一种更开放、更灵活的方式,可以访问我们市场中的插件。新的处理方式将应用到后续添加的所有文档。",
"conversion.descriptionChunk2": " —— 这是一种更开放、更灵活的方式,可以访问 Marketplace 中的集成。新的处理方式将应用到后续添加的所有文档。",
"conversion.errorMessage": "转换数据集为知识流水线失败",
"conversion.successMessage": "成功将数据集转换为知识流水线",
"conversion.title": "转换为知识流水线",

View File

@ -165,7 +165,7 @@
"error.noReleasesFound": "未找到发布版本。请检查 GitHub 仓库或输入的 URL。",
"findMoreInMarketplace": "在 Marketplace 中查找更多",
"from": "来自",
"fromMarketplace": "来自市场",
"fromMarketplace": "来自 Marketplace",
"install": "{{num}} 次安装",
"installAction": "安装",
"installFrom": "安装源",
@ -218,16 +218,16 @@
"list.source.github": "从 GitHub 安装",
"list.source.local": "本地集成包",
"list.source.marketplace": "从 Marketplace 安装",
"marketplace.allPlugins": "所有插件",
"marketplace.allPlugins": "所有集成",
"marketplace.and": "和",
"marketplace.becomePartner": "成为合作伙伴",
"marketplace.difyMarketplace": "Dify 市场",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "探索",
"marketplace.empower": "助力您的 AI 开发",
"marketplace.moreFrom": "更多来自市场",
"marketplace.noPluginFound": "未找到插件",
"marketplace.moreFrom": "来自 Marketplace 的更多内容",
"marketplace.noPluginFound": "未找到集成",
"marketplace.partnerTip": "此插件由 Dify 合作伙伴认证",
"marketplace.pluginsHeroSubtitle": "使用社区构建的插件助力您的 AI 开发。",
"marketplace.pluginsHeroSubtitle": "使用社区构建的集成助力您的 AI 开发。",
"marketplace.pluginsHeroTitle": "探索 · 扩展 · 构建",
"marketplace.pluginsResult": "{{num}} 个插件结果",
"marketplace.sortBy": "排序方式",

View File

@ -235,8 +235,8 @@
"common.previewPlaceholder": "在下面的框中输入内容开始调试聊天机器人",
"common.processData": "数据处理",
"common.publish": "发布",
"common.publishToMarketplace": "发布到市场",
"common.publishToMarketplaceFailed": "发布到市场失败",
"common.publishToMarketplace": "发布到 Marketplace",
"common.publishToMarketplaceFailed": "发布到 Marketplace 失败",
"common.publishUpdate": "发布更新",
"common.published": "已发布",
"common.publishedAt": "发布于",
@ -1241,8 +1241,8 @@
"tabs.hideActions": "收起工具",
"tabs.installed": "已安装",
"tabs.logic": "逻辑",
"tabs.noFeaturedPlugins": "前往插件市场查看更多工具",
"tabs.noFeaturedTriggers": "前往插件市场查看更多触发器",
"tabs.noFeaturedPlugins": "前往 Marketplace 查看更多工具",
"tabs.noFeaturedTriggers": "前往 Marketplace 查看更多触发器",
"tabs.noPluginsFound": "未找到集成",
"tabs.noResult": "未找到匹配项",
"tabs.noSnippetsFound": "未找到 snippets",

View File

@ -152,11 +152,11 @@
"marketplace.template.fetchFailed": "獲取模板失敗",
"marketplace.template.importConfirm": "匯入",
"marketplace.template.importFailed": "匯入模板失敗",
"marketplace.template.modalTitle": "從市場匯入",
"marketplace.template.modalTitle": "從 Marketplace 匯入",
"marketplace.template.overview": "概覽",
"marketplace.template.publishedBy": "由",
"marketplace.template.usageCount": "使用次數",
"marketplace.template.viewOnMarketplace": "在市場上查看",
"marketplace.template.viewOnMarketplace": "在 Marketplace 查看",
"maxActiveRequests": "同時最大請求數",
"maxActiveRequestsPlaceholder": "輸入 0 以表示無限",
"maxActiveRequestsTip": "每個應用程式可同時活躍請求的最大數量0為無限制",

View File

@ -52,7 +52,7 @@
"plans.premium.includesTitle": "來自社群的一切,加上:",
"plans.premium.name": "高級",
"plans.premium.price": "可擴展的",
"plans.premium.priceTip": "根據雲端市場",
"plans.premium.priceTip": "根據 Cloud Marketplace",
"plans.professional.description": "讓個人和小團隊能夠以經濟實惠的方式釋放更多能力。",
"plans.professional.for": "適合獨立開發者/小型團隊",
"plans.professional.name": "Professional",

View File

@ -235,7 +235,7 @@
"mainNav.help.openMenu": "開啟幫助選單",
"mainNav.home": "首頁",
"mainNav.integrations": "集成",
"mainNav.marketplace": "外掛市場",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "未找到 Web 應用",
"mainNav.webApps.searchPlaceholder": "搜尋 Web 應用",
"mainNav.workspace.credits": "{{count}} 點額度",
@ -464,7 +464,7 @@
"modelProvider.embeddingModel.required": "請選擇 Embedding 模型",
"modelProvider.embeddingModel.tip": "設定知識庫文件嵌入處理的預設模型,檢索和匯入知識庫均使用該 Embedding 模型進行向量化處理,切換後將導致已匯入的知識庫與問題之間的向量維度不一致,從而導致檢索失敗。為避免檢索失敗,請勿隨意切換該模型。",
"modelProvider.emptyProviderTip": "請先安裝模型提供者。",
"modelProvider.emptyProviderTipWithMarketplace": "Please install a model provider first. You can install models from the <marketplace>Marketplace</marketplace>.",
"modelProvider.emptyProviderTipWithMarketplace": "請先安裝模型供應商。你可以從 <marketplace>Marketplace</marketplace> 安裝模型。",
"modelProvider.emptyProviderTitle": "未設置模型提供者",
"modelProvider.encrypted.back": "技術進行加密和儲存。",
"modelProvider.encrypted.front": "您的金鑰將使用",
@ -516,17 +516,17 @@
"modelProvider.selector.creditsExhausted": "額度已用盡",
"modelProvider.selector.creditsExhaustedTip": "AI 額度已用盡,請升級方案或新增 API Key。",
"modelProvider.selector.disabled": "已停用",
"modelProvider.selector.discoverMoreInMarketplace": "在插件市場探索更多",
"modelProvider.selector.discoverMoreInMarketplace": "在 Marketplace 探索更多",
"modelProvider.selector.emptySetting": "請前往設定進行配置",
"modelProvider.selector.emptyTip": "無可用模型",
"modelProvider.selector.fromMarketplace": "從插件市場安裝",
"modelProvider.selector.fromMarketplace": "從 Marketplace 安裝",
"modelProvider.selector.incompatible": "不相容",
"modelProvider.selector.incompatibleTip": "此模型在目前版本中不可用,請選擇其他可用模型。",
"modelProvider.selector.install": "安裝",
"modelProvider.selector.modelProviderSettings": "模型供應商設定",
"modelProvider.selector.noModelFoundForSearch": "未找到「{{query}}」相關模型",
"modelProvider.selector.noProviderConfigured": "未配置模型供應商",
"modelProvider.selector.noProviderConfiguredDesc": "前往插件市場安裝,或在設定中配置供應商。",
"modelProvider.selector.noProviderConfiguredDesc": "前往 Marketplace 安裝,或在設定中配置供應商。",
"modelProvider.selector.onlyCompatibleModelsShown": "僅顯示相容的模型",
"modelProvider.selector.rerankTip": "請設定 Rerank 模型",
"modelProvider.selector.tip": "該模型已被刪除。請添模型或選擇其他模型。",
@ -701,7 +701,7 @@
"settings.customEndpoint": "自訂端點",
"settings.customTool": "自訂工具",
"settings.dataSource": "資料來源",
"settings.discoverMoreIntegrationsInMarketplace": "在插件市場探索更多整合",
"settings.discoverMoreIntegrationsInMarketplace": "在 Marketplace 探索更多整合",
"settings.expand": "展開",
"settings.extension": "擴充",
"settings.filter": "Filter",

View File

@ -15,7 +15,7 @@
"conversion.confirm.content": "此動作是永久性的。您將無法恢復到以前的方法。請確認轉換。",
"conversion.confirm.title": "證實",
"conversion.descriptionChunk1": "您現在可以轉換現有的知識庫,以使用知識流水線進行文件處理",
"conversion.descriptionChunk2": "— 一種更開放和靈活的方法,可以訪問我們市場中的插件。這會將新的處理方法套用至所有未來的文件。",
"conversion.descriptionChunk2": "— 一種更開放和靈活的方法,可以訪問 Marketplace 中的集成。這會將新的處理方法套用至所有未來的文件。",
"conversion.errorMessage": "無法將資料集轉換成流水線",
"conversion.successMessage": "已成功將資料集轉換成流水線",
"conversion.title": "轉換為知識流水線",

View File

@ -15,7 +15,7 @@
"inputField.manage": "司",
"publishToast.desc": "當管道未發佈時,可以在知識庫節點中修改區塊結構,管道編排和變更會自動儲存為草稿。",
"publishToast.title": "此管線尚未發佈",
"ragToolSuggestions.noRecommendationPlugins": "沒有推薦的集成,請在 <CustomLink>市場</CustomLink> 中尋找更多",
"ragToolSuggestions.noRecommendationPlugins": "沒有推薦的集成,請在 <CustomLink>Marketplace</CustomLink> 中尋找更多",
"ragToolSuggestions.title": "RAG 的建議",
"result.resultPreview.error": "執行期間發生錯誤",
"result.resultPreview.footerTip": "在測試運行模式下,可預覽最多 {{count}} 個區塊",

View File

@ -125,7 +125,7 @@
"detailPanel.operation.remove": "刪除",
"detailPanel.operation.update": "更新",
"detailPanel.operation.updateTooltip": "更新以取得最新模型。",
"detailPanel.operation.viewDetail": "查看詳情",
"detailPanel.operation.viewDetail": "在 Marketplace 查看",
"detailPanel.serviceOk": "服務正常",
"detailPanel.strategyNum": "{{num}} {{strategy}} 包括",
"detailPanel.switchVersion": "Switch 版本",
@ -202,11 +202,11 @@
"list.source.local": "從本地包檔安裝",
"list.source.marketplace": "從 Marketplace 安裝",
"marketplace.and": "和",
"marketplace.difyMarketplace": "Dify 市場",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "發現",
"marketplace.empower": "為您的 AI 開發提供支援",
"marketplace.moreFrom": "來自 Marketplace 的更多內容",
"marketplace.noPluginFound": "未找到插件",
"marketplace.noPluginFound": "未找到集成",
"marketplace.partnerTip": "由 Dify 合作夥伴驗證",
"marketplace.pluginsResult": "{{num}} 個結果",
"marketplace.sortBy": "排序方式",
@ -240,15 +240,15 @@
"searchTools": "搜尋工具...",
"source.github": "GitHub 的",
"source.local": "本地包檔",
"source.marketplace": "市場",
"source.marketplace": "Marketplace",
"task.clearAll": "全部清除",
"task.errorMsg.github": "此插件無法自動安裝。\n請從 GitHub 安裝。",
"task.errorMsg.marketplace": "此插件無法自動安裝。\n請從插件市場安裝。",
"task.errorMsg.marketplace": "此插件無法自動安裝。\n請從 Marketplace 安裝。",
"task.errorMsg.unknown": "此插件無法安裝。\n無法識別插件來源。",
"task.errorPlugins": "Failed to Install Plugins",
"task.installError": "{{errorLength}} 個插件安裝失敗,點擊查看",
"task.installFromGithub": "從 GitHub 安裝",
"task.installFromMarketplace": "從插件市場安裝",
"task.installFromMarketplace": "從 Marketplace 安裝",
"task.installSuccess": "{{successLength}} plugins installed successfully",
"task.installed": "Installed",
"task.installedError": "{{errorLength}} 個插件安裝失敗",

View File

@ -235,8 +235,8 @@
"common.previewPlaceholder": "在下面的框中輸入內容開始測試聊天機器人",
"common.processData": "資料處理",
"common.publish": "發佈",
"common.publishToMarketplace": "發佈到市場",
"common.publishToMarketplaceFailed": "發佈到市場失敗",
"common.publishToMarketplace": "發佈到 Marketplace",
"common.publishToMarketplaceFailed": "發佈到 Marketplace 失敗",
"common.publishUpdate": "發布更新",
"common.published": "已發佈",
"common.publishedAt": "發佈於",