diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 689cd227b3a..bf523070740 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -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(() => { 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 && }` 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. diff --git a/api/commands/account.py b/api/commands/account.py index 0d99ce7a0fa..7f4f0a744f3 100644 --- a/api/commands/account.py +++ b/api/commands/account.py @@ -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")) diff --git a/api/controllers/console/app/agent_app_feature.py b/api/controllers/console/app/agent_app_feature.py index d155dae6ac3..358e552beb0 100644 --- a/api/controllers/console/app/agent_app_feature.py +++ b/api/controllers/console/app/agent_app_feature.py @@ -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) diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py index 43b41903f60..b66c97c274c 100644 --- a/api/controllers/console/app/audio.py +++ b/api/controllers/console/app/audio.py @@ -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, diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py index 912eb26574c..ccbe9405fe5 100644 --- a/api/controllers/console/auth/email_register.py +++ b/api/controllers/console/auth/email_register.py @@ -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() diff --git a/api/controllers/console/auth/forgot_password.py b/api/controllers/console/auth/forgot_password.py index d82f63c11db..061c29a13a2 100644 --- a/api/controllers/console/auth/forgot_password.py +++ b/api/controllers/console/auth/forgot_password.py @@ -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) diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index 78d1583fde9..670d1c7818d 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -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 diff --git a/api/controllers/console/explore/audio.py b/api/controllers/console/explore/audio.py index 756dfe84f6c..c2104ccfc61 100644 --- a/api/controllers/console/explore/audio.py +++ b/api/controllers/console/explore/audio.py @@ -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.") diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index ad98dd303fb..6aef9129780 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -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: diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index b3230d77e69..4ea77e04b96 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -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 diff --git a/api/controllers/inner_api/knowledge/retrieval.py b/api/controllers/inner_api/knowledge/retrieval.py index 1c1320fde42..e34dedea286 100644 --- a/api/controllers/inner_api/knowledge/retrieval.py +++ b/api/controllers/inner_api/knowledge/retrieval.py @@ -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, diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index 0ff225271df..5653fbae432 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -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") diff --git a/api/controllers/service_api/app/audio.py b/api/controllers/service_api/app/audio.py index 2b5a9ba83a1..59ed4b4a4b1 100644 --- a/api/controllers/service_api/app/audio.py +++ b/api/controllers/service_api/app/audio.py @@ -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 diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index c762c914861..801c1f5a629 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -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 diff --git a/api/controllers/web/forgot_password.py b/api/controllers/web/forgot_password.py index d0e023e40ee..ecc91113c32 100644 --- a/api/controllers/web/forgot_password.py +++ b/api/controllers/web/forgot_password.py @@ -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) diff --git a/api/services/account_service.py b/api/services/account_service.py index 21b5f1eedba..80411dd288e 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -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: diff --git a/api/services/agent_app_feature_service.py b/api/services/agent_app_feature_service.py index b8e98653c8e..5fd794bb10f 100644 --- a/api/services/agent_app_feature_service.py +++ b/api/services/agent_app_feature_service.py @@ -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. diff --git a/api/services/audio_service.py b/api/services/audio_service.py index a9024eb3bdd..14c5c0111e5 100644 --- a/api/services/audio_service.py +++ b/api/services/audio_service.py @@ -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}: diff --git a/api/services/knowledge_retrieval_inner_service.py b/api/services/knowledge_retrieval_inner_service.py index fccc81c4a29..8759413f533 100644 --- a/api/services/knowledge_retrieval_inner_service.py +++ b/api/services/knowledge_retrieval_inner_service.py @@ -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) diff --git a/api/services/webapp_auth_service.py b/api/services/webapp_auth_service.py index 2b63d9171e9..6ecc8eb8bc9 100644 --- a/api/services/webapp_auth_service.py +++ b/api/services/webapp_auth_service.py @@ -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 diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 9f8e4b83093..262ccc18f83 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -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 diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py index bb7921a5f45..109332e16c9 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py @@ -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 diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py index 014c1588fee..812aa299c1b 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py @@ -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 diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py index d043c0d413a..d87afb87669 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py @@ -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 diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py b/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py index 2c6a9902401..d568a1c0b04 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py @@ -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") diff --git a/api/tests/test_containers_integration_tests/services/test_audio_service_db.py b/api/tests/test_containers_integration_tests/services/test_audio_service_db.py index 2593b53fe84..c9cf60bcfb1 100644 --- a/api/tests/test_containers_integration_tests/services/test_audio_service_db.py +++ b/api/tests/test_containers_integration_tests/services/test_audio_service_db.py @@ -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, ) diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py index e419428ca66..1600fcda50d 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -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 diff --git a/api/tests/unit_tests/controllers/service_api/app/test_audio.py b/api/tests/unit_tests/controllers/service_api/app/test_audio.py index 1cfe152c864..52d050ff55a 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_audio.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_audio.py @@ -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", diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 3b5c6cc9bd6..c748fc0962e 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -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 diff --git a/api/tests/unit_tests/services/test_audio_service.py b/api/tests/unit_tests/services/test_audio_service.py index 5d148974f87..788a47c5c31 100644 --- a/api/tests/unit_tests/services/test_audio_service.py +++ b/api/tests/unit_tests/services/test_audio_service.py @@ -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: diff --git a/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py b/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py index 287d787ad70..7a8efe85f13 100644 --- a/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py +++ b/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py @@ -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) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5c1d04ee120..48f94044d8d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/packages/dify-ui/src/themes/dark.css b/packages/dify-ui/src/themes/dark.css index 1b24e8fb489..3f4a163a725 100644 --- a/packages/dify-ui/src/themes/dark.css +++ b/packages/dify-ui/src/themes/dark.css @@ -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; diff --git a/packages/dify-ui/src/themes/light.css b/packages/dify-ui/src/themes/light.css index 3feb4afb47f..dd3252f3614 100644 --- a/packages/dify-ui/src/themes/light.css +++ b/packages/dify-ui/src/themes/light.css @@ -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; diff --git a/packages/dify-ui/src/themes/theme.css b/packages/dify-ui/src/themes/theme.css index 3e35feb8eb8..c14e54ea549 100644 --- a/packages/dify-ui/src/themes/theme.css +++ b/packages/dify-ui/src/themes/theme.css @@ -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); diff --git a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx index 81b08046a94..741580f6a5c 100644 --- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx @@ -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', () => { diff --git a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx b/web/app/components/header/account-dropdown/main-nav-menu-content.tsx index ee2ebcc981e..1b84397a1ed 100644 --- a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx +++ b/web/app/components/header/account-dropdown/main-nav-menu-content.tsx @@ -127,7 +127,7 @@ export function MainNavMenuContent({ setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.LANGUAGE })} + onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })} > { 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) diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index ec1ac9887d2..7113714ac4f 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -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) diff --git a/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx b/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx new file mode 100644 index 00000000000..7ca34890a35 --- /dev/null +++ b/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx @@ -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) => { + 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 + }) => { + const setTimezone = components?.setTimezone + if (setTimezone) + return React.cloneElement(setTimezone, undefined, i18nKey) + + return {i18nKey} + }, +})) + +vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker', () => ({ + default: () =>
, +})) + +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( + minutes} + onAutoUpgradeChange={vi.fn()} + onPluginsChange={vi.fn()} + onRequestClose={onRequestClose} + onUpdateTimeChange={vi.fn()} + renderTimePickerTrigger={() => } + />, + ) + + fireEvent.click(screen.getByText('autoUpdate.changeTimezone')) + + expect(onRequestClose).toHaveBeenCalledTimes(1) + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PREFERENCES }) + }) +}) diff --git a/web/app/components/header/account-setting/constants.ts b/web/app/components/header/account-setting/constants.ts index d8128631306..67a89afc0c2 100644 --- a/web/app/components/header/account-setting/constants.ts +++ b/web/app/components/header/account-setting/constants.ts @@ -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 diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index b04404bde17..a03fb30cea2 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -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(null) @@ -119,7 +121,7 @@ export default function AccountSetting({ activeIcon: , }, { - key: ACCOUNT_SETTING_TAB.LANGUAGE, + key: ACCOUNT_SETTING_TAB.PREFERENCES, name: t('settings.preferences', { ns: 'common' }), title: t('account.general', { ns: 'common' }), icon: , @@ -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 && } {activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && } {activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && } - {activeMenu === ACCOUNT_SETTING_TAB.LANGUAGE && } + {activeMenu === ACCOUNT_SETTING_TAB.PREFERENCES && }
diff --git a/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/preference-page/__tests__/index.spec.tsx similarity index 96% rename from web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx rename to web/app/components/header/account-setting/preference-page/__tests__/index.spec.tsx index edeb14cb1c3..55ee81481ad 100644 --- a/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/preference-page/__tests__/index.spec.tsx @@ -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 ( - ) @@ -104,7 +104,7 @@ const createUserProfile = (overrides: Partial = {}): const renderPage = () => { render( <> - + , ) @@ -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' }) diff --git a/web/app/components/header/account-setting/language-page/index.tsx b/web/app/components/header/account-setting/preference-page/index.tsx similarity index 99% rename from web/app/components/header/account-setting/language-page/index.tsx rename to web/app/components/header/account-setting/preference-page/index.tsx index fabd84b4c71..ce59fe59740 100644 --- a/web/app/components/header/account-setting/language-page/index.tsx +++ b/web/app/components/header/account-setting/preference-page/index.tsx @@ -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) diff --git a/web/app/components/header/account-setting/update-setting-dialog-form.tsx b/web/app/components/header/account-setting/update-setting-dialog-form.tsx index 65efcf9c9d1..bdfbd5720ab 100644 --- a/web/app/components/header/account-setting/update-setting-dialog-form.tsx +++ b/web/app/components/header/account-setting/update-setting-dialog-form.tsx @@ -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} diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 0dad69b1d16..497b31ea314 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -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', () => { diff --git a/web/app/components/main-nav/components/nav-link.css b/web/app/components/main-nav/components/nav-link.css new file mode 100644 index 00000000000..20839c746ea --- /dev/null +++ b/web/app/components/main-nav/components/nav-link.css @@ -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); + } +} diff --git a/web/app/components/main-nav/components/nav-link.tsx b/web/app/components/main-nav/components/nav-link.tsx index 10865c94d18..628be088263 100644 --- a/web/app/components/main-nav/components/nav-link.tsx +++ b/web/app/components/main-nav/components/nav-link.tsx @@ -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', )} > - - {item.label} + + + {item.label} ) } diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index fb2db5f3ff3..207e40fdc30 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -320,7 +320,7 @@ const MainNav = ({ : : ( <> -