mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 21:11:16 +08:00
Merge branch 'main' into feat/refine-snippet-siderbar
This commit is contained in:
commit
f2c07194f8
@ -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.
|
||||
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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}:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' })
|
||||
@ -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)
|
||||
@ -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}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
48
web/app/components/main-nav/components/nav-link.css
Normal file
48
web/app/components/main-nav/components/nav-link.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
))}
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "文字",
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
"conversion.confirm.content": "この操作は永久的です。以前の方法に戻すことはできません。変換することを確認してください。",
|
||||
"conversion.confirm.title": "確認",
|
||||
"conversion.descriptionChunk1": "既存のナレッジベースを文書処理のためにナレッジパイプラインを使用するように変換できます。",
|
||||
"conversion.descriptionChunk2": "— よりオープンで柔軟なアプローチを採用し、私たちのマーケットプレイスからのプラグインへのアクセスを提供します。これにより、すべての将来のドキュメントに新しい処理方法が適用されることになります。",
|
||||
"conversion.descriptionChunk2": "— よりオープンで柔軟なアプローチを採用し、マーケットプレイスからのインテグレーションへのアクセスを提供します。これにより、すべての将来のドキュメントに新しい処理方法が適用されることになります。",
|
||||
"conversion.errorMessage": "データセットをパイプラインに変換できませんでした",
|
||||
"conversion.successMessage": "データセットをパイプラインに正常に変換しました",
|
||||
"conversion.title": "ナレッジパイプラインに変換する",
|
||||
|
||||
@ -207,7 +207,7 @@
|
||||
"marketplace.discover": "探索",
|
||||
"marketplace.empower": "AI 開発をサポートする",
|
||||
"marketplace.moreFrom": "マーケットプレイスからのさらなる情報",
|
||||
"marketplace.noPluginFound": "プラグインが見つかりません",
|
||||
"marketplace.noPluginFound": "インテグレーションが見つかりません",
|
||||
"marketplace.partnerTip": "このプラグインは Dify のパートナーによって認証されています",
|
||||
"marketplace.pluginsResult": "{{num}} 件の結果",
|
||||
"marketplace.sortBy": "並べ替え",
|
||||
|
||||
@ -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": "右側のパネルから開始ノードを選択",
|
||||
|
||||
@ -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 表示不限制)",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "筛选",
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
"conversion.confirm.content": "此操作是永久性的。您将无法恢复到之前的方式。请确认转换。",
|
||||
"conversion.confirm.title": "确认",
|
||||
"conversion.descriptionChunk1": "您现在可以将现有知识库转换为使用知识流水线来处理文档",
|
||||
"conversion.descriptionChunk2": " —— 这是一种更开放、更灵活的方式,可以访问我们市场中的插件。新的处理方式将应用到后续添加的所有文档。",
|
||||
"conversion.descriptionChunk2": " —— 这是一种更开放、更灵活的方式,可以访问 Marketplace 中的集成。新的处理方式将应用到后续添加的所有文档。",
|
||||
"conversion.errorMessage": "转换数据集为知识流水线失败",
|
||||
"conversion.successMessage": "成功将数据集转换为知识流水线",
|
||||
"conversion.title": "转换为知识流水线",
|
||||
|
||||
@ -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": "排序方式",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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為無限制)",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
"conversion.confirm.content": "此動作是永久性的。您將無法恢復到以前的方法。請確認轉換。",
|
||||
"conversion.confirm.title": "證實",
|
||||
"conversion.descriptionChunk1": "您現在可以轉換現有的知識庫,以使用知識流水線進行文件處理",
|
||||
"conversion.descriptionChunk2": "— 一種更開放和靈活的方法,可以訪問我們市場中的插件。這會將新的處理方法套用至所有未來的文件。",
|
||||
"conversion.descriptionChunk2": "— 一種更開放和靈活的方法,可以訪問 Marketplace 中的集成。這會將新的處理方法套用至所有未來的文件。",
|
||||
"conversion.errorMessage": "無法將資料集轉換成流水線",
|
||||
"conversion.successMessage": "已成功將資料集轉換成流水線",
|
||||
"conversion.title": "轉換為知識流水線",
|
||||
|
||||
@ -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}} 個區塊",
|
||||
|
||||
@ -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}} 個插件安裝失敗",
|
||||
|
||||
@ -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": "發佈於",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user